diff --git a/.agents/skills/openclaw-ci-limits/SKILL.md b/.agents/skills/openclaw-ci-limits/SKILL.md index f6027b4d744c..e46a6e33681a 100644 --- a/.agents/skills/openclaw-ci-limits/SKILL.md +++ b/.agents/skills/openclaw-ci-limits/SKILL.md @@ -158,10 +158,12 @@ These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`: - `CI` concurrency key version, PR cancellation, and non-canceling canonical `main` single-flight with one coalesced pending tip. - `preflight` and hosted `security-fast` start immediately without a debounce - or standalone admission job. On Node-relevant canonical main pushes, - preflight also owns the sole dependency sticky-disk write and 8 GiB prune - before fanout; replacement visibility is proved only by a later exact-marker - restore because Blacksmith snapshot promotion can lag job completion. + or standalone admission job. On Node-relevant canonical main pushes and + same-repo pull requests, preflight owns the sole immutable semantic + dependency-cache write of workspace `node_modules` plus the local pnpm store + before fanout; all Blacksmith Node jobs are restore-only consumers and exact + misses fall back to the ordinary pnpm-store cache, while + hosted/fork/manual paths use only that store cache. - CI matrix caps: fast/check lanes at 12, Node test shards at 28, Windows and Android at 2. - Canonical PR Node tests use one precise changed-target job when possible; diff --git a/.github/actions/register-bind-mount-cleanup/action.yml b/.github/actions/register-bind-mount-cleanup/action.yml deleted file mode 100644 index 643a6e89510a..000000000000 --- a/.github/actions/register-bind-mount-cleanup/action.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Register bind mount cleanup -description: Unmount a bind mount during post-job cleanup. -inputs: - path: - description: Absolute bind-mount target to unmount. - required: true -runs: - using: node24 - main: main.cjs - post: post.cjs - post-if: always() diff --git a/.github/actions/register-bind-mount-cleanup/main.cjs b/.github/actions/register-bind-mount-cleanup/main.cjs deleted file mode 100644 index 90c6bc7ff41b..000000000000 --- a/.github/actions/register-bind-mount-cleanup/main.cjs +++ /dev/null @@ -1,17 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); - -const mountPath = (process.env.INPUT_PATH ?? "").trim(); -const statePath = process.env.GITHUB_STATE; - -if (!mountPath || !path.isAbsolute(mountPath) || /[\r\n]/u.test(mountPath)) { - console.error("::error::Bind mount cleanup path must be an absolute single-line path"); - process.exit(1); -} -if (!statePath) { - console.error("::error::GITHUB_STATE is unavailable"); - process.exit(1); -} - -fs.appendFileSync(statePath, `mountPath=${mountPath}\n`, "utf8"); -console.log(`Registered bind mount cleanup for ${mountPath}`); diff --git a/.github/actions/register-bind-mount-cleanup/post.cjs b/.github/actions/register-bind-mount-cleanup/post.cjs deleted file mode 100644 index c24c287cc9a5..000000000000 --- a/.github/actions/register-bind-mount-cleanup/post.cjs +++ /dev/null @@ -1,31 +0,0 @@ -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); - -const mountPath = (process.env.STATE_mountPath ?? "").trim(); -if (!mountPath || !path.isAbsolute(mountPath) || /[\r\n]/u.test(mountPath)) { - console.error("::error::Saved bind mount cleanup path is invalid"); - process.exit(1); -} - -const mountpoint = spawnSync("mountpoint", ["-q", mountPath], { stdio: "inherit" }); -if (mountpoint.error) { - console.error(`::error::Failed to inspect bind mount: ${mountpoint.error.message}`); - process.exit(1); -} -if (mountpoint.status === 32) { - console.log(`Bind mount already absent: ${mountPath}`); - process.exit(0); -} -if (mountpoint.status !== 0) { - console.error(`::error::mountpoint exited with status ${mountpoint.status}`); - process.exit(1); -} - -const unmount = spawnSync("sudo", ["umount", mountPath], { stdio: "inherit" }); -if (unmount.error || unmount.status !== 0) { - const detail = unmount.error?.message ?? `status ${unmount.status}`; - console.error(`::error::Failed to unmount ${mountPath}: ${detail}`); - process.exit(1); -} - -console.log(`Unmounted bind mount: ${mountPath}`); diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index 7e404d640b60..1bf431e3310c 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -31,20 +31,12 @@ inputs: description: Whether to save the pnpm store with actions/cache after install when no exact cache restored. required: false default: "false" - sticky-disk: - description: > - Mount a Blacksmith sticky disk for the pnpm store and bind its - node_modules directory over the checkout's stock node_modules path. - Only valid on Blacksmith Linux runners; pass use-actions-cache: "false" - alongside. + dependency-cache: + description: Whether to restore workspace node_modules and its local pnpm store from the exact semantic dependency cache. required: false default: "false" - save-sticky-disk: - description: > - Whether this job may commit the shared dependency snapshot. One - designated job per node-version key; only honored outside pull_request - events so feature-branch installs can never publish a protected - snapshot. + save-dependency-cache: + description: Whether to save workspace node_modules and its local pnpm store after a successful install on an exact cache miss. required: false default: "false" vitest-fs-cache: @@ -94,74 +86,85 @@ runs: source "$GITHUB_ACTION_PATH/../setup-pnpm-store-cache/ensure-node.sh" openclaw_ensure_node "$REQUESTED_NODE_VERSION" + - name: Configure dependency cache store + if: inputs.dependency-cache == 'true' + shell: bash + run: | + set -euo pipefail + # Keep both sides of pnpm's hard links below the workspace so one tar + # archive preserves them without relying on runner-home path depth. + echo "PNPM_CONFIG_STORE_DIR=$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store" >> "$GITHUB_ENV" + + - name: Resolve dependency cache key + id: dependency-cache-key + if: inputs.dependency-cache == 'true' + shell: bash + env: + FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} + run: | + set -euo pipefail + deps_input_fingerprint="$(node "$GITHUB_ACTION_PATH/dependency-fingerprint.mjs" \ + --workspace "$GITHUB_WORKSPACE" --frozen-lockfile "$FROZEN_LOCKFILE")" + cache_key="${GITHUB_REPOSITORY:?}-node-deps-v2-os-${RUNNER_OS:?}-arch-${RUNNER_ARCH:?}-node-$(node --version)-${deps_input_fingerprint:?}" + echo "key=$cache_key" >> "$GITHUB_OUTPUT" + + - name: Prepare dependency cache restore + if: inputs.dependency-cache == 'true' + shell: bash + run: | + rm -rf "$GITHUB_WORKSPACE/node_modules" "$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store" + find \ + "$GITHUB_WORKSPACE/ui" \ + "$GITHUB_WORKSPACE/packages" \ + "$GITHUB_WORKSPACE/extensions" \ + "$GITHUB_WORKSPACE/examples" \ + -mindepth 1 -maxdepth 2 \( -type d -o -type l \) -name node_modules \ + -exec rm -rf -- {} + + + - name: Restore exact dependency cache + id: dependency-cache + if: inputs.dependency-cache == 'true' + continue-on-error: true + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + node_modules + ui/node_modules + packages/*/node_modules + examples/*/node_modules + .cache/openclaw-pnpm-store + key: ${{ steps.dependency-cache-key.outputs.key }} + + - name: Prepare dependency cache miss fallback + if: inputs.dependency-cache == 'true' && steps.dependency-cache.outputs.cache-hit != 'true' + shell: bash + run: | + # actions/cache treats service, download, and extraction failures as + # misses. Clear any partial extraction before restoring the pnpm store. + rm -rf "$GITHUB_WORKSPACE/node_modules" "$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store" + find \ + "$GITHUB_WORKSPACE/ui" \ + "$GITHUB_WORKSPACE/packages" \ + "$GITHUB_WORKSPACE/extensions" \ + "$GITHUB_WORKSPACE/examples" \ + -mindepth 1 -maxdepth 2 \( -type d -o -type l \) -name node_modules \ + -exec rm -rf -- {} + + - name: Setup pnpm id: setup-pnpm uses: ./.github/actions/setup-pnpm-store-cache with: node-version: ${{ inputs.node-version }} - use-actions-cache: ${{ inputs.use-actions-cache }} + # On an exact dependency-cache hit, the same archive already restored + # the complete store. Every miss can seed it from the coarser cache, + # including legacy Blacksmith callers that disabled that old fallback. + use-actions-cache: ${{ ((inputs.dependency-cache == 'true' && steps.dependency-cache.outputs.cache-hit != 'true') || (inputs.dependency-cache != 'true' && inputs.use-actions-cache == 'true')) && 'true' || 'false' }} - name: Setup TruffleHog if: inputs.install-trufflehog == 'true' shell: bash run: bash scripts/install-trufflehog.sh - - name: Validate sticky pnpm layout - if: inputs.sticky-disk == 'true' - shell: bash - run: | - set -euo pipefail - for config_name in modules-dir virtual-store-dir; do - config_value="$(pnpm config get "$config_name")" - case "$config_value" in - ""|undefined|null) ;; - *) - echo "::error::$config_name must be unset when sticky-disk is enabled; sticky mode requires pnpm's stock node_modules layout" - exit 2 - ;; - esac - done - - - name: Mount dependency sticky disk - if: inputs.sticky-disk == 'true' - uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # main (post-v1.4.0 hot-attach fix) - with: - # One stable disk per Node line. v7 starts a fresh lineage for the - # preflight-serialized writer after Blacksmith acknowledged repeated v6 - # commits but kept restoring its original snapshot. The v2 per-PR/per-manifest-hash keys - # saturated Blacksmith's installation-wide sticky-disk budget. Install - # inputs, runner platform, and the exact Node patch live in the runtime - # marker below, so changes refresh this disk in place. - key: ${{ github.repository }}-node-deps-bind-v7-${{ inputs.node-version }} - path: /var/tmp/openclaw-node-deps - # Single semantic writer: only the designated trusted-push job may - # commit, so pull_request clones stay read-only. Like every sticky - # disk here, this gate binds cooperating code, not hostile code: the - # enforced trust boundary is the fork/dispatch runner gate in ci.yml, - # and same-repo PR authors already hold repository write access. - # Warm validated snapshots stay read-only so asynchronous publication - # does not perpetually chase no-op commits. The canonical writer records - # the action's allocation baseline below; after any real capture and - # store pruning, preflight forces a verified delta before this action's - # post phase. The action also skips commit after failed/cancelled steps. - commit: ${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'on-change' || 'false' }} - - - name: Record sticky disk allocation baseline - if: inputs.sticky-disk == 'true' && inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' - shell: bash - run: | - set -euo pipefail - sticky_root=/var/tmp/openclaw-node-deps - initial_usage_bytes="$(df -B1 --output=used "$sticky_root" | tail -n1 | tr -d '[:space:]')" - if [[ ! "$initial_usage_bytes" =~ ^[0-9]+$ ]] || [[ "$initial_usage_bytes" -le 0 ]]; then - echo "::error::Could not record sticky disk allocation baseline" - exit 1 - fi - rebuild_signal="${RUNNER_TEMP:?}/openclaw-sticky-deps-rebuilt" - rm -f "$rebuild_signal" - echo "OPENCLAW_STICKY_INITIAL_USAGE_BYTES=$initial_usage_bytes" >> "$GITHUB_ENV" - echo "OPENCLAW_STICKY_REBUILD_SIGNAL=$rebuild_signal" >> "$GITHUB_ENV" - - name: Restore and save Vitest transform cache if: inputs.vitest-fs-cache == 'true' && inputs.save-vitest-fs-cache == 'true' && runner.os != 'Windows' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 @@ -256,48 +259,6 @@ runs: echo "NODE_COMPILE_CACHE_PORTABLE=1" >> "$GITHUB_ENV" echo "OPENCLAW_NODE_COMPILE_CACHE_WRITER=$CACHE_WRITER" >> "$GITHUB_ENV" - # Post actions run last-in-first-out. Register after stickydisk so this bind - # is gone before stickydisk flushes, unmounts, and snapshots its filesystem. - - name: Register sticky bind cleanup - if: inputs.sticky-disk == 'true' - uses: ./.github/actions/register-bind-mount-cleanup - with: - path: ${{ github.workspace }}/node_modules - - - name: Bind sticky node_modules into workspace - if: inputs.sticky-disk == 'true' - shell: bash - env: - FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} - run: | - set -euo pipefail - sticky_root=/var/tmp/openclaw-node-deps - sticky_modules="$sticky_root/node_modules" - sticky_store="$sticky_root/store" - workspace_modules="$GITHUB_WORKSPACE/node_modules" - - # Compute before mounting node_modules. The helper hashes tracked - # manifests canonically. Audited lifecycle hooks retain only install - # scripts; unaudited hook drift fails closed instead of risking stale output. - deps_input_fingerprint="$(node "$GITHUB_ACTION_PATH/dependency-fingerprint.mjs" \ - --workspace "$GITHUB_WORKSPACE" --frozen-lockfile "$FROZEN_LOCKFILE")" - - mkdir -p "$sticky_modules" "$sticky_store" "$workspace_modules" - sudo mount --bind "$sticky_modules" "$workspace_modules" - mountpoint -q "$workspace_modules" - if [ "$(stat -c %d "$sticky_store")" != "$(stat -c %d "$workspace_modules")" ]; then - echo "::error::pnpm store and workspace node_modules must share a filesystem" - exit 1 - fi - findmnt --target "$workspace_modules" - deps_fingerprint="os-${RUNNER_OS:?}-arch-${RUNNER_ARCH:?}-node-$(node --version)-${deps_input_fingerprint:?}" - # zizmor: ignore[github-env] static trusted path defined in this composite action. - echo "PNPM_CONFIG_STORE_DIR=$sticky_store" >> "$GITHUB_ENV" - echo "OPENCLAW_STICKY_DEPS_FINGERPRINT=$deps_fingerprint" >> "$GITHUB_ENV" - # pnpm exec may reconcile a restored workspace before nested builds. - # Sticky jobs already have every build tool, so invoke their Node entrypoints directly. - echo "OPENCLAW_BUILD_ALL_NO_PNPM=1" >> "$GITHUB_ENV" - - name: Setup Bun if: inputs.install-bun == 'true' shell: bash @@ -329,10 +290,9 @@ runs: shell: bash env: CI: "true" + DEPENDENCY_CACHE: ${{ inputs.dependency-cache }} + DEPENDENCY_CACHE_HIT: ${{ steps.dependency-cache.outputs.cache-hit }} FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} - STICKY_DISK: ${{ inputs.sticky-disk }} - STICKY_ROOT: /var/tmp/openclaw-node-deps - STICKY_WRITER: ${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }} run: | set -euo pipefail export PATH="$NODE_BIN:$PATH" @@ -348,45 +308,19 @@ runs: ;; esac - sticky_marker="$STICKY_ROOT/.openclaw-deps-fingerprint" - sticky_fingerprint="" - sticky_fingerprint_matches="false" - sticky_snapshot_matches="false" - if [ "$STICKY_DISK" = "true" ] && [ -f "$sticky_marker" ]; then - sticky_fingerprint="$(<"$sticky_marker")" - fi - if [ "$STICKY_DISK" = "true" ] && [ -n "$sticky_fingerprint" ] && - [ "$sticky_fingerprint" = "${OPENCLAW_STICKY_DEPS_FINGERPRINT:?}" ]; then - sticky_fingerprint_matches="true" - if bash "$GITHUB_ACTION_PATH/sticky-importers.sh" restore "$STICKY_ROOT" "$GITHUB_WORKSPACE"; then - sticky_snapshot_matches="true" - else - echo "::warning::Sticky dependency fingerprint matches, but restored importer contents are incomplete; reinstalling" - fi - fi - if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" != "true" ] && - [ "$sticky_snapshot_matches" != "true" ]; then - # Read-only PR clones cannot refresh a stale snapshot. Installing into - # that clone can saturate its ext4 device until short jobs time out. - # Detach only the workspace bind; the action still discards its clone. - sudo umount "$GITHUB_WORKSPACE/node_modules" - rm -rf "$GITHUB_WORKSPACE/node_modules" - mkdir -p "$GITHUB_WORKSPACE/node_modules" - ephemeral_store="${RUNNER_TEMP:?}/openclaw-pnpm-store" - mkdir -p "$ephemeral_store" - export PNPM_CONFIG_STORE_DIR="$ephemeral_store" - echo "PNPM_CONFIG_STORE_DIR=$ephemeral_store" >> "$GITHUB_ENV" - echo "Sticky dependency snapshot is unusable; using runner-local storage for this read-only run" - fi - install_args=( install - --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true ) + if [ "$DEPENDENCY_CACHE" = "true" ]; then + # Both trees live below the workspace. Prefer real hard links so the + # single cache archive can preserve store/package identity; pnpm + # safely falls back to copies for files it cannot hard-link. + install_args+=(--package-import-method=hardlink) + fi if [ -n "$LOCKFILE_FLAG" ]; then install_args+=("$LOCKFILE_FLAG") fi @@ -403,94 +337,79 @@ runs: append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency append_pnpm_option_arg PNPM_CONFIG_STORE_DIR store-dir append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir - sticky_writer_rebuild="false" - if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" = "true" ] && - [ "$sticky_snapshot_matches" != "true" ]; then - # Pnpm can trust stale hidden install metadata even with --force. Clear only - # the writer-owned modules tree; the warmed store remains on the sticky disk. - find "$GITHUB_WORKSPACE/node_modules" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - install_args+=(--force) - sticky_writer_rebuild="true" - fi run_pnpm_install() { - if [ "$sticky_writer_rebuild" = "true" ]; then - # A full relink exceeds the ordinary retry cap; restarting discards - # several minutes of progress even when the warmed store is healthy. - timeout --signal=TERM --kill-after=15s 15m \ - pnpm "${install_args[@]}" --config.fetch-retries=0 - elif [ "$STICKY_DISK" = "true" ]; then - # Pnpm can keep retrying optional platform tarballs after the - # required tree is linked. Retry the whole frozen transaction from - # its warmed store instead of letting minute backoffs outlive this cap. - timeout --signal=TERM --kill-after=15s 4m \ - pnpm "${install_args[@]}" --config.fetch-retries=0 - else - pnpm "${install_args[@]}" - fi + local fetch_mode="$1" + pnpm "${install_args[@]}" "$fetch_mode" + } + clear_dependency_modules() { + rm -rf "$GITHUB_WORKSPACE/node_modules" + find \ + "$GITHUB_WORKSPACE/ui" \ + "$GITHUB_WORKSPACE/packages" \ + "$GITHUB_WORKSPACE/extensions" \ + "$GITHUB_WORKSPACE/examples" \ + -mindepth 1 -maxdepth 2 \( -type d -o -type l \) -name node_modules \ + -exec rm -rf -- {} + } if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then mkdir -p "$PNPM_CONFIG_MODULES_DIR" ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules" export NODE_PATH="$PNPM_CONFIG_MODULES_DIR${NODE_PATH:+:$NODE_PATH}" fi - if [ "$sticky_snapshot_matches" = "true" ]; then - echo "Sticky dependency snapshot matches the install fingerprint and importer contents; skipping pnpm install" + install_status=0 + if [ "$DEPENDENCY_CACHE_HIT" = "true" ]; then + run_pnpm_install --offline || install_status="$?" else - if [ "$sticky_fingerprint_matches" = "true" ]; then - echo "Sticky dependency snapshot importer contents are incomplete; reinstalling" - elif [ "$STICKY_DISK" = "true" ] && [ -n "$sticky_fingerprint" ]; then - echo "Sticky dependency snapshot is stale (disk: $sticky_fingerprint, want: $OPENCLAW_STICKY_DEPS_FINGERPRINT); reinstalling" - fi - # A stale marker must not survive a mid-install failure: the commit - # heuristics skip failed steps, but a wrong marker plus a partial - # tree would poison every consumer if one ever slipped through. - if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" = "true" ]; then - rm -f "$sticky_marker" - fi - install_attempts=2 - if [ "$sticky_writer_rebuild" = "true" ]; then - install_attempts=1 - elif [ "$STICKY_DISK" = "true" ]; then - install_attempts=3 - fi - install_status=1 - for (( attempt = 1; attempt <= install_attempts; attempt += 1 )); do - if run_pnpm_install; then - install_status=0 - break - else - install_status="$?" - fi - if [ "$attempt" -lt "$install_attempts" ]; then - echo "::warning::pnpm install attempt ${attempt}/${install_attempts} failed or timed out; retrying" - fi - done - if [ "$install_status" -ne 0 ]; then - echo "::error::pnpm install failed after ${install_attempts} attempts" - exit "$install_status" - fi - if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then - rm -rf node_modules - ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules - ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules" - fi - # Only the designated trusted writer captures importer archives and - # publishes the fingerprint; read-only clones are discarded at job - # end, so capturing there would only burn shard wall clock. - if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" = "true" ]; then - bash "$GITHUB_ACTION_PATH/sticky-importers.sh" capture "$STICKY_ROOT" "$GITHUB_WORKSPACE" "$OPENCLAW_STICKY_DEPS_FINGERPRINT" "${OPENCLAW_STICKY_REBUILD_SIGNAL:?}" - fi + run_pnpm_install --prefer-offline || install_status="$?" + fi + if [ "$install_status" -ne 0 ] && [ "$DEPENDENCY_CACHE_HIT" = "true" ]; then + echo "::warning::Cached dependency tree failed pnpm reconciliation; relinking it from the restored store" + clear_dependency_modules + install_status=0 + run_pnpm_install --offline || install_status="$?" + fi + if [ "$install_status" -ne 0 ] && [ "$DEPENDENCY_CACHE_HIT" = "true" ]; then + echo "::warning::Restored dependency store failed pnpm reconciliation; retrying from an empty store" + clear_dependency_modules + rm -rf "${PNPM_CONFIG_STORE_DIR:?}" + install_status=0 + run_pnpm_install --prefer-offline || install_status="$?" + fi + if [ "$install_status" -ne 0 ]; then + echo "::error::pnpm install failed" + exit "$install_status" + fi + if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then + rm -rf node_modules + ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules + ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules" fi - if [ "$STICKY_DISK" = "true" ]; then - # This step already establishes a content-validated snapshot or finishes - # a frozen-lockfile install. pnpm 11's redundant pre-run check treats - # our intentionally pruned plugin-local node_modules as stale and can - # launch concurrent implicit installs when later CI steps fan out. + if [ "$DEPENDENCY_CACHE" = "true" ]; then + # The exact archive includes importer links, and frozen offline + # reconciliation validates them without reaching the registry. Later + # build wrappers can use installed Node entrypoints directly. + echo "OPENCLAW_BUILD_ALL_NO_PNPM=1" >> "$GITHUB_ENV" + # Postinstall intentionally prunes plugin-local node_modules. Pnpm's + # redundant pre-run check treats that as stale and can launch unsafe + # concurrent implicit installs after CI fans out. # zizmor: ignore[github-env] static pnpm policy owned by this action. echo "pnpm_config_verify_deps_before_run=false" >> "$GITHUB_ENV" fi + - name: Save exact dependency cache + if: inputs.install-deps == 'true' && inputs.dependency-cache == 'true' && inputs.save-dependency-cache == 'true' && steps.dependency-cache.outputs.cache-hit != 'true' && steps.dependency-cache.outcome != 'failure' + continue-on-error: true + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + node_modules + ui/node_modules + packages/*/node_modules + examples/*/node_modules + .cache/openclaw-pnpm-store + key: ${{ steps.dependency-cache-key.outputs.key }} + - name: Restore and save build-all cache if: inputs.build-all-cache-scope != '' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 @@ -503,7 +422,7 @@ runs: ${{ github.repository }}-build-all-v1-${{ inputs.build-all-cache-scope }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}- - name: Save pnpm store cache - if: ${{ inputs.install-deps == 'true' && inputs.use-actions-cache == 'true' && inputs.save-actions-cache == 'true' && runner.os != 'Windows' && steps.setup-pnpm.outputs.store-cache-hit != 'true' }} + if: ${{ inputs.install-deps == 'true' && inputs.use-actions-cache == 'true' && (inputs.dependency-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && inputs.save-actions-cache == 'true' && runner.os != 'Windows' && steps.setup-pnpm.outputs.store-cache-hit != 'true' }} uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ steps.setup-pnpm.outputs.store-path }} diff --git a/.github/actions/setup-node-env/dependency-fingerprint.mjs b/.github/actions/setup-node-env/dependency-fingerprint.mjs index a8fdfb523670..d2b7f64c81ca 100644 --- a/.github/actions/setup-node-env/dependency-fingerprint.mjs +++ b/.github/actions/setup-node-env/dependency-fingerprint.mjs @@ -39,8 +39,6 @@ const INSTALL_INPUT_FILES = [ ".pnpmfile.cjs", "pnpmfile.cjs", ".github/actions/setup-node-env/dependency-fingerprint.mjs", - ".github/actions/setup-node-env/sticky-importers.sh", - ".github/actions/setup-node-env/verify-importers.mjs", "scripts/postinstall-bundled-plugins.mjs", "scripts/lib/package-dist-imports.mjs", "scripts/preinstall-package-manager-warning.mjs", diff --git a/.github/actions/setup-node-env/sticky-importers.sh b/.github/actions/setup-node-env/sticky-importers.sh deleted file mode 100644 index d0dc46d5e926..000000000000 --- a/.github/actions/setup-node-env/sticky-importers.sh +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -mode="${1:?mode is required}" -sticky_root="${2:?sticky root is required}" -workspace="${3:-}" -archive="$sticky_root/importer-node-modules.tar" -archive_checksum="$sticky_root/.openclaw-importer-archive.sha256" -importer_manifest="$sticky_root/importer-node-modules.manifest" -marker="$sticky_root/.openclaw-deps-fingerprint" -force_commit_sentinel="$sticky_root/.openclaw-force-commit" -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -archive_sha256() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum -- "$1" | awk '{print $1}' - else - shasum -a 256 "$1" | awk '{print $1}' - fi -} - -clear_importers() { - ( - cd "$workspace" - find . \( -type d -o -type l \) -name node_modules -prune \ - ! -path ./node_modules -exec rm -rf -- {} + - ) -} - -verify_importers() { - node "$script_dir/verify-importers.mjs" "$workspace" "$1" -} - -case "$mode" in - capture) - workspace="${workspace:?workspace is required}" - fingerprint="${4:?fingerprint is required}" - rebuild_signal="${5:?rebuild signal is required}" - mkdir -p "$sticky_root" - list_file="$(mktemp)" - temp_archive="$archive.tmp.$$" - temp_checksum="$archive_checksum.tmp.$$" - temp_manifest="$importer_manifest.tmp.$$" - temp_marker="$marker.tmp.$$" - cleanup() { - rm -f "$list_file" "$temp_archive" "$temp_checksum" "$temp_manifest" "$temp_marker" - } - trap cleanup EXIT - # Do not publish a fingerprint for an install whose importer resolution is - # already falling through to a wrong hoisted version. - rm -f "$marker" "$archive_checksum" "$importer_manifest" - ( - cd "$workspace" - find . \( -type d -o -type l \) -name node_modules -prune \ - ! -path ./node_modules -print0 >"$list_file" - tar --create --file "$temp_archive" --null --files-from "$list_file" - ) - tr '\0' '\n' <"$list_file" >"$temp_manifest" - # Record the exact importer set and check its live resolution before a - # writer can publish the marker. - verify_importers "$temp_manifest" - { - archive_sha256 "$temp_archive" - archive_sha256 "$temp_manifest" - } >"$temp_checksum" - mv "$temp_archive" "$archive" - mv "$temp_manifest" "$importer_manifest" - mv "$temp_checksum" "$archive_checksum" - # The marker lands last. Consumers also verify the archive bytes and every - # registry-backed importer resolution before trusting this snapshot. - printf '%s\n' "$fingerprint" >"$temp_marker" - mv "$temp_marker" "$marker" - : >"$rebuild_signal" - ;; - restore) - workspace="${workspace:?workspace is required}" - if [[ ! -f "$archive" || ! -f "$archive_checksum" || ! -f "$importer_manifest" ]]; then - echo "sticky importer archive, manifest, or checksum is missing under $sticky_root" >&2 - exit 1 - fi - expected_archive_checksum="$(sed -n '1p' "$archive_checksum" | tr -d '[:space:]')" - expected_manifest_checksum="$(sed -n '2p' "$archive_checksum" | tr -d '[:space:]')" - actual_archive_checksum="$(archive_sha256 "$archive")" - actual_manifest_checksum="$(archive_sha256 "$importer_manifest")" - if [[ ! "$expected_archive_checksum" =~ ^[a-f0-9]{64}$ ]] || \ - [[ ! "$expected_manifest_checksum" =~ ^[a-f0-9]{64}$ ]] || \ - [[ "$actual_archive_checksum" != "$expected_archive_checksum" ]] || \ - [[ "$actual_manifest_checksum" != "$expected_manifest_checksum" ]]; then - echo "sticky importer archive or manifest checksum mismatch" >&2 - exit 1 - fi - # A restored archive is authoritative for checkout-local importer links. - # Clear first so entries absent from the archive cannot survive from a - # reused workspace, and clear again when validation rejects the restore. - clear_importers - if ! tar --extract --file "$archive" --directory "$workspace"; then - clear_importers - exit 1 - fi - if ! verify_importers "$importer_manifest"; then - clear_importers - exit 1 - fi - ;; - ensure-change) - initial_usage_bytes="${3:?initial usage bytes are required}" - if [[ ! "$initial_usage_bytes" =~ ^[0-9]+$ ]] || [[ "$initial_usage_bytes" -le 0 ]]; then - echo "invalid initial sticky disk usage: $initial_usage_bytes" >&2 - exit 2 - fi - current_usage_bytes() { - df -B1 --output=used "$sticky_root" | tail -n1 | tr -d '[:space:]' - } - allocation_delta() { - local current="$1" - if [[ "$current" -ge "$initial_usage_bytes" ]]; then - echo $((current - initial_usage_bytes)) - else - echo $((initial_usage_bytes - current)) - fi - } - - # The pinned StickyDisk action commits only when the absolute whole-disk - # allocation delta exceeds 4096 bytes. Measure against the same baseline - # after store pruning, then leave a 64 KiB margin for its post phase. - target_delta_bytes=65536 - max_sentinel_bytes=1048576 - current="$(current_usage_bytes)" - if [[ ! "$current" =~ ^[0-9]+$ ]] || [[ "$current" -le 0 ]]; then - echo "could not read current sticky disk usage" >&2 - exit 1 - fi - delta="$(allocation_delta "$current")" - if [[ "$delta" -le "$target_delta_bytes" ]] && - [[ -f "$force_commit_sentinel" ]] && - [[ "$(stat -c %s "$force_commit_sentinel")" -ge "$max_sentinel_bytes" ]]; then - : >"$force_commit_sentinel" - sync - current="$(current_usage_bytes)" - delta="$(allocation_delta "$current")" - fi - for _ in 1 2 3; do - if [[ "$delta" -gt "$target_delta_bytes" ]]; then - echo "Sticky dependency rebuild changed allocation by ${delta} bytes" - exit 0 - fi - bytes_needed=$((initial_usage_bytes + target_delta_bytes + 4096 - current)) - blocks_needed=$(((bytes_needed + 4095) / 4096)) - if [[ "$blocks_needed" -lt 1 ]]; then - blocks_needed=1 - fi - dd if=/dev/zero bs=4096 count="$blocks_needed" status=none >>"$force_commit_sentinel" - sync - current="$(current_usage_bytes)" - delta="$(allocation_delta "$current")" - done - echo "could not force a detectable sticky disk allocation change (delta: ${delta} bytes)" >&2 - exit 1 - ;; - *) - echo "unsupported sticky importer mode: $mode" >&2 - exit 2 - ;; -esac diff --git a/.github/actions/setup-node-env/verify-importers.mjs b/.github/actions/setup-node-env/verify-importers.mjs deleted file mode 100644 index a74a4f77b641..000000000000 --- a/.github/actions/setup-node-env/verify-importers.mjs +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env node - -import { readFileSync, realpathSync, statSync } from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import YAML from "yaml"; - -const DEPENDENCY_FIELDS = [ - { name: "dependencies", optional: false }, - { name: "devDependencies", optional: false }, - { name: "optionalDependencies", optional: true }, -]; -const MAX_REPORTED_MISMATCHES = 12; - -function readImporters(workspace) { - const lockfilePath = path.join(workspace, "pnpm-lock.yaml"); - const lockfile = YAML.parse(readFileSync(lockfilePath, "utf8")); - if (!lockfile?.importers || typeof lockfile.importers !== "object") { - throw new Error(`${lockfilePath} does not contain importers`); - } - return lockfile.importers; -} - -function registryResolution(dependencyName, resolution) { - if (typeof resolution !== "string") { - return undefined; - } - const locator = resolution.split("(", 1)[0]; - if (locator.includes(":")) { - return undefined; - } - const versionSeparator = locator.startsWith("@") - ? locator.indexOf("@", locator.indexOf("/") + 1) - : locator.indexOf("@"); - if (versionSeparator > 0) { - return { - packageName: locator.slice(0, versionSeparator), - snapshotKey: resolution, - version: locator.slice(versionSeparator + 1), - }; - } - return { - packageName: dependencyName, - snapshotKey: `${dependencyName}@${resolution}`, - version: locator, - }; -} - -function packageNameParts(packageName) { - const parts = packageName.split("/"); - const valid = packageName.startsWith("@") - ? parts.length === 2 && parts.every(Boolean) - : parts.length === 1 && parts[0] !== ""; - if (!valid || parts.some((part) => part === "." || part === "..")) { - throw new Error(`invalid dependency name from pnpm lockfile: ${packageName}`); - } - return parts; -} - -function isWithinWorkspace(workspace, candidate) { - const relative = path.relative(workspace, candidate); - return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".."); -} - -function findInstalledManifest({ dependencyName, projectPath, workspace }) { - const dependencyParts = packageNameParts(dependencyName); - let current = projectPath; - for (;;) { - const candidate = path.join(current, "node_modules", ...dependencyParts, "package.json"); - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch (error) { - if (error.code !== "ENOENT" && error.code !== "ENOTDIR") { - throw error; - } - } - if (current === workspace) { - return undefined; - } - const parent = path.dirname(current); - if (parent === current || !isWithinWorkspace(workspace, parent)) { - return undefined; - } - current = parent; - } -} - -function parseManifest(manifestPath) { - try { - return JSON.parse(readFileSync(manifestPath, "utf8")); - } catch (error) { - throw new Error(`could not read ${manifestPath}: ${error.message}`, { cause: error }); - } -} - -function relativeDisplayPath(workspace, absolutePath) { - const relative = path.relative(workspace, absolutePath); - return relative || "."; -} - -function normalizeLocation(location) { - return location.split(path.sep).join("/"); -} - -function readHoistedResolutions(workspace) { - const modulesMetadataPath = path.join(workspace, "node_modules", ".modules.yaml"); - const metadata = YAML.parse(readFileSync(modulesMetadataPath, "utf8")); - if (!metadata?.hoistedLocations || typeof metadata.hoistedLocations !== "object") { - throw new Error(`${modulesMetadataPath} does not contain hoistedLocations`); - } - const byLocation = new Map(); - const bySnapshotKey = new Map(); - for (const [snapshotKey, locations] of Object.entries(metadata.hoistedLocations)) { - if (!Array.isArray(locations)) { - throw new Error(`invalid hoistedLocations entry for ${snapshotKey}`); - } - for (const location of locations) { - if (typeof location !== "string") { - throw new Error(`invalid hoisted location for ${snapshotKey}`); - } - const normalized = normalizeLocation(location); - const keys = byLocation.get(normalized) ?? new Set(); - keys.add(snapshotKey); - byLocation.set(normalized, keys); - const snapshotLocations = bySnapshotKey.get(snapshotKey) ?? new Set(); - snapshotLocations.add(normalized); - bySnapshotKey.set(snapshotKey, snapshotLocations); - } - } - return { byLocation, bySnapshotKey }; -} - -function readCapturedImporters(workspace, manifestPath) { - const importers = new Map(); - for (const entry of readFileSync(manifestPath, "utf8").split("\n")) { - if (!entry) { - continue; - } - const modulesPath = path.resolve(workspace, entry); - if ( - !isWithinWorkspace(workspace, modulesPath) || - path.basename(modulesPath) !== "node_modules" || - modulesPath === path.join(workspace, "node_modules") - ) { - throw new Error(`invalid importer manifest entry: ${entry}`); - } - const projectPath = path.dirname(modulesPath); - const importerPath = relativeDisplayPath(workspace, projectPath); - importers.set(importerPath, { modulesPath }); - } - return importers; -} - -function isPrunedImporter(importerPath) { - // postinstall-bundled-plugins.mjs deliberately removes every plugin source - // node_modules tree; installed plugins own those dependencies separately. - return importerPath.startsWith("extensions/"); -} - -function verifyImporters(workspace, manifestPath) { - const importers = readImporters(workspace); - const hoistedResolutions = readHoistedResolutions(workspace); - const capturedImporters = readCapturedImporters(workspace, manifestPath); - const mismatches = []; - let checked = 0; - - for (const [importerPath, { modulesPath }] of capturedImporters) { - if (!importers[importerPath] || typeof importers[importerPath] !== "object") { - throw new Error(`importer manifest entry is absent from pnpm-lock.yaml: ${importerPath}`); - } - try { - if (!statSync(modulesPath).isDirectory()) { - mismatches.push(`${importerPath}: captured node_modules path is not a directory`); - } - } catch (error) { - if (error.code !== "ENOENT" && error.code !== "ENOTDIR") { - throw error; - } - mismatches.push(`${importerPath}: captured node_modules directory is missing`); - } - } - - // The lockfile, not the captured manifest, owns the validation universe. A - // missing importer must still be checked against the version Node falls back to. - for (const [importerPath, importer] of Object.entries(importers)) { - if (isPrunedImporter(importerPath)) { - continue; - } - const projectPath = path.resolve(workspace, importerPath); - if (!isWithinWorkspace(workspace, projectPath)) { - throw new Error(`pnpm lockfile contains an importer outside the workspace: ${importerPath}`); - } - for (const field of DEPENDENCY_FIELDS) { - const dependencies = importer[field.name] ?? {}; - for (const [dependencyName, expected] of Object.entries(dependencies)) { - // Workspace, file, and git locators do not map directly to installed - // manifest versions; registry versions and aliases do. - const expectedResolution = registryResolution(dependencyName, expected?.version); - if (!expectedResolution) { - continue; - } - const resolvedManifestPath = findInstalledManifest({ - dependencyName, - projectPath, - workspace, - }); - const importerDisplay = relativeDisplayPath(workspace, projectPath); - if (!resolvedManifestPath) { - if (field.optional) { - continue; - } - mismatches.push( - `${importerDisplay}: ${dependencyName} ${expectedResolution.version} is not resolvable`, - ); - continue; - } - checked += 1; - const installedLocation = normalizeLocation( - relativeDisplayPath(workspace, path.dirname(resolvedManifestPath)), - ); - const expectedLocation = normalizeLocation( - path.join( - importerPath === "." ? "" : importerPath, - "node_modules", - ...packageNameParts(dependencyName), - ), - ); - const exactImporterSlot = hoistedResolutions.bySnapshotKey - .get(expectedResolution.snapshotKey) - ?.has(expectedLocation); - const installedSnapshotKeys = hoistedResolutions.byLocation.get(installedLocation); - // Hoisted pnpm installs may intentionally share a different peer-context - // variant from the root. Exact identity is required when pnpm metadata - // says this importer owns the lockfile snapshot in its local slot. - if (exactImporterSlot && !installedSnapshotKeys?.has(expectedResolution.snapshotKey)) { - const actualKeys = installedSnapshotKeys - ? [...installedSnapshotKeys] - .toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0)) - .join(", ") - : ""; - mismatches.push( - `${importerDisplay}: ${dependencyName} expected pnpm snapshot ${expectedResolution.snapshotKey}, resolved ${actualKeys} from ${installedLocation}`, - ); - } - const actual = parseManifest(resolvedManifestPath); - if ( - actual.name !== expectedResolution.packageName || - actual.version !== expectedResolution.version - ) { - const resolvedFrom = relativeDisplayPath( - workspace, - realpathSync(path.dirname(resolvedManifestPath)), - ); - mismatches.push( - `${importerDisplay}: ${dependencyName} expected ${expectedResolution.packageName}@${expectedResolution.version}, resolved ${actual.name ?? ""}@${actual.version ?? ""} from ${resolvedFrom}`, - ); - } - } - } - } - - if (mismatches.length > 0) { - const visible = mismatches.slice(0, MAX_REPORTED_MISMATCHES); - const remainder = mismatches.length - visible.length; - const suffix = remainder > 0 ? `\n... and ${remainder} more` : ""; - throw new Error( - `sticky importer dependency validation failed (${mismatches.length} mismatch${mismatches.length === 1 ? "" : "es"}):\n${visible.join("\n")}${suffix}`, - ); - } - console.log(`Verified ${checked} registry-backed importer dependency resolutions`); -} - -const workspace = path.resolve(process.argv[2] ?? process.cwd()); -const manifestPath = path.resolve(process.argv[3] ?? ""); -try { - if (!process.argv[3]) { - throw new Error("importer manifest path is required"); - } - verifyImporters(workspace, manifestPath); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -} diff --git a/.github/retired-sticky-disks.json b/.github/retired-sticky-disks.json index 579cfd1a981d..a0a2e6a6e12f 100644 --- a/.github/retired-sticky-disks.json +++ b/.github/retired-sticky-disks.json @@ -24,6 +24,11 @@ "architecture": "amd64", "region": "eu-west" }, + { + "key": "openclaw/openclaw-node-deps-bind-v7-24.x", + "architecture": "amd64", + "region": "eu-west" + }, { "key": "openclaw/openclaw-vitest-fs-v2-protected-Linux-X64-node-24.x", "architecture": "amd64", diff --git a/.github/workflows/ci-check-testbox.yml b/.github/workflows/ci-check-testbox.yml index 300abf254240..baf97d1cf03f 100644 --- a/.github/workflows/ci-check-testbox.yml +++ b/.github/workflows/ci-check-testbox.yml @@ -54,10 +54,9 @@ jobs: with: install-bun: "false" install-trufflehog: "true" - # Real Testbox hydration reuses the protected dependency snapshot. - # Pull-request validation runs on GitHub-hosted runners instead. - sticky-disk: ${{ github.event_name == 'workflow_dispatch' && 'true' || 'false' }} - use-actions-cache: ${{ github.event_name == 'workflow_dispatch' && 'false' || 'true' }} + # Testbox hydration uses the ordinary pnpm store cache. Canonical CI + # owns the exact dependency archive and never delegates here. + use-actions-cache: "true" - name: Ensure Testbox base commit if: github.event_name == 'pull_request' uses: ./.github/actions/ensure-base-commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98d4c0cefa0a..3368215cc092 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -869,61 +869,18 @@ jobs: if: steps.manifest.outputs.run_protocol_event_coverage == 'true' run: node scripts/check-protocol-event-coverage.mjs - # Canonical main cannot be cancelled by a newer push. Publish the sole - # dependency snapshot here before fanout. Blacksmith may expose a fresh - # commit only to a later run; readers retain the marker-checked fallback. - - name: Refresh sticky dependency snapshot - if: github.repository == 'openclaw/openclaw' && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.manifest.outputs.run_node == 'true' + # Publish one immutable semantic dependency archive before same-repo + # Blacksmith jobs fan out. Pull-request archives remain merge-ref scoped; + # main archives seed later pull requests through the default-branch scope. + - name: Publish exact dependency cache + if: github.repository == 'openclaw/openclaw' && steps.manifest.outputs.run_node == 'true' && ((github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) uses: ./.github/actions/setup-node-env with: + dependency-cache: "true" install-bun: "false" - save-sticky-disk: "true" - sticky-disk: "true" - use-actions-cache: "false" - - - name: Maintain sticky dependency store budget - if: github.repository == 'openclaw/openclaw' && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.manifest.outputs.run_node == 'true' - shell: bash - env: - OPENCLAW_PNPM_STORE_MAX_KIB: "8388608" - run: | - set -euo pipefail - store_dir="${PNPM_CONFIG_STORE_DIR:?}" - before_kib="$(du -sk "$store_dir" | cut -f1)" - after_kib="$before_kib" - pruned=false - - if [ "$before_kib" -gt "$OPENCLAW_PNPM_STORE_MAX_KIB" ]; then - echo "pnpm store is ${before_kib} KiB; pruning above ${OPENCLAW_PNPM_STORE_MAX_KIB} KiB ceiling" - PNPM_CONFIG_STORE_DIR="$store_dir" pnpm store prune - after_kib="$(du -sk "$store_dir" | cut -f1)" - pruned=true - else - echo "pnpm store is ${before_kib} KiB; below ${OPENCLAW_PNPM_STORE_MAX_KIB} KiB ceiling" - fi - - { - echo "### Dependency store maintenance" - echo - echo "- Before: ${before_kib} KiB" - echo "- After: ${after_kib} KiB" - echo "- Pruned: ${pruned}" - } >> "$GITHUB_STEP_SUMMARY" - - if [ "$after_kib" -gt "$OPENCLAW_PNPM_STORE_MAX_KIB" ]; then - echo "::warning::pnpm store remains above its 8 GiB maintenance ceiling after prune" - fi - - # StickyDisk's pinned on-change mode compares whole-filesystem - # allocation to its mount-time baseline. Only a successful real - # dependency capture creates this runner-local signal. Force and - # verify the delta after pruning so a same-size rebuild commits while - # validated warm restores remain read-only. - if [ -f "${OPENCLAW_STICKY_REBUILD_SIGNAL:?}" ]; then - bash "$GITHUB_WORKSPACE/.github/actions/setup-node-env/sticky-importers.sh" \ - ensure-change /var/tmp/openclaw-node-deps \ - "${OPENCLAW_STICKY_INITIAL_USAGE_BYTES:?}" - fi + save-actions-cache: "true" + save-dependency-cache: "true" + use-actions-cache: "true" # Run dependency-free security checks on a hosted runner in parallel with # scope detection. No downstream job waits for Python/pre-commit setup. @@ -1129,13 +1086,14 @@ jobs: - name: Audit production dependencies run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high - # Warm the lockfile- and pnpm-pinned Actions cache for PR and manual runs. - # Canonical main publishes its sticky snapshot in preflight before fanout. + # Warm the lockfile- and pnpm-pinned Actions cache for fork PRs, manual runs, + # and docs-only same-repo PRs. Node-relevant canonical main and same-repo PRs + # already publish it through the exact dependency-cache writer in preflight. pnpm-store-warmup: permissions: contents: read needs: [preflight] - if: ${{ (needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true') && !(github.repository == 'openclaw/openclaw' && github.event_name == 'push' && github.ref == 'refs/heads/main') }} + if: ${{ (needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true') && !(github.repository == 'openclaw/openclaw' && github.event_name == 'push' && github.ref == 'refs/heads/main') && !(github.repository == 'openclaw/openclaw' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.preflight.outputs.run_node == 'true') }} runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }} timeout-minutes: 20 steps: @@ -1228,10 +1186,9 @@ jobs: install-bun: "false" node-compile-cache: "true" node-compile-cache-scope: "build" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} save-node-compile-cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'true' || 'false' }} @@ -1498,7 +1455,7 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - name: Download exact-run built runtime @@ -1534,10 +1491,9 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - name: Verify native app i18n source @@ -1577,10 +1533,9 @@ jobs: with: node-version: "24.x" install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - &install_playwright_chromium @@ -1645,8 +1600,9 @@ jobs: with: node-version: "24.x" install-bun: "false" - # Hosted paths use Actions cache and never touch repository-global sticky snapshots. - sticky-disk: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'false' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false') }} + # Hosted paths use the pnpm store cache; first-attempt same-repo + # Blacksmith runs restore the preflight-published dependency tree. + dependency-cache: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'false' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false') }} use-actions-cache: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'true' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true') }} - *install_playwright_chromium @@ -1682,8 +1638,9 @@ jobs: with: node-version: "24.x" install-bun: "false" - # Hosted paths use Actions cache and never touch repository-global sticky snapshots. - sticky-disk: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'false' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false') }} + # Hosted paths use the pnpm store cache; first-attempt same-repo + # Blacksmith runs restore the preflight-published dependency tree. + dependency-cache: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'false' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false') }} use-actions-cache: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'true' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true') }} - *install_playwright_chromium @@ -1719,10 +1676,9 @@ jobs: with: node-version: "24.x" install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - name: Verify Control UI i18n source @@ -1808,10 +1764,9 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: ${{ matrix.task == 'bun-launcher' && 'true' || 'false' }} - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) @@ -1914,10 +1869,9 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - name: Set up Blacksmith Docker layer cache @@ -2095,10 +2049,9 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - name: Run plugin contract shard @@ -2138,10 +2091,9 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - name: Run channel contract shard @@ -2215,10 +2167,9 @@ jobs: with: node-version: "${{ matrix.node_version || '24.x' }}" install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Only the preflight writer saves an archive. Same-repo Blacksmith + # shards restore it; hosted and non-Node-24 paths use the pnpm store. + dependency-cache: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} vitest-fs-cache: "true" node-compile-cache: "true" @@ -2352,10 +2303,9 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} # check-lint's shard runner rebuilds the same plugin-sdk boundary @@ -2645,10 +2595,9 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} # Same-repo runs carry boundary artifacts on a Blacksmith sticky disk: @@ -2911,10 +2860,9 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - # Blacksmith same-repo runs clone dependencies from a sticky disk. - # Fork PRs must keep actions/cache: sticky snapshots are writable, - # repository-global state and must never be produced by fork code. - sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} + # Same-repo Blacksmith runs restore the dependency cache published by + # preflight; hosted paths use the pnpm store cache instead. + dependency-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - name: Check formatting diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index c7819ad822de..9a58ff165369 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -9,6 +9,7 @@ on: - ".github/workflows/plugin-npm-release.yml" - "extensions/**" - "package.json" + - "packages/normalization-core/**" - "scripts/generate-npm-package-lock.mjs" - "scripts/generate-npm-package-lock.mts" - "scripts/lib/npm-publish-plan.mjs" @@ -340,7 +341,9 @@ jobs: ref: ${{ github.workflow_sha }} path: .release-tooling fetch-depth: 1 - sparse-checkout: scripts + sparse-checkout: | + packages/normalization-core + scripts - name: Setup Node environment uses: ./.github/actions/setup-node-env diff --git a/.github/workflows/vitest-cache-warm.yml b/.github/workflows/vitest-cache-warm.yml index b9a5d3935ab9..22330ec8e833 100644 --- a/.github/workflows/vitest-cache-warm.yml +++ b/.github/workflows/vitest-cache-warm.yml @@ -33,7 +33,6 @@ jobs: save-actions-cache: "true" save-node-compile-cache: "true" save-vitest-fs-cache: "true" - sticky-disk: "false" use-actions-cache: "true" vitest-fs-cache: "true" diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index bca88e4b3331..79f9d7408a70 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -12875,7 +12875,7 @@ }, { "kind": "ui-call", - "line": 424, + "line": 433, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Model", "surface": "android", @@ -12883,7 +12883,7 @@ }, { "kind": "ui-call", - "line": 547, + "line": 556, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Some shared images were omitted or could not be added.", "surface": "android", @@ -12891,7 +12891,7 @@ }, { "kind": "ui-call", - "line": 703, + "line": 712, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat needs attention", "surface": "android", @@ -12899,7 +12899,7 @@ }, { "kind": "ui-call", - "line": 1054, + "line": 1063, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "All", "surface": "android", @@ -12907,7 +12907,7 @@ }, { "kind": "ui-call", - "line": 1134, + "line": 1143, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Show Sidebar", "surface": "android", @@ -12915,7 +12915,7 @@ }, { "kind": "ui-call", - "line": 1150, + "line": 1159, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Working", "surface": "android", @@ -12923,7 +12923,7 @@ }, { "kind": "ui-call", - "line": 1151, + "line": 1160, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready", "surface": "android", @@ -12931,7 +12931,7 @@ }, { "kind": "ui-call", - "line": 1152, + "line": 1161, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Offline", "surface": "android", @@ -12939,7 +12939,7 @@ }, { "kind": "ui-call", - "line": 1165, + "line": 1174, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat actions", "surface": "android", @@ -12947,7 +12947,7 @@ }, { "kind": "ui-call", - "line": 1170, + "line": 1179, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Refresh chat", "surface": "android", @@ -12955,7 +12955,7 @@ }, { "kind": "ui-call", - "line": 1189, + "line": 1198, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Dashboard", "surface": "android", @@ -12963,7 +12963,7 @@ }, { "kind": "ui-call", - "line": 1197, + "line": 1206, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Background tasks", "surface": "android", @@ -12971,7 +12971,7 @@ }, { "kind": "ui-call", - "line": 1218, + "line": 1227, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat", "surface": "android", @@ -12979,7 +12979,7 @@ }, { "kind": "ui-call", - "line": 1416, + "line": 1425, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Messages to recover", "surface": "android", @@ -12987,7 +12987,7 @@ }, { "kind": "ui-call", - "line": 1418, + "line": 1427, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.", "surface": "android", @@ -12995,7 +12995,7 @@ }, { "kind": "ui-call", - "line": 1470, + "line": 1479, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Loading thread", "surface": "android", @@ -13003,7 +13003,7 @@ }, { "kind": "ui-call", - "line": 1503, + "line": 1512, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Jump to latest", "surface": "android", @@ -13011,7 +13011,7 @@ }, { "kind": "ui-call", - "line": 1577, + "line": 1586, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready when you are", "surface": "android", @@ -13019,7 +13019,7 @@ }, { "kind": "ui-call", - "line": 1581, + "line": 1590, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Start with a prompt, or use voice.", "surface": "android", @@ -13027,7 +13027,7 @@ }, { "kind": "ui-call", - "line": 1583, + "line": 1592, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Use the recovery options below to reconnect.", "surface": "android", @@ -13035,7 +13035,7 @@ }, { "kind": "ui-call", - "line": 1585, + "line": 1594, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat is checking Gateway health.", "surface": "android", @@ -13043,7 +13043,7 @@ }, { "kind": "ui-call", - "line": 1608, + "line": 1617, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Fix connection", "surface": "android", @@ -13051,7 +13051,7 @@ }, { "kind": "ui-call", - "line": 1609, + "line": 1618, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Copy diagnostics", "surface": "android", @@ -13059,7 +13059,7 @@ }, { "kind": "ui-call", - "line": 1668, + "line": 1677, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Catch me up", "surface": "android", @@ -13067,7 +13067,7 @@ }, { "kind": "ui-call", - "line": 1669, + "line": 1678, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Summarize recent threads and next steps.", "surface": "android", @@ -13075,7 +13075,7 @@ }, { "kind": "ui-call", - "line": 1670, + "line": 1679, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Catch me up on my recent OpenClaw threads and suggest next steps.", "surface": "android", @@ -13083,7 +13083,7 @@ }, { "kind": "ui-call", - "line": 1674, + "line": 1683, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Plan the work", "surface": "android", @@ -13091,7 +13091,7 @@ }, { "kind": "ui-call", - "line": 1675, + "line": 1684, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Turn a goal into an actionable checklist.", "surface": "android", @@ -13099,7 +13099,7 @@ }, { "kind": "ui-call", - "line": 1676, + "line": 1685, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Help me turn this goal into a practical checklist: ", "surface": "android", @@ -13107,7 +13107,7 @@ }, { "kind": "ui-call", - "line": 1680, + "line": 1689, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Use this phone", "surface": "android", @@ -13115,7 +13115,7 @@ }, { "kind": "ui-call", - "line": 1681, + "line": 1690, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ask OpenClaw to use Android capabilities.", "surface": "android", @@ -13123,7 +13123,7 @@ }, { "kind": "ui-call", - "line": 1682, + "line": 1691, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "What can you help me do from this phone right now?", "surface": "android", @@ -13131,7 +13131,7 @@ }, { "kind": "ui-call", - "line": 1766, + "line": 1775, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw · Live", "surface": "android", @@ -13139,7 +13139,7 @@ }, { "kind": "ui-call", - "line": 1767, + "line": 1776, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "You", "surface": "android", @@ -13147,7 +13147,7 @@ }, { "kind": "ui-call", - "line": 1768, + "line": 1777, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "System", "surface": "android", @@ -13155,7 +13155,7 @@ }, { "kind": "ui-call", - "line": 1769, + "line": 1778, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw", "surface": "android", @@ -13163,7 +13163,7 @@ }, { "kind": "ui-call", - "line": 1808, + "line": 1817, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Image", "surface": "android", @@ -13171,279 +13171,7 @@ }, { "kind": "ui-call", - "line": 1824, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Additional images hidden: ${omittedImageCount}", - "surface": "android", - "id": "native.android.952a88e71b4aaee4" - }, - { - "kind": "ui-call", - "line": 1879, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Preparing audio…", - "surface": "android", - "id": "native.android.5aafbef3744d86f3" - }, - { - "kind": "ui-call", - "line": 1879, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Speaking…", - "surface": "android", - "id": "native.android.d7919c440a82f426" - }, - { - "kind": "ui-call", - "line": 1910, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Close", - "surface": "android", - "id": "native.android.bd78ce1e86e900b3" - }, - { - "kind": "ui-call", - "line": 1910, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "View all", - "surface": "android", - "id": "native.android.b686b1bc61494ee2" - }, - { - "kind": "ui-call", - "line": 1940, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Tools running", - "surface": "android", - "id": "native.android.781f84dfaf7da9d6" - }, - { - "kind": "ui-call", - "line": 1944, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "OpenClaw is working", - "surface": "android", - "id": "native.android.d851a32f367f8b31" - }, - { - "kind": "ui-call", - "line": 1949, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "+${toolCalls.size - 4} more", - "surface": "android", - "id": "native.android.0fd6b75cadc0ce75" - }, - { - "kind": "ui-call", - "line": 1969, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "+${moreWorkingCount} more working", - "surface": "android", - "id": "native.android.f2da5ffc1adaad58" - }, - { - "kind": "ui-call", - "line": 2040, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "+${diff.added}", - "surface": "android", - "id": "native.android.db391c2712f7340b" - }, - { - "kind": "ui-call", - "line": 2043, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "−${diff.removed}", - "surface": "android", - "id": "native.android.babb72da2d6270a2" - }, - { - "kind": "ui-call", - "line": 2068, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Subagent working", - "surface": "android", - "id": "native.android.e58b3c8805c79bd0" - }, - { - "kind": "ui-call", - "line": 2070, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Subagent failed", - "surface": "android", - "id": "native.android.bdfea93e16940c82" - }, - { - "kind": "ui-call", - "line": 2071, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Subagent cancelled", - "surface": "android", - "id": "native.android.9fde542e43b4e73b" - }, - { - "kind": "ui-call", - "line": 2072, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Subagent finished", - "surface": "android", - "id": "native.android.0bba48c12caa076a" - }, - { - "kind": "ui-named-argument", - "line": 2135, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "$completedCount/${steps.size}", - "surface": "android", - "id": "native.android.22d747642a3887cd" - }, - { - "kind": "ui-call", - "line": 2142, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Collapse plan checklist", - "surface": "android", - "id": "native.android.5d9378441e3bb86e" - }, - { - "kind": "ui-call", - "line": 2142, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Expand plan checklist", - "surface": "android", - "id": "native.android.cc46adc71637695d" - }, - { - "kind": "ui-call", - "line": 2275, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Dismiss shared-image warning", - "surface": "android", - "id": "native.android.f3806d79e43f568f" - }, - { - "kind": "ui-call", - "line": 2379, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Stop", - "surface": "android", - "id": "native.android.76d574acfac94fee" - }, - { - "kind": "ui-call", - "line": 2466, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Switch branch", - "surface": "android", - "id": "native.android.be42506226307680" - }, - { - "kind": "ui-call", - "line": 2490, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Untitled branch", - "surface": "android", - "id": "native.android.39ea147abba2af8a" - }, - { - "kind": "ui-call", - "line": 2505, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Current branch", - "surface": "android", - "id": "native.android.4e2cbf557e2e3cd6" - }, - { - "kind": "ui-call", - "line": 2517, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Messages: $count", - "surface": "android", - "id": "native.android.d79a4188788ccf49" - }, - { - "kind": "ui-call", - "line": 2525, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "$count · $updated", - "surface": "android", - "id": "native.android.76e548dffafc9de9" - }, - { - "kind": "ui-call", - "line": 2554, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Default", - "surface": "android", - "id": "native.android.2cb3ec7379426dfe" - }, - { - "kind": "ui-call", - "line": 2620, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Pin model", - "surface": "android", - "id": "native.android.f8d0f6ba7608abc3" - }, - { - "kind": "ui-call", - "line": 2620, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Unpin model", - "surface": "android", - "id": "native.android.09a13ed8f039a9c3" - }, - { - "kind": "ui-call", - "line": 2637, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "No commands found", - "surface": "android", - "id": "native.android.5953c956ff208e6e" - }, - { - "kind": "ui-call", - "line": 2679, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Command", - "surface": "android", - "id": "native.android.b1a0dca9f421aaaa" - }, - { - "kind": "ui-call", - "line": 2699, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Gateway offline", - "surface": "android", - "id": "native.android.8e3e367df24cae4b" - }, - { - "kind": "ui-call", - "line": 2745, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Close thinking level selector", - "surface": "android", - "id": "native.android.36ed01582459bc10" - }, - { - "kind": "ui-call", - "line": 2745, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Open thinking level selector", - "surface": "android", - "id": "native.android.82d6389036a1e211" - }, - { - "kind": "ui-call", - "line": 2811, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Attach image", - "surface": "android", - "id": "native.android.623e434c83e3c020" - }, - { - "kind": "ui-call", - "line": 2816, + "line": 1828, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attachment", "surface": "android", @@ -13451,15 +13179,279 @@ }, { "kind": "ui-call", - "line": 2821, + "line": 1833, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Attach video", + "source": "Additional images hidden: ${omittedImageCount}", "surface": "android", - "id": "native.android.1bf18cef7a720c58" + "id": "native.android.952a88e71b4aaee4" }, { "kind": "ui-call", - "line": 2852, + "line": 1888, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Preparing audio…", + "surface": "android", + "id": "native.android.5aafbef3744d86f3" + }, + { + "kind": "ui-call", + "line": 1888, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Speaking…", + "surface": "android", + "id": "native.android.d7919c440a82f426" + }, + { + "kind": "ui-call", + "line": 1919, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Close", + "surface": "android", + "id": "native.android.bd78ce1e86e900b3" + }, + { + "kind": "ui-call", + "line": 1919, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "View all", + "surface": "android", + "id": "native.android.b686b1bc61494ee2" + }, + { + "kind": "ui-call", + "line": 1949, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Tools running", + "surface": "android", + "id": "native.android.781f84dfaf7da9d6" + }, + { + "kind": "ui-call", + "line": 1953, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "OpenClaw is working", + "surface": "android", + "id": "native.android.d851a32f367f8b31" + }, + { + "kind": "ui-call", + "line": 1958, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "+${toolCalls.size - 4} more", + "surface": "android", + "id": "native.android.0fd6b75cadc0ce75" + }, + { + "kind": "ui-call", + "line": 1978, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "+${moreWorkingCount} more working", + "surface": "android", + "id": "native.android.f2da5ffc1adaad58" + }, + { + "kind": "ui-call", + "line": 2049, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "+${diff.added}", + "surface": "android", + "id": "native.android.db391c2712f7340b" + }, + { + "kind": "ui-call", + "line": 2052, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "−${diff.removed}", + "surface": "android", + "id": "native.android.babb72da2d6270a2" + }, + { + "kind": "ui-call", + "line": 2077, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Subagent working", + "surface": "android", + "id": "native.android.e58b3c8805c79bd0" + }, + { + "kind": "ui-call", + "line": 2079, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Subagent failed", + "surface": "android", + "id": "native.android.bdfea93e16940c82" + }, + { + "kind": "ui-call", + "line": 2080, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Subagent cancelled", + "surface": "android", + "id": "native.android.9fde542e43b4e73b" + }, + { + "kind": "ui-call", + "line": 2081, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Subagent finished", + "surface": "android", + "id": "native.android.0bba48c12caa076a" + }, + { + "kind": "ui-named-argument", + "line": 2144, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "$completedCount/${steps.size}", + "surface": "android", + "id": "native.android.22d747642a3887cd" + }, + { + "kind": "ui-call", + "line": 2151, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Collapse plan checklist", + "surface": "android", + "id": "native.android.5d9378441e3bb86e" + }, + { + "kind": "ui-call", + "line": 2151, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Expand plan checklist", + "surface": "android", + "id": "native.android.cc46adc71637695d" + }, + { + "kind": "ui-call", + "line": 2284, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Dismiss shared-image warning", + "surface": "android", + "id": "native.android.f3806d79e43f568f" + }, + { + "kind": "ui-call", + "line": 2411, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Switch branch", + "surface": "android", + "id": "native.android.be42506226307680" + }, + { + "kind": "ui-call", + "line": 2435, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Untitled branch", + "surface": "android", + "id": "native.android.39ea147abba2af8a" + }, + { + "kind": "ui-call", + "line": 2450, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Current branch", + "surface": "android", + "id": "native.android.4e2cbf557e2e3cd6" + }, + { + "kind": "ui-call", + "line": 2462, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Messages: $count", + "surface": "android", + "id": "native.android.d79a4188788ccf49" + }, + { + "kind": "ui-call", + "line": 2470, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "$count · $updated", + "surface": "android", + "id": "native.android.76e548dffafc9de9" + }, + { + "kind": "ui-call", + "line": 2499, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Default", + "surface": "android", + "id": "native.android.2cb3ec7379426dfe" + }, + { + "kind": "ui-call", + "line": 2565, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Pin model", + "surface": "android", + "id": "native.android.f8d0f6ba7608abc3" + }, + { + "kind": "ui-call", + "line": 2565, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Unpin model", + "surface": "android", + "id": "native.android.09a13ed8f039a9c3" + }, + { + "kind": "ui-call", + "line": 2582, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "No commands found", + "surface": "android", + "id": "native.android.5953c956ff208e6e" + }, + { + "kind": "ui-call", + "line": 2624, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Command", + "surface": "android", + "id": "native.android.b1a0dca9f421aaaa" + }, + { + "kind": "ui-call", + "line": 2644, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Gateway offline", + "surface": "android", + "id": "native.android.8e3e367df24cae4b" + }, + { + "kind": "ui-call", + "line": 2706, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Add attachment", + "surface": "android", + "id": "native.android.3fea3a91bbd7ee4e" + }, + { + "kind": "ui-call", + "line": 2710, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Photos", + "surface": "android", + "id": "native.android.9db5d4b832ddaf20" + }, + { + "kind": "ui-call", + "line": 2714, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Videos", + "surface": "android", + "id": "native.android.827fd291f6fd3120" + }, + { + "kind": "ui-call", + "line": 2718, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Files", + "surface": "android", + "id": "native.android.8c3f70be12db2b69" + }, + { + "kind": "ui-call", + "line": 2752, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Message OpenClaw", "surface": "android", @@ -13467,7 +13459,23 @@ }, { "kind": "ui-call", - "line": 2881, + "line": 2823, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Context ${contextPercent}% used", + "surface": "android", + "id": "native.android.96cbc35556fd6f2e" + }, + { + "kind": "ui-call", + "line": 2836, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "${contextPercent}%", + "surface": "android", + "id": "native.android.3332655e560cb682" + }, + { + "kind": "ui-call", + "line": 2873, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "End Talk", "surface": "android", @@ -13475,7 +13483,7 @@ }, { "kind": "ui-call", - "line": 2881, + "line": 2873, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Start Talk", "surface": "android", @@ -13483,7 +13491,15 @@ }, { "kind": "ui-call", - "line": 2981, + "line": 2908, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", + "source": "Stop", + "surface": "android", + "id": "native.android.76d574acfac94fee" + }, + { + "kind": "ui-call", + "line": 2988, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Voice note · ${formatVoiceNoteDuration(duration)}", "surface": "android", @@ -13491,7 +13507,7 @@ }, { "kind": "ui-call", - "line": 2990, + "line": 2997, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Remove attachment", "surface": "android", @@ -13499,7 +13515,7 @@ }, { "kind": "ui-call", - "line": 3002, + "line": 3009, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "New chat", "surface": "android", @@ -13507,7 +13523,7 @@ }, { "kind": "ui-call", - "line": 3011, + "line": 3018, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Main", "surface": "android", @@ -13515,7 +13531,7 @@ }, { "kind": "ui-call", - "line": 3012, + "line": 3019, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Current", "surface": "android", @@ -13523,7 +13539,7 @@ }, { "kind": "ui-call", - "line": 3019, + "line": 3026, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$emoji $name", "surface": "android", @@ -13531,7 +13547,7 @@ }, { "kind": "ui-call", - "line": 3078, + "line": 3085, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Send", "surface": "android", @@ -13539,7 +13555,7 @@ }, { "kind": "ui-call", - "line": 3089, + "line": 3096, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat is still checking Gateway health.", "surface": "android", @@ -13547,7 +13563,7 @@ }, { "kind": "ui-call", - "line": 3090, + "line": 3097, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway is offline. Fix the connection below or copy diagnostics.", "surface": "android", @@ -13555,7 +13571,7 @@ }, { "kind": "ui-call", - "line": 3091, + "line": 3098, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway authentication needs attention.", "surface": "android", @@ -13563,31 +13579,7 @@ }, { "kind": "ui-call", - "line": 3110, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Context ${(it * 100).roundToInt()}%", - "surface": "android", - "id": "native.android.4d756c15804130ff" - }, - { - "kind": "ui-call", - "line": 3111, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "Context --", - "surface": "android", - "id": "native.android.5d9cafb38a925db9" - }, - { - "kind": "ui-call", - "line": 3112, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", - "source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}", - "surface": "android", - "id": "native.android.4e6c67df44d588d3" - }, - { - "kind": "ui-call", - "line": 3151, + "line": 3146, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Off", "surface": "android", @@ -13595,7 +13587,7 @@ }, { "kind": "ui-call", - "line": 3152, + "line": 3147, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Minimal", "surface": "android", @@ -13603,7 +13595,7 @@ }, { "kind": "ui-call", - "line": 3153, + "line": 3148, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Low", "surface": "android", @@ -13611,7 +13603,7 @@ }, { "kind": "ui-call", - "line": 3154, + "line": 3149, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Medium", "surface": "android", @@ -13619,7 +13611,7 @@ }, { "kind": "ui-call", - "line": 3155, + "line": 3150, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "High", "surface": "android", @@ -13627,7 +13619,7 @@ }, { "kind": "ui-call", - "line": 3156, + "line": 3151, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Xhigh", "surface": "android", @@ -13635,7 +13627,7 @@ }, { "kind": "ui-call", - "line": 3157, + "line": 3152, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Adaptive", "surface": "android", @@ -13643,7 +13635,7 @@ }, { "kind": "ui-call", - "line": 3158, + "line": 3153, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Max", "surface": "android", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 2b1f6b151f2a..2ac184f70418 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -578,6 +578,9 @@ enum class GatewayMethod( DesktopLaunch("desktop.launch"), DeviceScopesRequestUpgrade("device.scopes.requestUpgrade"), DeviceScopesWaitUpgrade("device.scopes.waitUpgrade"), + PortalList("portal.list"), + PortalOpen("portal.open"), + PortalClose("portal.close"), } enum class GatewayEvent( @@ -629,4 +632,5 @@ enum class GatewayEvent( TerminalData("terminal.data"), TerminalExit("terminal.exit"), UpdateAvailable("update.available"), + PortalChanged("portal.changed"), } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index 0d39de2d57d3..3101b3db4166 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -87,6 +87,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -121,9 +122,11 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -153,11 +156,14 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -216,16 +222,19 @@ internal fun chatReaderListBottomInset(showJumpToLatest: Boolean): Dp = internal enum class ChatComposerTrailingAction { StartTalk, StopTalk, + Stop, Send, } /** Talk must remain stoppable even when the active session adds text to the draft. */ internal fun resolveChatComposerTrailingAction( talkActive: Boolean, + runActive: Boolean, sendEnabled: Boolean, ): ChatComposerTrailingAction = when { talkActive -> ChatComposerTrailingAction.StopTalk + runActive -> ChatComposerTrailingAction.Stop sendEnabled -> ChatComposerTrailingAction.Send else -> ChatComposerTrailingAction.StartTalk } @@ -2280,26 +2289,6 @@ private fun ChatComposer( AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - ChatModelChip( - label = selectedModelLabel, - enabled = modelPickerEnabled, - onClick = onOpenModelPicker, - modifier = Modifier.weight(1f), - ) - ChatContextMeter( - thinkingLevel = thinkingLevel, - thinkingSupported = thinkingSupported, - expanded = thinkingSelectorExpanded, - contextUsage = contextUsage, - onClick = { thinkingSelectorExpanded = !thinkingSelectorExpanded }, - ) - } - if (thinkingSelectorExpanded && thinkingSupported) { ChatThinkingLevelSelector( options = thinkingOptions, @@ -2343,8 +2332,17 @@ private fun ChatComposer( onToggleDictation = onToggleDictation, talkActive = talkActive, onToggleTalk = onToggleTalk, + runActive = pendingRunCount > 0, + onAbort = onAbort, sendEnabled = sendEnabled, onSend = onSend, + selectedModelLabel = selectedModelLabel, + modelPickerEnabled = modelPickerEnabled, + onOpenModelPicker = onOpenModelPicker, + thinkingLevel = thinkingLevel, + thinkingSupported = thinkingSupported, + onToggleThinkingSelector = { thinkingSelectorExpanded = !thinkingSelectorExpanded }, + contextUsage = contextUsage, modifier = Modifier.weight(1f), ) } @@ -2360,27 +2358,6 @@ private fun ChatComposer( onCopyDiagnostics = onCopyDiagnostics, ) } - - if (pendingRunCount > 0) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Surface( - onClick = onAbort, - modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget), - shape = RoundedCornerShape(ClawTheme.radii.pill), - color = ClawTheme.colors.canvas, - contentColor = ClawTheme.colors.text, - ) { - Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box(modifier = Modifier.size(8.dp).background(ClawTheme.colors.danger, RoundedCornerShape(2.dp))) - Text(text = nativeString("Stop"), style = ClawTheme.type.label) - } - } - } - } } } @@ -2416,38 +2393,6 @@ private fun ChatThinkingLevelSelector( } } -@Composable -private fun ChatModelChip( - label: String, - enabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( - onClick = onClick, - enabled = enabled, - modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget), - shape = RoundedCornerShape(ClawTheme.radii.pill), - color = ClawTheme.colors.canvas, - contentColor = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textMuted, - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle) - Text( - text = label, - style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), - color = if (enabled) ClawTheme.colors.textMuted else ClawTheme.colors.textSubtle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun BranchSwitcherSheet( @@ -2712,68 +2657,6 @@ private fun ChatOfflineNotice( } } -@Composable -private fun ChatContextMeter( - thinkingLevel: String, - thinkingSupported: Boolean, - expanded: Boolean, - contextUsage: ChatContextUsage, - onClick: () -> Unit, -) { - val contextFraction = contextMeterWidth(contextUsage) ?: 0f - Row( - modifier = Modifier.width(178.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp), - ) { - Surface( - onClick = onClick, - enabled = thinkingSupported, - modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget), - shape = RoundedCornerShape(ClawTheme.radii.pill), - color = ClawTheme.colors.canvas, - contentColor = ClawTheme.colors.text, - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (thinkingSupported) { - Icon( - imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = if (expanded) nativeString("Close thinking level selector") else nativeString("Open thinking level selector"), - modifier = Modifier.size(13.dp), - tint = ClawTheme.colors.textSubtle, - ) - } - Text( - text = contextMeterLabel(contextUsage, thinkingLevel, thinkingSupported), - style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), - color = ClawTheme.colors.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - Box( - modifier = - Modifier - .weight(1f) - .height(3.dp) - .background(ClawTheme.colors.surfacePressed, RoundedCornerShape(999.dp)), - ) { - Box( - modifier = - Modifier - .fillMaxWidth(contextFraction) - .height(3.dp) - .background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)), - ) - } - } -} - @Composable private fun ChatInputPill( value: String, @@ -2788,11 +2671,21 @@ private fun ChatInputPill( onToggleDictation: () -> Unit, talkActive: Boolean, onToggleTalk: () -> Unit, + runActive: Boolean, + onAbort: () -> Unit, sendEnabled: Boolean, onSend: () -> Unit, + selectedModelLabel: String, + modelPickerEnabled: Boolean, + onOpenModelPicker: () -> Unit, + thinkingLevel: String, + thinkingSupported: Boolean, + onToggleThinkingSelector: () -> Unit, + contextUsage: ChatContextUsage, modifier: Modifier = Modifier, ) { val hardwareEnterHandler = remember { PhysicalChatSendKeyHandler() } + var attachmentMenuExpanded by rememberSaveable { mutableStateOf(false) } Surface( modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget), @@ -2801,78 +2694,177 @@ private fun ChatInputPill( contentColor = ClawTheme.colors.text, border = BorderStroke(1.dp, ClawTheme.colors.border), ) { - Row( - modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp), - ) { - Surface(onClick = onPickImages, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = ClawTheme.colors.surfaceRaised, contentColor = ClawTheme.colors.text) { - Box(contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.Default.Add, contentDescription = nativeString("Attach image"), modifier = Modifier.size(20.dp)) + Column { + Row( + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + Box { + Surface(onClick = { attachmentMenuExpanded = true }, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = ClawTheme.colors.surfaceRaised, contentColor = ClawTheme.colors.text) { + Box(contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Default.Add, contentDescription = nativeString("Add attachment"), modifier = Modifier.size(20.dp)) + } + } + DropdownMenu(expanded = attachmentMenuExpanded, onDismissRequest = { attachmentMenuExpanded = false }) { + DropdownMenuItem(text = { Text(nativeString("Photos")) }, leadingIcon = { Icon(Icons.Default.Photo, contentDescription = null) }, onClick = { + attachmentMenuExpanded = false + onPickImages() + }) + DropdownMenuItem(text = { Text(nativeString("Videos")) }, leadingIcon = { Icon(Icons.Default.Videocam, contentDescription = null) }, onClick = { + attachmentMenuExpanded = false + onPickVideo() + }) + DropdownMenuItem(text = { Text(nativeString("Files")) }, leadingIcon = { Icon(Icons.Default.AttachFile, contentDescription = null) }, onClick = { + attachmentMenuExpanded = false + onPickAudioOrDocument() + }) + } } - } - Surface(onClick = onPickAudioOrDocument, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = ClawTheme.colors.surfaceRaised, contentColor = ClawTheme.colors.text) { - Box(contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.Default.AttachFile, contentDescription = nativeString("Attachment"), modifier = Modifier.size(20.dp)) - } - } - Surface(onClick = onPickVideo, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = ClawTheme.colors.surfaceRaised, contentColor = ClawTheme.colors.text) { - Box(contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.Default.Videocam, contentDescription = nativeString("Attach video"), modifier = Modifier.size(20.dp)) - } - } - Box(modifier = Modifier.weight(1f)) { - ChatTextFieldValueAdapter( - value = value, - onValueChange = onValueChange, - keyHandler = hardwareEnterHandler, - ) { textFieldValue, updateTextFieldValue -> - BasicTextField( - value = textFieldValue, - onValueChange = updateTextFieldValue, - textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text), - cursorBrush = SolidColor(ClawTheme.colors.primary), - minLines = 1, - maxLines = 4, - modifier = - Modifier - .fillMaxWidth() - .onPreInterceptKeyBeforeSoftKeyboard { event -> - hardwareEnterHandler.handle( - event = event, - sendEnabled = sendEnabled, - textEmpty = textFieldValue.text.isEmpty(), - compositionActive = textFieldValue.composition != null, - onSend = onSend, - ) - }, - decorationBox = { innerTextField -> - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) { - if (value.isEmpty()) { - Text(text = nativeString("Message OpenClaw"), style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle) + Box(modifier = Modifier.weight(1f)) { + ChatTextFieldValueAdapter( + value = value, + onValueChange = onValueChange, + keyHandler = hardwareEnterHandler, + ) { textFieldValue, updateTextFieldValue -> + BasicTextField( + value = textFieldValue, + onValueChange = updateTextFieldValue, + textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text), + cursorBrush = SolidColor(ClawTheme.colors.primary), + minLines = 1, + maxLines = 4, + modifier = + Modifier + .fillMaxWidth() + .onPreInterceptKeyBeforeSoftKeyboard { event -> + hardwareEnterHandler.handle( + event = event, + sendEnabled = sendEnabled, + textEmpty = textFieldValue.text.isEmpty(), + compositionActive = textFieldValue.composition != null, + onSend = onSend, + ) + }, + decorationBox = { innerTextField -> + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text(text = nativeString("Message OpenClaw"), style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle) + } + innerTextField() } - innerTextField() - } - }, - ) + }, + ) + } + } + ChatComposerMicButton( + dictationActive = dictationActive, + dictationEnabled = dictationEnabled, + voiceNoteEnabled = recordVoiceNoteEnabled, + onToggleDictation = onToggleDictation, + onStartVoiceNote = onStartVoiceNote, + ) + when (resolveChatComposerTrailingAction(talkActive = talkActive, runActive = runActive, sendEnabled = sendEnabled)) { + ChatComposerTrailingAction.Send -> SendButton(enabled = true, onClick = onSend) + ChatComposerTrailingAction.StartTalk -> LiveTalkButton(active = false, onClick = onToggleTalk) + ChatComposerTrailingAction.StopTalk -> { + // Talk keeps the morph slot, but run abort must stay reachable while both overlap. + if (runActive) StopButton(onClick = onAbort) + LiveTalkButton(active = true, onClick = onToggleTalk) + } + ChatComposerTrailingAction.Stop -> StopButton(onClick = onAbort) } } - ChatComposerMicButton( - dictationActive = dictationActive, - dictationEnabled = dictationEnabled, - voiceNoteEnabled = recordVoiceNoteEnabled, - onToggleDictation = onToggleDictation, - onStartVoiceNote = onStartVoiceNote, + ChatComposerFooter( + selectedModelLabel = selectedModelLabel, + modelPickerEnabled = modelPickerEnabled, + onOpenModelPicker = onOpenModelPicker, + thinkingLevel = thinkingLevel, + thinkingSupported = thinkingSupported, + onToggleThinkingSelector = onToggleThinkingSelector, + contextUsage = contextUsage, ) - when (resolveChatComposerTrailingAction(talkActive = talkActive, sendEnabled = sendEnabled)) { - ChatComposerTrailingAction.Send -> SendButton(enabled = true, onClick = onSend) - ChatComposerTrailingAction.StartTalk -> LiveTalkButton(active = false, onClick = onToggleTalk) - ChatComposerTrailingAction.StopTalk -> LiveTalkButton(active = true, onClick = onToggleTalk) + } + } +} + +@Composable +private fun ChatComposerFooter( + selectedModelLabel: String, + modelPickerEnabled: Boolean, + onOpenModelPicker: () -> Unit, + thinkingLevel: String, + thinkingSupported: Boolean, + onToggleThinkingSelector: () -> Unit, + contextUsage: ChatContextUsage, +) { + val contextFraction = contextMeterWidth(contextUsage) + val contextPercent = contextFraction?.let { (it * 100).roundToInt() } + Row( + modifier = Modifier.fillMaxWidth().padding(start = 9.dp, end = 9.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + ChatComposerFooterChip( + label = selectedModelLabel, + enabled = modelPickerEnabled, + onClick = onOpenModelPicker, + modifier = Modifier.weight(1f, fill = false), + ) + if (thinkingSupported) { + ChatComposerFooterChip( + label = contextMeterThinkingLabel(thinkingLevel), + enabled = true, + onClick = onToggleThinkingSelector, + ) + } + Spacer(modifier = Modifier.weight(1f)) + if (contextFraction != null && contextPercent != null) { + val description = nativeString("Context \${contextPercent}% used", contextPercent) + val trackColor = ClawTheme.colors.surfacePressed + val progressColor = ClawTheme.colors.primary + Row( + modifier = Modifier.clearAndSetSemantics { contentDescription = description }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Canvas(modifier = Modifier.size(14.dp)) { + val stroke = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round) + drawCircle(color = trackColor, style = stroke) + drawArc(color = progressColor, startAngle = -90f, sweepAngle = contextFraction * 360f, useCenter = false, style = stroke) + } + Text(text = nativeString("\${contextPercent}%", contextPercent), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) } } } } +@Composable +private fun ChatComposerFooterChip( + label: String, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget), + shape = RoundedCornerShape(ClawTheme.radii.pill), + color = Color.Transparent, + contentColor = if (enabled) ClawTheme.colors.textMuted else ClawTheme.colors.textSubtle, + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(text = label, modifier = Modifier.weight(1f, fill = false), style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle) + } + } +} + @Composable private fun LiveTalkButton( active: Boolean, @@ -2886,8 +2878,8 @@ private fun LiveTalkButton( .size(ClawTheme.spacing.touchTarget) .semantics { contentDescription = buttonDescription }, shape = CircleShape, - color = ClawTheme.colors.danger, - contentColor = Color.White, + color = if (active) ClawTheme.colors.danger else ClawTheme.colors.surfaceRaised, + contentColor = if (active) Color.White else ClawTheme.colors.text, ) { Box(contentAlignment = Alignment.Center) { if (active) { @@ -2903,6 +2895,21 @@ private fun LiveTalkButton( } } +@Composable +private fun StopButton(onClick: () -> Unit) { + Surface( + onClick = onClick, + modifier = Modifier.size(ClawTheme.spacing.touchTarget), + shape = CircleShape, + color = ClawTheme.colors.danger, + contentColor = Color.White, + ) { + Box(contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Default.Stop, contentDescription = nativeString("Stop"), modifier = Modifier.size(20.dp)) + } + } +} + @Composable private fun LiveTalkWaveform(modifier: Modifier = Modifier) { val transition = rememberInfiniteTransition() @@ -3100,18 +3107,6 @@ internal fun contextMeterWidth(usage: ChatContextUsage): Float? { return (total.toDouble() / context.toDouble()).coerceIn(0.0, 1.0).toFloat() } -internal fun contextMeterLabel( - usage: ChatContextUsage, - thinkingLevel: String, - thinkingSupported: Boolean = true, -): String { - val contextLabel = - contextMeterWidth(usage)?.let { - nativeString("Context \${(it * 100).roundToInt()}%", (it * 100).roundToInt()) - } ?: nativeString("Context --") - return if (thinkingSupported) nativeString("\$contextLabel · \${contextMeterThinkingLabel(thinkingLevel)}", contextLabel, contextMeterThinkingLabel(thinkingLevel)) else contextLabel -} - internal fun contextMeterThinkingLabel(value: String): String { val normalized = value.trim().lowercase(Locale.US).ifEmpty { "off" } return when (normalized) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt index 1193551966a5..15962150d908 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt @@ -47,7 +47,6 @@ class ChatContextMeterTest { assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage) assertEquals(0.25f, contextMeterWidth(usage)) - assertEquals("Context 25% · High", contextMeterLabel(usage, "high")) } @Test @@ -72,7 +71,6 @@ class ChatContextMeterTest { ) assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage) - assertEquals("Context 41% · Off", contextMeterLabel(usage, "off")) } @Test @@ -80,7 +78,6 @@ class ChatContextMeterTest { val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null) assertNull(contextMeterWidth(usage)) - assertEquals("Context -- · Medium", contextMeterLabel(usage, "medium")) } @Test @@ -88,7 +85,6 @@ class ChatContextMeterTest { val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L) assertEquals(1.0f, contextMeterWidth(usage)) - assertEquals("Context 100% · Low", contextMeterLabel(usage, "low")) } @Test @@ -96,23 +92,17 @@ class ChatContextMeterTest { val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L) assertNull(contextMeterWidth(usage)) - assertEquals("Context -- · High", contextMeterLabel(usage, "high")) } @Test - fun contextMeterHidesThinkingLabelWhenUnsupported() { - val usage = ChatContextUsage(totalTokens = 2_500L, totalTokensFresh = true, contextTokens = 10_000L) - - assertEquals("Context 25%", contextMeterLabel(usage, "high", thinkingSupported = false)) - } - - @Test - fun contextMeterPreservesGatewayThinkingLevelIds() { - val usage = ChatContextUsage(totalTokens = null, totalTokensFresh = null, contextTokens = null) - - assertEquals("Context -- · xhigh", contextMeterLabel(usage, "xhigh")) - assertEquals("Context -- · adaptive", contextMeterLabel(usage, "adaptive")) - assertEquals("Context -- · ultra", contextMeterLabel(usage, "ultra")) + fun thinkingLabelsMapKnownLevelsAndPreserveGatewayIds() { + assertEquals("Off", contextMeterThinkingLabel("off")) + assertEquals("Low", contextMeterThinkingLabel("low")) + assertEquals("Medium", contextMeterThinkingLabel("medium")) + assertEquals("High", contextMeterThinkingLabel("high")) + assertEquals("xhigh", contextMeterThinkingLabel("xhigh")) + assertEquals("adaptive", contextMeterThinkingLabel("adaptive")) + assertEquals("ultra", contextMeterThinkingLabel("ultra")) } @Test diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt index 188b3fd960fe..1e49c8f68573 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt @@ -73,18 +73,22 @@ class ChatScreenTest { } @Test - fun activeTalkAlwaysKeepsTheStopControlVisible() { + fun composerTrailingActionPreservesTalkAndRunStopPrecedence() { assertEquals( ChatComposerTrailingAction.StopTalk, - resolveChatComposerTrailingAction(talkActive = true, sendEnabled = true), + resolveChatComposerTrailingAction(talkActive = true, runActive = true, sendEnabled = true), + ) + assertEquals( + ChatComposerTrailingAction.Stop, + resolveChatComposerTrailingAction(talkActive = false, runActive = true, sendEnabled = true), ) assertEquals( ChatComposerTrailingAction.Send, - resolveChatComposerTrailingAction(talkActive = false, sendEnabled = true), + resolveChatComposerTrailingAction(talkActive = false, runActive = false, sendEnabled = true), ) assertEquals( ChatComposerTrailingAction.StartTalk, - resolveChatComposerTrailingAction(talkActive = false, sendEnabled = false), + resolveChatComposerTrailingAction(talkActive = false, runActive = false, sendEnabled = false), ) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index c4591fdeed03..712cd8d2fb6a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -74,6 +74,17 @@ "cwd" ] }, + "portal": { + "emoji": "🌐", + "title": "Portal", + "detailKeys": [ + "action", + "port", + "id", + "title", + "path" + ] + }, "process": { "emoji": "🧰", "title": "Process", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index f5d0a84e2c40..f6d8ef99dd1e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1139,6 +1139,7 @@ public struct ConnectParams: Codable, Sendable { public let client: [String: AnyCodable] public let caps: [String]? public let commands: [String]? + public let workerruns: WorkerAdmissionHandshake? public let permissions: [String: AnyCodable]? public let pathenv: String? public let role: String? @@ -1154,6 +1155,7 @@ public struct ConnectParams: Codable, Sendable { client: [String: AnyCodable], caps: [String]? = nil, commands: [String]? = nil, + workerruns: WorkerAdmissionHandshake? = nil, permissions: [String: AnyCodable]? = nil, pathenv: String? = nil, role: String? = nil, @@ -1168,6 +1170,7 @@ public struct ConnectParams: Codable, Sendable { self.client = client self.caps = caps self.commands = commands + self.workerruns = workerruns self.permissions = permissions self.pathenv = pathenv self.role = role @@ -1184,6 +1187,7 @@ public struct ConnectParams: Codable, Sendable { case client case caps case commands + case workerruns = "workerRuns" case permissions case pathenv = "pathEnv" case role @@ -19228,6 +19232,190 @@ public struct ShutdownEvent: Codable, Sendable { } } +public struct PortalSummary: Codable, Sendable { + public let id: String + public let title: String + public let port: Int + public let listenport: Int + public let tokenquery: String? + public let url: String? + public let publicurl: String + public let path: String? + public let description: String? + public let createdatms: Int + + public init( + id: String, + title: String, + port: Int, + listenport: Int, + tokenquery: String? = nil, + url: String? = nil, + publicurl: String, + path: String? = nil, + description: String? = nil, + createdatms: Int) + { + self.id = id + self.title = title + self.port = port + self.listenport = listenport + self.tokenquery = tokenquery + self.url = url + self.publicurl = publicurl + self.path = path + self.description = description + self.createdatms = createdatms + } + + private enum CodingKeys: String, CodingKey { + case id + case title + case port + case listenport = "listenPort" + case tokenquery = "tokenQuery" + case url + case publicurl = "publicUrl" + case path + case description + case createdatms = "createdAtMs" + } +} + +public struct PortalListParams: Codable, Sendable {} + +public struct PortalListResult: Codable, Sendable { + public let portals: [PortalSummary] + + public init( + portals: [PortalSummary]) + { + self.portals = portals + } + + private enum CodingKeys: String, CodingKey { + case portals + } +} + +public struct PortalOpenParams: Codable, Sendable { + public let port: Int + public let title: String? + public let description: String? + public let path: String? + + public init( + port: Int, + title: String? = nil, + description: String? = nil, + path: String? = nil) + { + self.port = port + self.title = title + self.description = description + self.path = path + } + + private enum CodingKeys: String, CodingKey { + case port + case title + case description + case path + } +} + +public struct PortalOpenResult: Codable, Sendable { + public let id: String + public let title: String + public let port: Int + public let listenport: Int + public let tokenquery: String + public let url: String + public let publicurl: String + public let path: String? + public let description: String? + public let createdatms: Int + + public init( + id: String, + title: String, + port: Int, + listenport: Int, + tokenquery: String, + url: String, + publicurl: String, + path: String? = nil, + description: String? = nil, + createdatms: Int) + { + self.id = id + self.title = title + self.port = port + self.listenport = listenport + self.tokenquery = tokenquery + self.url = url + self.publicurl = publicurl + self.path = path + self.description = description + self.createdatms = createdatms + } + + private enum CodingKeys: String, CodingKey { + case id + case title + case port + case listenport = "listenPort" + case tokenquery = "tokenQuery" + case url + case publicurl = "publicUrl" + case path + case description + case createdatms = "createdAtMs" + } +} + +public struct PortalCloseParams: Codable, Sendable { + public let id: String + + public init( + id: String) + { + self.id = id + } + + private enum CodingKeys: String, CodingKey { + case id + } +} + +public struct PortalCloseResult: Codable, Sendable { + public let closed: Bool + + public init( + closed: Bool) + { + self.closed = closed + } + + private enum CodingKeys: String, CodingKey { + case closed + } +} + +public struct PortalChangedEvent: Codable, Sendable { + public let portals: [PortalSummary] + + public init( + portals: [PortalSummary]) + { + self.portals = portals + } + + private enum CodingKeys: String, CodingKey { + case portals + } +} + public enum BoardOp: Codable, Sendable { case tabCreate(BoardTabCreateOp) case tabUpdate(BoardTabUpdateOp) diff --git a/config/knip.config.ts b/config/knip.config.ts index 06f02ce4f507..4e0a6c4271d0 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -15,9 +15,6 @@ function bundledPluginFile(pluginId: string, relativePath: string, suffix = ""): const repositoryScriptEntries = [ // setup-node-env invokes this helper from composite-action YAML. ".github/actions/setup-node-env/dependency-fingerprint.mjs!", - ".github/actions/setup-node-env/verify-importers.mjs!", - ".github/actions/register-bind-mount-cleanup/main.cjs!", - ".github/actions/register-bind-mount-cleanup/post.cjs!", "apps/android/scripts/build-release-artifacts.ts!", "scripts/bundle-a2ui.mts!", "scripts/build-discord-activity-sdk.mts!", diff --git a/config/knip.scripts-exports.config.ts b/config/knip.scripts-exports.config.ts index b0cc86fb0d45..9ab2639954a4 100644 --- a/config/knip.scripts-exports.config.ts +++ b/config/knip.scripts-exports.config.ts @@ -24,8 +24,6 @@ const scriptEntries = productionConfig.workspaces["."].entry.filter( const repositoryToolEntries = [ ".github/actions/setup-node-env/dependency-fingerprint.mjs!", - ".github/actions/register-bind-mount-cleanup/main.cjs!", - ".github/actions/register-bind-mount-cleanup/post.cjs!", "apps/android/scripts/build-release-artifacts.ts!", "security/opengrep/check-rule-metadata.mjs!", "security/opengrep/compile-rules.mjs!", diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index d738d76fab05..42031111a6c1 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2306, + "core": 2308, "channel": 3575, "plugin": 3997 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 19ae80fd7a29..0566eecce612 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -aa6a639288bfde08ea8b8938f68ae6c414b45a9b976744b1cdb75aa590661cbb config-baseline.json -ce74623d1b19b178aee681a4d1e15eee17297f3a5d939ed1f0fba838abfabe6c config-baseline.core.json +ff3a4f23b20f40e493f2c5d4da1a8c6fba0206cf763a372e6437a3cf515d1099 config-baseline.json +d834d843e6f65a87490964c1e7a089ec8421b546a9274b84c5d2ef35ec14f5a1 config-baseline.core.json 1144184911193a239dd0e6415335a2a95771af27c4b1f5974e64851d6b2ed65d config-baseline.channel.json 250f573a93619d8a2f554028288af84551e572ef8bfdc820a43024572b55175a config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline/account-core.json b/docs/.generated/plugin-sdk-api-baseline/account-core.json index 89424a690ea3..b961ec52cda4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-core.json @@ -1 +1 @@ -{"contentHash":"ca54bfaaa3b3a4360b8fc5777d74ee94add20d789861ad8f84c564324b72d34a","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"} +{"contentHash":"76643cb5737109f0cac8ea83133774ba1254dc66eecc5fc8f74fabdd8356796a","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/account-helpers.json b/docs/.generated/plugin-sdk-api-baseline/account-helpers.json index ac1c60db1537..3302baa0b9e1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-helpers.json @@ -1 +1 @@ -{"contentHash":"de22787449794b11be3d6a69fe5e4d33e21369febee2a3c4f1eb62d8105d8fb5","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"} +{"contentHash":"f7012931478c6fe373daa77468fa03d52043a0e65c3bd45dd94cb841222d81e2","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/account-resolution.json b/docs/.generated/plugin-sdk-api-baseline/account-resolution.json index a029f429fb30..0ddaac0723b6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-resolution.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-resolution.json @@ -1 +1 @@ -{"contentHash":"5011920cb3bc682df7980e84b82f25399e6fd220b569f9b42758b275f3666e66","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"} +{"contentHash":"a681a00e79eccd3f3b2fab17c5d929677d94ae942543688b3b128bed988398e2","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 8a178ae0c44e..cdf83a0a9789 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"812e818c8d7c013b2287c4502426123222037a4235e130a69481272912062866","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"ae84192abc0c0c343008e51e9292f03c3719b1c57f15d5a868c0db3898bf9a4c","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 3b4e4add6542..b4e1dcebdb9f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"f639fea8b8ee53626452bdbce156724b09a3a29bb05a134eb8e8f8fb8062d4da","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"809729267555a298853438ac9d5a30e3667accc9dd20500d0434fd1cb86c45ec","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json b/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json index 6f9e1cb94598..184b05ca73b9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json @@ -1 +1 @@ -{"contentHash":"7b5bf5348d9f0df4e056c9787ea8952f67c9c1f654922528a196ced5420c1f7f","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"} +{"contentHash":"5bd6b57d5b5fafc9504efd61f1033c0e7d9523fce35de52957523341bed18007","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index d9524be6ed7d..775f63857e33 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"0a014810b900e53959679fa292ed45cb0f1a1a3cc6b1129021bfa63b871f8e68","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"333c6c2b268b36450e24838f07f17d6a63f7807d4541c7a6eada12c344082b93","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json index 116869273372..d6c520da670a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json @@ -1 +1 @@ -{"contentHash":"4ffa76fb2b34757ef4f6bd0ccc55182f4e963f7b9f055160b26d567584cc9bc6","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"} +{"contentHash":"31a2629e002e53ad33ccd08e5a2b78523aa24236256e0aec0700b6db8129c623","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json b/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json index 5788cdd77a64..7fbcdeb2df3a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json +++ b/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json @@ -1 +1 @@ -{"contentHash":"4406fa069d6376725fe9c113dd65aeee2ebfcd4accdefb9aa888a989ddab2429","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"} +{"contentHash":"01b10ef1e03b58d5fd391bd8a4f3b0421be26314c8bca49d8a224ccbcbafbed1","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json index 2c08e10846bf..4254ba9f2d17 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json @@ -1 +1 @@ -{"contentHash":"0db493ed0935458bfdba48f1d2f854561331768ecc13bcc65d16581eb212d4f8","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"} +{"contentHash":"496edef91c62e94fc29d9d666c1b832dec36315a05a383db2f0eea9623cbc464","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json index 0e434591a4df..97285863191a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json @@ -1 +1 @@ -{"contentHash":"9f38d8917387011027bd285233a15cb53723a9948f6c26472da2d45f397b561d","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"} +{"contentHash":"78e308f6e23dbac85cf8b9eb8ecad607fae673502432edab25ef27e650d5b81e","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json index 6267086844ef..c9cdb92518e6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json @@ -1 +1 @@ -{"contentHash":"c2170d7ee8ba6a9d7b816312b652f131b4fbf3952fd6cbfa649582c0106feda9","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"} +{"contentHash":"ef139545fa43dfa5e14a57dea50bca6c8a650debc0bd8e42cb0fb00564e8e7f0","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json index da6e3390698d..a3c5c7469c97 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"bd864f089ba285e271f9a98d149a85efad03bd687ddc50fd72da5b970501a492","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"} +{"contentHash":"289ccd9f5a88bceb9e201d7851c1f110f59a8e115c4f07f63d1a2703df20df2a","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json index 4019eb052d3f..c293199a0819 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json @@ -1 +1 @@ -{"contentHash":"c5bceaafa22ff897cd09dcf90712b4a756979aed8fab1bb1ca13ac9d4471a6a9","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"} +{"contentHash":"e0c7d7a5e247c45c2f7e169c775542c78055f0acc1eda3b60b84b0f8440bfaf1","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json index cea70721276f..286ab48dd9cb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json @@ -1 +1 @@ -{"contentHash":"0f099da585bd9b5fa887c0c3e572512debb5d69ee1ad0f1d011d182ffe300a5c","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"} +{"contentHash":"8435538ee0ab09ff5a1d7c6aab5b414439a1d5607210069917c2974acb168764","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json index 8afd419cd965..ca5da9ed0355 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json @@ -1 +1 @@ -{"contentHash":"3600f68d632471ecee191ffe0f2a1b419bb4ba544455d18930c4b96b22d8c2d2","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"} +{"contentHash":"e39cb3cbbd7828deec5c7088b48d028f9c46aca215aa18090dabc55a061c428d","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json index df8b69d6ddb1..161918062656 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json @@ -1 +1 @@ -{"contentHash":"e84e112bb8043500c4802580af7f41e73d3371411eae4fa661f28b65474fb026","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"} +{"contentHash":"dcc0b4622948450eab19bc2b8ae706a1b71a9b4a4969ff78f52027a39d8e606f","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json b/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json index 28dc8ccb91ea..980580c70bd8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json @@ -1 +1 @@ -{"contentHash":"dc308dc325f2fb6192ecead080dbba4f84214091101fdd07c0f3f2297b500cf5","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"} +{"contentHash":"bba3b4b62186570b01446bd10ab2a2e97255f2549a50f59acad53ed34a4d73f5","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-contract.json index fdd99f0d7f7f..8a3696b0f4e0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-contract.json @@ -1 +1 @@ -{"contentHash":"44234eb25f54df45dc999e0c4314dbe36bf043b2d972e82732c7f9a0283862ab","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"} +{"contentHash":"9e07df547f6ac4df96d9f14864c6f1c3cb637b52891d9f9abefa7ccefe8721dd","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index dbcb1039396a..26bd9f8250ef 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"02db4abe2f1f4578d438f7afacd68d44114e46b48b9582711e7e10191353e6c5","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"ed030f8bf72ca1b07419980b050fc25bbd285a35c808b888aeb29dd7ccbf30d3","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json b/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json index 71384f7b926d..5f5262e5b265 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json @@ -1 +1 @@ -{"contentHash":"9aa8d47743a80bd09d292a3b0f21529919e8e51f1aa765be513370b6e80706ef","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"} +{"contentHash":"6de14733b76cec50b27ddcdcb30222c0bc53eb2bbac9e9bb625c2150df94b70e","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 45b7d50451ab..191dbe1e172b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"6826237a93cc52b6039fddbd4b4e0e00e82b5c83cbadd36a055a50ab0480879c","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"844d124f9581a338da76e92b9ad728ae43d451cdc5ca9284d7a8c33486f5859d","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json b/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json index 87b079000315..ba55d54a7941 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json @@ -1 +1 @@ -{"contentHash":"4f1089cba4f061a6df633d593523eebf7645e19bc88fe152a5c7da5e7f213cbe","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"} +{"contentHash":"1d4d62e5f9885bf2263192488e721646ceb7183aff7400f3a8a975b5e9f9fcde","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json index c1099d46078b..79170a3e7226 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json @@ -1 +1 @@ -{"contentHash":"b931541242c239ef45e02e794fbeabf2ca69e4071a261266aacc698f738b32bd","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"} +{"contentHash":"68e07ef5558e83fe34a66f9803453cbe81659ce53e8668296e70ac029185d99f","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index 0c74fb7bdaa4..8e6f8f45d409 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"e0eefe9ad871dbfe2bb8dac626e44ecfcd55c8b35667e9fd041799f2f45ea6ba","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"d8345346a5e695d6d95d794180ae157ec304e28e3a4a542dcb700205010f2702","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json index 0116d09a1ec1..37aa3d2d7992 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json @@ -1 +1 @@ -{"contentHash":"6f32863c74ad4ea076404754618d38015ad48281964f1fc24692aae8cd0fcad6","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"} +{"contentHash":"b88cf53042923dfc06236ab45e2faa4da3ba21a85188722f667262cdab5c465f","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index e74031a0e4a1..0b1b87e8bba6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"bc8881a906f40f0a3ede29eb83efc1e4d3e59b62c00154b2480d85d69cbe4010","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"a962a9f1b15cfa053e51e2bff3406b8ff9bbefde5deee78178c1e4b4bb61e4d1","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index dd24f428bb5a..f4f93aca2a23 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"f4b35d03ac9df9788462f3e50b64819ff85aba0245b1a51208a288bd994edb8e","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"494057368f1ee648156548195c02103b884c5a98bc038b0986bd3f29519c2b69","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json index 13dd9510f1a9..b9a42c24bf73 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json @@ -1 +1 @@ -{"contentHash":"d1c4d7478b9dcb6c4567cb599839c84b843e005ff39f5d31bc317f26d6745853","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} +{"contentHash":"60c244e385d96d8142a1d2b713715610ae9441f0717a98aaa6b511a4addf38ab","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index c777e8b2845b..38ea83e478eb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"80b5ab5fedd16c952c83747f4093db46e49640b3cfa1fdaa5a9727ff31edf0a7","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"b84d3c182569569f83b54fcdead3ee77743a56bd949a393794ea547f4895de72","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-policy.json b/docs/.generated/plugin-sdk-api-baseline/channel-policy.json index 4141b8368ddf..801005b62487 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-policy.json @@ -1 +1 @@ -{"contentHash":"ce25306e19a7463a8f79bebe822c436a7064088e89fdb3a4f51fdff3d96a9070","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"} +{"contentHash":"101b45f041cfbd36fc54cfe169a00eb52305bd962d9d6866c9d631f974bc80d3","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json index 8e1af49fd205..fba4d863b9a2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json @@ -1 +1 @@ -{"contentHash":"d1c85fca4678c83f45606bf39c2304a1ebed29a0b12899b011a7c2d04fcf9553","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} +{"contentHash":"df634c3e8c3ea6d44b600fe7f8b76699e55e6bb5725c67a0c05b3b6a9f4d64dc","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json index 0285210bbd37..7703406fb20c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json @@ -1 +1 @@ -{"contentHash":"ea6b1a3fe6e37494ff269ead50dd8749a389f5d51db6933a07210dc2d7820e1c","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} +{"contentHash":"d3d9c4258a5cdfb5db16610d9312d20e7ee3620869fc78b637cc4f759043589f","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json b/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json index f628e15337b9..a595ea56b34f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json @@ -1 +1 @@ -{"contentHash":"4dc96cbce5c2379f9bf2b72f124e310e8bc84c740b414d0c5a33a22f6314d17f","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"} +{"contentHash":"93cabcf483dbb7c28992d9c142ed08ea9233a68f59510215b46a3ab19eb6ec15","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json index ad03ea2a57fb..7db6925f5252 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json @@ -1 +1 @@ -{"contentHash":"191b5318cf8fae32b4122cf6fcaa2e2a21bb3e93a1e223098bf508bee8c5323b","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} +{"contentHash":"510f3965e8109bba757d69d4b103ad514b4b40c7839d97d34a3cdb683edebdf3","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json index ec2201dffda8..8f8985167c6e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json @@ -1 +1 @@ -{"contentHash":"aebe7a9c65a23ade6f6546e50384ad136e69feb3062ea326f4b9d044af7ab017","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} +{"contentHash":"284e36bdc35a14d9d6fcbc54ba330cc8cbb4f776bca22bba18803727704ed750","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth.json b/docs/.generated/plugin-sdk-api-baseline/command-auth.json index a3d66e4736d1..ec21430b4c5e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth.json @@ -1 +1 @@ -{"contentHash":"fc8c5f2fedb947a0911e3496b8a14695a1f697aa801f8c59fdb3dddf0f95b171","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} +{"contentHash":"3a88f903879a4e8bf99742cb05c56e68f057ebc22283eb0787763559b58343ae","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-detection.json b/docs/.generated/plugin-sdk-api-baseline/command-detection.json index 3d7c5971c7ab..34956c5b95bf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-detection.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-detection.json @@ -1 +1 @@ -{"contentHash":"d259a6cdf11d9ec8687ffb426347afc978e259813c47681d81cd61bd68443757","entrypoint":"command-detection","importSpecifier":"openclaw/plugin-sdk/command-detection"} +{"contentHash":"c962c27e1df6e286fa99fdb55266c77c34b56993290ad034066ef8a8b4698307","entrypoint":"command-detection","importSpecifier":"openclaw/plugin-sdk/command-detection"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-status.json b/docs/.generated/plugin-sdk-api-baseline/command-status.json index e77b838c9c20..2918cbcfc517 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-status.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-status.json @@ -1 +1 @@ -{"contentHash":"fd6eac0297be07a4ac68eb1a12f091c9fabc36e257c022998f69cd91fb765726","entrypoint":"command-status","importSpecifier":"openclaw/plugin-sdk/command-status"} +{"contentHash":"76ff5bbad5f25af7608995790a386ee3eccd3a34db6eab66827642e180773551","entrypoint":"command-status","importSpecifier":"openclaw/plugin-sdk/command-status"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-contracts.json b/docs/.generated/plugin-sdk-api-baseline/config-contracts.json index 00eaadd9d4de..efd51f2023ed 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-contracts.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-contracts.json @@ -1 +1 @@ -{"contentHash":"657d052b745a903daaa80b7ae2525d89cf093e803bc2b69bc91073fd20ceb106","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"} +{"contentHash":"8ccbfcb14ef710956045cb6d53bcf566a9b7f6cd202668497d65e87222410b7c","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json index 18d68e0be4e8..decb70b8b88c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json @@ -1 +1 @@ -{"contentHash":"9bcbdee2499e17f5411a42bd45919dfcd7cac92f12e7b0d034b519bbc46c70c5","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} +{"contentHash":"3221e540f383fdcb9b3d4ac87c1c8433ccd3c33b25be3e5c7e55477c8fa24eca","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index 5b741b271be5..8c0bfa945b96 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"770e079b242b9c56e8876f8fd08b402a78f1870367dca19d006ef147f9d493a4","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"45a369a5ffbe0aa84263993881dc03feaf16b32567ebcb5545f06c24f57b80b7","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json b/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json index e7fbda2e72b6..64d048280cd3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json @@ -1 +1 @@ -{"contentHash":"1f751e446e40a43c6bdf3ef1c13dfd86e6b543d804858a145f7a59d458ce04a9","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"} +{"contentHash":"b2f5a9cffda3217d1ffd41fb811b2ee13c6fe817536e88c07d0db14173029759","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index ca65ae570cac..aa548b997b88 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"edfa6b7a219aef521935ac17a20c5cd74e91bf4988eb3fad5ac75fc2e5015596","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"b64946f34d1ab9c7263958543ddc48532563d75b2182060a803a7a400dbba60f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json index 83a9ef62e0b3..6806fbcd2353 100644 --- a/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json @@ -1 +1 @@ -{"contentHash":"3a22e4de719dcc7055c6cc3771b72b1366b41b2d43613bcd904fd11283ca49a4","entrypoint":"diagnostic-runtime","importSpecifier":"openclaw/plugin-sdk/diagnostic-runtime"} +{"contentHash":"76f03283b5d78aca66035a95c695cf8549e1ae4a6c1c48fd7a00e44422bfe510","entrypoint":"diagnostic-runtime","importSpecifier":"openclaw/plugin-sdk/diagnostic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json b/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json index fe8685d01a85..14bcc27da824 100644 --- a/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json @@ -1 +1 @@ -{"contentHash":"5cb3edfc636dd00bf8a6fb5d577c057c6fc38a535fb473fa042244087a2c407b","entrypoint":"directory-runtime","importSpecifier":"openclaw/plugin-sdk/directory-runtime"} +{"contentHash":"5afab775380f00070113c9e463a8bf4b0f1ac1d9b09f8948425aba5a178f2ac5","entrypoint":"directory-runtime","importSpecifier":"openclaw/plugin-sdk/directory-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index edeb54f4fba1..c14ccc4091a0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"6490014377554ea6b62c657ab22c53fcec385b4ad24cdfccd95ebd9a79717e59","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"5dceb33d4f128b96962d6038b92f6dd50d501983d14224236d04da410e9de474","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/extension-shared.json b/docs/.generated/plugin-sdk-api-baseline/extension-shared.json index 8275af47ae0b..d469b1b810b0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/extension-shared.json +++ b/docs/.generated/plugin-sdk-api-baseline/extension-shared.json @@ -1 +1 @@ -{"contentHash":"21749b994b61953b5a3f274b26ae9c497616521659b8dd02b6ab59cecc881f77","entrypoint":"extension-shared","importSpecifier":"openclaw/plugin-sdk/extension-shared"} +{"contentHash":"f404d619a327692c0ff6e6c82ea592e93aa193dd08be98a84c33096951690caa","entrypoint":"extension-shared","importSpecifier":"openclaw/plugin-sdk/extension-shared"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 7bbe64aef447..d9c6c94bfa7c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"b691fcfb34a5f228938d06c50f9f8a26bb63ce06644e9df25f44876601e4cebb","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"ab874857dc00ef64368085dc0e2ef13d4abcc52dd40cb3c9baeb275681ebd04a","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/health.json b/docs/.generated/plugin-sdk-api-baseline/health.json index 5f1153addcf2..25bdc58367b2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/health.json +++ b/docs/.generated/plugin-sdk-api-baseline/health.json @@ -1 +1 @@ -{"contentHash":"3f25999ca07382c0014dd474bffffa2410b2e3c403cfba58641eae8d25f7b20c","entrypoint":"health","importSpecifier":"openclaw/plugin-sdk/health"} +{"contentHash":"5305c7c75c86c24e1b445370136422abf1804f85a0a31294ee03af7c38d902ee","entrypoint":"health","importSpecifier":"openclaw/plugin-sdk/health"} diff --git a/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json b/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json index a077570c8236..9e38584cde91 100644 --- a/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json @@ -1 +1 @@ -{"contentHash":"a726635b09ec7e6bd252582b7400b234bbd70d9f816675fe632bf1702b0c9f3b","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"} +{"contentHash":"9eeaa0de58ffbd07e63d6c6c25ec345d2178e822d40e1fe58f36872f172436b7","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 78798cf75996..33adfbe774ef 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"bb5122c6ac5f4dfe381493b9d128a303108434edca9434ad5528781d005271b8","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"a8c1c70f72a1c4d860682f9a00d09c9634f9c3b8044eadf5cf7f65475ecad73d","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json index b20cfea0cc1c..c55428080ea1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json @@ -1 +1 @@ -{"contentHash":"4d37083b179c1ea8adf47c13a224d6b05ef817a7b23920a02c836b0e64e54f5b","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} +{"contentHash":"f467cd02e663b2913d114e43133616aeb493024caf120f6359622e1e9ef41427","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/logging-core.json b/docs/.generated/plugin-sdk-api-baseline/logging-core.json index 28b802ad049a..0120066f36dd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/logging-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/logging-core.json @@ -1 +1 @@ -{"contentHash":"0559d15dde2d56ea677302ef074ddfe2c09c0dfbc2b84a5ad1e077d375f53e9a","entrypoint":"logging-core","importSpecifier":"openclaw/plugin-sdk/logging-core"} +{"contentHash":"0da8056fdf977ab74c89cbebb186895b5a1955302853a2f15784d552d7c30487","entrypoint":"logging-core","importSpecifier":"openclaw/plugin-sdk/logging-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json b/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json index 97792265d10e..189048ccd0a1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json @@ -1 +1 @@ -{"contentHash":"df3587c8c0300c86bcace11bcd3bd42f263ec1b527dd02fd5dded32496dd804d","entrypoint":"media-local-roots","importSpecifier":"openclaw/plugin-sdk/media-local-roots"} +{"contentHash":"654fa60879292e92a574e62daee2efdc0d26bd10dd1ba954dbf3843d8021b82a","entrypoint":"media-local-roots","importSpecifier":"openclaw/plugin-sdk/media-local-roots"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json index da4711560dc9..4d20a6a2ff64 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json @@ -1 +1 @@ -{"contentHash":"22eacf52b20db9ad9fe809b750911c6b137c53a4d766d7404ab60013a53ec901","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} +{"contentHash":"3dbf4bdcaf3b0545cca9ac8f4dfcde947d698f6a457d51fa001974598d016c6d","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json b/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json index a0f3c24d50b4..68d14ec90196 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json @@ -1 +1 @@ -{"contentHash":"86454131178316a2ba29ac4f871cbfe2223c71f584cfc10baac91f3541d57442","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"} +{"contentHash":"a7ed309dd80eff0eff856d6c24b924cb15c127e1e2e6a69279e45abbd6120aed","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-understanding.json b/docs/.generated/plugin-sdk-api-baseline/media-understanding.json index 4d62f05828b6..1597cba95a67 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-understanding.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-understanding.json @@ -1 +1 @@ -{"contentHash":"957d3fa32636f080a63823c0aec4247ed9d02baf39c7860879296c428e17d48f","entrypoint":"media-understanding","importSpecifier":"openclaw/plugin-sdk/media-understanding"} +{"contentHash":"ebfcda0b59c34586370fc234f07f80152fbc44d1c78bd4032363315826ad6c9b","entrypoint":"media-understanding","importSpecifier":"openclaw/plugin-sdk/media-understanding"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 2f8591069a4b..63fac0e92df7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"9060d4011e1249ca8aa0b01f2e2825d71b29eff14dc84f7f702440dc01e73460","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"2811b02c2aebc989cdf8f83af563e260da85b2d3bfcbf9b71ea8c4a29a12cb5b","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json b/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json index 23328ba06b88..59332d6f7da0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json +++ b/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json @@ -1 +1 @@ -{"contentHash":"fe951309f828ff94718b092d0017d40eeabf407663bc01179ce4a49309f60773","entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation"} +{"contentHash":"d7ecd46ea448722f37a5e17628d33a30b53d22ed4e2b8daa74f22e939f12fe32","entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json b/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json index 5f0c886ca4f0..1a198cae8c0e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json @@ -1 +1 @@ -{"contentHash":"2dcdb96ebaa91c8e938598fad3b3f5ecbd6999802ca7528c3f3c35aedb67c454","entrypoint":"memory-host-core","importSpecifier":"openclaw/plugin-sdk/memory-host-core"} +{"contentHash":"9c179624b39b9a4e7ae1847d9ca42ebfc34668bcf73246d06ea3558591d0f4d1","entrypoint":"memory-host-core","importSpecifier":"openclaw/plugin-sdk/memory-host-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index bf326a52a6cb..c35d6cb3b364 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"034bb7d9d0e7b1d8f0176c30c409db9f9f013a1c507eea53beb5c3da1d807340","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"96db0eb66488c8b02cb903e87c72a3c15d449d03b814c1bbf625772ff9eef938","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json index fac258f0c74d..5117959febfd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json @@ -1 +1 @@ -{"contentHash":"5afa8f742a472a31c3cbdbff8bebab674109049148bc24248874e813057a0c46","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} +{"contentHash":"a41b2cb57c392c10e0c16d4b0f8b4d9c9df82c5b8cb1ecac6223881645ce74bb","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json index 3d1be7f693c0..815d77df3641 100644 --- a/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json @@ -1 +1 @@ -{"contentHash":"8a51693c575603e3c263703a4162d91019e74c004c1ed26e6a6e6c590a784503","entrypoint":"native-command-config-runtime","importSpecifier":"openclaw/plugin-sdk/native-command-config-runtime"} +{"contentHash":"8f061f10edc2f699b8ad6d7c607c7d69d1a4d9a36d42989ab304c183caba3366","entrypoint":"native-command-config-runtime","importSpecifier":"openclaw/plugin-sdk/native-command-config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json b/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json index 480b6531aa69..f377ef1dbb72 100644 --- a/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json +++ b/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json @@ -1 +1 @@ -{"contentHash":"086b6a9f959c896ecbb529cd2e44054306a69c9ad4c331f323a948b524643188","entrypoint":"native-command-registry","importSpecifier":"openclaw/plugin-sdk/native-command-registry"} +{"contentHash":"b8b59df62269a2619eeb2f64a5714690124ec432d98ba23df4aa037d6971a7a1","entrypoint":"native-command-registry","importSpecifier":"openclaw/plugin-sdk/native-command-registry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json index 349b5da67fc6..8305e0b7c4c5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json @@ -1 +1 @@ -{"contentHash":"45c491f92e76d0523e2fafc47f362620e2598a5fdef95aed50ce4e8ff108423d","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} +{"contentHash":"9c0b8ea3e147d1de8173cdde492358c6780a399f7b0aa1ad3b6931fa46a78a5a","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json index 7db9c5481728..346a01605647 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json @@ -1 +1 @@ -{"contentHash":"fc14a640d5328c2d317eef0e9e5e11f40c81f31fb3f44e4f31587f725ba0e22c","entrypoint":"plugin-config-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-config-runtime"} +{"contentHash":"6ddb8caaf90a1087c7144354b07bf2b62cbc83173ffc0cad5f09cad8fc1b6d1e","entrypoint":"plugin-config-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index eb8a21e5eb31..d829e7f3fa4b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"a36c13699a318b3fb1b6701ab3280c45e5a4f5ca982cef29778d10af8a5ae97a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"f27f40ef92fc2dc6eebae6bdfc581c938fd654a76cd281f64669481ebf058684","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index b78837d1e262..4f3a8f7a2f48 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"bdf0b57a425cac872d156021006cce6813893bee30e77c5ef4c055343697dba0","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"2095966e155ab87c76a45bf0c5ca7d82ac9a25bde638537aa8263416acb264e6","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json index 1657b605ae7a..707a7d80f9a2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json @@ -1 +1 @@ -{"contentHash":"2c97bf2bccffb72065e3432432788926559cdc97f77a129786c4cc91246882a4","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} +{"contentHash":"1a53b2916dad0237deb2042c038fd1828957c514bf559a1639ef078ec2ff2077","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 084ac522181e..a287a763faa5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"62f6dc31a2b1789b3667fe16c2d465681aa4c01e2cd9b2a6da062fd995235963","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"13af1c1667ef2cb15b5a6d6770cc2c43948dda902120871015f26aed0d7a6cc5","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json index eb8279990b1e..7ab3fdba9b71 100644 --- a/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"1271e5eec56d6d72fcb47498dfa7b0eef0e25f136190f6ee103bc11930e4cdd9","entrypoint":"question-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/question-gateway-runtime"} +{"contentHash":"68b4d6f2a48e54a5db9fe5655a3ca0e46351f7a4caa21d065d7ccf3b628abde3","entrypoint":"question-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/question-gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json b/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json index 6f85231f4858..bef0b6de899a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json @@ -1 +1 @@ -{"contentHash":"12c9cb08e664eca320085e6ab274fd70008686e709da50f8d0b1db60ed706f90","entrypoint":"reply-chunking","importSpecifier":"openclaw/plugin-sdk/reply-chunking"} +{"contentHash":"7059cece2048a14ea093cdcaf9d45ce5f578f1d8522b1b600a5aeb60376a66ae","entrypoint":"reply-chunking","importSpecifier":"openclaw/plugin-sdk/reply-chunking"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json index 25f11c76f48c..936afa00b8d7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json @@ -1 +1 @@ -{"contentHash":"8dc220fc0bdf4ab69de389162dfe792335b83ddc36b900868fda4d418e3e0586","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} +{"contentHash":"35c993147ea09db79c62c87822fb923039f87323d244ece52d872a2b5f19a3cd","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-payload.json b/docs/.generated/plugin-sdk-api-baseline/reply-payload.json index 5a6bb25bc50f..253247ad6b52 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-payload.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-payload.json @@ -1 +1 @@ -{"contentHash":"560a2f2591955d9d1bf27498825ce6585ecca0528dcfecd02ea81a55e06164a1","entrypoint":"reply-payload","importSpecifier":"openclaw/plugin-sdk/reply-payload"} +{"contentHash":"aa91cf06f0ac5fb25ded9c937a3cf3e6e7ba692da0c8d4c1ceb659f13e17e706","entrypoint":"reply-payload","importSpecifier":"openclaw/plugin-sdk/reply-payload"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json index a73fd6f36326..05ef4a6f03ac 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json @@ -1 +1 @@ -{"contentHash":"664a2e42bd0e6ed65f9f8ae72bb79a7d0b89b3bd30e0f01390f3b5f4075cbf7a","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} +{"contentHash":"88e867535fca9357f158882f981694d76d9d29034b1a591c66a709c7e3e7a9fe","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/routing.json b/docs/.generated/plugin-sdk-api-baseline/routing.json index 8a2c39c6fb5b..444fc60dcbf4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/routing.json +++ b/docs/.generated/plugin-sdk-api-baseline/routing.json @@ -1 +1 @@ -{"contentHash":"d012d30a5588fbca2356796ab5fa4b88b1cca27343e04a44c0a989e3b5d405d3","entrypoint":"routing","importSpecifier":"openclaw/plugin-sdk/routing"} +{"contentHash":"e6978c1ead017ec2ff17d275e71efde9cce987b47704ea0641d9f21f9a2bd848","entrypoint":"routing","importSpecifier":"openclaw/plugin-sdk/routing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json b/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json index 3f49ac1afcf2..bd3bb32c6b77 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json @@ -1 +1 @@ -{"contentHash":"bfdc7c3120a5284944f6e4afbe639797832db915e9a6c4a6cca1446e59c03257","entrypoint":"runtime-config-snapshot","importSpecifier":"openclaw/plugin-sdk/runtime-config-snapshot"} +{"contentHash":"a2cc06e6088fd2ab1c87f3feaa067aa004de9683b7ffd6422598cc49a2131ef9","entrypoint":"runtime-config-snapshot","importSpecifier":"openclaw/plugin-sdk/runtime-config-snapshot"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json index 1069301d29d8..967eacfabe39 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json @@ -1 +1 @@ -{"contentHash":"b33bfa1835dbfedd3fef5d48720d57cf166fd60dd21727f1c18c49162eb2ab87","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} +{"contentHash":"ca82a040b8a7b2c72b6994f2f9e8cf111c37952ae6541a262409bc8845dd34a5","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime.json b/docs/.generated/plugin-sdk-api-baseline/runtime.json index bb4336f3081d..4273e4f1a0e1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime.json @@ -1 +1 @@ -{"contentHash":"678066cdd3d6b3e15384ce4b62c374b533fd92092d72b64e8d2a4659d8dc5dd1","entrypoint":"runtime","importSpecifier":"openclaw/plugin-sdk/runtime"} +{"contentHash":"8ed192bda18455093bac17415bff2e6247482e19b5e39cd4fdcf77ef32225cbe","entrypoint":"runtime","importSpecifier":"openclaw/plugin-sdk/runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json index b9adef89e83e..a7fa0900a6fc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json @@ -1 +1 @@ -{"contentHash":"aedf69e90fe8358e2dea78f2da7612773795c58bf56ee91838428fd8a9b505bc","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} +{"contentHash":"25fef1ad61081a884d7df06225a81e6f0f22e992ffa65b467e906e04f083767b","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json index cacfdf89c53d..ef8d7193acf0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json @@ -1 +1 @@ -{"contentHash":"6d67b6c12e0697673a14fa7e97c17937c46d09c8c37aab3c7a077d525ee3bbc9","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} +{"contentHash":"8ca2614364c682459caab5065e113989494848a4b6b0eb7eae5c58bab4a63732","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json index 44a2936d522f..be4f903ce7e0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json @@ -1 +1 @@ -{"contentHash":"26008b5b883abc446107f21c3ebba458c776d63c87eb79200e500d38c07869a7","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} +{"contentHash":"c32351ef7200a68e5cd7237f0a48627cc71a34bad237dbb747d1922d68bee46b","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json index 1adceaef9175..43bd08e9fc8f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json @@ -1 +1 @@ -{"contentHash":"b8218ab6c7789147d0ced29d4dfa1487af511a2a91f12808587ee97c702947c9","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} +{"contentHash":"e186ccb10168335ef3aaab4c0e62a4acdf34ae3bf96db5addad699cf0ebcd383","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json index a93b3946b42a..70cf7ce144cd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json @@ -1 +1 @@ -{"contentHash":"a4791b5d0a776811ed8beb14a10450948c7cf2c5be08872c83d4ac09dd8d2154","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} +{"contentHash":"44c8002745939dda2cadf7e9e88ddcda40cf8684332abd0f7aede307c1d9b462","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json index ca69597c0829..1f1fda46a879 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json @@ -1 +1 @@ -{"contentHash":"125d008a70654ff2013391d54755c7668bf49293d8629454ef5c9d2ab07d1d18","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} +{"contentHash":"7572bc03dcfd8641921d185df393d410282fb3eb95f328449f5250c2c50d8362","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup.json b/docs/.generated/plugin-sdk-api-baseline/setup.json index a77ef8d3de3c..aa4d968df26d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup.json @@ -1 +1 @@ -{"contentHash":"d122c7e96108909e3ea2bb220e49bfe0e1da221655884203ceb07b4a78dc84a4","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} +{"contentHash":"610f0ddda0d6488ec9558eb3c3642aa37157affb8ed502868498c2b8b1929fce","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json index 5af3908d8639..52e60a710a77 100644 --- a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json @@ -1 +1 @@ -{"contentHash":"c78fa67bd8c4bb9bc3d1188876a5d5a8a345c409a1bd55fe74e8b27053858ad8","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} +{"contentHash":"6bf95e6b195c9c8df6f4a2aa0629e53028fbecb5bc1f8af9c20a2221fbd0dade","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/speech-settings.json b/docs/.generated/plugin-sdk-api-baseline/speech-settings.json index 028b6fe65ae4..a29a1095c5f9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/speech-settings.json +++ b/docs/.generated/plugin-sdk-api-baseline/speech-settings.json @@ -1 +1 @@ -{"contentHash":"1307d56ecd36e77f2583bad440cb5781c064ce56755c41c7d63c846fb7d1d177","entrypoint":"speech-settings","importSpecifier":"openclaw/plugin-sdk/speech-settings"} +{"contentHash":"99c8490f019c8c9ecaca02b94cbe752386eef5ba2e5f02310593588ce6768446","entrypoint":"speech-settings","importSpecifier":"openclaw/plugin-sdk/speech-settings"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json index fefe4ccb5d22..d63f0d5123b8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json @@ -1 +1 @@ -{"contentHash":"646f40b3101481f438b90807e0865d7e4f07d9da951dd7f82b2b3c77eca3e10b","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} +{"contentHash":"8931852b1eafb6f95d9fe3dac7936436def9d4089815ac0ff8c97e106be4727d","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json index 639fcbea1b7f..c6030c1887b7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json @@ -1 +1 @@ -{"contentHash":"cabb7f5f5e7516c2948b84da18caf1c9f8af4fba6ac1d8441cfa5b5d215af84a","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} +{"contentHash":"f488c59b2c3ac1aee5d13073f85e986828b6c79ba5ade6ec23ba15c6a4a9433d","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/status-helpers.json b/docs/.generated/plugin-sdk-api-baseline/status-helpers.json index a66ac36464b6..d665bb3815e7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/status-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/status-helpers.json @@ -1 +1 @@ -{"contentHash":"0714c3ee4d2388fb476a891d8c0cbefc74b4765fc748846d190bd015b62d3864","entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers"} +{"contentHash":"5d466bf5186356cdb87531a463f5791d7b8b3d74a853a0d40748ff8bc59cc538","entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/telegram-account.json b/docs/.generated/plugin-sdk-api-baseline/telegram-account.json index b71943dc7748..b7f9767814be 100644 --- a/docs/.generated/plugin-sdk-api-baseline/telegram-account.json +++ b/docs/.generated/plugin-sdk-api-baseline/telegram-account.json @@ -1 +1 @@ -{"contentHash":"7d4d8f8eb94af9fc28456f6e1c5371ead887c263be8b2254864b1216ec8296a5","entrypoint":"telegram-account","importSpecifier":"openclaw/plugin-sdk/telegram-account"} +{"contentHash":"b2beba95a9528eeb7a9c8713714718f43e952ae477005d952ff6cce6e6371dbe","entrypoint":"telegram-account","importSpecifier":"openclaw/plugin-sdk/telegram-account"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 261089d57bc7..5b9aa42551bd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"3662752cd7db434787d728355a4fb46e8f4be5b88dece9c29d96225bc02da6db","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"d10f2818c55f491ead95bb6a65205c9829b67c13502e19a333d449e95871ab91","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index b6f15aef72f9..756155b8a49a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"66ab292503af6befc63d5962f3312a8ebab4ab3bf3a56f47fe4f6a465d7c40b9","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"f73812037a8411a776caeee2435e3b629ac6dbeee30f961062698e9cb58730a7","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json b/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json index aacf96ee055a..b3489cac77ed 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json @@ -1 +1 @@ -{"contentHash":"431ae89ff59e0131e1019e132a2d1afb0608a23e8ff919cc9b60f69f614cb90a","entrypoint":"webhook-request-guards","importSpecifier":"openclaw/plugin-sdk/webhook-request-guards"} +{"contentHash":"42351fc155ba065d9ee60a84d66abb2d7796ba4b1d67a4b9ff80e6a3e39b4a5f","entrypoint":"webhook-request-guards","importSpecifier":"openclaw/plugin-sdk/webhook-request-guards"} diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index fdb0b5e8a03b..fae273bbc76d 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -1746,5 +1746,9 @@ { "source": "Connect a machine", "target": "连接机器" + }, + { + "source": "Portals", + "target": "门户" } ] diff --git a/docs/ci.md b/docs/ci.md index fa6f2b7c608c..714be4125671 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -28,38 +28,38 @@ dispatch. ## Pipeline overview -| Job | Purpose | When it runs | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| `preflight` | Detect changed scopes and build the CI manifest; on canonical Node-relevant `main`, refresh and maintain the dependency snapshot before fanout | Always on non-draft pushes and PRs | -| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs | -| `pnpm-store-warmup` | Warm the lockfile-pinned Actions cache for pull requests and manual runs without blocking Linux Node shards | Node or docs-check lanes selected outside main | -| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, startup memory, and embedded built-artifact checks | Node-relevant changes | -| `control-ui-i18n` | Verify generated Control UI locale bundles, metadata, and translation memory; advisory on automatic runs, blocking on manual release CI | Control UI i18n-relevant changes and manual CI | -| `checks-fast-core` | Fast Linux correctness lanes: suppression-baseline max-lines ratchet, bundled + protocol, Bun launcher, and the CI-routing fast task | Node-relevant changes | -| `qa-smoke-ci-profile` | Self-contained balanced parts of the automatic QA Smoke coverage set; full taxonomy coverage remains available through explicit QA profiles | Node-relevant changes | -| `checks-fast-contracts-plugins-*` | Two weighted plugin contract shards | Node-relevant changes | -| `checks-fast-contracts-channels-*` | Two weighted channel contract shards | Node-relevant changes | -| `checks-node-*` | Changed-target Node tests on pull requests; full core shards on `main`, manual, release, and broad-fallback runs | Node-relevant changes | -| `check-*` | Sharded main local gate equivalent: guards, transient npm-lock validation, bundled-channel config metadata, prod types, lint, dependencies, test types | Node-relevant changes | -| `check-additional-*` | Boundary check stripes (including prompt snapshot drift), session accessor/transcript reader/SQLite transaction boundaries, extension lint groups, package boundary compile/canary, and runtime topology architecture | Node-relevant changes | -| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Manual CI dispatch for releases | -| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed (PRs and manual dispatch) | -| `native-i18n` | Verify native source extraction and localization safety on source PRs; enforce full translated/platform-generated parity on generated PRs and manual CI | Native i18n-relevant changes | -| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes | -| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes | -| `macos-node` | Focused macOS TypeScript tests: launchd, Homebrew, runtime paths, packaging scripts, process-group wrapper | macOS-relevant changes | -| `macos-swift` | Swift lint and build for the macOS app, plus tests for the app and shared OpenClawKit package | macOS-relevant changes | -| `ios-build` | Swift lint, Debug and Release builds, focused simulator lifecycle tests, and the full release screenshot matrix when screenshot-pipeline owners changed | iOS/capture changes | -| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes | -| `openclaw/ci-gate` | Final aggregate: requires preflight and security; accepts skips only for manifest-disabled downstream lanes | Every non-draft CI run | -| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch | -| `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch | +| Job | Purpose | When it runs | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `preflight` | Detect changed scopes and build the CI manifest; on canonical Node-relevant `main` and same-repo PRs, publish or restore the exact dependency cache before fanout | Always on non-draft pushes and PRs | +| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs | +| `pnpm-store-warmup` | Warm the lockfile-pinned Actions cache for fork PRs, manual runs, and same-repo docs-only PRs | Node or docs-check lanes without an exact-cache writer | +| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, startup memory, and embedded built-artifact checks | Node-relevant changes | +| `control-ui-i18n` | Verify generated Control UI locale bundles, metadata, and translation memory; advisory on automatic runs, blocking on manual release CI | Control UI i18n-relevant changes and manual CI | +| `checks-fast-core` | Fast Linux correctness lanes: suppression-baseline max-lines ratchet, bundled + protocol, Bun launcher, and the CI-routing fast task | Node-relevant changes | +| `qa-smoke-ci-profile` | Self-contained balanced parts of the automatic QA Smoke coverage set; full taxonomy coverage remains available through explicit QA profiles | Node-relevant changes | +| `checks-fast-contracts-plugins-*` | Two weighted plugin contract shards | Node-relevant changes | +| `checks-fast-contracts-channels-*` | Two weighted channel contract shards | Node-relevant changes | +| `checks-node-*` | Changed-target Node tests on pull requests; full core shards on `main`, manual, release, and broad-fallback runs | Node-relevant changes | +| `check-*` | Sharded main local gate equivalent: guards, transient npm-lock validation, bundled-channel config metadata, prod types, lint, dependencies, test types | Node-relevant changes | +| `check-additional-*` | Boundary check stripes (including prompt snapshot drift), session accessor/transcript reader/SQLite transaction boundaries, extension lint groups, package boundary compile/canary, and runtime topology architecture | Node-relevant changes | +| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Manual CI dispatch for releases | +| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed (PRs and manual dispatch) | +| `native-i18n` | Verify native source extraction and localization safety on source PRs; enforce full translated/platform-generated parity on generated PRs and manual CI | Native i18n-relevant changes | +| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes | +| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes | +| `macos-node` | Focused macOS TypeScript tests: launchd, Homebrew, runtime paths, packaging scripts, process-group wrapper | macOS-relevant changes | +| `macos-swift` | Swift lint and build for the macOS app, plus tests for the app and shared OpenClawKit package | macOS-relevant changes | +| `ios-build` | Swift lint, Debug and Release builds, focused simulator lifecycle tests, and the full release screenshot matrix when screenshot-pipeline owners changed | iOS/capture changes | +| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes | +| `openclaw/ci-gate` | Final aggregate: requires preflight and security; accepts skips only for manifest-disabled downstream lanes | Every non-draft CI run | +| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch | +| `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch | Standalone Periphery workflows enforce zero dead-code findings for the iOS and macOS apps. The shared OpenClawKit workflow scans both consumers in parallel and reports a declaration only when Periphery emits the same Swift USR from both builds. Its generated `OpenClawProtocol/GatewayModels.swift` schema contract is retained as generator-owned code rather than treated as app-local dead code. ## Fail-fast order -1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs. Canonical `main` starts immediately, but its concurrency group admits only one complete run and coalesces later pushes into one newest pending run. Node-relevant main pushes also serialize the sole dependency-disk writer and its size maintenance here before downstream jobs may mount the key; Blacksmith may expose a fresh commit only to a later workflow run, so same-run consumers retain the marker-checked local fallback. +1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs. Canonical `main` starts immediately, but its concurrency group admits only one complete run and coalesces later pushes into one newest pending run. On Node-relevant canonical `main` pushes and same-repository pull requests, preflight is the sole exact dependency-cache writer; downstream jobs wait for it, then restore the immutable archive or fall back to the ordinary pnpm-store cache on a miss. 2. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs. 3. `build-artifacts` and the locale checks overlap with the fast Linux lanes. Control UI and native app source PRs exclude generated locale snapshots/resources; their serialized refresh workflows repair and auto-merge isolated generated PRs in the background. Source CI still blocks stale source inventories and unsafe localization calls. Generated PRs, manual CI, and release prep enforce full translated/platform-generated parity. Canonical `release/YYYY.M.PATCH` branches may include release-prep locale repairs with the other generated release output. 4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`. @@ -119,12 +119,12 @@ The slowest Node test families are split or balanced so each job stays small wit - Auto-reply runs as balanced workers, with the reply subtree split into agent-runner, commands, dispatch, session, and state-routing shards. - Agentic gateway/server (control-plane) configs split across chat, auth, model, HTTP/plugin, runtime, and startup lanes instead of waiting on built artifacts. - Normal CI packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites. Heavy fixed suites stay on 8 vCPU while most bundled and lower-weight lanes use 4 vCPU. Compact-small bins 2, 5, and 8 use existing 8-vCPU capacity because recent hosted runs showed they repeatedly owned the critical path while the 4-vCPU queue was materially longer; routing happens after packing, so group ownership, coverage, and the existing registration count do not change. -- Pull requests on the canonical repository reuse the changed-test resolver against the synthetic merged-tree diff. Precise changes run one targeted Node job; each selected test file gets its own process so stateful suite isolation remains intact. The planner combines sibling tests with import-graph dependents and falls back to the existing 23-job compact full-suite plan for workspace package, package/lockfile, shared harness, split-config, renamed, or deleted changes, public extension-contract changes, tests with special shard setup, partially resolved or empty targets, oversized path or target plans, and planner errors. Targeted plans always retain the full built-artifact boundary gate because its repository scanners cannot be derived from imports. `main` pushes run the same full compact suite: pending intermediate push events can be coalesced, so the newest surviving run must validate the complete integration tree rather than only its final single-push diff. Manual dispatches and release gates retain the full named per-shard matrix. Compact packing uses hosted means to tail-balance regular 8-vCPU bins while retaining median admission and 4-vCPU striping weights, so recurrent slow tails rebalance without changing the bounded job count or post-pack runner advisory; the high-variance source/security group remains isolated so its tail does not serialize unrelated groups. +- Pull requests on the canonical repository reuse the changed-test resolver against the synthetic merged-tree diff. Precise changes run one targeted Node job; each selected test file gets its own process so stateful suite isolation remains intact. The planner combines sibling tests with import-graph dependents and falls back to the existing 25-job compact full-suite plan for workspace package, package/lockfile, shared harness, split-config, renamed, or deleted changes, public extension-contract changes, tests with special shard setup, partially resolved or empty targets, oversized path or target plans, and planner errors. Targeted plans always retain the full built-artifact boundary gate because its repository scanners cannot be derived from imports. `main` pushes run the same full compact suite: pending intermediate push events can be coalesced, so the newest surviving run must validate the complete integration tree rather than only its final single-push diff. Manual dispatches and release gates retain the full named per-shard matrix. Compact packing uses hosted means to tail-balance regular 8-vCPU bins while retaining median admission and 4-vCPU striping weights, so recurrent slow tails rebalance without changing the bounded job count or post-pack runner advisory; the high-variance source/security group remains isolated so its tail does not serialize unrelated groups. - The full Node matrix admits the consistently slow serial tooling, auto-reply command shards, and broad core-fast cache writer first. This keeps the 28-job cap while preventing critical-path work and the next run's transform seed from slipping into a later wave. - The three serial Control UI browser shards greedily pack discovered test files by source byte size. This zero-state duration proxy avoids Vitest's equal-file-count hash clustering, automatically accounts for new and changed files, and preserves the same complete test inventory without adding runners. - Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. - Linux Node shard jobs persist Vitest's experimental filesystem module cache through the upstream Actions cache API, which Blacksmith transparently accelerates on its runners. Every CI shard is restore-only and unpacks the protected seed into its own runner-local root; the shard wrapper then gives concurrent Vitest processes separate live subdirectories. Only the non-cancelling daily or explicitly dispatched warmer saves a new immutable archive, so pull requests cannot publish transforms or mint per-PR cache families. The warmer launches each selected shard/config envelope in a fresh child process with concurrency one, preserving its include patterns and environment while reusing the same serial cache leaf. This prevents config-global state from leaking, avoids expanding filtered shards into whole configs, and retains transforms produced by the previous child. A transform-input fingerprint clears incompatible lockfile, package, tsconfig, and Vitest-config generations. The protected writer scans and prunes its restored cache to 75% after it exceeds 2 GiB. Vitest hashes module id, source content, environment, and resolved transform config, so ordinary partial source changes keep unchanged entries warm while changed modules miss safely. Coarse restore prefixes bridge workflow runs; normal Actions cache LRU and inactivity eviction bound old immutable archives. -- Trusted Blacksmith Linux Node jobs also bind the pnpm store and `node_modules` from one protected dependency disk per supported Node line. GitHub-hosted jobs, including manual dispatches, fork pull requests, and same-repo retries of both UI E2E jobs, use the Actions cache path instead. Package manifests, install settings, runner platform, and the exact Node patch stay out of the disk key; an exact runtime and install-input fingerprint decides whether a job reuses the tree or reinstalls and refreshes the same disk. Manifests are canonicalized before hashing. The repository-owned `openclaw` metadata block and non-install scripts are excluded because pnpm and the audited direct root hooks do not read them, so runtime schema, publication metadata, formatting, and ordinary test/build script edits keep the warm dependency tree; unaudited lifecycle-hook drift fails closed until its source inputs join the fingerprint contract. Dependency, package-manager, hook-source, and lockfile changes always invalidate the snapshot. A matching fingerprint is necessary but not sufficient: setup also checks the importer archive and manifest checksums, then verifies registry-backed lockfile dependencies retained by postinstall against the package manifests Node resolves from their importers. Missing or stale importer content falls back to a fresh install instead of serving the root hoist. A pull request whose read-only snapshot is unusable detaches the workspace bind and installs into runner-local storage, avoiding slow writes to a clone it cannot publish. Sticky cold installs disable pnpm's inner fetch retries and make up to three bounded full-install attempts from the progressively warmed store; a timeout remains a failure. After a content-validated restore or frozen-lockfile install, setup disables pnpm's redundant pre-run dependency check: the repository intentionally prunes plugin-local `node_modules`, which pnpm otherwise treats as stale and repairs through unsafe concurrent implicit installs during shard fanout. Canonical main preflight is the sole writer and measures the store on every refresh, running `pnpm store prune` only after retired package versions push it above 8 GiB. Validated warm restores no longer publish no-op snapshots: the writer uses StickyDisk's allocation-change mode, records its mount-time allocation baseline, and only a successful dependency capture creates a runner-local rebuild signal. After store pruning, preflight compares the final whole-disk allocation to that baseline and, when needed, allocates a bounded sentinel until the absolute delta has a verified 64 KiB margin over StickyDisk's 4 KiB threshold. Blacksmith snapshot publication is asynchronous even after a writer job completes, so the first run after a fresh key or fingerprint can remain cold; later content-validated exact-marker restores are the rollout proof. Required Blacksmith CI jobs and first-attempt same-repo pull requests get disposable clones, so dependency changes do not create new disks, competing snapshots, or a cache lock that can cancel builds. +- Trusted Blacksmith Linux Node jobs restore root `node_modules`, retained workspace importer links, and the workspace-local pnpm store from one immutable upstream Actions cache, which Blacksmith transparently serves from its colocated backend. Pnpm imports with hard links where the filesystem permits, and keeping the complete installed tree and store in one archive preserves those links; plugin importer trees that postinstall intentionally removes remain absent. The key includes an explicit archive format, runner OS and architecture, the exact Node patch, and the semantic install-input fingerprint; there are no stale-prefix fallbacks. Manifests are canonicalized before hashing. The repository-owned `openclaw` metadata block and non-install scripts are excluded because pnpm and the audited direct root hooks do not read them, so runtime schema, publication metadata, formatting, and ordinary test/build script edits keep the dependency tree warm; unaudited lifecycle-hook drift fails closed until its source inputs join the fingerprint contract. Dependency, package-manager, hook-source, and lockfile changes always select a new immutable archive. Every exact restore runs frozen offline pnpm reconciliation, so an unchanged archive validates without registry access or importer relinking. If reconciliation fails, setup first clears every importer tree and rebuilds it offline from the restored store, then clears both modules and store and retries from the network rather than serving a partial tree. Setup then disables pnpm's redundant pre-run dependency check because postinstall intentionally prunes plugin-local `node_modules`, which pnpm otherwise treats as stale and can repair through unsafe concurrent installs during shard fanout. Preflight is the sole writer and saves immediately after a successful deterministic install: canonical `main` publishes the default-branch seed, while same-repo pull requests publish only into their merge-ref scope before their dependent jobs fan out. Consumers are restore-only; an exact miss automatically falls back to the coarser pnpm store cache. Manual dispatches, fork pull requests, and hosted retries use only that store cache, and the separate store-warmer is skipped when preflight already owns either exact-cache write. Cache restore/save failures are optimization misses rather than correctness failures, and normal branch scoping, LRU, and inactivity eviction bound obsolete archives. The former mutable dependency StickyDisk path was retired after repeated successful writers acknowledged commits that later runs still restored as empty filesystems. - Node shard and build-artifact jobs also restore Node's portable on-disk compile cache through immutable Actions caches. Independent `test` and `build` namespaces prevent their writers from replacing each other's archives: the scheduled test warmer owns the protected test seed, while `build-artifacts` may publish at most one protected build archive per UTC day from trusted `main` pushes. PR and ordinary test jobs only read protected snapshots, so feature-branch bytecode never enters the shared seed and PR traffic creates no cache archives. This reuses V8 bytecode for Node-loaded orchestration, build tooling, and external dependencies across different checkout paths, including when only part of the source graph changes. Vitest child processes disable an inherited compile cache because coverage can be enabled inside dynamic configs and V8 coverage can lose source-position precision when scripts are deserialized from bytecode. - The build-artifact job also persists content-fingerprinted `build-all` step outputs. CI's self-built plugin SDK declarations hash the complete repository-owned TypeScript/JSON source graph, exclude installed and generated directories, and restore both flat declarations and package bridges after `tsdown` clears `dist`. Documentation, workflow, plugin, and other changes outside that graph can reuse the declaration snapshot; source changes rebuild it before the export gate runs. The built Doctor plugin-index proof reuses that exact `dist/` output instead of invoking the E2E harness's fallback TypeScript build a second time. - Full declaration builds split `tsdown` into AI, workspace-package, and unified groups. Each group caches declarations only, then still rebuilds runtime JavaScript before restoring those declarations. Core or plugin changes therefore invalidate only the large unified graph, while workspace-package changes conservatively invalidate every dependent declaration group. Public full builds generally use an immutable Actions cache; coarse restore keys seed partial changes, per-group content fingerprints reject stale data, and GitHub's cache quota evicts old generations. The weekly Node 22 lane instead publishes a 14-day artifact after successful `main` runs and restores only artifacts whose immutable producer identity resolves to that workflow on `main`, avoiding quota churn without allowing PR code to write a shared cache. Private-QA declarations are never persisted in Actions caches because cache namespaces are not confidentiality boundaries. @@ -139,7 +139,7 @@ job budget. Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest` and then builds the Play debug APK. The third-party flavor has no separate source set or manifest; its unit-test lane still compiles the flavor with the SMS/call-log BuildConfig flags, while avoiding a duplicate debug APK packaging job on every Android-relevant push. Each current Gradle task has one protected sticky disk; PR jobs use disposable clones, while protected runs refresh content-addressed Gradle entries in place. -Blacksmith sticky-disk keys are deliberately bounded by supported runtime or task dimensions, never PR number, commit, run, branch, or dependency hash. Runtime transform and compile caches use Actions cache instead of sticky disks because immutable archives expose verifiable restore/save results and avoid mutable snapshot-promotion failures. After a sticky key-version migration, add only the exact obsolete key, architecture, and region identities to `.github/retired-sticky-disks.json`, dispatch `Sticky Disk Cleanup` from `main` with the same dimensions and confirmation, verify deletion, then remove those entries. The workflow routes ARM identities to an ARM runner, rejects runner-region mismatches, uses Blacksmith's exact-key deletion action, and never deletes Docker builder caches or wildcard prefixes. Actions cache archives use normal LRU and inactivity eviction. +Remaining Blacksmith sticky-disk keys are deliberately bounded by supported task dimensions, never PR number, commit, run, branch, or dependency hash. Dependency, runtime transform, and compile caches use Actions cache instead because immutable archives expose verifiable restore/save results and avoid mutable snapshot-promotion failures. After a sticky key-version migration, add only the exact obsolete key, architecture, and region identities to `.github/retired-sticky-disks.json`, dispatch `Sticky Disk Cleanup` from `main` with the same dimensions and confirmation, verify deletion, then remove those entries. The workflow routes ARM identities to an ARM runner, rejects runner-region mismatches, uses Blacksmith's exact-key deletion action, and never deletes Docker builder caches or wildcard prefixes. Actions cache archives use normal LRU and inactivity eviction. The `check-dependencies` shard runs production Knip dependency, unused-file, and unused-export checks. The unused-file guard fails when a PR adds a new unreviewed unused file or leaves a stale allowlist entry, while preserving intentional dynamic plugin, generated, build, live-test, and package bridge surfaces that Knip cannot resolve statically. The unused-export guard excludes test-support files and fails on every unused production export; intentional dynamic consumers must be modeled in `config/knip.config.ts`. Historical targets run the export guard when they provide it and retain their older dead-code fallback otherwise. diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index b0412cd6033c..30d48786a168 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -222,6 +222,10 @@ openclaw sessions cleanup --json pressure-gated: it only removes stale probe rows when session-entry maintenance/cap pressure is reached. When it runs, model-run cleanup happens before global stale cleanup and capping. +- `maxEntries` caps only eviction-eligible rows. Protected rows are reported as + `keep` and stay outside the allowance, so the total row count can exceed the + configured cap. `--enforce` does not remove that protection; unarchive, + unpin, or explicitly delete sessions you no longer want to retain. Flags: diff --git a/docs/concepts/session.md b/docs/concepts/session.md index cd10f46bf3b9..031532c0a128 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -196,6 +196,13 @@ Session store reads do not prune or cap entries during Gateway startup, so startup and isolated cron sessions do not pay for a full store cleanup. `openclaw sessions cleanup --enforce` applies the cap immediately. +`maxEntries` counts only eviction-eligible session rows. Protected rows - +archived or pinned sessions, active or admitted work, model-locked sessions, +and durable external conversation pointers - stay outside that allowance, so +the total stored row count can exceed `maxEntries`. Cleanup does not unprotect +those rows; unarchive, unpin, or explicitly delete sessions you no longer want +to retain. + Gateway model-run probe sessions are short-lived by default. Rows matching `agent:*:explicit:model-run-` use fixed `24h` retention, but cleanup is pressure-gated: it only removes stale probe rows when session-entry @@ -207,10 +214,10 @@ Maintenance preserves durable external conversation pointers, including group sessions and thread-scoped chat sessions, while still allowing synthetic cron, hook, heartbeat, ACP, and sub-agent entries to age out. -Archived sessions are user-shelved and exempt from every automatic maintenance -path, including age pruning, entry caps, model-run cleanup, and disk-budget -eviction. They remain archived until you unarchive them or explicitly delete -them. +Archived and pinned sessions are user-protected and exempt from every automatic +maintenance path, including age pruning, entry caps, model-run cleanup, and +disk-budget eviction. They remain protected until you unarchive, unpin, or +explicitly delete them. If you previously used DM isolation and later returned `session.dmScope` to `main`, preview stale peer-keyed DM rows with diff --git a/docs/docs.json b/docs/docs.json index 5210dbaaeebe..8a58e8df66ee 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1725,6 +1725,7 @@ "network", "gateway/pairing", "gateway/discovery", + "gateway/portals", "gateway/bonjour" ] } diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index e374d0406e9e..c9be8ff998a2 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1248,7 +1248,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - **`maintenance`**: session-store cleanup + retention controls. - `mode`: `enforce` applies cleanup and is the default; `warn` emits warnings only. - `pruneAfter`: age cutoff for stale entries (default `30d`). - - `maxEntries`: maximum number of SQLite session entries (default `500`). Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the cap immediately. + - `maxEntries`: maximum number of eviction-eligible SQLite session entries (default `500`). Archived or pinned sessions, active or admitted work, model-locked sessions, and durable external conversation pointers stay outside the allowance, so the total row count can exceed this value. Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the eligible-row cap immediately but does not unprotect rows. Unarchive, unpin, or explicitly delete protected sessions to reduce their count. - Short-lived gateway model-run probe sessions use fixed `24h` retention, but cleanup is pressure-gated: it only removes stale strict model-run probe rows when session-entry maintenance/cap pressure is reached. Only strict explicit probe keys matching `agent:*:explicit:model-run-` are eligible; normal direct, group, thread, cron, hook, heartbeat, ACP, and sub-agent sessions do not inherit this 24h retention. When model-run cleanup runs, it runs before the broader `pruneAfter` stale-entry cleanup and `maxEntries` cap. - Legacy `rotateBytes` is rejected by the current schema; `openclaw doctor --fix` removes it from older configs. - `resetArchiveRetention`: age-based retention for reset/deleted transcript archives. By default, archives remain until disk-budget eviction; set a duration to opt into wall-clock deletion, or `false` to disable it explicitly. diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index aadecf17c4d5..506b9a969d1f 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -38,7 +38,7 @@ Local onboarding defaults new local configs to `tools.profile: "coding"` when un | `group:sessions` | `sessions`, `sessions_list`, `sessions_history`, `sessions_search`, `conversations_list`, `conversations_send`, `conversations_turn`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `subagents`, `session_status`, `suggest_task`, `dismiss_task` | | `group:memory` | `memory_search`, `memory_get` | | `group:web` | `web_search`, `x_search`, `web_fetch` | -| `group:ui` | `browser`, `screen`, `terminal`, `canvas`, `show_widget` | +| `group:ui` | `browser`, `screen`, `dashboard`, `terminal`, `portal`, `canvas`, `show_widget` | | `group:automation` | `heartbeat_respond`, `cron`, `gateway` | | `group:messaging` | `message` | | `group:nodes` | `nodes`, `computer` | diff --git a/docs/gateway/portals.md b/docs/gateway/portals.md new file mode 100644 index 000000000000..41b5f7bf21ea --- /dev/null +++ b/docs/gateway/portals.md @@ -0,0 +1,114 @@ +--- +title: "Portals" +summary: "Expose agent-run development servers to the operator through the Gateway" +read_when: + - Showing a development server in the Control UI + - Declaring workspace development servers for an agent + - Troubleshooting portal access or live reload +--- + +Portals expose a development server running on the Gateway host to the operator's browser. They proxy HTTP and WebSockets for live reload and appear in **Control UI → Portals**. + +## Quick start + +Ask the agent to open a portal: + +- "Show me in a portal." +- "Start the app in a portal." + +The agent opens a portal for the application's port, then starts the development server with a background `exec` call. Opening a portal only creates the proxy listener; it does not inject environment variables into your server. The agent sets `PORT` (the port it opened) and `PUBLIC_URL` (the portal's public base URL) in that `exec` command's own environment, so the app binds the expected port and generates correct absolute URLs. + +## Declare development servers + +Optionally commit `.openclaw/portals.json` to the workspace repository so the agent can discover the available development servers: + +```json +{ + "portals": [ + { + "name": "web", + "command": "pnpm dev", + "cwd": ".", + "port": 3000, + "title": "App", + "description": "Use the seeded test account." + } + ] +} +``` + +The Gateway never executes these commands automatically. The agent reads the file and decides when to run a declared server. + +| Field | Required | Description | +| ------------- | -------- | -------------------------------------------------- | +| `name` | yes | Stable name the agent uses to identify the server. | +| `command` | yes | Command the agent starts with background `exec`. | +| `port` | yes | Local TCP port the application listens on. | +| `cwd` | no | Working directory relative to the workspace root. | +| `title` | no | Display title shown on the Portals page. | +| `description` | no | Operator guidance shown beside the portal. | +| `path` | no | Initial URL path. It must begin with `/`. | + +## Application contract + +The application must honor `PORT`. Use `PUBLIC_URL` when it needs to generate absolute URLs. + +The proxy rewrites `Host` to the local target, so typical development servers such as Vite and Next.js need no additional configuration. WebSockets and hot module replacement are proxied through the same portal. + +## Availability and configuration + +Portals add no dedicated configuration key. The `portal` tool follows ordinary tool policy, described in [Tools configuration](/gateway/config-tools). + +Out of the box: + +- `portal` belongs to `group:ui` and the `coding` profile, so coding agents have it while `messaging` and `minimal` agents do not. +- Sandboxed sessions never receive it, because opening a portal starts a listener on the Gateway host. +- It is blocked for HTTP `POST /tools/invoke` and restricted to the session owner, the same treatment `terminal` gets. + +To turn portals off everywhere, deny the tool in the global policy: + +```json5 +{ + tools: { deny: ["portal"] }, +} +``` + +To turn them off for a single agent, leaving the others unchanged: + +```json5 +{ + agents: { entries: { "": { tools: { deny: ["portal"] } } } }, +} +``` + +`tools.profile`, `tools.allow`, `byProvider`, and `toolsBySender` apply to `portal` as they do to any other tool, so portals can also be limited to specific providers, models, or senders without a portal-specific setting. + +One consequence worth planning for: portal listeners bind the same interfaces as the Gateway. A Gateway bound to a LAN or tailnet address publishes its portal listener ports on that network too. Reaching one still requires the portal token, but deny the tool when the Gateway host must not offer operator-reachable application ports at all. + +## Security model + +Each portal uses a separate origin on its own port and binds to the same interfaces as the Gateway. Access requires the token in the portal URL. On the first request, the proxy stores that token in an HttpOnly cookie and removes it from subsequent upstream requests. The proxy validates this cookie itself and never forwards it to the application. + +Browser cookies are hostname-scoped rather than port-scoped, so the proxy isolates each application's cookie jar with an `oc_portal__` name prefix. Requests forward only cookies with that portal's prefix and strip it before reaching the application; Gateway cookies, unprefixed cookies, and cookies for other portals are dropped. Application `Set-Cookie` responses receive the prefix, and any `Domain` attribute is removed so the cookie stays host-only. + +Portals proxy only the selected local development server. They never serve Gateway data, and every portal ends when the Gateway restarts. + +## Limitations + +- The development server must run on the Gateway host. Remote worker support is planned. +- A proxy or tunnel in front of the Gateway does not automatically expose portal listener ports. The Control UI detects this and shows a reachable URL with retry guidance instead of mounting a dead iframe. +- Browser-side cookie code sees the prefixed names in `document.cookie`. Applications that manage cookies in browser code must account for the prefix; unprefixed cookies written directly by browser code are not forwarded to the target. + +## Troubleshooting + +### The portal shows a 502 waiting page + +The proxy is ready, but the application is not listening on the selected port. The page retries automatically. Check the background process and confirm that the server honors `PORT`. + +### The portal is not reachable from this browser + +The Control UI could reach the Gateway but could not reach the portal's separate listener port. This commonly happens when a proxy or tunnel exposes only the main Gateway port. Open the displayed portal URL from a browser on the Gateway host, or expose that portal listener port through the same network path, then select **Retry**. + +### Close a portal + +Ask the agent to "close the portal," or use the close button on the **Control UI → Portals** page. diff --git a/docs/nodes/computer-use.md b/docs/nodes/computer-use.md index 9b9cd71696e1..39b5962dbfdf 100644 --- a/docs/nodes/computer-use.md +++ b/docs/nodes/computer-use.md @@ -16,7 +16,7 @@ The agent emits one uniform command, `computer.act`; it cannot tell how a node f - A paired, connected node advertising both `computer.act` and `screen.snapshot`, with `screen.snapshot` returning `displayFrameId`. - **macOS fulfiller:** app setting **Allow Computer Control** enabled. It defaults on; an explicit off choice stays off. - **macOS fulfiller:** **Accessibility** and Event Posting access granted to OpenClaw (for pointer/keyboard injection), plus **Screen Recording** permission (for `screen.snapshot`). -- **Windows/Linux fulfiller:** bundled `cua-computer` plugin enabled. Its package includes the pinned CUA Driver SDK 0.14.1 runtime; no `cua-driver` executable, daemon, or MCP server is configured. +- **Windows/Linux fulfiller:** bundled `cua-computer` plugin enabled. Its package includes the pinned CUA Driver SDK 0.19.3 runtime; no `cua-driver` executable, daemon, or MCP server is configured. - The pairing update that includes `computer.act` approved on the gateway. - A vision-capable agent model. - Tool policy that exposes `computer`. The default `coding` profile does not. Add `computer` to `tools.alsoAllow`; sandboxed agents also need it in `tools.sandbox.tools.alsoAllow`. @@ -37,7 +37,7 @@ Screenshots are kept **model-only**: they are never auto-delivered to the chat c ## Windows and Linux (experimental, via CUA Driver SDK) -The bundled `cua-computer` plugin provides an experimental fulfiller for Windows and Linux node hosts. It is disabled by default and uses the pinned CUA Driver SDK 0.14.1 contract directly: +The bundled `cua-computer` plugin provides an experimental fulfiller for Windows and Linux node hosts. It is disabled by default and uses the pinned CUA Driver SDK 0.19.3 contract directly: 1. Enable the plugin: diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 6802062b9b4c..6fed46693de3 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -406,6 +406,29 @@ Gateway transcript, and reject attachments and images. Claude Desktop rows and nodes that do not advertise the run command remain view-only. The macOS app node does not advertise this command yet, so its rows remain view-only. +### Host OpenClaw sessions + +A headless node host can separately opt into full OpenClaw session hosting from +its local installation: + +```json5 +{ + nodeHost: { + workerRuns: { enabled: true }, + }, +} +``` + +Restart the node host after enabling this setting. At startup it advertises the +exact OpenClaw version, worker-bundle hash, and worker protocol features of its +own installation. The Gateway offers the device as a session host only while +that advertisement is live, and provisioning requires the node and Gateway +versions to match exactly. If they differ, update the node before retrying. + +This setting completes device-environment provisioning but does not yet enable +turn launch on the device. The local-install chain adds supervised launch and +workspace transport in subsequent steps. + See [Anthropic: Claude sessions across computers](/providers/anthropic#claude-sessions-across-computers) for the Control UI behavior and storage sources. diff --git a/docs/plan/runners.md b/docs/plan/runners.md index 84bdfa6649ad..0e18660ab9f3 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -427,6 +427,10 @@ Independently mergeable PR series; 3–5 can interleave after 1c. column. Fault-injection tests gate exit: device sleep mid-turn, node WS blip mid-turn (turn survives), gateway restart with offline device, credential expiry, slot saturation, dispatch-with-no-live-runner timeout. + The local-install route lands as three PRs: **A** enables node session-host + advertisement and completes receipt/credential provisioning, **B** wires + supervised worker launch, and **C** adds workspace transport. Milestone 7 + then upgrades this paired-machine claim to Gateway-pinned bundle bytes. 7. **Bundle push + updates**: consent split, push over paired channel, version surfacing, stale-node dispatch refusal. 8. **Stop-and-continue moves**: drain + reclaim + re-dispatch to another diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index f9fe3da59a65..f55f9b0e4575 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -48,7 +48,7 @@ Per agent, on the Gateway host (resolved via `src/config/sessions.ts`): | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------- | | `mode` | `"enforce"` | or `"warn"` (report only, no mutation) | | `pruneAfter` | `"30d"` | stale-entry age cutoff | -| `maxEntries` | `500` | cap on session entries | +| `maxEntries` | `500` | cap on eviction-eligible live session rows | | `resetArchiveRetention` | keep (no age cutoff) | age cutoff for `*.reset.*`/`*.deleted.*` transcript archives; a duration opts into deletion | | `maxDiskBytes` | `10gb` | per-agent sessions disk budget; `false`, `0`, or `"0"` disables | | `highWaterBytes` | 80% of `maxDiskBytes` | target after cleanup; zero-resolving values use the default, and negatives are invalid | @@ -70,9 +70,11 @@ openclaw sessions cleanup --dry-run openclaw sessions cleanup --enforce ``` -Maintenance keeps durable external conversation pointers such as group sessions and thread-scoped chat sessions, but synthetic runtime entries (cron, hooks, heartbeat, ACP, sub-agents) can still be removed once they exceed the configured age, count, or disk budget. Isolated cron runs use a separate `cron.sessionRetention` control, independent of model-run probe retention. +`maxEntries` excludes protected rows: archived or pinned sessions, active or admitted work, model-locked sessions, and durable external conversation pointers such as group sessions and thread-scoped chat sessions. Those rows do not consume the allowance, so the total live session row count can exceed `maxEntries`. Synthetic runtime entries (cron, hooks, heartbeat, ACP, sub-agents) can still be removed once they exceed the configured age, count, or disk budget. Isolated cron runs use a separate `cron.sessionRetention` control, independent of model-run probe retention. -Normal Gateway writes flow through the session accessor, which serializes per-agent SQLite mutations through the runtime writer path. Runtime code should prefer the accessor helpers in `src/config/sessions/session-accessor.ts`; legacy `sessions.json` helpers are migration and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store ` is the explicit offline repair path for a selected legacy store and always stays local (as does `--dry-run`). `maxEntries` cleanup is batched for production-sized stores, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it down. Reads never prune or cap entries during Gateway startup - only writes or `openclaw sessions cleanup --enforce` do, and the latter also applies the cap immediately and prunes old unreferenced legacy transcript, checkpoint, and trajectory artifacts even with no disk budget configured. +`--dry-run` previews maintenance against the eligible population; `--enforce` applies that cleanup immediately but does not remove protection. To reduce protected history, unarchive, unpin, or explicitly delete sessions you no longer want to retain. + +Normal Gateway writes flow through the session accessor, which serializes per-agent SQLite mutations through the runtime writer path. Runtime code should prefer the accessor helpers in `src/config/sessions/session-accessor.ts`; legacy `sessions.json` helpers are migration and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store ` is the explicit offline repair path for a selected legacy store and always stays local (as does `--dry-run`). `maxEntries` cleanup is batched for production-sized stores, so the eligible population may briefly exceed the configured cap before the next high-water cleanup rewrites it down. Reads never prune or cap entries during Gateway startup - only writes or `openclaw sessions cleanup --enforce` do, and the latter also applies the cap immediately and prunes old unreferenced legacy transcript, checkpoint, and trajectory artifacts even with no disk budget configured. OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups during Gateway writes. The current schema rejects the legacy `session.maintenance.rotateBytes` key, and `openclaw doctor --fix` removes it from older configs. diff --git a/docs/web/urls.md b/docs/web/urls.md index 6899fdb62a0f..7ad00a92846f 100644 --- a/docs/web/urls.md +++ b/docs/web/urls.md @@ -137,6 +137,7 @@ no route-specific URL parameters. | New session | `/new` | - | `?agent=`, `?catalog=` | | Activity | `/activity` | - | `?view=run&run=`, `?view=run&execution=` | | Apps | `/apps` | - | - | +| Portals | `/portals` | - | - | | Agents | `/settings/agents` | `/agents` | `/settings/agents/[/]` | | Channels | `/settings/channels` | `/channels` | Shared settings parameters below | | Connection | `/settings/connection` | - | Shared settings parameters below | diff --git a/extensions/acpx/src/pi-session-catalog-plugin.ts b/extensions/acpx/src/pi-session-catalog-plugin.ts index 04e4c68ac04e..023de795c2a3 100644 --- a/extensions/acpx/src/pi-session-catalog-plugin.ts +++ b/extensions/acpx/src/pi-session-catalog-plugin.ts @@ -102,11 +102,12 @@ export function registerPiSessionCatalog(api: OpenClawPluginApi): void { const provider: SessionCatalogProvider = { id: "pi", label: "Pi", + supportsProcessHomeIsolation: true, list: async (query) => await (await loadCatalogRuntime()).list(query), read: async (request) => await (await loadCatalogRuntime()).read(request), continueSession: async (request) => await (await loadCatalogRuntime()).continueSession(request), - checkUpstreamActivity: async (probes) => - await (await loadCatalogRuntime()).checkUpstreamActivity(probes), + checkUpstreamActivity: async (probes, policy) => + await (await loadCatalogRuntime()).checkUpstreamActivity(probes, policy), openTerminal: async (request) => await (await loadCatalogRuntime()).openTerminal(request), }; api.registerSessionCatalog(provider); diff --git a/extensions/acpx/src/pi-session-catalog-runtime.ts b/extensions/acpx/src/pi-session-catalog-runtime.ts index 140ade618aad..464f8dcea4f8 100644 --- a/extensions/acpx/src/pi-session-catalog-runtime.ts +++ b/extensions/acpx/src/pi-session-catalog-runtime.ts @@ -37,7 +37,7 @@ import { readLocalPiTranscriptPage, type PiSessionPage, } from "./pi-session-catalog.js"; -import { piSessionStoreAvailable } from "./pi-session-paths.js"; +import { piSessionStore, piSessionStoreAvailable } from "./pi-session-paths.js"; import { checkPiUpstreamActivity, linkContinuedPiSession } from "./pi-session-upstream-activity.js"; const LOCAL_HOST_ID = "gateway"; @@ -248,7 +248,13 @@ async function listPiHosts( const canContinue = resolvePiContinuationAvailability(api).available; const requested = query.hostIds ? new Set(query.hostIds) : undefined; const hosts: SessionCatalogHost[] = []; - if ((!requested || requested.has(LOCAL_HOST_ID)) && piSessionStoreAvailable(process.env)) { + const wantsLocal = !requested || requested.has(LOCAL_HOST_ID); + const localStore = wantsLocal ? piSessionStore(process.env) : undefined; + if ( + localStore && + (query.allowProcessHomeFallback !== false || !localStore.usesProcessHomeFallback) && + piSessionStoreAvailable(process.env, localStore) + ) { try { hosts.push({ hostId: LOCAL_HOST_ID, @@ -507,6 +513,7 @@ async function readPiTranscript( throw new Error("cursor is invalid"); } if (request.hostId === LOCAL_HOST_ID) { + assertPiLocalAccess(request.hostId, request.allowProcessHomeFallback); return await readLocalPiTranscriptPage({ threadId: request.threadId, ...(request.limit ? { limit: request.limit } : {}), @@ -544,6 +551,16 @@ async function readPiTranscript( }; } +function assertPiLocalAccess(hostId: string, allowProcessHomeFallback?: boolean): void { + if ( + hostId === LOCAL_HOST_ID && + allowProcessHomeFallback === false && + piSessionStore(process.env).usesProcessHomeFallback + ) { + throw new PiCatalogParamsError("local Pi sessions are unavailable in isolated state"); + } +} + export async function listPiSessions(paramsJSON?: string | null): Promise { return JSON.stringify(await listLocalPiSessionPage(parseNodeParams(paramsJSON))); } @@ -587,10 +604,23 @@ export function createPiSessionCatalogRuntime(api: OpenClawPluginApi) { return { list: async (query) => await listPiHosts(api, query), read: async (request) => await readPiTranscript(api.runtime, request), - continueSession: async (request) => - await continuePiSession(api, request.hostId, request.threadId), - checkUpstreamActivity: checkPiUpstreamActivity, - openTerminal: async (request) => await openPiTerminal({ runtime: api.runtime, ...request }), + continueSession: async (request) => { + assertPiLocalAccess(request.hostId, request.allowProcessHomeFallback); + return await continuePiSession(api, request.hostId, request.threadId); + }, + checkUpstreamActivity: (probes, policy) => + checkPiUpstreamActivity( + probes.filter( + (probe) => + probe.hostId !== LOCAL_HOST_ID || + policy?.allowProcessHomeFallback !== false || + !piSessionStore(process.env).usesProcessHomeFallback, + ), + ), + openTerminal: async (request) => { + assertPiLocalAccess(request.hostId, request.allowProcessHomeFallback); + return await openPiTerminal({ runtime: api.runtime, ...request }); + }, } satisfies Pick< SessionCatalogProvider, "list" | "read" | "continueSession" | "checkUpstreamActivity" | "openTerminal" diff --git a/extensions/acpx/src/pi-session-catalog.test.ts b/extensions/acpx/src/pi-session-catalog.test.ts index 470cf6b59bcb..3cdd91606065 100644 --- a/extensions/acpx/src/pi-session-catalog.test.ts +++ b/extensions/acpx/src/pi-session-catalog.test.ts @@ -193,11 +193,29 @@ describe("Pi session catalog", () => { registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); await expect( - provider!.read({ hostId: "gateway", threadId: "pi-session", limit: 2 }), + provider!.read({ + allowProcessHomeFallback: false, + hostId: "gateway", + threadId: "pi-session", + limit: 2, + }), ).resolves.toMatchObject({ threadId: "pi-session", items: expect.any(Array) }); - await expect(provider!.list({})).resolves.toEqual([ + await expect(provider!.list({ allowProcessHomeFallback: false })).resolves.toEqual([ expect.objectContaining({ hostId: "gateway", sessions: [expect.any(Object)] }), ]); + + for (const key of ["PI_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_DIR"] as const) { + delete process.env[key]; + } + process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-isolated-home-")); + temporaryDirectories.push(process.env.HOME); + const request = { hostId: "gateway", threadId: "pi-session" }; + const isolatedRequest = { ...request, allowProcessHomeFallback: false }; + for (const operation of [provider!.continueSession, provider!.openTerminal]) { + await expect(operation?.(isolatedRequest)).rejects.toThrow( + "local Pi sessions are unavailable in isolated state", + ); + } }); it("recognizes Pi sessions when the agent directory uses a symlinked path", async () => { diff --git a/extensions/acpx/src/pi-session-paths.test.ts b/extensions/acpx/src/pi-session-paths.test.ts index 30d5f243b6ef..4d6c79b270f8 100644 --- a/extensions/acpx/src/pi-session-paths.test.ts +++ b/extensions/acpx/src/pi-session-paths.test.ts @@ -36,6 +36,7 @@ describe("Pi session paths", () => { expect(piSessionStore({ PI_CODING_AGENT_DIR: ` ${agentDir} ` })).toEqual({ root: path.join(agentDir, "sessions"), flat: false, + usesProcessHomeFallback: false, }); }); @@ -57,6 +58,7 @@ describe("Pi session paths", () => { expect(piSessionStore(env, projectDirectory)).toEqual({ root: path.join(projectDirectory, ".pi", "sessions"), flat: true, + usesProcessHomeFallback: false, }); await fs.rm(path.join(projectDirectory, ".pi", "settings.json")); @@ -67,6 +69,16 @@ describe("Pi session paths", () => { expect(piSessionStore(env, projectDirectory)).toEqual({ root: path.join(agentDirectory, "custom-sessions"), flat: true, + usesProcessHomeFallback: false, }); }); + + it("marks only the default home-derived store as a process-HOME fallback", () => { + const home = path.join(os.tmpdir(), "pi-home"); + expect(piSessionStore({ HOME: home }).usesProcessHomeFallback).toBe(true); + expect( + piSessionStore({ HOME: home, PI_CODING_AGENT_SESSION_DIR: path.join(home, "sessions") }) + .usesProcessHomeFallback, + ).toBe(false); + }); }); diff --git a/extensions/acpx/src/pi-session-paths.ts b/extensions/acpx/src/pi-session-paths.ts index d09b582052fb..0417b50de3bf 100644 --- a/extensions/acpx/src/pi-session-paths.ts +++ b/extensions/acpx/src/pi-session-paths.ts @@ -56,10 +56,14 @@ function settingsSessionDir(file: string): string | undefined { export function piSessionStore( env: NodeJS.ProcessEnv, cwd = process.cwd(), -): { root: string; flat: boolean } { +): { root: string; flat: boolean; usesProcessHomeFallback: boolean } { const customSessionDir = env.PI_CODING_AGENT_SESSION_DIR?.trim(); if (customSessionDir) { - return { root: resolveConfiguredPath(customSessionDir, env), flat: true }; + return { + root: resolveConfiguredPath(customSessionDir, env), + flat: true, + usesProcessHomeFallback: false, + }; } const home = piHome(env); const customAgentDir = env.PI_CODING_AGENT_DIR?.trim(); @@ -71,15 +75,21 @@ export function piSessionStore( return { root: resolveConfiguredPath(projectSessionDir, env, path.join(cwd, ".pi")), flat: true, + usesProcessHomeFallback: false, }; } const globalSessionDir = settingsSessionDir(path.join(agentDir, "settings.json")); if (globalSessionDir) { - return { root: resolveConfiguredPath(globalSessionDir, env, agentDir), flat: true }; + return { + root: resolveConfiguredPath(globalSessionDir, env, agentDir), + flat: true, + usesProcessHomeFallback: false, + }; } return { root: path.join(agentDir, "sessions"), flat: false, + usesProcessHomeFallback: !customAgentDir, }; } @@ -99,9 +109,12 @@ export function piAcpSessionStoreRoot(env: NodeJS.ProcessEnv): string | undefine return path.join(agentDir, "sessions"); } -export function piSessionStoreAvailable(env: NodeJS.ProcessEnv): boolean { +export function piSessionStoreAvailable( + env: NodeJS.ProcessEnv, + store?: ReturnType, +): boolean { try { - return statSync(piSessionStore(env).root).isDirectory(); + return statSync((store ?? piSessionStore(env)).root).isDirectory(); } catch { return false; } diff --git a/extensions/anthropic/session-catalog-registration.ts b/extensions/anthropic/session-catalog-registration.ts index cb5bf14631fd..83aef5fcb820 100644 --- a/extensions/anthropic/session-catalog-registration.ts +++ b/extensions/anthropic/session-catalog-registration.ts @@ -43,8 +43,11 @@ function isClaudeSessionCatalogEnabled(pluginConfig: unknown): boolean { // Claude session store; otherwise the gateway must skip the node capability. function claudeProjectsAvailable(env: NodeJS.ProcessEnv): boolean { const homeDir = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir(); + const configDir = env.CLAUDE_CONFIG_DIR?.trim(); try { - return statSync(path.join(homeDir, ".claude", "projects")).isDirectory(); + return statSync( + path.join(configDir ? path.resolve(configDir) : path.join(homeDir, ".claude"), "projects"), + ).isDirectory(); } catch { return false; } @@ -62,6 +65,7 @@ function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { const provider: SessionCatalogProvider = { id: "claude", label: "Claude Code", + supportsProcessHomeIsolation: true, resolveCreateSession: ({ agentId }) => api.runtime.agent.resolveSessionCatalogCreateTarget({ config: currentConfig(api), @@ -76,8 +80,8 @@ function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { startTerminalSession: async (request) => await (await loadCatalogRuntime()).startTerminalSession(request), openTerminal: async (request) => await (await loadCatalogRuntime()).openTerminal(request), - checkUpstreamActivity: async (probes) => - await (await loadCatalogRuntime()).checkUpstreamActivity(probes), + checkUpstreamActivity: async (probes, policy) => + await (await loadCatalogRuntime()).checkUpstreamActivity(probes, policy), }; api.registerSessionCatalog(provider); } diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index d856ac314484..efe30ecd20de 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -69,6 +69,7 @@ function captureCatalogProvider(runtime: PluginRuntime): SessionCatalogProvider const homes: string[] = []; const originalHome = process.env.HOME; const originalPath = process.env.PATH; +const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; const nodeHostMocks = vi.hoisted(() => ({ runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })), userShellPaths: new Map(), @@ -491,6 +492,11 @@ afterEach(async () => { nodeHostMocks.userShellPaths.clear(); process.env.HOME = originalHome; process.env.PATH = originalPath; + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } await Promise.all(homes.splice(0).map((home) => fs.rm(home, { recursive: true, force: true }))); }); @@ -544,6 +550,42 @@ describe("Claude session catalog", () => { ); }); + it("lists an explicit CLAUDE_CONFIG_DIR while isolated", async () => { + const home = await createHome(); + const configParent = await createHome(); + const sessionId = "explicit-config-root"; + await writeProject({ + home: configParent, + entries: [{ sessionId, summary: "Explicit config session", isSidechain: false }], + transcripts: { [sessionId]: [message(sessionId, "user", "explicit root", 1)] }, + }); + await writeDesktopMetadata(home, "private", { + cliSessionId: sessionId, + sessionId: "desktop-private", + title: "Private desktop title", + }); + process.env.HOME = home; + process.env.CLAUDE_CONFIG_DIR = path.join(configParent, ".claude"); + const provider = captureCatalogProvider({ + nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) }, + } as unknown as PluginRuntime); + + await expect( + provider.list({ allowProcessHomeFallback: false, hostIds: ["gateway:local"] }), + ).resolves.toEqual([ + expect.objectContaining({ + hostId: "gateway:local", + sessions: [ + expect.objectContaining({ + threadId: sessionId, + name: "Explicit config session", + source: "claude-cli", + }), + ], + }), + ]); + }); + it("preserves date-first parsing for numeric-looking index timestamps", async () => { const home = await createHome(); const sessionId = "numeric-looking-timestamps"; @@ -2601,7 +2643,7 @@ describe("Claude session catalog", () => { ]); }); - it("omits the Gateway's same-install node host from native discovery", async () => { + it("keeps remote nodes while isolated state suppresses local HOME discovery", async () => { const home = await createHome(); process.env.HOME = home; const invoke = vi.fn(async ({ nodeId }: { nodeId: string }) => ({ @@ -2639,9 +2681,47 @@ describe("Claude session catalog", () => { }, } as unknown as PluginRuntime); - const hosts = await provider.list({}); + const hosts = await provider.list({ allowProcessHomeFallback: false }); - expect(hosts.map((host) => host.hostId)).toEqual(["gateway:local", "node:remote-node"]); + expect(hosts.map((host) => host.hostId)).toEqual(["node:remote-node"]); + await expect( + provider.read({ + allowProcessHomeFallback: false, + hostId: "gateway:local", + threadId: "private-thread", + }), + ).rejects.toThrow("local Claude sessions are unavailable in isolated state"); + await expect( + provider.continueSession?.({ + allowProcessHomeFallback: false, + hostId: "gateway:local", + threadId: "private-thread", + }), + ).rejects.toThrow("local Claude sessions are unavailable in isolated state"); + await expect( + provider.openTerminal?.({ + allowProcessHomeFallback: false, + hostId: "gateway:local", + threadId: "private-thread", + }), + ).rejects.toThrow("local Claude sessions are unavailable in isolated state"); + await expect( + provider.startTerminalSession?.({ + allowProcessHomeFallback: false, + agentId: "main", + cwd: process.cwd(), + }), + ).rejects.toThrow("local Claude sessions are unavailable in isolated state"); + // Node starts are outside the process-HOME guard: they must surface the + // truthful capability error, not the isolation rejection. + await expect( + provider.startTerminalSession?.({ + allowProcessHomeFallback: false, + agentId: "main", + cwd: process.cwd(), + nodeId: "remote-node", + }), + ).rejects.toThrow("Paired-node Claude terminal start is unavailable"); expect(invoke).toHaveBeenCalledTimes(1); expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "remote-node" })); }); diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index ff9d79795bb1..7c541c121782 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -426,8 +426,8 @@ async function childDirectories(root: string): Promise { } } -function projectsDir(homeDir: string): string { - return path.join(homeDir, ".claude", "projects"); +function projectsDir(homeDir: string, configDir?: string): string { + return path.join(configDir ?? path.join(homeDir, ".claude"), "projects"); } async function readProjectsTreeSnapshot(root: string): Promise { @@ -514,6 +514,24 @@ function currentHomeDir(env: NodeJS.ProcessEnv = process.env): string { return env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir(); } +function configuredClaudeConfigDir(env: NodeJS.ProcessEnv = process.env): string | undefined { + const configured = env.CLAUDE_CONFIG_DIR?.trim(); + return configured ? path.resolve(configured) : undefined; +} + +function gatewayClaudeScanOptions(allowProcessHomeFallback?: boolean): { + configDir?: string; + includeDesktop: boolean; +} { + const configDir = configuredClaudeConfigDir(); + // Upstream Claude Code's "Respect CLAUDE_CONFIG_DIR everywhere" convention replaces ~/.claude. + // Claude Desktop stays HOME/Library-scoped, so isolated scans exclude its metadata. + return { + ...(configDir ? { configDir } : {}), + includeDesktop: allowProcessHomeFallback !== false, + }; +} + async function readDesktopMetadata(homeDir: string): Promise<{ active: Map; archived: Set; @@ -907,11 +925,14 @@ async function discoverCliRecords( async function scanClaudeSessions( homeDir: string, snapshot: ClaudeProjectsTreeSnapshot, + includeDesktop: boolean, ): Promise<{ records: CatalogRecord[]; complete: boolean }> { const context: ClaudeSessionScanContext = { ...snapshot, complete: true, safeFiles: new Map() }; const [indexed, desktop] = await Promise.all([ readIndexRecords(context), - readDesktopMetadata(homeDir), + includeDesktop + ? readDesktopMetadata(homeDir) + : Promise.resolve({ active: new Map(), archived: new Set() }), ]); const records = indexed.records; await discoverCliRecords(context, records, indexed.sidechainIds); @@ -963,15 +984,17 @@ async function scanClaudeSessions( async function listClaudeSessions( homeDir = currentHomeDir(), - options: { forceRefresh?: boolean } = {}, + options: { forceRefresh?: boolean; configDir?: string; includeDesktop?: boolean } = {}, ): Promise { - const root = projectsDir(homeDir); + const root = projectsDir(homeDir, options.configDir); + const includeDesktop = options.includeDesktop !== false; + const cacheKey = `${root}\0${includeDesktop ? "desktop" : "cli"}`; const [treeSnapshot, desktopStoreAvailable] = await Promise.all([ readProjectsTreeSnapshot(root), - desktopSessionStoreAvailable(homeDir), + includeDesktop ? desktopSessionStoreAvailable(homeDir) : Promise.resolve(false), ]); const now = Date.now(); - const cached = claudeSessionScanCache.get(root); + const cached = claudeSessionScanCache.get(cacheKey); // Child membership + file mtime/size signatures invalidate CLI rows on the next poll; five minutes // backstops metadata anomalies. Desktop has a 60s bound when its macOS store exists; Linux skips it. // Specific-thread force refresh bypasses both, or a stale page could hide a just-created session. @@ -983,10 +1006,15 @@ async function listClaudeSessions( cached.desktopStoreAvailable === desktopStoreAvailable && (!desktopStoreAvailable || cached.desktopExpiresAt > now) ) { - setBoundedCache(claudeSessionScanCache, root, cached, MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES); + setBoundedCache( + claudeSessionScanCache, + cacheKey, + cached, + MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES, + ); return await cached.records; } - const scan = scanClaudeSessions(homeDir, treeSnapshot); + const scan = scanClaudeSessions(homeDir, treeSnapshot, includeDesktop); let scanComplete = true; const records = scan.then((result) => { scanComplete = result.complete; @@ -999,18 +1027,18 @@ async function listClaudeSessions( desktopExpiresAt: now + CLAUDE_DESKTOP_SCAN_TTL_MS, records, }; - setBoundedCache(claudeSessionScanCache, root, entry, MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES); + setBoundedCache(claudeSessionScanCache, cacheKey, entry, MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES); try { const result = await records; - if (!scanComplete && claudeSessionScanCache.get(root) === entry) { + if (!scanComplete && claudeSessionScanCache.get(cacheKey) === entry) { // Partial results still serve this caller, but retry within 15s so transient per-file I/O // cannot hide recovered sessions behind the five-minute unchanged-tree backstop. entry.hardExpiresAt = Date.now() + CLAUDE_PARTIAL_SCAN_TTL_MS; } return result; } catch (error) { - if (claudeSessionScanCache.get(root) === entry) { - claudeSessionScanCache.delete(root); + if (claudeSessionScanCache.get(cacheKey) === entry) { + claudeSessionScanCache.delete(cacheKey); } throw error; } @@ -1093,12 +1121,16 @@ function readListParams(value: unknown): { export async function listLocalClaudeSessionPage( value: unknown, - homeDir = currentHomeDir(), + homeDir?: string, + scanOptions?: { configDir?: string; includeDesktop?: boolean }, ): Promise { + const resolvedHome = homeDir ?? currentHomeDir(); + const resolvedScanOptions = + scanOptions ?? (homeDir === undefined ? gatewayClaudeScanOptions(true) : {}); const params = readListParams(value); const offset = decodeOffset(params.cursor, "catalog"); const search = params.searchTerm?.toLocaleLowerCase(); - const records = (await listClaudeSessions(homeDir)).filter((record) => { + const records = (await listClaudeSessions(resolvedHome, resolvedScanOptions)).filter((record) => { if (!search) { return true; } @@ -1147,18 +1179,22 @@ function readTranscriptParams( export async function readLocalClaudeTranscriptPage( value: unknown, - homeDir = currentHomeDir(), + homeDir?: string, + scanOptions?: { configDir?: string; includeDesktop?: boolean }, ): Promise> { + const resolvedHome = homeDir ?? currentHomeDir(); + const resolvedScanOptions = + scanOptions ?? (homeDir === undefined ? gatewayClaudeScanOptions(true) : {}); const params = readTranscriptParams(value); - let filePath = (await listClaudeSessions(homeDir)).find( + let filePath = (await listClaudeSessions(resolvedHome, resolvedScanOptions)).find( (record) => record.threadId === params.threadId, )?.filePath; if (!filePath) { // A just-created session can race the stamp snapshot. Specific reads must retry against disk so // opening a new thread never fails only because the assembled catalog is still warm. - filePath = (await listClaudeSessions(homeDir, { forceRefresh: true })).find( - (record) => record.threadId === params.threadId, - )?.filePath; + filePath = ( + await listClaudeSessions(resolvedHome, { ...resolvedScanOptions, forceRefresh: true }) + ).find((record) => record.threadId === params.threadId)?.filePath; } if (!filePath) { throw new ClaudeCatalogParamsError("Claude session is unavailable"); @@ -1421,13 +1457,16 @@ function parseGatewayQuery(value: unknown): { async function listClaudeSessionCatalog(params: { runtime: PluginRuntime; query?: unknown; + allowProcessHomeFallback?: boolean; listNodes?: Parameters[0]["listNodes"]; onHost?: (host: ClaudeSessionCatalogHost) => void; }): Promise { const query = parseGatewayQuery(params.query); const requested = query.hostIds ? new Set(query.hostIds) : undefined; + const scanOptions = gatewayClaudeScanOptions(params.allowProcessHomeFallback); const localHosts: Promise[] = - !requested || requested.has(CLAUDE_LOCAL_SESSION_HOST_ID) + (params.allowProcessHomeFallback !== false || scanOptions.configDir !== undefined) && + (!requested || requested.has(CLAUDE_LOCAL_SESSION_HOST_ID)) ? [ (async () => { try { @@ -1436,13 +1475,17 @@ async function listClaudeSessionCatalog(params: { label: "Local Claude", kind: "gateway", connected: true, - ...(await listLocalClaudeSessionPage({ - limit: query.limitPerHost, - ...(query.search ? { searchTerm: query.search } : {}), - ...(query.cursors?.[CLAUDE_LOCAL_SESSION_HOST_ID] !== undefined - ? { cursor: query.cursors[CLAUDE_LOCAL_SESSION_HOST_ID] } - : {}), - })), + ...(await listLocalClaudeSessionPage( + { + limit: query.limitPerHost, + ...(query.search ? { searchTerm: query.search } : {}), + ...(query.cursors?.[CLAUDE_LOCAL_SESSION_HOST_ID] !== undefined + ? { cursor: query.cursors[CLAUDE_LOCAL_SESSION_HOST_ID] } + : {}), + }, + currentHomeDir(), + scanOptions, + )), }; } catch { return { @@ -1574,17 +1617,23 @@ async function readClaudeSessionTranscript(params: { threadId: string; cursor?: string; limit: number; + allowProcessHomeFallback?: boolean; }): Promise { const cursor = readOptionalCursor(params.cursor, "transcript"); if (params.hostId === CLAUDE_LOCAL_SESSION_HOST_ID) { + assertClaudeLocalAccess(params.hostId, params.allowProcessHomeFallback); return { hostId: params.hostId, label: "Local Claude", - ...(await readLocalClaudeTranscriptPage({ - threadId: params.threadId, - limit: params.limit, - ...(cursor !== undefined ? { cursor } : {}), - })), + ...(await readLocalClaudeTranscriptPage( + { + threadId: params.threadId, + limit: params.limit, + ...(cursor !== undefined ? { cursor } : {}), + }, + currentHomeDir(), + gatewayClaudeScanOptions(params.allowProcessHomeFallback), + )), }; } if (!params.hostId.startsWith("node:")) { @@ -1632,10 +1681,21 @@ async function readClaudeSessionTranscript(params: { }; } +function assertClaudeLocalAccess(hostId: string, allowProcessHomeFallback?: boolean): void { + if ( + hostId === CLAUDE_LOCAL_SESSION_HOST_ID && + allowProcessHomeFallback === false && + configuredClaudeConfigDir() === undefined + ) { + throw new ClaudeCatalogParamsError("local Claude sessions are unavailable in isolated state"); + } +} + async function readBoundedClaudeHistory(params: { runtime: PluginRuntime; hostId: string; threadId: string; + allowProcessHomeFallback?: boolean; }): Promise { const items: ClaudeTranscriptItem[] = []; let cursor: string | undefined; @@ -1646,6 +1706,7 @@ async function readBoundedClaudeHistory(params: { hostId: params.hostId, threadId: params.threadId, limit: Math.min(MAX_TRANSCRIPT_LIMIT, CLAUDE_HISTORY_IMPORT_MAX_ITEMS - items.length), + allowProcessHomeFallback: params.allowProcessHomeFallback, ...(cursor ? { cursor } : {}), }); for (const item of page.items) { @@ -1699,7 +1760,9 @@ async function continueClaudeSession( api: OpenClawPluginApi, hostId: string, threadId: string, + allowProcessHomeFallback?: boolean, ): Promise<{ sessionKey: string }> { + const scanOptions = gatewayClaudeScanOptions(allowProcessHomeFallback); const sourceKey = adoptedSourceKey(hostId, threadId); const linkSession = async (sessionKey: string, history?: ClaudeTranscriptItem[]) => await upstream.linkContinued({ @@ -1707,10 +1770,17 @@ async function continueClaudeSession( hostId, threadId, ...(history ? { history } : {}), - listLocalSessions: listClaudeSessions, + listLocalSessions: () => listClaudeSessions(currentHomeDir(), scanOptions), readRemote: async () => - (await readClaudeSessionTranscript({ runtime: api.runtime, hostId, threadId, limit: 1 })) - .items, + ( + await readClaudeSessionTranscript({ + runtime: api.runtime, + hostId, + threadId, + limit: 1, + allowProcessHomeFallback, + }) + ).items, }); const existing = listBoundClaudeSessions(api).get(sourceKey); if (existing) { @@ -1724,7 +1794,9 @@ async function continueClaudeSession( let nodeId: string | undefined; let record: ClaudeSessionCatalogSession | undefined; if (hostId === CLAUDE_LOCAL_SESSION_HOST_ID) { - record = (await listClaudeSessions()).find((candidate) => candidate.threadId === threadId); + record = (await listClaudeSessions(currentHomeDir(), scanOptions)).find( + (candidate) => candidate.threadId === threadId, + ); if (!record || !isResumableClaudeSource(record.source)) { throw new ClaudeCatalogParamsError("only local Claude Code sessions can be continued"); } @@ -1761,7 +1833,12 @@ async function continueClaudeSession( throw new ClaudeCatalogParamsError("Claude session transcript is unavailable"); } } - const history = await readBoundedClaudeHistory({ runtime: api.runtime, hostId, threadId }); + const history = await readBoundedClaudeHistory({ + runtime: api.runtime, + hostId, + threadId, + allowProcessHomeFallback, + }); const config = currentClaudeSessionCatalogConfig(api); const adoptingAgentId = resolveDefaultAgentId(config); // Adopt onto the model this agent actually routes to the CLI backend; the @@ -1915,48 +1992,84 @@ export function createClaudeSessionCatalogRuntime( list: async (query) => { const adopted = listBoundClaudeSessions(api, query.sessionEntries); const localCliAvailable = catalogTerminal.isClaudeCliAvailable(); - const { listNodes, onHost, sessionEntries: _sessionEntries, ...gatewayQuery } = query; + const { + allowProcessHomeFallback, + listNodes, + onHost, + sessionEntries: _sessionEntries, + ...gatewayQuery + } = query; const mapHost = (host: ClaudeSessionCatalogHost) => toGenericClaudeHost(host, adopted, localCliAvailable); const result = await listClaudeSessionCatalog({ runtime: api.runtime, query: gatewayQuery, + allowProcessHomeFallback, listNodes, ...(onHost ? { onHost: (host) => onHost(mapHost(host)) } : {}), }); return result.hosts.map(mapHost); }, read: async (request) => { + const { allowProcessHomeFallback, ...catalogRequest } = request; const page = await readClaudeSessionTranscript({ runtime: api.runtime, - hostId: request.hostId, - threadId: request.threadId, - cursor: request.cursor, - limit: request.limit ?? DEFAULT_TRANSCRIPT_LIMIT, + hostId: catalogRequest.hostId, + threadId: catalogRequest.threadId, + cursor: catalogRequest.cursor, + limit: catalogRequest.limit ?? DEFAULT_TRANSCRIPT_LIMIT, + allowProcessHomeFallback, }); return { ...page, items: page.items.map(toGenericClaudeItem) }; }, - continueSession: async (request) => - await continueClaudeSession(api, request.hostId, request.threadId), - startTerminalSession: (request) => catalogTerminal.startClaudeCatalogTerminal(request), - openTerminal: (request) => - catalogTerminal.openClaudeCatalogTerminal({ + continueSession: async (request) => { + assertClaudeLocalAccess(request.hostId, request.allowProcessHomeFallback); + return await continueClaudeSession( + api, + request.hostId, + request.threadId, + request.allowProcessHomeFallback, + ); + }, + startTerminalSession: async (request) => { + // Node launches run in the paired node's environment, not gateway HOME; + // only local starts fall under the process-HOME isolation guard. + if (!request.nodeId) { + assertClaudeLocalAccess(CLAUDE_LOCAL_SESSION_HOST_ID, request.allowProcessHomeFallback); + } + return await catalogTerminal.startClaudeCatalogTerminal(request); + }, + openTerminal: async (request) => { + assertClaudeLocalAccess(request.hostId, request.allowProcessHomeFallback); + return await catalogTerminal.openClaudeCatalogTerminal({ api, ...request, - listClaudeSessions, + listClaudeSessions: () => + listClaudeSessions( + currentHomeDir(), + gatewayClaudeScanOptions(request.allowProcessHomeFallback), + ), resolveNodeClaudeRecord, - }), - checkUpstreamActivity: async (probes) => - await upstream.checkClaudeUpstreamActivity(probes, async (probe) => { + }); + }, + checkUpstreamActivity: async (probes, policy) => { + const localAllowed = + policy?.allowProcessHomeFallback !== false || configuredClaudeConfigDir() !== undefined; + const eligible = probes.filter( + (probe) => probe.hostId !== CLAUDE_LOCAL_SESSION_HOST_ID || localAllowed, + ); + return await upstream.checkClaudeUpstreamActivity(eligible, async (probe) => { return ( await readClaudeSessionTranscript({ runtime: api.runtime, hostId: probe.hostId, threadId: probe.threadId, limit: MAX_TRANSCRIPT_LIMIT, + allowProcessHomeFallback: policy?.allowProcessHomeFallback, }) ).items; - }), + }); + }, }; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/beam/src/session-catalog.ts b/extensions/beam/src/session-catalog.ts index 6bf78fb57489..137093441155 100644 --- a/extensions/beam/src/session-catalog.ts +++ b/extensions/beam/src/session-catalog.ts @@ -86,6 +86,7 @@ export function createBeamSessionCatalog(store: BeamStore): SessionCatalogProvid return { id: "beam", label: "Beam", + supportsProcessHomeIsolation: true, async list(params) { const search = params.search?.trim().toLowerCase(); const sessions = (await store.list()) diff --git a/extensions/codex/src/app-server/event-projector.terminal-errors.test.ts b/extensions/codex/src/app-server/event-projector.terminal-errors.test.ts index c094f25884f6..ea2c77e2a98d 100644 --- a/extensions/codex/src/app-server/event-projector.terminal-errors.test.ts +++ b/extensions/codex/src/app-server/event-projector.terminal-errors.test.ts @@ -198,6 +198,23 @@ describe("CodexAppServerEventProjector terminal errors", () => { expect(result.lastAssistant).toBeUndefined(); }); + it.each([ + { codexErrorInfo: "serverOverloaded", expected: true }, + { codexErrorInfo: "usageLimitExceeded", expected: false }, + { codexErrorInfo: "unauthorized", expected: false }, + ])( + "projects $codexErrorInfo terminal error recovery eligibility as $expected", + async ({ codexErrorInfo, expected }) => { + const projector = await createProjector(); + + await projector.handleNotification( + appServerError({ message: "provider failure", willRetry: false, codexErrorInfo }), + ); + + expect(projector.settledTurnFailureFinalizationAllowed).toBe(expected); + }, + ); + it("uses Codex rate-limit resets for usage-limit app-server errors", async () => { const resetsAt = Math.ceil(Date.now() / 1000) + 120; const projector = await createProjector(undefined, { diff --git a/extensions/codex/src/app-server/event-projector.test-harness.ts b/extensions/codex/src/app-server/event-projector.test-harness.ts index 644796e29188..6aa0587857cc 100644 --- a/extensions/codex/src/app-server/event-projector.test-harness.ts +++ b/extensions/codex/src/app-server/event-projector.test-harness.ts @@ -283,11 +283,12 @@ export function agentMessageDelta(delta: string, itemId = "msg-1"): ProjectorNot export function appServerError(params: { message: string; willRetry: boolean; + codexErrorInfo?: string; }): ProjectorNotification { return forCurrentTurn("error", { error: { message: params.message, - codexErrorInfo: null, + codexErrorInfo: params.codexErrorInfo ?? null, additionalDetails: null, }, willRetry: params.willRetry, diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 1c1c94f55ee3..08f20c63f96b 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -76,6 +76,8 @@ export class CodexAppServerEventProjector { private readonly toolProgressProjection: CodexToolProgressProjection; private readonly toolTranscriptProjection: CodexToolTranscriptProjection; private completedTurn: CodexTurn | undefined; + /** Structured overloads may continue once the exact settled transcript is captured. */ + settledTurnFailureFinalizationAllowed = false; private promptError: unknown; private promptErrorSource: AttemptFailureSource | null = null; private synthesizedMissingToolResultError: string | null = null; @@ -284,6 +286,9 @@ export class CodexAppServerEventProjector { if (params.willRetry === true) { break; } + this.settledTurnFailureFinalizationAllowed = + (isJsonObject(params.error) ? params.error.codexErrorInfo : undefined) === + "serverOverloaded"; this.promptError = this.formatCodexErrorMessage(params) ?? "codex app-server error"; this.promptErrorSource = "prompt"; break; @@ -493,6 +498,8 @@ export class CodexAppServerEventProjector { return; } this.completedTurn = turn; + this.settledTurnFailureFinalizationAllowed = + turn.status === "failed" && turn.error?.codexErrorInfo === "serverOverloaded"; if (turn.status !== "completed") { this.responseCompletions.clear(); } diff --git a/extensions/codex/src/app-server/run-attempt-finalize.ts b/extensions/codex/src/app-server/run-attempt-finalize.ts index 154ced4c00a4..d981c281b6db 100644 --- a/extensions/codex/src/app-server/run-attempt-finalize.ts +++ b/extensions/codex/src/app-server/run-attempt-finalize.ts @@ -323,9 +323,9 @@ export async function finalizeCodexAttempt( const { assistantTranscriptOwned, assistantTranscriptIdempotencyKey, terminalAnchor } = mirrorOutcome; const shouldCaptureSettledTurnFinalizationContext = - turnSucceeded && result.assistantTexts.every((text) => !text.trim()) && - result.messagesSnapshot.some((message) => message.role === "toolResult"); + result.messagesSnapshot.some((message) => message.role === "toolResult") && + (!finalPromptError || activeProjector.settledTurnFailureFinalizationAllowed); const settledTurnFinalizationContext = shouldCaptureSettledTurnFinalizationContext ? await captureCodexSettledTurnFinalizationContext({ ...activeTranscriptTarget, diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 0d7b38ef1a01..032f383467ae 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -3911,59 +3911,96 @@ describe("runCodexAppServerAttempt", () => { }); }); - it("captures the complete mirrored branch through a settled tool-result boundary", async () => { - const storePath = path.join(tempDir, "settled-finalization-context.sqlite"); - const sessionId = "session-settled-finalization-context"; - const sessionFile = `agent:main:${sessionId}`; - const workspaceDir = path.join(tempDir, "workspace-settled-finalization-context"); - const harness = createStartedThreadHarness(); - const params = createParams(sessionFile, workspaceDir); - await attachSqliteSessionTarget(params, storePath, sessionId); - params.prompt = "Send the update to Alice."; - const run = runCodexAppServerAttempt(params); - await harness.waitForMethod("turn/start"); - await harness.notify( - itemNotification("item/started", { - type: "commandExecution", - id: "tool-settled", - command: "echo sent-to-alice", - cwd: workspaceDir, - processId: null, - source: "agent", - status: "inProgress", - commandActions: [], - aggregatedOutput: null, - exitCode: null, - durationMs: null, - }), - ); - await harness.notify( - itemNotification("item/completed", { - type: "commandExecution", - id: "tool-settled", - command: "echo sent-to-alice", - cwd: workspaceDir, - processId: 42, - source: "agent", - status: "completed", - commandActions: [], - aggregatedOutput: "sent-to-alice\n", - exitCode: 0, - durationMs: 12, - }), - ); - await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); - const result = await run; - expect(result.settledTurnFinalizationContext).toMatchObject({ - source: "openclaw-transcript", - messages: [ - expect.objectContaining({ role: "user" }), - expect.objectContaining({ role: "assistant" }), - expect.objectContaining({ role: "toolResult", toolCallId: "tool-settled" }), - ], - }); - expect(Object.isFrozen(result.settledTurnFinalizationContext?.messages)).toBe(true); - }); + it.each([ + { label: "completed turn", failure: undefined, expectedContext: true }, + { + label: "provider overload after the tool result", + failure: { + message: "Selected model is at capacity. Please try a different model.", + codexErrorInfo: "serverOverloaded", + }, + expectedContext: true, + }, + { + label: "usage limit after the tool result", + failure: { + message: "Usage limit exceeded.", + codexErrorInfo: "usageLimitExceeded", + }, + expectedContext: false, + }, + { + label: "unauthorized response after the tool result", + failure: { + message: "Unauthorized.", + codexErrorInfo: "unauthorized", + }, + expectedContext: false, + }, + ])( + "captures the complete mirrored branch through a settled tool-result boundary for a $label", + async ({ failure, expectedContext }) => { + const storePath = path.join(tempDir, "settled-finalization-context.sqlite"); + const sessionId = "session-settled-finalization-context"; + const sessionFile = `agent:main:${sessionId}`; + const workspaceDir = path.join(tempDir, "workspace-settled-finalization-context"); + const harness = createStartedThreadHarness(); + const params = createParams(sessionFile, workspaceDir); + await attachSqliteSessionTarget(params, storePath, sessionId); + params.prompt = "Send the update to Alice."; + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await harness.notify( + itemNotification("item/started", { + type: "commandExecution", + id: "tool-settled", + command: "echo sent-to-alice", + cwd: workspaceDir, + processId: null, + source: "agent", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }), + ); + await harness.notify( + itemNotification("item/completed", { + type: "commandExecution", + id: "tool-settled", + command: "echo sent-to-alice", + cwd: workspaceDir, + processId: 42, + source: "agent", + status: "completed", + commandActions: [], + aggregatedOutput: "sent-to-alice\n", + exitCode: 0, + durationMs: 12, + }), + ); + if (failure) { + await harness.notify(turnCompleted({ id: "turn-1", status: "failed", error: failure })); + } else { + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + } + const result = await run; + expect(Boolean(readAttemptTerminal(result).promptError)).toBe(Boolean(failure)); + expect(Boolean(result.settledTurnFinalizationContext)).toBe(expectedContext); + if (result.settledTurnFinalizationContext) { + expect(result.settledTurnFinalizationContext).toMatchObject({ + source: "openclaw-transcript", + messages: [ + expect.objectContaining({ role: "user" }), + expect.objectContaining({ role: "assistant" }), + expect.objectContaining({ role: "toolResult", toolCallId: "tool-settled" }), + ], + }); + expect(Object.isFrozen(result.settledTurnFinalizationContext.messages)).toBe(true); + } + }, + ); it("preserves every command failure from official app-server events", async () => { const sessionFile = path.join(tempDir, "session-multi-command-failure.jsonl"); const workspaceDir = path.join(tempDir, "workspace-multi-command-failure"); diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts index 067025258d4c..e398f5ccd5ba 100644 --- a/extensions/codex/src/session-catalog.test.ts +++ b/extensions/codex/src/session-catalog.test.ts @@ -14,6 +14,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveCodexAppServerHomeDir } from "./app-server/auth-start-options.js"; import { resolveCodexAppServerUserHomeDir } from "./app-server/config.js"; @@ -1163,16 +1164,10 @@ describe("Codex supervision catalog", () => { config, runtime, control, + includeLocal: false, }); expect(result.hosts).toEqual([ - { - hostId: CODEX_LOCAL_SESSION_HOST_ID, - label: "Local Codex", - kind: "gateway", - connected: true, - sessions: [{ threadId: "local", status: "idle", archived: false }], - }, { hostId: "node:devbox", label: "Dev Box", @@ -1183,9 +1178,7 @@ describe("Codex supervision catalog", () => { sessions: [{ threadId: "remote", name: "Remote task", status: "idle", archived: false }], }, ]); - expect(control.listPage).toHaveBeenCalledWith( - expect.not.objectContaining({ archived: expect.anything() }), - ); + expect(control.listPage).not.toHaveBeenCalled(); expect(invoke).toHaveBeenCalledWith( expect.objectContaining({ nodeId: "devbox", @@ -3362,6 +3355,38 @@ describe("Codex supervision actions", () => { model: "openai/gpt-5.6-sol", agentRuntime: "codex", }); + await withEnvAsync({ CODEX_HOME: undefined }, async () => { + await expect( + provider?.continueSession?.({ + allowProcessHomeFallback: false, + hostId: CODEX_LOCAL_SESSION_HOST_ID, + threadId: "thread-1", + clientScopes: ["operator.admin"], + }), + ).rejects.toThrow("local Codex sessions are unavailable in isolated state"); + await expect( + provider?.archive?.({ + allowProcessHomeFallback: false, + hostId: CODEX_LOCAL_SESSION_HOST_ID, + threadId: "thread-1", + confirmNoOtherRunner: true, + }), + ).rejects.toThrow("local Codex sessions are unavailable in isolated state"); + await expect( + provider?.openTerminal?.({ + allowProcessHomeFallback: false, + hostId: CODEX_LOCAL_SESSION_HOST_ID, + threadId: "thread-1", + }), + ).rejects.toThrow("local Codex sessions are unavailable in isolated state"); + await expect( + provider?.startTerminalSession?.({ + allowProcessHomeFallback: false, + agentId: "main", + cwd: process.cwd(), + }), + ).rejects.toThrow("local Codex sessions are unavailable in isolated state"); + }); await expect( provider?.archive?.({ hostId: CODEX_LOCAL_SESSION_HOST_ID, @@ -3378,6 +3403,7 @@ describe("Codex supervision actions", () => { ).resolves.toEqual({ ok: true }); await expect( provider?.archive?.({ + allowProcessHomeFallback: false, hostId: "node:devbox", threadId: "thread-remote", confirmNoOtherRunner: true, @@ -3385,6 +3411,7 @@ describe("Codex supervision actions", () => { ).rejects.toThrow("paired-node Codex sessions are view-only"); await expect( provider?.continueSession?.({ + allowProcessHomeFallback: false, hostId: "node:devbox", threadId: "thread-remote", clientScopes: ["operator.admin"], diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts index 628bd64675d5..1b3f8d7eb50e 100644 --- a/extensions/codex/src/session-catalog.ts +++ b/extensions/codex/src/session-catalog.ts @@ -598,11 +598,13 @@ async function listCodexSessionCatalog(params: { listNodes?: Parameters[0]["listNodes"]; onHost?: (host: CodexSessionCatalogHost) => void; sessionEntries?: SessionCatalogEntrySnapshot; + includeLocal?: boolean; }): Promise { const query = readGatewayParams(params.query); const requestedHostIds = query.hostIds ? new Set(query.hostIds) : undefined; const localHosts = - !requestedHostIds || requestedHostIds.has(CODEX_LOCAL_SESSION_HOST_ID) + params.includeLocal !== false && + (!requestedHostIds || requestedHostIds.has(CODEX_LOCAL_SESSION_HOST_ID)) ? [ listGatewayHost({ bindingStore: params.bindingStore, @@ -1441,9 +1443,28 @@ function registerCodexSessionCatalog(params: { getPluginConfig: () => unknown; getRuntimeConfig: () => OpenClawConfig | undefined; }): void { + const usesProcessHomeFallback = () => { + const start = resolveCodexSupervisionAppServerRuntimeOptions({ + pluginConfig: params.getPluginConfig(), + }).start; + return ( + start.transport === "stdio" && start.homeScope === "user" && !process.env.CODEX_HOME?.trim() + ); + }; + const assertLocalAccess = (hostId: string, allowProcessHomeFallback?: boolean) => { + if ( + hostId === CODEX_LOCAL_SESSION_HOST_ID && + allowProcessHomeFallback === false && + usesProcessHomeFallback() + ) { + throw new CatalogParamsError("local Codex sessions are unavailable in isolated state"); + } + }; + const checkUpstreamActivity = upstream.createChecker(params); const provider: SessionCatalogProvider = { id: "codex", label: "Codex", + supportsProcessHomeIsolation: true, resolveCreateSession: ({ agentId }) => resolveCodexCatalogCreateSession( params.getRuntimeConfig() ?? (params.api.config as OpenClawConfig), @@ -1451,7 +1472,8 @@ function registerCodexSessionCatalog(params: { ), list: async (query) => { const localTerminalAvailable = resolveLocalCodexTerminalExecutable() !== undefined; - const { listNodes, onHost, sessionEntries, ...gatewayQuery } = query; + const { allowProcessHomeFallback, listNodes, onHost, sessionEntries, ...gatewayQuery } = + query; const mapHost = (host: CodexSessionCatalogHost) => toGenericCatalogHost(host, localTerminalAvailable); return ( @@ -1463,22 +1485,26 @@ function registerCodexSessionCatalog(params: { query: gatewayQuery, listNodes, sessionEntries, + includeLocal: allowProcessHomeFallback !== false || !usesProcessHomeFallback(), ...(onHost ? { onHost: (host) => onHost(mapHost(host)) } : {}), }) ).hosts.map(mapHost); }, read: async (request) => { + const { allowProcessHomeFallback, ...catalogRequest } = request; + assertLocalAccess(catalogRequest.hostId, allowProcessHomeFallback); const page = await readCodexSessionTranscript({ runtime: params.api.runtime, control: params.control, - hostId: request.hostId, - threadId: request.threadId, - cursor: request.cursor, - limit: request.limit ?? DEFAULT_TRANSCRIPT_PAGE_LIMIT, + hostId: catalogRequest.hostId, + threadId: catalogRequest.threadId, + cursor: catalogRequest.cursor, + limit: catalogRequest.limit ?? DEFAULT_TRANSCRIPT_PAGE_LIMIT, }); return { ...page, items: page.items.map(toGenericTranscriptItem) }; }, continueSession: async (request) => { + assertLocalAccess(request.hostId, request.allowProcessHomeFallback); const config = params.getRuntimeConfig(); if (!config) { throw new Error("OpenClaw runtime config is unavailable"); @@ -1508,8 +1534,17 @@ function registerCodexSessionCatalog(params: { }); return codexUpstreamContinueResult(continued.sessionKey, request.threadId, upstreamBaseline); }, - checkUpstreamActivity: upstream.createChecker(params), + checkUpstreamActivity: (probes, policy) => + checkUpstreamActivity( + probes.filter( + (probe) => + probe.hostId !== CODEX_LOCAL_SESSION_HOST_ID || + policy?.allowProcessHomeFallback !== false || + !usesProcessHomeFallback(), + ), + ), archive: async (request) => { + assertLocalAccess(request.hostId, request.allowProcessHomeFallback); const runnerConfirmation: unknown = request.confirmNoOtherRunner; if (runnerConfirmation !== true) { throw new CatalogParamsError( @@ -1532,21 +1567,28 @@ function registerCodexSessionCatalog(params: { }); return { ok: true }; }, - openTerminal: (request) => - openCodexCatalogTerminal({ + openTerminal: async (request) => { + assertLocalAccess(request.hostId, request.allowProcessHomeFallback); + return await openCodexCatalogTerminal({ api: params.api, control: params.control, getPluginConfig: params.getPluginConfig, getRuntimeConfig: params.getRuntimeConfig, parseCatalogPage, ...request, - }), - startTerminalSession: (request) => - startCodexCatalogTerminal({ + }); + }, + startTerminalSession: async (request) => { + assertLocalAccess( + request.nodeId ? `node:${request.nodeId}` : CODEX_LOCAL_SESSION_HOST_ID, + request.allowProcessHomeFallback, + ); + return await startCodexCatalogTerminal({ getPluginConfig: params.getPluginConfig, getRuntimeConfig: params.getRuntimeConfig, ...request, - }), + }); + }, }; params.api.registerSessionCatalog(provider); } diff --git a/extensions/cua-computer/package.json b/extensions/cua-computer/package.json index d0f13f81afa6..a5d014fd8aeb 100644 --- a/extensions/cua-computer/package.json +++ b/extensions/cua-computer/package.json @@ -4,7 +4,7 @@ "description": "Experimental CUA Driver SDK computer control for Windows and Linux node hosts", "type": "module", "dependencies": { - "@trycua/cua-driver": "0.14.1", + "@trycua/cua-driver": "0.19.3", "rastermill": "0.3.1", "zod": "4.4.3" }, diff --git a/extensions/cua-computer/src/driver-client.test.ts b/extensions/cua-computer/src/driver-client.test.ts index 33ac5aad5276..d6f44d091e4f 100644 --- a/extensions/cua-computer/src/driver-client.test.ts +++ b/extensions/cua-computer/src/driver-client.test.ts @@ -23,7 +23,7 @@ const sdk = { createTrustedSession: mocks.createTrustedSession, }; -import { createCuaDriver } from "./driver-client.js"; +import { ClickButton, createCuaDriver, ScrollDirection } from "./driver-client.js"; const authorization = { allowedModes: ["unrestricted"], @@ -48,6 +48,22 @@ describe("CUA Driver direct session", () => { }); }); + it("matches the installed CUA Driver desktop input enum contract", async () => { + const driverSdk = await import("@trycua/cua-driver"); + + expect(ClickButton).toEqual({ + Left: driverSdk.ClickButton.Left, + Right: driverSdk.ClickButton.Right, + Middle: driverSdk.ClickButton.Middle, + }); + expect(ScrollDirection).toEqual({ + Up: driverSdk.ScrollDirection.Up, + Down: driverSdk.ScrollDirection.Down, + Left: driverSdk.ScrollDirection.Left, + Right: driverSdk.ScrollDirection.Right, + }); + }); + it("uses configured creation and one fixed trusted OpenClaw session", async () => { const driver = createCuaDriver({ loadSdk: () => sdk as never }); diff --git a/extensions/cua-computer/src/driver-client.ts b/extensions/cua-computer/src/driver-client.ts index a9640b6097da..832f746c2d1c 100644 --- a/extensions/cua-computer/src/driver-client.ts +++ b/extensions/cua-computer/src/driver-client.ts @@ -16,7 +16,7 @@ type CuaDriverSdk = Pick< export type CuaToolResult = import("@trycua/cua-driver").ToolResult; -// These numeric values are part of the pinned 0.14.1 SDK contract. Keeping +// These numeric values are part of the pinned 0.19.3 SDK contract. Keeping // them local avoids loading the native library while OpenClaw is only // registering the bundled plugin. export const ClickButton = { diff --git a/extensions/discord/src/monitor/message-handler.process.ack.test.ts b/extensions/discord/src/monitor/message-handler.process.ack.test.ts index 36dafc0ec6e5..e4bc28873156 100644 --- a/extensions/discord/src/monitor/message-handler.process.ack.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.ack.test.ts @@ -244,24 +244,6 @@ describe("processDiscordMessage ack reactions", () => { } }); - it("debounces intermediate phase reactions and jumps to done for short runs", async () => { - dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { - await params?.replyOptions?.onReasoningStream?.(); - await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - return createNoQueuedDispatchResult(); - }); - - const ctx = await createAutomaticSourceDeliveryContext(); - - await runProcessDiscordMessage(ctx); - - const emojis = getReactionEmojis(); - expect(emojis).toContain("👀"); - expect(emojis).toContain(DEFAULT_EMOJIS.done); - expect(emojis).not.toContain(DEFAULT_EMOJIS.thinking); - expect(emojis).not.toContain(DEFAULT_EMOJIS.coding); - }); - it("marks automatic visible replies as failed when final Discord delivery fails", async () => { dispatchInboundMessage.mockResolvedValueOnce({ queuedFinal: false, @@ -308,12 +290,14 @@ describe("processDiscordMessage ack reactions", () => { args: { action: "react", channelId: "c1", - messageId: "m1", + messageId: "tracked-m1", emoji: "📈", trackToolCalls: true, }, }); - await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + await new Promise((resolve) => { + setTimeout(resolve, DEFAULT_TIMING.debounceMs); + }); return createNoQueuedDispatchResult(); }); @@ -321,12 +305,13 @@ describe("processDiscordMessage ack reactions", () => { cfg: { messages: { ackReaction: "👀" } }, }); - await runProcessDiscordMessage(ctx); + const runPromise = runProcessDiscordMessage(ctx); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); await vi.runAllTimersAsync(); + await runPromise; - expectReactionCallsContain("c1", "m1", "📈"); - expectReactionCallsContain("c1", "m1", "✉️"); - expectReactionCallsContain("c1", "m1", DEFAULT_EMOJIS.done); + expectReactionCallsContain("c1", "tracked-m1", "📈"); + expectReactionCallsContain("c1", "tracked-m1", "✉️"); }); it("resolves tracked reaction to targets like the Discord reaction action", async () => { @@ -343,7 +328,9 @@ describe("processDiscordMessage ack reactions", () => { trackToolCalls: true, }, }); - await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + await new Promise((resolve) => { + setTimeout(resolve, DEFAULT_TIMING.debounceMs); + }); return createNoQueuedDispatchResult(); }); @@ -351,8 +338,10 @@ describe("processDiscordMessage ack reactions", () => { cfg: { messages: { ackReaction: "👀" } }, }); - await runProcessDiscordMessage(ctx); + const runPromise = runProcessDiscordMessage(ctx); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); await vi.runAllTimersAsync(); + await runPromise; const resolveCall = firstMockCall( discordTargetMocks.resolveDiscordTargetChannelId, @@ -364,37 +353,6 @@ describe("processDiscordMessage ack reactions", () => { ); expectReactionCallsContain("dm-u1", "m1", "📈"); expectReactionCallsContain("dm-u1", "m1", "✉️"); - expectReactionCallsContain("dm-u1", "m1", DEFAULT_EMOJIS.done); - }); - - it("shows stall emojis for long no-progress runs", async () => { - vi.useFakeTimers(); - let releaseDispatch: (() => void) | undefined; - const dispatchGate = new Promise((resolve) => { - releaseDispatch = () => resolve(); - }); - dispatchInboundMessage.mockImplementationOnce(async () => { - await dispatchGate; - return createNoQueuedDispatchResult(); - }); - - const ctx = await createAutomaticSourceDeliveryContext(); - const runPromise = runProcessDiscordMessage(ctx); - - await vi.advanceTimersByTimeAsync(30_001); - if (!releaseDispatch) { - throw new Error("Expected Discord dispatch release callback to be initialized"); - } - releaseDispatch(); - await vi.runAllTimersAsync(); - - await runPromise; - const emojis = ( - sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> - ).map((call) => call[2]); - expect(emojis).toContain(DEFAULT_EMOJIS.stallSoft); - expect(emojis).toContain(DEFAULT_EMOJIS.stallHard); - expect(emojis).toContain(DEFAULT_EMOJIS.done); }); it("falls back to plain ack when status reactions are disabled", async () => { @@ -407,10 +365,7 @@ describe("processDiscordMessage ack reactions", () => { cfg: { messages: { ackReaction: "👀", - statusReactions: { - enabled: false, - timing: { debounceMs: 0 }, - }, + statusReactions: { enabled: false }, }, session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, }, @@ -437,12 +392,7 @@ describe("processDiscordMessage ack reactions", () => { const ctx = await createAutomaticSourceDeliveryContext({ cfg: { - messages: { - ackReaction: "👀", - statusReactions: { - timing: { debounceMs: 0 }, - }, - }, + messages: { ackReaction: "👀" }, session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, }, }); @@ -456,23 +406,4 @@ describe("processDiscordMessage ack reactions", () => { expect(emojis).toContain(DEFAULT_EMOJIS.compacting); expect(emojis).toContain(DEFAULT_EMOJIS.thinking); }); - - it("keeps the plain ack reaction when status reactions are disabled", async () => { - const ctx = await createAutomaticSourceDeliveryContext({ - cfg: { - messages: { - ackReaction: "👀", - statusReactions: { - enabled: false, - }, - }, - session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, - }, - }); - - await runProcessDiscordMessage(ctx); - - expect(getReactionEmojis()).toEqual(["👀"]); - expect(sendMocks.removeReactionDiscord).not.toHaveBeenCalled(); - }); }); diff --git a/extensions/discord/src/monitor/message-handler.process.draft-final.test.ts b/extensions/discord/src/monitor/message-handler.process.draft-final.test.ts index 914fc6c7de9d..8b0b626fa37b 100644 --- a/extensions/discord/src/monitor/message-handler.process.draft-final.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.draft-final.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { BASE_CHANNEL_ROUTE, - createAutomaticSourceDeliveryContext, createBaseContext, createDiscordDraftStream, createMockDraftStream, @@ -19,6 +18,7 @@ import { } from "./message-handler.process.test-harness.js"; import type { DispatchInboundParams } from "./message-handler.process.test-harness.js"; import { + createAutomaticDraftContext, createMockDraftStreamForTest, expectFinalWithProgressReceipt, expectFreshFinalText, @@ -45,7 +45,7 @@ async function runHookSafetyFinalReply(mode: (typeof PREVIEW_MODES)[number]) { await params?.dispatcher.waitForIdle(); return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode } }, }); await runProcessDiscordMessage(ctx); @@ -88,7 +88,7 @@ describe("processDiscordMessage provider preview hook safety", () => { }); it("keeps explicitly disabled previews off without hooks", async () => { - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "off" } }, }); @@ -104,7 +104,7 @@ describe("processDiscordMessage provider preview hook safety", () => { await params?.dispatcher.sendFinalReply({ text: "Hello" }); return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ cfg: { agents: { defaults: { blockStreamingDefault: "on" } } }, discordConfig: { streaming: { mode: "partial" } }, }); @@ -153,7 +153,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, cfg: { channels: { discord: { mentionAliases: { Sentinel: "1485891428809707651" } } }, @@ -172,7 +172,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, }); @@ -188,7 +188,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, }); @@ -205,7 +205,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, }); @@ -224,7 +224,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, cfg: { channels: { discord: { mentionAliases: { Sentinel: "1485891428809707651" } } }, @@ -258,7 +258,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress" }, maxLinesPerMessage: 5 }, }); @@ -301,7 +301,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ baseSessionKey: BASE_CHANNEL_ROUTE.sessionKey, discordConfig: { streaming: { mode: "progress" }, maxLinesPerMessage: 5 }, route: BASE_CHANNEL_ROUTE, @@ -326,7 +326,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { maxLinesPerMessage: 5, streaming: { @@ -365,7 +365,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress" }, maxLinesPerMessage: 5 }, }); @@ -391,7 +391,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress" } }, }); await runProcessDiscordMessage(ctx); @@ -401,7 +401,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async () => createNoQueuedDispatchResult()); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling", narration: false } }, }, @@ -417,7 +417,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async () => createNoQueuedDispatchResult()); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling", commandText: "status" } }, }, @@ -447,7 +447,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress" }, maxLinesPerMessage: 5 }, }); @@ -472,7 +472,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress" }, maxLinesPerMessage: 5 }, }); @@ -492,7 +492,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress" } }, }); @@ -518,7 +518,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling", thinking: true } }, }, @@ -547,7 +547,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling", thinking: true } }, }, @@ -585,7 +585,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } }, }, @@ -615,7 +615,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { maxLinesPerMessage: 5, streaming: { mode: "progress", progress: { label: "Shelling" } }, @@ -648,7 +648,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { maxLinesPerMessage: 5, streaming: { mode: "progress", progress: { label: "Shelling" } }, diff --git a/extensions/discord/src/monitor/message-handler.process.draft-progress.test.ts b/extensions/discord/src/monitor/message-handler.process.draft-progress.test.ts index 3b4547ee94e1..528877c1ed55 100644 --- a/extensions/discord/src/monitor/message-handler.process.draft-progress.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.draft-progress.test.ts @@ -5,7 +5,6 @@ import { notifyDiscordActiveTurnThreadReplyDelivered, } from "../active-turn-thread-route.js"; import { - createAutomaticSourceDeliveryContext, createNoQueuedDispatchResult, createNonTerminalToolWarningPayload, deliverDiscordReply, @@ -15,6 +14,7 @@ import { } from "./message-handler.process.test-harness.js"; import type { DispatchInboundParams } from "./message-handler.process.test-harness.js"; import { + createAutomaticDraftContext, createMockDraftStreamForTest, expectFinalWithProgressReceipt, getDeliveredFinalTexts, @@ -42,7 +42,7 @@ describe("processDiscordMessage draft streaming progress", () => { await params?.dispatcher.sendFinalReply({ text: "done" }); return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress" } }, }); @@ -84,7 +84,7 @@ describe("processDiscordMessage draft streaming progress", () => { ).toBe(true); return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Investigating" } }, }, @@ -129,7 +129,7 @@ describe("processDiscordMessage draft streaming progress", () => { }); return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Investigating" } }, }, @@ -171,7 +171,7 @@ describe("processDiscordMessage draft streaming progress", () => { }); return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Investigating" } }, }, @@ -228,7 +228,7 @@ describe("processDiscordMessage draft streaming progress", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -269,7 +269,7 @@ describe("processDiscordMessage draft streaming progress", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -302,7 +302,7 @@ describe("processDiscordMessage draft streaming progress", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -351,7 +351,7 @@ describe("processDiscordMessage draft streaming progress", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } }, }, @@ -386,7 +386,7 @@ describe("processDiscordMessage draft streaming progress", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -422,7 +422,7 @@ describe("processDiscordMessage draft streaming progress", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -460,7 +460,7 @@ describe("processDiscordMessage draft streaming progress", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -484,7 +484,7 @@ describe("processDiscordMessage draft streaming progress", () => { dispatchInboundMessage.mockImplementationOnce(async () => createNoQueuedDispatchResult()); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -514,7 +514,7 @@ describe("processDiscordMessage draft streaming progress", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 1 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -545,7 +545,7 @@ describe("processDiscordMessage draft streaming progress", () => { return { queuedFinal: true, counts: { final: 2, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -582,7 +582,7 @@ describe("processDiscordMessage draft streaming progress", () => { return { queuedFinal: true, counts: { final: 2, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } }, }, @@ -617,7 +617,7 @@ describe("processDiscordMessage draft streaming progress", () => { }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } }, }, @@ -651,7 +651,7 @@ describe("processDiscordMessage draft streaming progress", () => { return { queuedFinal: true, counts: { final: 2, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } }, }, @@ -683,7 +683,7 @@ describe("processDiscordMessage draft streaming progress", () => { return { queuedFinal: true, counts: { final: 2, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } }, }, @@ -715,7 +715,7 @@ describe("processDiscordMessage draft streaming progress", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } }, }, @@ -744,7 +744,7 @@ describe("processDiscordMessage draft streaming progress", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -779,7 +779,7 @@ describe("processDiscordMessage draft streaming progress", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -815,7 +815,7 @@ describe("processDiscordMessage draft streaming progress", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -841,7 +841,7 @@ describe("processDiscordMessage draft streaming progress", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", diff --git a/extensions/discord/src/monitor/message-handler.process.draft-reasoning.test.ts b/extensions/discord/src/monitor/message-handler.process.draft-reasoning.test.ts index 8fce6e4a6acb..3c6713f64d83 100644 --- a/extensions/discord/src/monitor/message-handler.process.draft-reasoning.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.draft-reasoning.test.ts @@ -1,7 +1,6 @@ // Discord message processing coverage split by cohesive behavior. import { describe, expect, it } from "vitest"; import { - createAutomaticSourceDeliveryContext, createNoQueuedDispatchResult, dispatchInboundMessageForTest as dispatchInboundMessage, runInPartialStreamMode, @@ -10,6 +9,7 @@ import { } from "./message-handler.process.test-harness.js"; import type { DispatchInboundParams } from "./message-handler.process.test-harness.js"; import { + createAutomaticDraftContext, createBlockModeContext, createMockDraftStreamForTest, firstDispatchParams, @@ -46,7 +46,7 @@ async function runReasoningProgressDraft( return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress } }, }); await runProcessDiscordMessage(ctx); @@ -68,7 +68,7 @@ describe("processDiscordMessage draft streaming reasoning", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -104,7 +104,7 @@ describe("processDiscordMessage draft streaming reasoning", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -140,7 +140,7 @@ describe("processDiscordMessage draft streaming reasoning", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -291,7 +291,7 @@ describe("processDiscordMessage draft streaming reasoning", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -313,7 +313,7 @@ describe("processDiscordMessage draft streaming reasoning", () => { dispatchInboundMessage.mockImplementationOnce(async () => createNoQueuedDispatchResult()); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial", @@ -339,7 +339,7 @@ describe("processDiscordMessage draft streaming reasoning", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" } }, }); diff --git a/extensions/discord/src/monitor/message-handler.process.draft-recovery.test.ts b/extensions/discord/src/monitor/message-handler.process.draft-recovery.test.ts index 6738f2eab990..792a5e2b07f0 100644 --- a/extensions/discord/src/monitor/message-handler.process.draft-recovery.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.draft-recovery.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { BASE_CHANNEL_ROUTE, - createAutomaticSourceDeliveryContext, createNoQueuedDispatchResult, createNonTerminalToolWarningPayload, deliverDiscordReply, @@ -17,6 +16,7 @@ import { } from "./message-handler.process.test-harness.js"; import type { DispatchInboundParams } from "./message-handler.process.test-harness.js"; import { + createAutomaticDraftContext, createBlockModeContext, createMockDraftStreamForTest, expectFinalWithProgressReceipt, @@ -31,7 +31,7 @@ import { registerDiscordProcessTestLifecycle(); -type AutomaticDeliveryOverrides = Parameters[0]; +type AutomaticDeliveryOverrides = Parameters[0]; type FinalReplyPayload = Parameters[0]; async function runFinalReplyScenario( @@ -44,7 +44,7 @@ async function runFinalReplyScenario( return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, ...overrides, }); @@ -84,7 +84,7 @@ describe("processDiscordMessage draft streaming recovery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ baseSessionKey: BASE_CHANNEL_ROUTE.sessionKey, discordConfig: { streaming: { mode: "progress" }, maxLinesPerMessage: 120 }, route: BASE_CHANNEL_ROUTE, @@ -110,7 +110,7 @@ describe("processDiscordMessage draft streaming recovery", () => { }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 1 }, }); @@ -278,7 +278,7 @@ describe("processDiscordMessage draft streaming recovery", () => { return { queuedFinal: true, counts: { final: 2, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, }); @@ -300,7 +300,7 @@ describe("processDiscordMessage draft streaming recovery", () => { return { queuedFinal: true, counts: { final: 2, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, }); @@ -320,7 +320,7 @@ describe("processDiscordMessage draft streaming recovery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, }); @@ -344,7 +344,7 @@ describe("processDiscordMessage draft streaming recovery", () => { }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "off" } }, }); @@ -367,7 +367,7 @@ describe("processDiscordMessage draft streaming recovery", () => { return { queuedFinal: true, counts: { final: 2, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "partial" }, maxLinesPerMessage: 5 }, }); @@ -399,7 +399,7 @@ describe("processDiscordMessage draft streaming recovery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "off" } }, }); @@ -467,7 +467,7 @@ describe("processDiscordMessage draft streaming recovery", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -508,7 +508,7 @@ describe("processDiscordMessage draft streaming recovery", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", @@ -542,7 +542,7 @@ describe("processDiscordMessage draft streaming recovery", () => { return createNoQueuedDispatchResult(); }); - const ctx = await createAutomaticSourceDeliveryContext({ + const ctx = await createAutomaticDraftContext({ discordConfig: { streaming: { mode: "progress", progress: { label: false } }, }, diff --git a/extensions/discord/src/monitor/message-handler.process.test-helpers.ts b/extensions/discord/src/monitor/message-handler.process.test-helpers.ts index 7c76458c22f1..14c1388b2ff9 100644 --- a/extensions/discord/src/monitor/message-handler.process.test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.process.test-helpers.ts @@ -10,6 +10,27 @@ import { } from "./message-handler.process.test-harness.js"; import type { DispatchInboundParams } from "./message-handler.process.test-harness.js"; +type AutomaticSourceDeliveryOverrides = Parameters[0]; + +export async function createAutomaticDraftContext( + overrides: AutomaticSourceDeliveryOverrides = {}, +): Promise>> { + const cfg = (overrides.cfg ?? {}) as { + messages?: Record; + } & Record; + // Draft tests own preview behavior; keep reaction timers out of their fake-clock lifecycle. + return await createAutomaticSourceDeliveryContext({ + ...overrides, + cfg: { + ...cfg, + messages: { + ...cfg.messages, + statusReactions: { enabled: false }, + }, + }, + }); +} + export function getReactionEmojis(): string[] { return ( sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> @@ -178,7 +199,7 @@ export async function runSingleChunkFinalScenario(discordConfig: Record = { streaming: { mode: "block" } }, ) { - return await createAutomaticSourceDeliveryContext({ + return await createAutomaticDraftContext({ cfg: { messages: { ackReaction: "👀" }, session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, diff --git a/extensions/discord/src/send.assets-and-retries.test-support.ts b/extensions/discord/src/send.assets-and-retries.test-support.ts new file mode 100644 index 000000000000..4a862fd52666 --- /dev/null +++ b/extensions/discord/src/send.assets-and-retries.test-support.ts @@ -0,0 +1,460 @@ +import { MessageFlags, Routes } from "discord-api-types/v10"; +import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RateLimitError } from "./internal/discord.js"; +import { + makeDiscordRest, + requestBody, + requestPath, + timerDelayAt, + type MockCallSource, +} from "./send.test-harness.js"; + +type SendAssetsAndRetriesDeps = { + listGuildEmojisDiscord: typeof import("./send.js").listGuildEmojisDiscord; + reactMessageDiscord: typeof import("./send.js").reactMessageDiscord; + sendMessageDiscord: typeof import("./send.js").sendMessageDiscord; + sendPollDiscord: typeof import("./send.js").sendPollDiscord; + sendStickerDiscord: typeof import("./send.js").sendStickerDiscord; + uploadEmojiDiscord: typeof import("./send.js").uploadEmojiDiscord; + uploadStickerDiscord: typeof import("./send.js").uploadStickerDiscord; +}; + +export function registerSendAssetsAndRetriesTests(deps: SendAssetsAndRetriesDeps): void { + const { + listGuildEmojisDiscord, + reactMessageDiscord, + sendMessageDiscord, + sendPollDiscord, + sendStickerDiscord, + uploadEmojiDiscord, + uploadStickerDiscord, + } = deps; + const discordTestConfig = { + channels: { + discord: { + accounts: { + default: {}, + }, + }, + }, + }; + + const discordClientOpts = (rest: ReturnType["rest"]) => ({ + cfg: discordTestConfig, + rest, + token: "t", + }); + + function createRateLimitError( + response: Response, + body: { message: string; retry_after: number; global: boolean }, + request?: Request, + ): RateLimitError { + const fallbackRequest = + request ?? + new Request("https://discord.com/api/v10/channels/789/messages", { + method: "POST", + }); + const RateLimitErrorCtor = RateLimitError as unknown as new ( + response: Response, + body: { message: string; retry_after: number; global: boolean }, + request?: Request, + ) => RateLimitError; + return new RateLimitErrorCtor(response, body, fallbackRequest); + } + + describe("listGuildEmojisDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists emojis for a guild", async () => { + const { rest, getMock } = makeDiscordRest(); + getMock.mockResolvedValue([{ id: "e1", name: "party" }]); + await listGuildEmojisDiscord("g1", discordClientOpts(rest)); + expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1")); + }); + }); + + describe("uploadEmojiDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uploads emoji assets", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock.mockResolvedValue({ id: "e1" }); + await uploadEmojiDiscord( + { + guildId: "g1", + name: "party_blob", + mediaUrl: "file:///tmp/party.png", + roleIds: ["r1"], + }, + discordClientOpts(rest), + ); + expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.guildEmojis("g1")); + expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ + name: "party_blob", + image: "data:image/png;base64,aW1n", + roles: ["r1"], + }); + expect(loadWebMediaRaw).toHaveBeenCalledWith("file:///tmp/party.png", 256 * 1024); + }); + }); + + describe("uploadStickerDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uploads sticker assets", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock.mockResolvedValue({ id: "s1" }); + await uploadStickerDiscord( + { + guildId: "g1", + name: "openclaw_wave", + description: "OpenClaw waving", + tags: "👋", + mediaUrl: "file:///tmp/wave.png", + }, + discordClientOpts(rest), + ); + expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.guildStickers("g1")); + const stickerBody = requestBody(postMock as unknown as MockCallSource); + expect(stickerBody.name).toBe("openclaw_wave"); + expect(stickerBody.description).toBe("OpenClaw waving"); + expect(stickerBody.tags).toBe("👋"); + const files = stickerBody.files as Array<{ name?: string; contentType?: string }>; + expect(files).toHaveLength(1); + expect(files[0]?.name).toBe("asset.png"); + expect(files[0]?.contentType).toBe("image/png"); + expect(loadWebMediaRaw).toHaveBeenCalledWith("file:///tmp/wave.png", 512 * 1024); + }); + }); + + describe("sendStickerDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends sticker payloads", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); + const res = await sendStickerDiscord("channel:789", ["123"], { + cfg: discordTestConfig, + rest, + token: "t", + content: "hiya", + }); + expect(res.messageId).toBe("msg1"); + expect(res.channelId).toBe("789"); + expect(res.receipt.parts[0]?.platformMessageId).toBe("msg1"); + expect(res.receipt.parts[0]?.kind).toBe("card"); + expect(requestPath(postMock as unknown as MockCallSource)).toBe( + Routes.channelMessages("789"), + ); + expect(requestBody(postMock as unknown as MockCallSource)).toMatchObject({ + content: "hiya", + flags: MessageFlags.SuppressEmbeds, + sticker_ids: ["123"], + enforce_nonce: true, + }); + expect(requestBody(postMock as unknown as MockCallSource).nonce).toMatch(/^[0-9a-f]{24}$/); + }); + + it("allows sticker content link embeds when disabled", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); + await sendStickerDiscord("channel:789", ["123"], { + cfg: discordTestConfig, + rest, + token: "t", + content: "https://example.com", + suppressEmbeds: false, + }); + + expect(requestBody(postMock as unknown as MockCallSource)).toMatchObject({ + content: "https://example.com", + sticker_ids: ["123"], + enforce_nonce: true, + }); + expect(requestBody(postMock as unknown as MockCallSource).nonce).toMatch(/^[0-9a-f]{24}$/); + }); + + it("reuses a single nonce across a retried 502 for stickers", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock + .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 })) + .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); + await sendStickerDiscord("channel:789", ["123"], { + cfg: discordTestConfig, + rest, + token: "t", + content: "hiya", + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }); + expect(postMock).toHaveBeenCalledTimes(2); + const firstNonce = requestBody(postMock as unknown as MockCallSource, 0).nonce; + const secondNonce = requestBody(postMock as unknown as MockCallSource, 1).nonce; + expect(firstNonce).toMatch(/^[0-9a-f]{24}$/); + expect(secondNonce).toBe(firstNonce); + }); + }); + + describe("sendPollDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends polls with answers", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); + const res = await sendPollDiscord( + "channel:789", + { + question: "Lunch?", + options: ["Pizza", "Sushi"], + }, + { + cfg: discordTestConfig, + rest, + token: "t", + }, + ); + expect(res.messageId).toBe("msg1"); + expect(res.channelId).toBe("789"); + expect(res.receipt.parts[0]?.platformMessageId).toBe("msg1"); + expect(res.receipt.parts[0]?.kind).toBe("card"); + expect(requestPath(postMock as unknown as MockCallSource)).toBe( + Routes.channelMessages("789"), + ); + expect(requestBody(postMock as unknown as MockCallSource).flags).toBe( + MessageFlags.SuppressEmbeds, + ); + expect(requestBody(postMock as unknown as MockCallSource).poll).toEqual({ + question: { text: "Lunch?" }, + answers: [{ poll_media: { text: "Pizza" } }, { poll_media: { text: "Sushi" } }], + duration: 24, + allow_multiselect: false, + layout_type: 1, + }); + expect(requestBody(postMock as unknown as MockCallSource)).toMatchObject({ + enforce_nonce: true, + }); + expect(requestBody(postMock as unknown as MockCallSource).nonce).toMatch(/^[0-9a-f]{24}$/); + }); + + it("reuses a single nonce across a retried 502 for polls", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock + .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 })) + .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); + await sendPollDiscord( + "channel:789", + { + question: "Lunch?", + options: ["Pizza", "Sushi"], + }, + { + cfg: discordTestConfig, + rest, + token: "t", + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + expect(postMock).toHaveBeenCalledTimes(2); + const firstNonce = requestBody(postMock as unknown as MockCallSource, 0).nonce; + const secondNonce = requestBody(postMock as unknown as MockCallSource, 1).nonce; + expect(firstNonce).toMatch(/^[0-9a-f]{24}$/); + expect(secondNonce).toBe(firstNonce); + }); + + it("combines silent and suppress-embeds flags for polls", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); + await sendPollDiscord( + "channel:789", + { + question: "Lunch?", + options: ["Pizza", "Sushi"], + }, + { + cfg: discordTestConfig, + rest, + token: "t", + content: "https://example.com", + silent: true, + }, + ); + + expect(requestBody(postMock as unknown as MockCallSource).flags).toBe( + MessageFlags.SuppressEmbeds | MessageFlags.SuppressNotifications, + ); + }); + }); + + function createMockRateLimitError(retryAfter = 0.001): RateLimitError { + const request = new Request("https://discord.com/api/v10/channels/789/messages", { + method: "POST", + }); + const response = new Response(null, { + status: 429, + headers: { + "X-RateLimit-Scope": "user", + "X-RateLimit-Bucket": "test-bucket", + }, + }); + return createRateLimitError( + response, + { + message: "You are being rate limited.", + retry_after: retryAfter, + global: false, + }, + request, + ); + } + + describe("retry rate limits", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("retries on Discord rate limits", async () => { + const { rest, postMock } = makeDiscordRest(); + const rateLimitError = createMockRateLimitError(0); + + postMock + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); + + const res = await sendMessageDiscord("channel:789", "hello", { + cfg: discordTestConfig, + rest, + token: "t", + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }); + + expect(res.messageId).toBe("msg1"); + expect(postMock).toHaveBeenCalledTimes(2); + }); + + it("uses retry_after delays when rate limited", async () => { + const setTimeoutSpy = vi.spyOn(global, "setTimeout"); + try { + const { rest, postMock } = makeDiscordRest(); + const rateLimitError = createMockRateLimitError(0.001); + + postMock + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); + + const promise = sendMessageDiscord("channel:789", "hello", { + cfg: discordTestConfig, + rest, + token: "t", + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 }, + }); + + const result = await promise; + expect(result.messageId).toBe("msg1"); + expect(result.channelId).toBe("789"); + expect(result.receipt.primaryPlatformMessageId).toBe("msg1"); + expect(result.receipt.platformMessageIds).toEqual(["msg1"]); + expect(timerDelayAt(setTimeoutSpy as unknown as MockCallSource)).toBe(1); + } finally { + setTimeoutSpy.mockRestore(); + } + }); + + it("stops after max retry attempts", async () => { + const { rest, postMock } = makeDiscordRest(); + const rateLimitError = createMockRateLimitError(0); + + postMock.mockRejectedValue(rateLimitError); + + await expect( + sendMessageDiscord("channel:789", "hello", { + cfg: discordTestConfig, + rest, + token: "t", + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }), + ).rejects.toBeInstanceOf(RateLimitError); + expect(postMock).toHaveBeenCalledTimes(2); + }); + + it("does not retry permanent non-rate-limit errors", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock.mockRejectedValueOnce(new Error("invalid request")); + + await expect( + sendMessageDiscord("channel:789", "hello", discordClientOpts(rest)), + ).rejects.toThrow("invalid request"); + expect(postMock).toHaveBeenCalledTimes(1); + }); + + it("retries ambiguous network errors with one stable enforced nonce", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); + + const result = await sendMessageDiscord("channel:789", "hello", { + cfg: discordTestConfig, + rest, + token: "t", + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }); + + expect(result.messageId).toBe("msg1"); + expect(postMock).toHaveBeenCalledTimes(2); + const firstBody = requestBody(postMock as unknown as MockCallSource, 0); + const secondBody = requestBody(postMock as unknown as MockCallSource, 1); + expect(firstBody.enforce_nonce).toBe(true); + expect(secondBody.nonce).toBe(firstBody.nonce); + }); + + it("retries reactions on rate limits", async () => { + const { rest, putMock } = makeDiscordRest(); + const rateLimitError = createMockRateLimitError(0); + + putMock.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(undefined); + + const res = await reactMessageDiscord("chan1", "msg1", "ok", { + cfg: discordTestConfig, + rest, + token: "t", + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }); + + expect(res.ok).toBe(true); + expect(putMock).toHaveBeenCalledTimes(2); + }); + + it("retries media upload without duplicating overflow text", async () => { + const { rest, postMock } = makeDiscordRest(); + const rateLimitError = createMockRateLimitError(0); + const text = "a".repeat(2005); + + postMock + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }) + .mockResolvedValueOnce({ id: "msg2", channel_id: "789" }); + + const res = await sendMessageDiscord("channel:789", text, { + cfg: discordTestConfig, + rest, + token: "t", + mediaUrl: "https://example.com/photo.jpg", + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }); + + expect(res.messageId).toBe("msg1"); + expect(postMock).toHaveBeenCalledTimes(3); + }); + }); +} diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index e6db36986a60..0a4f1283ab28 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -1,14 +1,12 @@ -import { ChannelType, MessageFlags, Routes } from "discord-api-types/v10"; +import { ChannelType, Routes } from "discord-api-types/v10"; // Discord tests cover send.creates thread plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; -import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { RateLimitError } from "./internal/discord.js"; +import { registerSendAssetsAndRetriesTests } from "./send.assets-and-retries.test-support.js"; import { makeDiscordRest, requestBody, requestPath, - timerDelayAt, type MockCallSource, } from "./send.test-harness.js"; @@ -107,24 +105,6 @@ function createDiscordForumPayloadHarness(parentType: ChannelType = ChannelType. }; } -function createRateLimitError( - response: Response, - body: { message: string; retry_after: number; global: boolean }, - request?: Request, -): RateLimitError { - const fallbackRequest = - request ?? - new Request("https://discord.com/api/v10/channels/789/messages", { - method: "POST", - }); - const RateLimitErrorCtor = RateLimitError as unknown as new ( - response: Response, - body: { message: string; retry_after: number; global: boolean }, - request?: Request, - ) => RateLimitError; - return new RateLimitErrorCtor(response, body, fallbackRequest); -} - beforeAll(async () => { ({ addRoleDiscord, @@ -158,6 +138,16 @@ afterAll(() => { vi.doUnmock("openclaw/plugin-sdk/web-media"); }); +registerSendAssetsAndRetriesTests({ + listGuildEmojisDiscord: (...args) => listGuildEmojisDiscord(...args), + reactMessageDiscord: (...args) => reactMessageDiscord(...args), + sendMessageDiscord: (...args) => sendMessageDiscord(...args), + sendPollDiscord: (...args) => sendPollDiscord(...args), + sendStickerDiscord: (...args) => sendStickerDiscord(...args), + uploadEmojiDiscord: (...args) => uploadEmojiDiscord(...args), + uploadStickerDiscord: (...args) => uploadStickerDiscord(...args), +}); + describe("sendMessageDiscord", () => { it.each([ { @@ -557,393 +547,3 @@ describe("sendMessageDiscord", () => { expect(requestBody(putMock as unknown as MockCallSource)).toEqual({ delete_message_days: 2 }); }); }); - -describe("listGuildEmojisDiscord", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("lists emojis for a guild", async () => { - const { rest, getMock } = makeDiscordRest(); - getMock.mockResolvedValue([{ id: "e1", name: "party" }]); - await listGuildEmojisDiscord("g1", discordClientOpts(rest)); - expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1")); - }); -}); - -describe("uploadEmojiDiscord", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("uploads emoji assets", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock.mockResolvedValue({ id: "e1" }); - await uploadEmojiDiscord( - { - guildId: "g1", - name: "party_blob", - mediaUrl: "file:///tmp/party.png", - roleIds: ["r1"], - }, - discordClientOpts(rest), - ); - expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.guildEmojis("g1")); - expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ - name: "party_blob", - image: "data:image/png;base64,aW1n", - roles: ["r1"], - }); - expect(loadWebMediaRaw).toHaveBeenCalledWith("file:///tmp/party.png", 256 * 1024); - }); -}); - -describe("uploadStickerDiscord", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("uploads sticker assets", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock.mockResolvedValue({ id: "s1" }); - await uploadStickerDiscord( - { - guildId: "g1", - name: "openclaw_wave", - description: "OpenClaw waving", - tags: "👋", - mediaUrl: "file:///tmp/wave.png", - }, - discordClientOpts(rest), - ); - expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.guildStickers("g1")); - const stickerBody = requestBody(postMock as unknown as MockCallSource); - expect(stickerBody.name).toBe("openclaw_wave"); - expect(stickerBody.description).toBe("OpenClaw waving"); - expect(stickerBody.tags).toBe("👋"); - const files = stickerBody.files as Array<{ name?: string; contentType?: string }>; - expect(files).toHaveLength(1); - expect(files[0]?.name).toBe("asset.png"); - expect(files[0]?.contentType).toBe("image/png"); - expect(loadWebMediaRaw).toHaveBeenCalledWith("file:///tmp/wave.png", 512 * 1024); - }); -}); - -describe("sendStickerDiscord", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("sends sticker payloads", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); - const res = await sendStickerDiscord("channel:789", ["123"], { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - content: "hiya", - }); - expect(res.messageId).toBe("msg1"); - expect(res.channelId).toBe("789"); - expect(res.receipt.parts[0]?.platformMessageId).toBe("msg1"); - expect(res.receipt.parts[0]?.kind).toBe("card"); - expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.channelMessages("789")); - expect(requestBody(postMock as unknown as MockCallSource)).toMatchObject({ - content: "hiya", - flags: MessageFlags.SuppressEmbeds, - sticker_ids: ["123"], - enforce_nonce: true, - }); - expect(requestBody(postMock as unknown as MockCallSource).nonce).toMatch(/^[0-9a-f]{24}$/); - }); - - it("allows sticker content link embeds when disabled", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); - await sendStickerDiscord("channel:789", ["123"], { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - content: "https://example.com", - suppressEmbeds: false, - }); - - expect(requestBody(postMock as unknown as MockCallSource)).toMatchObject({ - content: "https://example.com", - sticker_ids: ["123"], - enforce_nonce: true, - }); - expect(requestBody(postMock as unknown as MockCallSource).nonce).toMatch(/^[0-9a-f]{24}$/); - }); - - it("reuses a single nonce across a retried 502 for stickers", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock - .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 })) - .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); - await sendStickerDiscord("channel:789", ["123"], { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - content: "hiya", - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }); - expect(postMock).toHaveBeenCalledTimes(2); - const firstNonce = requestBody(postMock as unknown as MockCallSource, 0).nonce; - const secondNonce = requestBody(postMock as unknown as MockCallSource, 1).nonce; - expect(firstNonce).toMatch(/^[0-9a-f]{24}$/); - expect(secondNonce).toBe(firstNonce); - }); -}); - -describe("sendPollDiscord", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("sends polls with answers", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); - const res = await sendPollDiscord( - "channel:789", - { - question: "Lunch?", - options: ["Pizza", "Sushi"], - }, - { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - }, - ); - expect(res.messageId).toBe("msg1"); - expect(res.channelId).toBe("789"); - expect(res.receipt.parts[0]?.platformMessageId).toBe("msg1"); - expect(res.receipt.parts[0]?.kind).toBe("card"); - expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.channelMessages("789")); - expect(requestBody(postMock as unknown as MockCallSource).flags).toBe( - MessageFlags.SuppressEmbeds, - ); - expect(requestBody(postMock as unknown as MockCallSource).poll).toEqual({ - question: { text: "Lunch?" }, - answers: [{ poll_media: { text: "Pizza" } }, { poll_media: { text: "Sushi" } }], - duration: 24, - allow_multiselect: false, - layout_type: 1, - }); - expect(requestBody(postMock as unknown as MockCallSource)).toMatchObject({ - enforce_nonce: true, - }); - expect(requestBody(postMock as unknown as MockCallSource).nonce).toMatch(/^[0-9a-f]{24}$/); - }); - - it("reuses a single nonce across a retried 502 for polls", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock - .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 })) - .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); - await sendPollDiscord( - "channel:789", - { - question: "Lunch?", - options: ["Pizza", "Sushi"], - }, - { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }, - ); - expect(postMock).toHaveBeenCalledTimes(2); - const firstNonce = requestBody(postMock as unknown as MockCallSource, 0).nonce; - const secondNonce = requestBody(postMock as unknown as MockCallSource, 1).nonce; - expect(firstNonce).toMatch(/^[0-9a-f]{24}$/); - expect(secondNonce).toBe(firstNonce); - }); - - it("combines silent and suppress-embeds flags for polls", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); - await sendPollDiscord( - "channel:789", - { - question: "Lunch?", - options: ["Pizza", "Sushi"], - }, - { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - content: "https://example.com", - silent: true, - }, - ); - - expect(requestBody(postMock as unknown as MockCallSource).flags).toBe( - MessageFlags.SuppressEmbeds | MessageFlags.SuppressNotifications, - ); - }); -}); - -function createMockRateLimitError(retryAfter = 0.001): RateLimitError { - const request = new Request("https://discord.com/api/v10/channels/789/messages", { - method: "POST", - }); - const response = new Response(null, { - status: 429, - headers: { - "X-RateLimit-Scope": "user", - "X-RateLimit-Bucket": "test-bucket", - }, - }); - return createRateLimitError( - response, - { - message: "You are being rate limited.", - retry_after: retryAfter, - global: false, - }, - request, - ); -} - -describe("retry rate limits", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("retries on Discord rate limits", async () => { - const { rest, postMock } = makeDiscordRest(); - const rateLimitError = createMockRateLimitError(0); - - postMock - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); - - const res = await sendMessageDiscord("channel:789", "hello", { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }); - - expect(res.messageId).toBe("msg1"); - expect(postMock).toHaveBeenCalledTimes(2); - }); - - it("uses retry_after delays when rate limited", async () => { - const setTimeoutSpy = vi.spyOn(global, "setTimeout"); - try { - const { rest, postMock } = makeDiscordRest(); - const rateLimitError = createMockRateLimitError(0.001); - - postMock - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); - - const promise = sendMessageDiscord("channel:789", "hello", { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 }, - }); - - const result = await promise; - expect(result.messageId).toBe("msg1"); - expect(result.channelId).toBe("789"); - expect(result.receipt.primaryPlatformMessageId).toBe("msg1"); - expect(result.receipt.platformMessageIds).toEqual(["msg1"]); - expect(timerDelayAt(setTimeoutSpy as unknown as MockCallSource)).toBe(1); - } finally { - setTimeoutSpy.mockRestore(); - } - }); - - it("stops after max retry attempts", async () => { - const { rest, postMock } = makeDiscordRest(); - const rateLimitError = createMockRateLimitError(0); - - postMock.mockRejectedValue(rateLimitError); - - await expect( - sendMessageDiscord("channel:789", "hello", { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }), - ).rejects.toBeInstanceOf(RateLimitError); - expect(postMock).toHaveBeenCalledTimes(2); - }); - - it("does not retry permanent non-rate-limit errors", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock.mockRejectedValueOnce(new Error("invalid request")); - - await expect( - sendMessageDiscord("channel:789", "hello", discordClientOpts(rest)), - ).rejects.toThrow("invalid request"); - expect(postMock).toHaveBeenCalledTimes(1); - }); - - it("retries ambiguous network errors with one stable enforced nonce", async () => { - const { rest, postMock } = makeDiscordRest(); - postMock - .mockRejectedValueOnce(new TypeError("fetch failed")) - .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); - - const result = await sendMessageDiscord("channel:789", "hello", { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }); - - expect(result.messageId).toBe("msg1"); - expect(postMock).toHaveBeenCalledTimes(2); - const firstBody = requestBody(postMock as unknown as MockCallSource, 0); - const secondBody = requestBody(postMock as unknown as MockCallSource, 1); - expect(firstBody.enforce_nonce).toBe(true); - expect(secondBody.nonce).toBe(firstBody.nonce); - }); - - it("retries reactions on rate limits", async () => { - const { rest, putMock } = makeDiscordRest(); - const rateLimitError = createMockRateLimitError(0); - - putMock.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(undefined); - - const res = await reactMessageDiscord("chan1", "msg1", "ok", { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }); - - expect(res.ok).toBe(true); - expect(putMock).toHaveBeenCalledTimes(2); - }); - - it("retries media upload without duplicating overflow text", async () => { - const { rest, postMock } = makeDiscordRest(); - const rateLimitError = createMockRateLimitError(0); - const text = "a".repeat(2005); - - postMock - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }) - .mockResolvedValueOnce({ id: "msg2", channel_id: "789" }); - - const res = await sendMessageDiscord("channel:789", text, { - cfg: DISCORD_TEST_CFG, - rest, - token: "t", - mediaUrl: "https://example.com/photo.jpg", - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }); - - expect(res.messageId).toBe("msg1"); - expect(postMock).toHaveBeenCalledTimes(3); - }); -}); diff --git a/extensions/opencode/session-catalog-plugin.ts b/extensions/opencode/session-catalog-plugin.ts index 146702d17fce..59caecff29a7 100644 --- a/extensions/opencode/session-catalog-plugin.ts +++ b/extensions/opencode/session-catalog-plugin.ts @@ -171,6 +171,22 @@ function isOpenCodeSessionCatalogEnabled(pluginConfig: unknown): boolean { ); } +function openCodeUsesProcessHomeFallback(env: NodeJS.ProcessEnv): boolean { + return !env.OPENCODE_DB?.trim() && !path.isAbsolute(env.XDG_DATA_HOME?.trim() ?? ""); +} + +function assertOpenCodeLocalAccess(hostId: string, allowProcessHomeFallback?: boolean): void { + if ( + hostId === LOCAL_HOST_ID && + allowProcessHomeFallback === false && + openCodeUsesProcessHomeFallback(process.env) + ) { + throw new OpenCodeCatalogParamsError( + "local OpenCode sessions are unavailable in isolated state", + ); + } +} + function createOpenCodeSessionNodeHostCommands( api: OpenClawPluginApi, ): OpenClawPluginNodeHostCommand[] { @@ -350,6 +366,7 @@ async function listOpenCodeHosts( const hosts: SessionCatalogHost[] = []; if ( (!requested || requested.has(LOCAL_HOST_ID)) && + (query.allowProcessHomeFallback !== false || !openCodeUsesProcessHomeFallback(process.env)) && resolveNodeHostExecutable("opencode", { env: process.env, pathEnv: process.env.PATH ?? "", @@ -411,6 +428,7 @@ async function readOpenCodeTranscript( throw new Error("cursor is invalid"); } if (request.hostId === LOCAL_HOST_ID) { + assertOpenCodeLocalAccess(request.hostId, request.allowProcessHomeFallback); return await readLocalOpenCodeTranscriptPage({ threadId: request.threadId, ...(request.limit ? { limit: request.limit } : {}), @@ -564,18 +582,31 @@ export function registerOpenCodeSessionCatalog(api: OpenClawPluginApi): void { api.registerSessionCatalog({ id: "opencode", label: "OpenCode", + supportsProcessHomeIsolation: true, list: async (query) => await listOpenCodeHosts(api, query), read: async (request) => await readOpenCodeTranscript(api.runtime, request), - continueSession: async (request) => - await continueOpenCodeSession(api, request.hostId, request.threadId), - checkUpstreamActivity: checkOpenCodeUpstreamActivity, - openTerminal: async (request) => - await openOpenCodeCatalogTerminal({ + continueSession: async (request) => { + assertOpenCodeLocalAccess(request.hostId, request.allowProcessHomeFallback); + return await continueOpenCodeSession(api, request.hostId, request.threadId); + }, + checkUpstreamActivity: (probes, policy) => + checkOpenCodeUpstreamActivity( + probes.filter( + (probe) => + probe.hostId !== LOCAL_HOST_ID || + policy?.allowProcessHomeFallback !== false || + !openCodeUsesProcessHomeFallback(process.env), + ), + ), + openTerminal: async (request) => { + assertOpenCodeLocalAccess(request.hostId, request.allowProcessHomeFallback); + return await openOpenCodeCatalogTerminal({ runtime: api.runtime, ...request, parseNodeSessionPage, unwrapNodePayload, - }), + }); + }, }); for (const command of createOpenCodeSessionNodeHostCommands(api)) { api.registerNodeHostCommand(command); diff --git a/extensions/opencode/session-catalog.test.ts b/extensions/opencode/session-catalog.test.ts index 13b5efce6727..87627cf27e08 100644 --- a/extensions/opencode/session-catalog.test.ts +++ b/extensions/opencode/session-catalog.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import type { SessionTranscriptWriteLockContext } from "openclaw/plugin-sdk/session-transcript-runtime"; +import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; type ResolveAcpSessionAvailability = @@ -456,6 +457,47 @@ describe("OpenCode session catalog", () => { ]); }); + itWithCli("allows a relative OPENCODE_DB as an explicit isolated-state root", async () => { + await installFakeOpenCode(); + const { provider } = captureOpenCodeSessionRegistrations(); + + await withEnvAsync( + { OPENCODE_DB: undefined, XDG_DATA_HOME: undefined }, + async () => + await Promise.all([ + expect( + provider!.list({ allowProcessHomeFallback: false, hostIds: ["gateway"] }), + ).resolves.toEqual([]), + expect( + provider!.continueSession?.({ + allowProcessHomeFallback: false, + hostId: "gateway", + threadId: "ses_test", + }), + ).rejects.toThrow("local OpenCode sessions are unavailable in isolated state"), + expect( + provider!.openTerminal?.({ + allowProcessHomeFallback: false, + hostId: "gateway", + threadId: "ses_test", + }), + ).rejects.toThrow("local OpenCode sessions are unavailable in isolated state"), + ]), + ); + await withEnvAsync({ OPENCODE_DB: "relative.db", XDG_DATA_HOME: undefined }, async () => { + await expect( + provider!.list({ allowProcessHomeFallback: false, hostIds: ["gateway"] }), + ).resolves.toEqual([expect.objectContaining({ hostId: "gateway" })]); + await expect( + provider!.read({ + allowProcessHomeFallback: false, + hostId: "gateway", + threadId: "ses_test", + }), + ).resolves.toMatchObject({ hostId: "gateway", threadId: "ses_test" }); + }); + }); + itWithCli( "memoizes the CLI database query across cadence and invalidates by config identity", async () => { diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts index 10f677beaed9..f3778796112d 100644 --- a/extensions/telegram/src/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -2,14 +2,14 @@ import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-core"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; -import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import { + coerceSecretRef, hasConfiguredSecretInput, normalizeSecretInputString, } from "openclaw/plugin-sdk/secret-input"; -import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input-runtime"; +import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/secret-provider-alias"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { mergeTelegramAccountConfig, diff --git a/extensions/telegram/src/account-owner.test.ts b/extensions/telegram/src/account-owner.test.ts new file mode 100644 index 000000000000..0d846b10e614 --- /dev/null +++ b/extensions/telegram/src/account-owner.test.ts @@ -0,0 +1,42 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it } from "vitest"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; + +describe("resolveTelegramAccountOwnerAgentId", () => { + it("resolves distinct routed owners for Telegram accounts", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { main: {}, ops: {}, research: {} }, + }, + channels: { + telegram: { + accounts: { + primary: { botToken: "123456:primary" }, + alerts: { botToken: "123456:alerts" }, + }, + }, + }, + bindings: [ + { agentId: "main", match: { channel: "telegram", accountId: "primary" } }, + { agentId: "ops", match: { channel: "telegram", accountId: "alerts" } }, + ], + } as OpenClawConfig; + + expect(resolveTelegramAccountOwnerAgentId({ cfg, accountId: "primary" })).toBe("main"); + expect(resolveTelegramAccountOwnerAgentId({ cfg, accountId: "alerts" })).toBe("ops"); + }); + + it("rejects explicit multi-agent ownership without an account route", () => { + const cfg = { + agents: { + ownership: "explicit", + entries: { main: {}, ops: {} }, + }, + } as OpenClawConfig; + + expect(() => resolveTelegramAccountOwnerAgentId({ cfg, accountId: "default" })).toThrow( + /Add a channel-wide binding for telegram:default/, + ); + }); +}); diff --git a/extensions/telegram/src/account-owner.ts b/extensions/telegram/src/account-owner.ts new file mode 100644 index 000000000000..1307b24d70ce --- /dev/null +++ b/extensions/telegram/src/account-owner.ts @@ -0,0 +1,14 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; + +/** Resolves the agent that owns account-scoped Telegram runtime state. */ +export function resolveTelegramAccountOwnerAgentId(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string { + return resolveAgentRoute({ + cfg: params.cfg, + channel: "telegram", + accountId: params.accountId, + }).agentId; +} diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index 92af733796f2..45905ffa755d 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -26,6 +26,7 @@ import { import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; import { createTelegramActionGate, resolveDefaultTelegramAccountId, @@ -154,8 +155,9 @@ function readTelegramThreadId(params: Record) { } function resolveActionTopicNameCacheScope(cfg: OpenClawConfig, accountId?: string | null): string { + const resolvedAccountId = accountId ?? resolveDefaultTelegramAccountId(cfg); const storePath = resolveStorePath(cfg.session?.store, { - agentId: accountId ?? resolveDefaultTelegramAccountId(cfg), + agentId: resolveTelegramAccountOwnerAgentId({ cfg, accountId: resolvedAccountId }), }); return resolveTopicNameCacheScope(storePath); } diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index 5b5e9866bedd..b89351daa2a4 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -22,6 +22,7 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; import { getOrCreateAccountThrottler } from "./account-throttler.js"; import { resolveTelegramAccount } from "./accounts.js"; import { normalizeTelegramApiRoot } from "./api-root.js"; @@ -43,7 +44,6 @@ import { } from "./bot-processing-outcome.js"; import { createTelegramUpdateTracker } from "./bot-update-tracker.js"; import type { TelegramUpdateKeyContext } from "./bot-updates.js"; -import { resolveDefaultAgentId } from "./bot.agent.runtime.js"; import { apiThrottler, Bot, sequentialize, type ApiClientOptions } from "./bot.runtime.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; @@ -95,6 +95,10 @@ export function createTelegramBotCore( cfg, accountId: opts.accountId, }); + const ownerAgentId = + opts.ownerAgentId?.trim() || + resolveTelegramAccountOwnerAgentId({ cfg, accountId: account.accountId }); + const runtimeOpts = { ...opts, ownerAgentId }; const threadBindingPolicy = resolveThreadBindingSpawnPolicy({ cfg, channel: "telegram", @@ -279,7 +283,7 @@ export function createTelegramBotCore( accountId: account.accountId, cfg, telegramCfg, - opts, + opts: runtimeOpts, }); const groupHistories = new Map(); const botHistorySender = buildTelegramSelfSenderName(account.name, opts.botInfo); @@ -328,7 +332,7 @@ export function createTelegramBotCore( sessionKey?: string; cfg: OpenClawConfig; }) => { - const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg); + const agentId = params.agentId ?? ownerAgentId; const sessionKey = params.sessionKey ?? `agent:${agentId}:telegram:group:${buildTelegramGroupPeerId(params.chatId, params.messageThreadId)}`; @@ -394,7 +398,7 @@ export function createTelegramBotCore( resolveTelegramGroupConfig, sendChatActionHandler, runtime, - opts, + opts: runtimeOpts, telegramDeps, }); @@ -410,7 +414,7 @@ export function createTelegramBotCore( resolveGroupPolicy, resolveTelegramGroupConfig, shouldSkipUpdate, - opts, + opts: runtimeOpts, telegramDeps: { ...telegramDeps, sendMessageTelegram: defaultTelegramNativeCommandDeps.sendMessageTelegram, @@ -420,8 +424,9 @@ export function createTelegramBotCore( registerTelegramHandlers({ cfg, accountId: account.accountId, + ownerAgentId, bot, - opts, + opts: runtimeOpts, telegramTransport, runtime, mediaMaxBytes, diff --git a/extensions/telegram/src/bot-handlers.agent.runtime.ts b/extensions/telegram/src/bot-handlers.agent.runtime.ts index f1f826ec2da1..5024911dc9b4 100644 --- a/extensions/telegram/src/bot-handlers.agent.runtime.ts +++ b/extensions/telegram/src/bot-handlers.agent.runtime.ts @@ -1,6 +1,2 @@ // Telegram plugin module implements bot handlers.agent behavior. -export { - resolveAgentDir, - resolveDefaultAgentId, - resolveDefaultModelForAgent, -} from "openclaw/plugin-sdk/agent-runtime"; +export { resolveAgentDir, resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; diff --git a/extensions/telegram/src/bot-handlers.callback-router.ts b/extensions/telegram/src/bot-handlers.callback-router.ts index f26c2614bb55..772e52e8d273 100644 --- a/extensions/telegram/src/bot-handlers.callback-router.ts +++ b/extensions/telegram/src/bot-handlers.callback-router.ts @@ -13,11 +13,7 @@ import { hasTelegramApprovalCallbackPrefix, parseTelegramApprovalCallbackData, } from "./approval-callback-data.js"; -import { - resolveAgentDir, - resolveDefaultAgentId, - resolveDefaultModelForAgent, -} from "./bot-handlers.agent.runtime.js"; +import { resolveAgentDir, resolveDefaultModelForAgent } from "./bot-handlers.agent.runtime.js"; import { createTelegramCallbackMessageActions, handleTelegramQuestionCallback, @@ -464,7 +460,18 @@ async function handleTelegramModelCallback(params: { if (page === undefined) { return true; } - const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(runtimeCfg); + const agentId = + paginationMatch[2]?.trim() || + messageRuntime.resolveTelegramSessionState({ + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(ctx.me), + senderId, + runtimeCfg, + }).agentId; const result = await retryModelAction(async () => { const skillCommands = telegramDeps.listSkillCommandsForAgents({ cfg: runtimeCfg, diff --git a/extensions/telegram/src/bot-handlers.event-bindings.ts b/extensions/telegram/src/bot-handlers.event-bindings.ts index 40620f6c91cd..fcac65a535d6 100644 --- a/extensions/telegram/src/bot-handlers.event-bindings.ts +++ b/extensions/telegram/src/bot-handlers.event-bindings.ts @@ -58,7 +58,7 @@ export function createTelegramEventBindings({ authorization, registerMessages, }: CreateTelegramEventBindingsOptions): TelegramEventBindings { - const { accountId, bot, cfg, runtime, shouldSkipUpdate, telegramDeps } = params; + const { accountId, ownerAgentId, bot, cfg, runtime, shouldSkipUpdate, telegramDeps } = params; const { authorizeTelegramEventSender, resolveTelegramEventAuthorizationContext } = authorization; const { buildSyntheticContext, @@ -95,7 +95,10 @@ export function createTelegramEventBindings({ } if ( reactionMode === "own" && - !telegramDeps.wasSentByBot(chatId, messageId, authorizationCfg) + !telegramDeps.wasSentByBot(chatId, messageId, authorizationCfg, { + accountId, + agentId: ownerAgentId, + }) ) { logVerbose( `telegram: skipped reaction on msg ${messageId} in chat ${chatId} (own mode, not sent by bot)`, diff --git a/extensions/telegram/src/bot-handlers.message-context.runtime.test.ts b/extensions/telegram/src/bot-handlers.message-context.runtime.test.ts index 70098dfeb8c1..b79a162db5ba 100644 --- a/extensions/telegram/src/bot-handlers.message-context.runtime.test.ts +++ b/extensions/telegram/src/bot-handlers.message-context.runtime.test.ts @@ -1,8 +1,11 @@ // Telegram tests cover forum topic recovery from the real message cache. import type { Message } from "grammy/types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createTelegramMessageContextRuntime } from "./bot-handlers.message-context.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createTelegramMessageContextRuntime, + createTelegramMessageSessionRuntime, +} from "./bot-handlers.message-context.js"; import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import { resetTelegramMessageCacheForTest } from "./runtime.test-support.js"; @@ -21,6 +24,7 @@ function createRuntime() { return createTelegramMessageContextRuntime({ cfg, accountId: "default", + ownerAgentId: "main", opts: { token: "test" }, telegramCfg: {}, telegramDeps: { @@ -45,6 +49,56 @@ describe("resolveCachedMessageThreadSpec", () => { resetTelegramMessageCacheForTest(); }); + it("keeps account cache ownership separate from a topic-routed session owner", () => { + const resolveStorePath = vi.fn( + (_store, options: { agentId?: string }) => + `/tmp/openclaw-telegram-owner-${options.agentId}.json`, + ); + const cfg = { + agents: { + ownership: "explicit", + entries: { main: {}, ops: {}, research: {} }, + }, + bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "*" } }], + } as OpenClawConfig; + createTelegramMessageContextRuntime({ + cfg, + accountId: "primary", + ownerAgentId: "main", + opts: { token: "test" }, + telegramCfg: {}, + telegramDeps: { + resolveStorePath, + } as unknown as RegisterTelegramHandlerParams["telegramDeps"], + }); + const sessionRuntime = createTelegramMessageSessionRuntime({ + accountId: "primary", + resolveTelegramGroupConfig: () => ({ topicConfig: { agentId: "research" } }), + telegramDeps: { + resolveStorePath, + } as unknown as RegisterTelegramHandlerParams["telegramDeps"], + }); + + const session = sessionRuntime.resolveTelegramSessionState({ + chatId: CHAT_ID, + isGroup: true, + isForum: true, + messageThreadId: TOPIC_ID, + senderId: 10, + runtimeCfg: cfg, + }); + + expect(resolveStorePath.mock.calls.map(([, options]) => options?.agentId)).toEqual([ + "main", + "research", + ]); + expect(session).toMatchObject({ + agentId: "research", + storePath: "/tmp/openclaw-telegram-owner-research.json", + }); + expect(session.sessionKey).toContain("agent:research:"); + }); + it("recovers the topic of a recorded forum message", async () => { const runtime = createRuntime(); await runtime.recordMessageForReplyChain(forumMessage(100, TOPIC_ID), { diff --git a/extensions/telegram/src/bot-handlers.message-context.ts b/extensions/telegram/src/bot-handlers.message-context.ts index 3e17fe09210b..62448a4ec12a 100644 --- a/extensions/telegram/src/bot-handlers.message-context.ts +++ b/extensions/telegram/src/bot-handlers.message-context.ts @@ -1,5 +1,4 @@ import type { Message } from "grammy/types"; -import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { formatMediaPlaceholderText } from "openclaw/plugin-sdk/channel-inbound"; import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; @@ -293,17 +292,18 @@ export function createTelegramMessageSessionRuntime({ export function createTelegramMessageContextRuntime({ cfg, accountId, + ownerAgentId, opts, telegramCfg, telegramDeps, }: Pick< RegisterTelegramHandlerParams, - "cfg" | "accountId" | "opts" | "telegramCfg" | "telegramDeps" + "cfg" | "accountId" | "ownerAgentId" | "opts" | "telegramCfg" | "telegramDeps" >) { const messageCache = createTelegramMessageCache({ scope: resolveTelegramMessageCacheScope( telegramDeps.resolveStorePath(cfg.session?.store, { - agentId: cfg.agents ? resolveDefaultAgentId(cfg) : "main", + agentId: ownerAgentId, }), ), }); diff --git a/extensions/telegram/src/bot-handlers.message-pipeline.ts b/extensions/telegram/src/bot-handlers.message-pipeline.ts index 493cf88e3ff2..2569d96ddb14 100644 --- a/extensions/telegram/src/bot-handlers.message-pipeline.ts +++ b/extensions/telegram/src/bot-handlers.message-pipeline.ts @@ -161,6 +161,7 @@ function resolveRetainedTelegramMedia(params: { export function createTelegramMessagePipeline({ cfg, accountId, + ownerAgentId, bot, opts, telegramTransport, @@ -210,6 +211,7 @@ export function createTelegramMessagePipeline({ } = createTelegramMessageContextRuntime({ cfg, accountId, + ownerAgentId, opts, telegramCfg, telegramDeps, diff --git a/extensions/telegram/src/bot-handlers.reaction.runtime.test.ts b/extensions/telegram/src/bot-handlers.reaction.runtime.test.ts index d1c456a0a9e2..e81ff396fa7b 100644 --- a/extensions/telegram/src/bot-handlers.reaction.runtime.test.ts +++ b/extensions/telegram/src/bot-handlers.reaction.runtime.test.ts @@ -55,6 +55,7 @@ function registerHandler(cfg: OpenClawConfig): ReactionHandler { const handlers = new Map(); const params: RegisterTelegramHandlerParams = { accountId: "default", + ownerAgentId: "main", bot: { on: (name: string, handler: ReactionHandler) => { handlers.set(name, handler); diff --git a/extensions/telegram/src/bot-handlers.runtime.test.ts b/extensions/telegram/src/bot-handlers.runtime.test.ts index 959e01cc61af..76e9d3663b73 100644 --- a/extensions/telegram/src/bot-handlers.runtime.test.ts +++ b/extensions/telegram/src/bot-handlers.runtime.test.ts @@ -13,6 +13,7 @@ describe("registerTelegramHandlers", () => { const params: RegisterTelegramHandlerParams = { cfg: {}, accountId: "default", + ownerAgentId: "main", bot, mediaMaxBytes: 1, opts: { token: "tok" }, diff --git a/extensions/telegram/src/bot-handlers.types.ts b/extensions/telegram/src/bot-handlers.types.ts index 4d5308c72707..a54a44a0c048 100644 --- a/extensions/telegram/src/bot-handlers.types.ts +++ b/extensions/telegram/src/bot-handlers.types.ts @@ -73,6 +73,7 @@ type TelegramHandlerLogger = { export type RegisterTelegramHandlerParams = { cfg: OpenClawConfig; accountId: string; + ownerAgentId: string; bot: Bot; mediaMaxBytes: number; opts: TelegramBotOptions; diff --git a/extensions/telegram/src/bot-message-context.prompt-context.test.ts b/extensions/telegram/src/bot-message-context.prompt-context.test.ts index 420c9906e70a..4fd04db30480 100644 --- a/extensions/telegram/src/bot-message-context.prompt-context.test.ts +++ b/extensions/telegram/src/bot-message-context.prompt-context.test.ts @@ -123,6 +123,7 @@ describe("buildTelegramMessageContext prompt context", () => { const messageContextRuntime = createTelegramMessageContextRuntime({ cfg: registrationCfg, accountId: "default", + ownerAgentId: "main", opts: { token: "test-token", botInfo: { id: 7, username: "bot", first_name: "Bot" }, @@ -209,6 +210,7 @@ describe("buildTelegramMessageContext prompt context", () => { const messageContextRuntime = createTelegramMessageContextRuntime({ cfg, accountId: "default", + ownerAgentId: "main", opts: { token: "test-token", botInfo: { id: 7, username: "bot", first_name: "Bot" }, diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index d1369d8ca67f..8898c48e95b1 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -17,6 +17,7 @@ import { expandTelegramAllowFromWithAccessGroups, resolveTelegramDmAllow, } from "./access-groups.js"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; import { resolveDefaultTelegramAccountId } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { @@ -127,6 +128,7 @@ export const buildTelegramMessageContext = async ({ bot, cfg, account, + ownerAgentId, historyLimit, dmHistoryLimit, groupHistories, @@ -178,7 +180,9 @@ export const buildTelegramMessageContext = async ({ const topicNameCacheScope = resolveTopicNameCacheScope( await resolveTelegramMessageContextStorePath({ cfg, - agentId: account.accountId, + agentId: + ownerAgentId?.trim() || + resolveTelegramAccountOwnerAgentId({ cfg, accountId: account.accountId }), sessionRuntime, }), ); diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index 641bc2586254..f60db6d242c1 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -102,6 +102,7 @@ export type BuildTelegramMessageContextParams = { bot: Bot; cfg: OpenClawConfig; account: { accountId: string }; + ownerAgentId?: string; historyLimit: number; dmHistoryLimit: number; groupHistories: Map; diff --git a/extensions/telegram/src/bot-message-dispatch-delivery.ts b/extensions/telegram/src/bot-message-dispatch-delivery.ts index 13f928ece74f..1fb7d3d0bbf6 100644 --- a/extensions/telegram/src/bot-message-dispatch-delivery.ts +++ b/extensions/telegram/src/bot-message-dispatch-delivery.ts @@ -119,6 +119,7 @@ async function recordPromptContextMessage( turn.telegramDeps.recordOutboundMessageForPromptContext ?? recordOutboundMessageForPromptContext )({ cfg: turn.cfg, + ownerAgentId: turn.opts.ownerAgentId, account: { accountId: context.route.accountId, ...(turn.telegramCfg.name !== undefined ? { name: turn.telegramCfg.name } : {}), @@ -167,6 +168,7 @@ function createDeliveryBaseOptions(turn: Turn) { const { context } = turn; return { cfg: turn.cfg, + ownerAgentId: turn.opts.ownerAgentId, chatId: String(context.chatId), accountId: context.route.accountId, sessionKeyForInternalHooks: context.ctxPayload.SessionKey, diff --git a/extensions/telegram/src/bot-message-dispatch-draft.ts b/extensions/telegram/src/bot-message-dispatch-draft.ts index 6a316e5db232..5b53d62d5faa 100644 --- a/extensions/telegram/src/bot-message-dispatch-draft.ts +++ b/extensions/telegram/src/bot-message-dispatch-draft.ts @@ -125,12 +125,16 @@ export function createDraftState(params: TurnConfig): TelegramDraftStateSlice { } : {}), onProviderMessage: async (message) => { - recordSentMessage(params.context.chatId, message.message_id, params.cfg); + recordSentMessage(params.context.chatId, message.message_id, params.cfg, { + accountId: params.context.route.accountId, + agentId: params.opts.ownerAgentId, + }); await ( params.telegramDeps.recordOutboundMessageForPromptContext ?? recordOutboundMessageForPromptContext )({ cfg: params.cfg, + ownerAgentId: params.opts.ownerAgentId, account: { accountId: params.context.route.accountId, ...(params.telegramCfg.name !== undefined ? { name: params.telegramCfg.name } : {}), diff --git a/extensions/telegram/src/bot-message-dispatch.types.ts b/extensions/telegram/src/bot-message-dispatch.types.ts index 6f0c7fa0aea1..1d705ba9170d 100644 --- a/extensions/telegram/src/bot-message-dispatch.types.ts +++ b/extensions/telegram/src/bot-message-dispatch.types.ts @@ -35,7 +35,7 @@ export type DispatchTelegramMessageParams = { textLimit: number; telegramCfg: TelegramAccountConfig; telegramDeps?: TelegramBotDeps; - opts: Pick; + opts: Pick; retryDispatchErrors?: boolean; suppressFailureFallback?: boolean; /** diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index 56720dbca09a..38896a744605 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -67,7 +67,10 @@ type TelegramMessageProcessorDeps = Omit< > & { runtime: RuntimeEnv; telegramDeps: TelegramBotDeps; - opts: Pick; + opts: Pick< + TelegramBotOptions, + "token" | "ownerAgentId" | "allowFrom" | "groupAllowFrom" | "replyToMode" + >; }; export function resolveTelegramMessageTurnSettings(params: { @@ -197,6 +200,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep bot, cfg: turnCfg, account, + ownerAgentId: opts.ownerAgentId, historyLimit: turnSettings.historyLimit, dmHistoryLimit: turnSettings.dmHistoryLimit, groupHistories, diff --git a/extensions/telegram/src/bot-native-command-dispatch.ts b/extensions/telegram/src/bot-native-command-dispatch.ts index c614b9171729..d21cca3c9680 100644 --- a/extensions/telegram/src/bot-native-command-dispatch.ts +++ b/extensions/telegram/src/bot-native-command-dispatch.ts @@ -20,6 +20,7 @@ import { import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; import { resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy } from "./bot-access.js"; @@ -100,7 +101,13 @@ export type TelegramCommandExecutorParams = { telegramDeps?: TelegramNativeCommandDeps; opts: Pick< TelegramBotOptions, - "token" | "botInfo" | "allowFrom" | "groupAllowFrom" | "replyToMode" | "accountAbortSignal" + | "token" + | "ownerAgentId" + | "botInfo" + | "allowFrom" + | "groupAllowFrom" + | "replyToMode" + | "accountAbortSignal" >; }; @@ -447,6 +454,7 @@ export async function prepareTelegramCommandDispatch( policySessionKey?: string; }): DeliveryBaseOptions => ({ cfg: runtimeCfg, + ownerAgentId: params.opts.ownerAgentId, chatId: String(auth.chatId), accountId: route.accountId, sessionKeyForInternalHooks: keys?.sessionKeyForInternalHooks, @@ -505,7 +513,12 @@ export async function dispatchTelegramBuiltinTurn(params: { if (dispatch.isForum && dispatch.resolvedThreadId != null) { try { const storePath = resolveStorePath(dispatch.runtimeCfg.session?.store, { - agentId: dispatch.route.accountId, + agentId: + dispatch.opts.ownerAgentId ?? + resolveTelegramAccountOwnerAgentId({ + cfg: dispatch.runtimeCfg, + accountId: dispatch.route.accountId, + }), }); topicName = await getTopicName( dispatch.chatId, diff --git a/extensions/telegram/src/bot-native-command-plugins.ts b/extensions/telegram/src/bot-native-command-plugins.ts index 592f741d98e2..232220f43690 100644 --- a/extensions/telegram/src/bot-native-command-plugins.ts +++ b/extensions/telegram/src/bot-native-command-plugins.ts @@ -281,7 +281,10 @@ export async function executeTelegramPluginCommand( buttons: telegramResultData?.buttons, }, ); - recordSentMessage(dispatch.chatId, progressMessageId, dispatch.runtimeCfg); + recordSentMessage(dispatch.chatId, progressMessageId, dispatch.runtimeCfg, { + accountId: dispatch.route.accountId, + agentId: dispatch.opts.ownerAgentId, + }); emitTelegramMessageSentHooks({ sessionKeyForInternalHooks: dispatch.targetSessionKey, chatId: String(dispatch.chatId), diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 8fbf7df8a7e3..3f181e629e12 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -64,7 +64,13 @@ type RegisterTelegramNativeCommandsParams = { telegramDeps?: TelegramNativeCommandDeps; opts: Pick< TelegramBotOptions, - "token" | "botInfo" | "allowFrom" | "groupAllowFrom" | "replyToMode" | "accountAbortSignal" + | "token" + | "ownerAgentId" + | "botInfo" + | "allowFrom" + | "groupAllowFrom" + | "replyToMode" + | "accountAbortSignal" >; }; diff --git a/extensions/telegram/src/bot.agent.runtime.ts b/extensions/telegram/src/bot.agent.runtime.ts deleted file mode 100644 index 2ffad5e472cf..000000000000 --- a/extensions/telegram/src/bot.agent.runtime.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Telegram plugin module implements bot.agent behavior. -export { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; diff --git a/extensions/telegram/src/bot.forum-ingress.response-body-timeout.integration.test.ts b/extensions/telegram/src/bot.forum-ingress.response-body-timeout.integration.test.ts index b320d1312084..80fed6a0c138 100644 --- a/extensions/telegram/src/bot.forum-ingress.response-body-timeout.integration.test.ts +++ b/extensions/telegram/src/bot.forum-ingress.response-body-timeout.integration.test.ts @@ -94,6 +94,7 @@ describe("Telegram supergroup ingress with a stalled Bot API response body", () }; const params: RegisterTelegramHandlerParams = { accountId: "default", + ownerAgentId: "main", bot, cfg: {}, mediaMaxBytes: 1, diff --git a/extensions/telegram/src/bot.media.e2e.test-harness.ts b/extensions/telegram/src/bot.media.e2e.test-harness.ts index a82c0ed22494..249d4bcce812 100644 --- a/extensions/telegram/src/bot.media.e2e.test-harness.ts +++ b/extensions/telegram/src/bot.media.e2e.test-harness.ts @@ -361,14 +361,9 @@ vi.doMock("./bot-message-context.session.runtime.js", async () => { }; }); -vi.mock("./bot.agent.runtime.js", () => ({ - resolveDefaultAgentId: vi.fn(() => "default"), -})); - vi.mock("./bot-handlers.agent.runtime.js", () => ({ resolveAgentDir: vi.fn(() => "/tmp/agent"), resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), - resolveDefaultAgentId: vi.fn(() => "default"), resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-test", diff --git a/extensions/telegram/src/bot.types.ts b/extensions/telegram/src/bot.types.ts index a05701f4356f..18efc049e96d 100644 --- a/extensions/telegram/src/bot.types.ts +++ b/extensions/telegram/src/bot.types.ts @@ -8,6 +8,8 @@ import type { TelegramTransport } from "./fetch.js"; export type TelegramBotOptions = { token: string; accountId?: string; + /** Agent that owns account-scoped Telegram runtime state. */ + ownerAgentId?: string; runtime?: RuntimeEnv; requireMention?: boolean; allowFrom?: Array; diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index f1f0f6ad8bec..c220e903e18f 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -686,6 +686,7 @@ export function emitTelegramMessageSentHooks(params: EmitMessageSentHookParams): export async function deliverReplies(params: { replies: ReplyPayload[]; cfg?: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig; + ownerAgentId?: string; chatId: string; accountId?: string; sessionKeyForInternalHooks?: string; @@ -737,8 +738,16 @@ export async function deliverReplies(params: { deliveredCount: 0, ...(params.promptContextSequence ? { promptContext: params.promptContextSequence } : {}), }; - const recordMessageId = (messageId: number) => + const recordMessageId = (messageId: number) => { + if (params.accountId || params.ownerAgentId) { + recordSentMessage(params.chatId, messageId, params.cfg, { + accountId: params.accountId, + agentId: params.ownerAgentId, + }); + return; + } recordSentMessage(params.chatId, messageId, params.cfg); + }; const mediaLoader = params.mediaLoader ?? loadWebMedia; const transcriptMirror = params.transcriptMirror; const deliveredContents: Array<{ text: string; mediaUrls: string[] }> = []; diff --git a/extensions/telegram/src/channel.gateway.test.ts b/extensions/telegram/src/channel.gateway.test.ts index fa0ba9974a99..85d57256c69c 100644 --- a/extensions/telegram/src/channel.gateway.test.ts +++ b/extensions/telegram/src/channel.gateway.test.ts @@ -211,6 +211,7 @@ function startTelegramAccount( function latestMonitorOptions(): { token?: string; accountId?: string; + ownerAgentId?: string; useWebhook?: boolean; botInfo?: unknown; } { @@ -330,6 +331,60 @@ describe("telegramPlugin gateway startup", () => { expect(monitorOptions.useWebhook).toBe(false); }); + it("starts a multi-agent account with its routed owner", async () => { + installTelegramRuntime(); + probeTelegram.mockResolvedValue({ + ok: false, + status: 500, + error: "Bad Gateway", + elapsedMs: 12, + }); + monitorTelegramProvider.mockResolvedValue(undefined); + const cfg = { + agents: { + ownership: "explicit", + entries: { main: {}, ops: {}, research: {} }, + }, + channels: { telegram: { botToken: "123456:bad-token" } }, + bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "*" } }], + } as OpenClawConfig; + const account = telegramPlugin.config.resolveAccount(cfg, "default"); + const startAccount = telegramPlugin.gateway?.startAccount; + if (!startAccount) { + throw new Error("expected Telegram startAccount gateway handler"); + } + + await startAccount(createStartAccountContext({ account, cfg })); + + expect(latestMonitorOptions()).toMatchObject({ + accountId: "default", + ownerAgentId: "main", + }); + }); + + it("rejects genuinely ambiguous multi-agent account ownership before startup", async () => { + installTelegramRuntime(); + const cfg = { + agents: { + ownership: "explicit", + entries: { main: {}, ops: {}, research: {} }, + }, + channels: { telegram: { botToken: "123456:bad-token" } }, + } as OpenClawConfig; + const account = telegramPlugin.config.resolveAccount(cfg, "default"); + const startAccount = telegramPlugin.gateway?.startAccount; + if (!startAccount) { + throw new Error("expected Telegram startAccount gateway handler"); + } + + await expect(startAccount(createStartAccountContext({ account, cfg }))).rejects.toMatchObject({ + name: "AgentSelectionRequiredError", + code: "AGENT_SELECTION_REQUIRED", + }); + expect(probeTelegram).not.toHaveBeenCalled(); + expect(monitorTelegramProvider).not.toHaveBeenCalled(); + }); + it("uses the getMe request guard for startup probe timeout", async () => { installTelegramRuntime(); probeTelegram.mockResolvedValue({ diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 150909bc02df..df5760f75ecf 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -3,11 +3,11 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import type { ResolvedTelegramAccount } from "./accounts.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupContract } from "./setup-core.js"; +import { createTelegramSetupPluginBase } from "./setup-plugin.js"; import { telegramSetupWizard } from "./setup-surface.js"; -import { createTelegramPluginBase } from "./shared.js"; export const telegramSetupPlugin: ChannelPlugin = { - ...createTelegramPluginBase({ + ...createTelegramSetupPluginBase({ setupWizard: telegramSetupWizard, setupContract: telegramSetupContract, }), diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a19ef08f8977..62a238972d65 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -29,7 +29,7 @@ import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-run import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { channelBlockedPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import type { RoutePeer } from "openclaw/plugin-sdk/routing"; +import { resolveAgentRoute, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, @@ -56,6 +56,12 @@ import { import type { TelegramBotInfo } from "./bot-info.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { telegramMessageActions as telegramMessageActionsImpl } from "./channel-actions.js"; +import { + findTelegramTokenOwnerAccountId, + formatDuplicateTelegramTokenReason, + resolveTelegramConfigAccessorAccount, + telegramConfigAdapter, +} from "./config-adapter.js"; import { resolveTelegramConversationBaseSessionKey } from "./conversation-route.js"; import { listTelegramDirectoryGroupsFromConfig, @@ -86,13 +92,7 @@ import { } from "./session-conversation.js"; import { telegramSetupContract } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; -import { - createTelegramPluginBase, - findTelegramTokenOwnerAccountId, - formatDuplicateTelegramTokenReason, - resolveTelegramConfigAccessorAccount, - telegramConfigAdapter, -} from "./shared.js"; +import { createTelegramPluginBase } from "./shared.js"; import { withTelegramStartupProbeSlot } from "./startup-probe-limiter.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; @@ -1058,6 +1058,11 @@ export const telegramPlugin = createChatChannelPlugin({ gateway: { startAccount: async (ctx) => { const account = ctx.account; + const ownerAgentId = resolveAgentRoute({ + cfg: ctx.cfg, + channel: "telegram", + accountId: account.accountId, + }).agentId; const setStatus = createAccountStatusSink({ accountId: account.accountId, setStatus: ctx.setStatus, @@ -1140,6 +1145,7 @@ export const telegramPlugin = createChatChannelPlugin({ return resolveTelegramMonitor()({ token, accountId: account.accountId, + ownerAgentId, config: ctx.cfg, runtime: ctx.runtime, channelRuntime: ctx.channelRuntime, diff --git a/extensions/telegram/src/config-adapter.ts b/extensions/telegram/src/config-adapter.ts new file mode 100644 index 000000000000..82307fd5077a --- /dev/null +++ b/extensions/telegram/src/config-adapter.ts @@ -0,0 +1,179 @@ +// Telegram plugin module implements shared config adapter behavior. +import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-core"; +import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { + adaptScopedAccountAccessor, + createScopedChannelConfigAdapter, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + mergeTelegramAccountConfig, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + type ResolvedTelegramAccount, +} from "./accounts.js"; + +const TELEGRAM_CHANNEL = "telegram" as const; + +type TelegramConfigAccessorAccount = { + config: TelegramAccountConfig; +}; + +export function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = (account.token ?? "").trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +export function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + +/** + * Returns true when the runtime token resolver (`resolveTelegramToken`) would + * block channel-level fallthrough for the given accountId. This mirrors the + * guard in `token.ts` so that status-check functions (`isConfigured`, + * `unconfiguredReason`, `describeAccount`) stay consistent with the gateway + * runtime behavior. + * + * The guard fires when: + * 1. The accountId is not the default account, AND + * 2. The config has an explicit `accounts` section with entries, AND + * 3. The accountId is not found in that `accounts` section. + * + * See: https://github.com/openclaw/openclaw/issues/53876 + */ +function isBlockedByMultiBotGuard(cfg: OpenClawConfig, accountId: string): boolean { + if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { + return false; + } + const accounts = cfg.channels?.telegram?.accounts; + const hasConfiguredAccounts = + Boolean(accounts) && + typeof accounts === "object" && + !Array.isArray(accounts) && + Object.keys(accounts).length > 0; + if (!hasConfiguredAccounts) { + return false; + } + // Use resolveNormalizedAccountEntry (same as resolveTelegramToken in token.ts) + // so keys such as "Carey Notifications" match "carey-notifications". + return !resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId); +} + +export function resolveTelegramConfigAccessorAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): TelegramConfigAccessorAccount { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultTelegramAccountId(params.cfg), + ); + return { config: mergeTelegramAccountConfig(params.cfg, accountId) }; +} + +export const telegramConfigAdapter = createScopedChannelConfigAdapter< + ResolvedTelegramAccount, + TelegramConfigAccessorAccount +>({ + sectionKey: TELEGRAM_CHANNEL, + listAccountIds: listTelegramAccountIds, + resolveAccount: adaptScopedAccountAccessor(resolveTelegramAccount), + resolveAccessorAccount: resolveTelegramConfigAccessorAccount, + inspectAccount: adaptScopedAccountAccessor(inspectTelegramAccount), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), + resolveDefaultTo: (account) => account.config.defaultTo, +}); + +export function createTelegramPluginConfig(): ChannelPlugin["config"] { + return { + ...telegramConfigAdapter, + hasConfiguredState: ({ env }) => + typeof env?.TELEGRAM_BOT_TOKEN === "string" && env.TELEGRAM_BOT_TOKEN.trim().length > 0, + isConfigured: (account, cfg) => { + // Inspect the complete token resolution, including channel-level fallbacks used by + // binding-created account IDs in a single-bot setup. + if (isBlockedByMultiBotGuard(cfg, account.accountId)) { + return false; + } + const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId }); + // "configured_unavailable" is configured state, but cannot start the runtime. + if (!inspected.token?.trim()) { + return false; + } + return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + }, + unconfiguredReason: (account, cfg) => { + if (isBlockedByMultiBotGuard(cfg, account.accountId)) { + return `not configured: unknown accountId "${account.accountId}" in multi-bot setup`; + } + const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId }); + if (!inspected.token?.trim()) { + return inspected.tokenStatus === "configured_unavailable" + ? `not configured: token ${inspected.tokenSource} is configured but unavailable` + : "not configured"; + } + const ownerAccountId = findTelegramTokenOwnerAccountId({ + cfg, + accountId: account.accountId, + }); + return ownerAccountId + ? formatDuplicateTelegramTokenReason({ accountId: account.accountId, ownerAccountId }) + : "not configured"; + }, + describeAccount: (account, cfg) => { + if (isBlockedByMultiBotGuard(cfg, account.accountId)) { + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: false, + tokenSource: "none" as const, + }; + } + const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId }); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: + inspected.tokenStatus !== "missing" && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + tokenSource: inspected.tokenSource, + tokenStatus: inspected.tokenStatus, + }; + }, + }; +} diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index aa4e372364fe..f89b60c77356 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -1,4 +1,4 @@ -import { createChannelConfigUiHints } from "openclaw/plugin-sdk/channel-core"; +import { createChannelConfigUiHints } from "openclaw/plugin-sdk/channel-config-ui-hints"; import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core"; export const telegramChannelConfigUiHints = { diff --git a/extensions/telegram/src/message-topic-binding.test.ts b/extensions/telegram/src/message-topic-binding.test.ts index a0c0463b1252..0846373ce325 100644 --- a/extensions/telegram/src/message-topic-binding.test.ts +++ b/extensions/telegram/src/message-topic-binding.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveTelegramMessageCacheScope } from "./message-cache-persistence.js"; import { createTelegramMessageCache } from "./message-cache.js"; import { resolveTelegramMessageMutationChatId } from "./message-topic-binding.js"; +import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js"; import { setTelegramRuntime } from "./runtime.js"; import { clearTelegramRuntimeForTest, @@ -253,6 +254,39 @@ describe("Telegram message topic binding", () => { ).resolves.toBe("-1001"); }); + it("reads an outbound topic binding from the routed account owner's cache after restart", async () => { + const multiAgentCfg = { + agents: { + ownership: "explicit", + entries: { main: {}, ops: {}, research: {} }, + }, + channels: { + telegram: { accounts: { alerts: { botToken: "123456:alerts" } } }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "alerts" } }], + session: { store: "/tmp/openclaw-telegram-topic-owner/{agentId}/sessions.json" }, + } as OpenClawConfig; + await recordOutboundMessageForPromptContext({ + cfg: multiAgentCfg, + account: { accountId: "alerts", name: "Alerts" }, + chatId: -1001, + message: topicMessage(902, 77), + messageId: 902, + successfulSendThread: { scope: "forum", id: 77 }, + }); + resetTelegramMessageCacheForTest(); + + await expect( + resolveTelegramMessageMutationChatId({ + chatId: "-1001:topic:77", + messageId: 902, + cfg: multiAgentCfg, + accountId: "alerts", + context: delegatedContext({ requesterAccountId: "alerts" }), + }), + ).resolves.toBe("-1001"); + }); + it("rejects legacy and wrong-topic cache entries", async () => { await recordMessage({ messageId: 899, threadId: 77, providerObserved: false }); await recordMessage({ messageId: 900, threadId: 88, providerObserved: true }); diff --git a/extensions/telegram/src/message-topic-binding.ts b/extensions/telegram/src/message-topic-binding.ts index e41e765623b4..2872d4893f99 100644 --- a/extensions/telegram/src/message-topic-binding.ts +++ b/extensions/telegram/src/message-topic-binding.ts @@ -1,6 +1,5 @@ // Telegram provider-owned authorization for message mutations in forum topics. import { normalizeAccountId, normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-core"; -import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import type { ChannelMessageActionContext, ChannelThreadingToolContext, @@ -8,6 +7,7 @@ import type { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; import { resolveDefaultTelegramAccountId } from "./accounts.js"; import { resolveTelegramMessageCacheScope } from "./message-cache-persistence.js"; import { @@ -115,7 +115,10 @@ export async function resolveTelegramMessageMutationChatId(params: { const cache = createTelegramMessageCache({ scope: resolveTelegramMessageCacheScope( resolveStorePath(params.cfg.session?.store, { - agentId: params.cfg.agents ? resolveDefaultAgentId(params.cfg) : "main", + agentId: resolveTelegramAccountOwnerAgentId({ + cfg: params.cfg, + accountId: selectedAccountId, + }), }), ), }); diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index 9b125ab45726..852e3fc9f07b 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -934,10 +934,11 @@ describe("monitorTelegramProvider (grammY)", () => { }); const webhookCall = latestMockCall(startTelegramWebhookSpy, "startTelegramWebhook") as [ - { host?: string; setStatus?: unknown }, + { host?: string; ownerAgentId?: string; setStatus?: unknown }, ]; const webhookOptions = webhookCall[0]; expect(webhookOptions?.host).toBe("0.0.0.0"); + expect(webhookOptions?.ownerAgentId).toBe("main"); expect(webhookOptions?.setStatus).toBe(setStatus); expect(runSpy).not.toHaveBeenCalled(); }); diff --git a/extensions/telegram/src/monitor.ts b/extensions/telegram/src/monitor.ts index 1da60e201796..130bf703b267 100644 --- a/extensions/telegram/src/monitor.ts +++ b/extensions/telegram/src/monitor.ts @@ -13,6 +13,7 @@ import { } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { isTelegramExecApprovalHandlerConfigured } from "./exec-approvals.js"; @@ -139,6 +140,9 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { cfg, accountId: opts.accountId, }); + const ownerAgentId = + opts.ownerAgentId?.trim() || + resolveTelegramAccountOwnerAgentId({ cfg, accountId: account.accountId }); const token = opts.token?.trim() || account.token; if (!token) { throw new Error( @@ -164,6 +168,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { await startTelegramWebhook({ token, accountId: account.accountId, + ownerAgentId, config: cfg, path: opts.webhookPath, port: opts.webhookPort, @@ -268,6 +273,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { token, config: cfg, accountId: account.accountId, + ownerAgentId, runtime: opts.runtime, proxyFetch, botInfo: opts.botInfo, diff --git a/extensions/telegram/src/monitor.types.ts b/extensions/telegram/src/monitor.types.ts index 6f6a67bb2376..4056f8fed297 100644 --- a/extensions/telegram/src/monitor.types.ts +++ b/extensions/telegram/src/monitor.types.ts @@ -10,6 +10,7 @@ import type { TelegramBotInfo } from "./bot-info.js"; export type MonitorTelegramOpts = { token?: string; accountId?: string; + ownerAgentId?: string; config?: OpenClawConfig; runtime?: RuntimeEnv; channelRuntime?: ChannelRuntimeSurface; diff --git a/extensions/telegram/src/outbound-message-context.ts b/extensions/telegram/src/outbound-message-context.ts index 0b32c6623caa..f3f7a65e07cc 100644 --- a/extensions/telegram/src/outbound-message-context.ts +++ b/extensions/telegram/src/outbound-message-context.ts @@ -1,9 +1,9 @@ // Telegram plugin module implements outbound message context behavior. import type { Message } from "grammy/types"; -import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; import type { TelegramThreadSpec } from "./bot/helpers.js"; import { buildTelegramSelfSenderName } from "./group-history-window.js"; import { resolveTelegramMessageCacheScope } from "./message-cache-persistence.js"; @@ -139,6 +139,8 @@ export async function recordOutboundMessageForPromptContext(params: { successfulSendThread?: TelegramThreadSpec; promptContextTimestampMs?: number; promptContextProjection?: TelegramPromptContextProjection; + /** Pre-resolved account owner from the active Telegram runtime. */ + ownerAgentId?: string; /** Edits refresh an existing cache entry without inserting another self-history turn. */ recordGroupHistory?: boolean; }): Promise { @@ -155,7 +157,12 @@ export async function recordOutboundMessageForPromptContext(params: { const cache = createTelegramMessageCache({ scope: resolveTelegramMessageCacheScope( resolveStorePath(params.cfg.session?.store, { - agentId: params.cfg.agents ? resolveDefaultAgentId(params.cfg) : "main", + agentId: + params.ownerAgentId?.trim() || + resolveTelegramAccountOwnerAgentId({ + cfg: params.cfg, + accountId: params.account.accountId, + }), }), ), }); diff --git a/extensions/telegram/src/polling-session.test.ts b/extensions/telegram/src/polling-session.test.ts index b2d967655351..6591c5c34bcf 100644 --- a/extensions/telegram/src/polling-session.test.ts +++ b/extensions/telegram/src/polling-session.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { Worker } from "node:worker_threads"; import { expectDefined } from "@openclaw/normalization-core"; import { Bot } from "grammy"; +import type { Update } from "grammy/types"; import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; import { DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS as TELEGRAM_SPOOLED_RETRY_MAX_ATTEMPTS } from "openclaw/plugin-sdk/channel-outbound"; import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; @@ -582,34 +583,52 @@ async function waitForApiMiddleware( throw new Error("Telegram API middleware was not installed"); } -type TestTelegramUpdate = { - update_id: number; - message: { - text: string; - chat: { id: number; type: "private" | "supergroup"; is_forum?: boolean }; - message_thread_id?: number; - is_topic_message?: boolean; - }; +type TestTelegramUpdate = Update & { + message: NonNullable & { text: string }; +}; + +const testTelegramSender = { + id: 111, + is_bot: false as const, + first_name: "Ada", }; function topicUpdate(updateId: number, threadId: number, text: string): TestTelegramUpdate { return { update_id: updateId, message: { + message_id: updateId, + date: 1_736_380_800, + from: testTelegramSender, text, message_thread_id: threadId, is_topic_message: true, - chat: { id: -100, type: "supergroup" }, + chat: { id: -100, type: "supergroup", title: "Test group" }, }, }; } function directUpdate(updateId: number, chatId: number, text: string): TestTelegramUpdate { + const message = { + message_id: updateId, + date: 1_736_380_800, + from: testTelegramSender, + text, + }; + if (chatId < 0) { + return { + update_id: updateId, + message: { + ...message, + chat: { id: chatId, type: "supergroup", title: "Test group" }, + }, + }; + } return { update_id: updateId, message: { - text, - chat: { id: chatId, type: chatId < 0 ? "supergroup" : "private" }, + ...message, + chat: { id: chatId, type: "private", first_name: "Ada" }, }, }; } @@ -952,6 +971,7 @@ describe("TelegramPollingSession", () => { token: "tok", config: {}, accountId: "default", + ownerAgentId: "ops", runtime: undefined, proxyFetch: undefined, abortSignal: abort.signal, @@ -970,6 +990,7 @@ describe("TelegramPollingSession", () => { expect( mockObjectArg(createTelegramBotMock, "createTelegramBot").minimumClientTimeoutSeconds, ).toBe(45); + expect(mockObjectArg(createTelegramBotMock, "createTelegramBot").ownerAgentId).toBe("ops"); expect(computeBackoffMock).toHaveBeenCalledTimes(1); expect(computeBackoffMock).toHaveBeenCalledWith( { @@ -1291,9 +1312,10 @@ describe("TelegramPollingSession", () => { const abort = new AbortController(); const handleUpdate = vi.fn(async () => undefined); const init = vi.fn(async () => undefined); + const update = directUpdate(42, 123, "hello"); await writeTelegramSpooledUpdate({ spoolDir: tempDir, - update: { update_id: 42, message: { text: "hello" } }, + update, }); const { createWorker, runPromise } = startIsolatedIngressSession({ @@ -1331,7 +1353,7 @@ describe("TelegramPollingSession", () => { persistenceFloorUpdateId: null, }); expect(init).toHaveBeenCalledBefore(handleUpdate); - expect(handleUpdate).toHaveBeenCalledWith({ update_id: 42, message: { text: "hello" } }); + expect(handleUpdate).toHaveBeenCalledWith(update); }); }); @@ -1340,6 +1362,7 @@ describe("TelegramPollingSession", () => { const abort = new AbortController(); const handleUpdate = vi.fn(async () => undefined); const worker = createListeningIngressWorker(); + const update = directUpdate(42, 123, "hello"); const { runPromise } = startIsolatedIngressSession({ abort, spoolDir: tempDir, @@ -1351,7 +1374,7 @@ describe("TelegramPollingSession", () => { worker.emit({ type: "update", requestId: "write-1", - update: { update_id: 42, message: { text: "hello" } }, + update, queued: 1, }); await waitForTelegramTestState(() => @@ -1360,9 +1383,7 @@ describe("TelegramPollingSession", () => { updateId: 42, }), ); - await waitForTelegramTestState(() => - expect(handleUpdate).toHaveBeenCalledWith({ update_id: 42, message: { text: "hello" } }), - ); + await waitForTelegramTestState(() => expect(handleUpdate).toHaveBeenCalledWith(update)); await waitForTelegramTestState(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([]), ); @@ -1485,10 +1506,11 @@ describe("TelegramPollingSession", () => { }); try { await waitForTelegramTestState(() => expect(worker.hasListener()).toBe(true)); + const update = directUpdate(42, 123, "hello"); worker.emit({ type: "update", requestId: "offset-gap", - update: { update_id: 42, message: { text: "hello" } }, + update, queued: 1, }); await waitForTelegramTestState(() => @@ -1526,10 +1548,11 @@ describe("TelegramPollingSession", () => { }); try { await waitForTelegramTestState(() => expect(worker.hasListener()).toBe(true)); + const update = directUpdate(43, 123, "hello"); worker.emit({ type: "update", requestId: "offset-failure", - update: { update_id: 43, message: { text: "hello" } }, + update, queued: 1, }); await waitForTelegramTestState(() => @@ -1564,10 +1587,11 @@ describe("TelegramPollingSession", () => { }); try { await waitForTelegramTestState(() => expect(worker.hasListener()).toBe(true)); + const update = directUpdate(44, 123, "hello"); worker.emit({ type: "update", requestId: "offset-catching-up", - update: { update_id: 44, message: { text: "hello" } }, + update, queued: 1, }); await waitForTelegramTestState(() => @@ -1610,12 +1634,13 @@ describe("TelegramPollingSession", () => { getCommittedUpdateId: firstOffsetPersistence.getCommittedUpdateId, persistUpdateId: firstOffsetPersistence.persistUpdateId, }); + const update = directUpdate(42, 123, "hello"); try { await waitForTelegramTestState(() => expect(firstWorker.hasListener()).toBe(true)); firstWorker.emit({ type: "update", requestId: "first-delivery", - update: { update_id: 42, message: { text: "hello" } }, + update, queued: 1, }); await waitForTelegramTestState(() => @@ -1662,7 +1687,7 @@ describe("TelegramPollingSession", () => { restartWorker.emit({ type: "update", requestId: "restart-replay", - update: { update_id: 42, message: { text: "hello" } }, + update, queued: 1, }); await waitForTelegramTestState(() => @@ -1720,6 +1745,7 @@ describe("TelegramPollingSession", () => { const abort = new AbortController(); const handleUpdate = vi.fn(async () => abort.abort()); const worker = createListeningIngressWorker(); + const update = directUpdate(42, 123, "hello"); const { runPromise } = startIsolatedIngressSession({ abort, spoolDir: tempDir, @@ -1732,7 +1758,7 @@ describe("TelegramPollingSession", () => { worker.emit({ type: "update", requestId: "write-1", - update: { update_id: 42, message: { text: "hello" } }, + update, queued: 1, }); await waitForTelegramTestState(() => @@ -1742,9 +1768,7 @@ describe("TelegramPollingSession", () => { }), ); worker.emit({ type: "spooled", updateId: 42, queued: 1 }); - await waitForTelegramTestState(() => - expect(handleUpdate).toHaveBeenCalledWith({ update_id: 42, message: { text: "hello" } }), - ); + await waitForTelegramTestState(() => expect(handleUpdate).toHaveBeenCalledWith(update)); await waitForTelegramTestState(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([]), ); @@ -1789,9 +1813,11 @@ describe("TelegramPollingSession", () => { }, } as TelegramRuntime); + const firstUpdate = directUpdate(1, 123, "pre-seeded"); + const secondUpdate = directUpdate(2, 123, "during-drain"); await writeTelegramSpooledUpdate({ spoolDir: tempDir, - update: { update_id: 1, message: { text: "pre-seeded" } }, + update: firstUpdate, }); const handleUpdate = vi.fn(async () => undefined); const worker = createListeningIngressWorker(); @@ -1810,7 +1836,7 @@ describe("TelegramPollingSession", () => { worker.emit({ type: "update", requestId: "write-2", - update: { update_id: 2, message: { text: "during-drain" } }, + update: secondUpdate, queued: 1, }); expect(worker.ackSpooledUpdate).not.toHaveBeenCalledWith("write-2", expect.anything()); @@ -1825,16 +1851,10 @@ describe("TelegramPollingSession", () => { worker.emit({ type: "spooled", updateId: 2, queued: 1 }); await waitForTelegramTestState(() => - expect(handleUpdate).toHaveBeenCalledWith({ - update_id: 1, - message: { text: "pre-seeded" }, - }), + expect(handleUpdate).toHaveBeenCalledWith(firstUpdate), ); await waitForTelegramTestState(() => - expect(handleUpdate).toHaveBeenCalledWith({ - update_id: 2, - message: { text: "during-drain" }, - }), + expect(handleUpdate).toHaveBeenCalledWith(secondUpdate), ); await waitForTelegramTestState(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([]), @@ -1851,9 +1871,10 @@ describe("TelegramPollingSession", () => { await withTempSpool(async (tempDir) => { const abort = new AbortController(); const handleUpdate = vi.fn(async () => undefined); + const update = directUpdate(42, 123, "pre-upgrade pending"); await writeTelegramSpooledUpdate({ spoolDir: tempDir, - update: { update_id: 42, message: { text: "pre-upgrade pending" } }, + update, }); const { createWorker, runPromise } = startIsolatedIngressSession({ @@ -1884,10 +1905,7 @@ describe("TelegramPollingSession", () => { lastUpdateId: null, persistenceFloorUpdateId: 42, }); - expect(handleUpdate).toHaveBeenCalledWith({ - update_id: 42, - message: { text: "pre-upgrade pending" }, - }); + expect(handleUpdate).toHaveBeenCalledWith(update); }); }); diff --git a/extensions/telegram/src/polling-session.ts b/extensions/telegram/src/polling-session.ts index 53cebe489b68..7ec6cb20ab02 100644 --- a/extensions/telegram/src/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -86,6 +86,7 @@ type TelegramPollingSessionOpts = { token: string; config: NonNullable[0]["config"]>; accountId: string; + ownerAgentId?: string; runtime: Parameters[0]["runtime"]; proxyFetch: Parameters[0]["proxyFetch"]; botInfo?: Parameters[0]["botInfo"]; @@ -310,6 +311,7 @@ export class TelegramPollingSession { proxyFetch: this.opts.proxyFetch, config: this.opts.config, accountId: this.opts.accountId, + ownerAgentId: this.opts.ownerAgentId, botInfo: this.opts.botInfo, ...(botApiAbortSignal ? { fetchAbortSignal: botApiAbortSignal } : {}), ...(this.opts.abortSignal ? { accountAbortSignal: this.opts.abortSignal } : {}), diff --git a/extensions/telegram/src/progress-draft-preview.test.ts b/extensions/telegram/src/progress-draft-preview.test.ts index e8b7c458a378..6457c8879408 100644 --- a/extensions/telegram/src/progress-draft-preview.test.ts +++ b/extensions/telegram/src/progress-draft-preview.test.ts @@ -5,13 +5,16 @@ import { describe, expect, it } from "vitest"; import { renderTelegramProgressDraftPreview } from "./progress-draft-preview.js"; function renderToolLine(name: string) { - const line = buildChannelProgressDraftLine({ - event: "tool", - toolCallId: "call-1", - name, - phase: "start", - args: { command: "echo alpha", description: "print text" }, - }); + const line = buildChannelProgressDraftLine( + { + event: "tool", + toolCallId: "call-1", + name, + phase: "start", + args: { command: "echo alpha", description: "print text" }, + }, + { commandText: "raw" }, + ); if (!line) { throw new Error(`expected a progress line for ${name}`); } diff --git a/extensions/telegram/src/send-context.ts b/extensions/telegram/src/send-context.ts index 6f76669776b3..015408975b5e 100644 --- a/extensions/telegram/src/send-context.ts +++ b/extensions/telegram/src/send-context.ts @@ -7,6 +7,7 @@ import { createChannelApiRetryRunner, type RetryConfig } from "openclaw/plugin-s import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; import { getOrCreateAccountThrottler } from "./account-throttler.js"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; @@ -427,6 +428,7 @@ export async function withTelegramNativeQuoteFallback(params: { export type TelegramApiContext = { cfg: OpenClawConfig; account: ResolvedTelegramAccount; + ownerAgentId: string; api: TelegramApi; clientOptionsLease?: TelegramClientOptionsLease | undefined; }; @@ -459,6 +461,7 @@ export function resolveTelegramApiContext(opts: { return { cfg, account, + ownerAgentId: resolveTelegramAccountOwnerAgentId({ cfg, accountId: account.accountId }), api, ...(clientOptionsLease ? { clientOptionsLease } : {}), }; diff --git a/extensions/telegram/src/send-message-text.ts b/extensions/telegram/src/send-message-text.ts index c863a8b8bde3..ff5f91bad0a0 100644 --- a/extensions/telegram/src/send-message-text.ts +++ b/extensions/telegram/src/send-message-text.ts @@ -66,6 +66,7 @@ function buildTelegramTextSendReceipt(params: { export function createTelegramTextSender(config: { cfg: OpenClawConfig; + ownerAgentId: string; account: ResolvedTelegramAccount; api: TelegramApi; chatId: string; @@ -97,6 +98,7 @@ export function createTelegramTextSender(config: { }) { const { cfg, + ownerAgentId, account, api, chatId, @@ -226,7 +228,10 @@ export function createTelegramTextSender(config: { await beforeFirstAccepted?.(); } sentChunkCount += 1; - recordSentMessage(chatId, messageId, cfg); + recordSentMessage(chatId, messageId, cfg, { + accountId: account.accountId, + agentId: ownerAgentId, + }); await reportDelivery( messageId, params.result?.chat?.id ?? chatId, diff --git a/extensions/telegram/src/send-message.ts b/extensions/telegram/src/send-message.ts index c856ec656de0..152ee5f7bfba 100644 --- a/extensions/telegram/src/send-message.ts +++ b/extensions/telegram/src/send-message.ts @@ -68,7 +68,7 @@ async function sendMessageTelegramWithContext( opts: TelegramSendOpts, apiContext: TelegramApiContext, ): Promise { - const { cfg, account, api } = apiContext; + const { cfg, account, api, ownerAgentId } = apiContext; const botUserId = resolveTelegramBotUserIdFromToken(opts.token || account.token); const { chatId, @@ -111,6 +111,7 @@ async function sendMessageTelegramWithContext( const projection = plan?.cursor.take(plan.finalPart && finalPart); const recorded = await recordOutboundMessageForPromptContext({ cfg, + ownerAgentId, account, ...(botUserId !== undefined ? { botUserId } : {}), chatId, @@ -165,6 +166,7 @@ async function sendMessageTelegramWithContext( const { sendChunkedText } = createTelegramTextSender({ cfg, + ownerAgentId, account, api, chatId, @@ -336,7 +338,10 @@ async function sendMessageTelegramWithContext( const acceptedMediaParams = toAcceptedThreadScopedParams(mediaDelivery.acceptedParams); const mediaMessageId = resolveTelegramMessageIdOrThrow(result, "media send"); const resolvedChatId = String(result?.chat?.id ?? chatId); - recordSentMessage(chatId, mediaMessageId, cfg); + recordSentMessage(chatId, mediaMessageId, cfg, { + accountId: account.accountId, + agentId: ownerAgentId, + }); let mediaDeliveryResult: TelegramSendResult | undefined; let mediaPromptRecorded = false; const reportMediaDelivery = async (hasInlineKeyboard: boolean) => { diff --git a/extensions/telegram/src/send-outbound.ts b/extensions/telegram/src/send-outbound.ts index 47b71bef04ff..cadeb6186a57 100644 --- a/extensions/telegram/src/send-outbound.ts +++ b/extensions/telegram/src/send-outbound.ts @@ -170,9 +170,12 @@ export async function finalizeTelegramOutbound(params: { onDeliveryResult?: TelegramSendOpts["onDeliveryResult"]; beforeActivity?: (result: { messageId: string; chatId: string }) => void; }): Promise { - const { cfg, account } = params.context; + const { cfg, account, ownerAgentId } = params.context; const messageId = resolveTelegramMessageIdOrThrow(params.result, params.resultContext); - recordSentMessage(params.prepared.chatId, messageId, cfg); + recordSentMessage(params.prepared.chatId, messageId, cfg, { + accountId: account.accountId, + agentId: ownerAgentId, + }); const resultIds = await reportTelegramProviderDelivery({ message: params.result, messageId, @@ -185,6 +188,7 @@ export async function finalizeTelegramOutbound(params: { ); const recorded = await recordOutboundMessageForPromptContext({ cfg, + ownerAgentId, account, botUserId: params.botUserId, chatId: params.prepared.chatId, diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index e91c61fdfb50..1c6e5af334bc 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import type { Bot } from "grammy"; import { isChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { createPluginStateKeyedStoreForTests, @@ -589,6 +590,38 @@ describe("sent-message-cache", () => { } }); + it("keeps sent-message ownership isolated across differently routed accounts", () => { + const multiAgentCfg = { + agents: { + ownership: "explicit", + entries: { main: {}, ops: {} }, + }, + channels: { + telegram: { + accounts: { + primary: { botToken: "123456:primary" }, + alerts: { botToken: "123456:alerts" }, + }, + }, + }, + bindings: [ + { agentId: "main", match: { channel: "telegram", accountId: "primary" } }, + { agentId: "ops", match: { channel: "telegram", accountId: "alerts" } }, + ], + session: { + store: "/tmp/openclaw-telegram-sent-owner/{agentId}/sessions.json", + }, + } as OpenClawConfig; + + recordSentMessage(123, 1, multiAgentCfg, { accountId: "primary" }); + + expect(wasSentByBot(123, 1, multiAgentCfg, { accountId: "primary" })).toBe(true); + expect(wasSentByBot(123, 1, multiAgentCfg, { accountId: "alerts" })).toBe(false); + + recordSentMessage(123, 1, multiAgentCfg, { accountId: "alerts" }); + expect(wasSentByBot(123, 1, multiAgentCfg, { accountId: "alerts" })).toBe(true); + }); + it("shares sent-message state across distinct module instances", async () => { const cacheA = await importFreshModule( import.meta.url, diff --git a/extensions/telegram/src/sent-message-cache.legacy-state.ts b/extensions/telegram/src/sent-message-cache.legacy-state.ts index 711d0ee4af3e..06353b7f6c40 100644 --- a/extensions/telegram/src/sent-message-cache.legacy-state.ts +++ b/extensions/telegram/src/sent-message-cache.legacy-state.ts @@ -5,9 +5,9 @@ // legacy-state import, so it stays a leaf. import { createHash } from "node:crypto"; import fs from "node:fs"; -import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-paths"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; export const TTL_MS = 24 * 60 * 60 * 1000; export const TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE = "telegram.sent-messages"; @@ -20,22 +20,39 @@ export type PersistedSentMessage = { timestamp: number; }; -export type SentMessageConfig = Pick; +export type SentMessageConfig = Pick< + OpenClawConfig, + "agents" | "bindings" | "channels" | "session" +>; -function resolveSentMessageAgentId(cfg?: SentMessageConfig, agentId?: string): string { - return agentId?.trim() || (cfg?.agents ? resolveDefaultAgentId(cfg as OpenClawConfig) : "main"); +function resolveSentMessageAgentId( + cfg?: SentMessageConfig, + owner?: { accountId?: string; agentId?: string }, +): string { + return ( + owner?.agentId?.trim() || + (cfg + ? resolveTelegramAccountOwnerAgentId({ + cfg: cfg as OpenClawConfig, + accountId: owner?.accountId, + }) + : "main") + ); } function sentMessageScopeKeyForStorePath(storePath: string): string { return createHash("sha256").update(storePath, "utf8").digest("hex").slice(0, 24); } -export function resolveSentMessageScopeKey(cfg?: SentMessageConfig, agentId?: string): string { +export function resolveSentMessageScopeKey( + cfg?: SentMessageConfig, + owner?: { accountId?: string; agentId?: string }, +): string { // This 24-hour cache follows the current agent owner. Do not revive a prior owner's // transient bucket when the configured default changes. return sentMessageScopeKeyForStorePath( resolveStorePath(cfg?.session?.store, { - agentId: resolveSentMessageAgentId(cfg, agentId), + agentId: resolveSentMessageAgentId(cfg, owner), }), ); } @@ -47,9 +64,12 @@ export function sentMessageEntryKey(scopeKey: string, chatId: string, messageId: .slice(0, 32); } -function resolveSentMessageStorePath(cfg?: SentMessageConfig, agentId?: string): string { +function resolveSentMessageStorePath( + cfg?: SentMessageConfig, + owner?: { accountId?: string; agentId?: string }, +): string { return `${resolveStorePath(cfg?.session?.store, { - agentId: resolveSentMessageAgentId(cfg, agentId), + agentId: resolveSentMessageAgentId(cfg, owner), })}.telegram-sent-messages.json`; } @@ -89,8 +109,9 @@ export function listTelegramLegacySentMessageCacheEntries(params: { }): Array<{ key: string; value: PersistedSentMessage; ttlMs?: number; timestamp?: number }> { const scopeKey = params.targetStorePath ? sentMessageScopeKeyForStorePath(params.targetStorePath) - : resolveSentMessageScopeKey(params.cfg, params.agentId); - const filePath = params.persistedPath ?? resolveSentMessageStorePath(params.cfg, params.agentId); + : resolveSentMessageScopeKey(params.cfg, { agentId: params.agentId }); + const filePath = + params.persistedPath ?? resolveSentMessageStorePath(params.cfg, { agentId: params.agentId }); const legacy = fs.existsSync(filePath) ? readLegacySentMessages(filePath) : new Map>(); diff --git a/extensions/telegram/src/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts index 3d66e698bd60..69e98fb3a46a 100644 --- a/extensions/telegram/src/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -95,9 +95,14 @@ function readPersistedSentMessages(scopeKey: string): SentMessageStore { return store; } -function getSentMessageBucket(cfg?: SentMessageConfig): SentMessageBucket { +type SentMessageOwner = { accountId?: string; agentId?: string }; + +function getSentMessageBucket( + cfg?: SentMessageConfig, + owner?: SentMessageOwner, +): SentMessageBucket { const state = getSentMessageState(); - const scopeKey = resolveSentMessageScopeKey(cfg); + const scopeKey = resolveSentMessageScopeKey(cfg, owner); const existing = state.bucketsByScope.get(scopeKey); if (existing) { return existing; @@ -111,8 +116,8 @@ function getSentMessageBucket(cfg?: SentMessageConfig): SentMessageBucket { return bucket; } -function getSentMessages(cfg?: SentMessageConfig): SentMessageStore { - return getSentMessageBucket(cfg).store; +function getSentMessages(cfg?: SentMessageConfig, owner?: SentMessageOwner): SentMessageStore { + return getSentMessageBucket(cfg, owner).store; } function persistSentMessage( @@ -132,11 +137,12 @@ export function recordSentMessage( chatId: number | string, messageId: number, cfg?: SentMessageConfig, + owner?: SentMessageOwner, ): void { const scopeKey = String(chatId); const idKey = String(messageId); const now = Date.now(); - const bucket = getSentMessageBucket(cfg); + const bucket = getSentMessageBucket(cfg, owner); const { store } = bucket; let entry = store.get(scopeKey); if (!entry) { @@ -159,10 +165,11 @@ export function wasSentByBot( chatId: number | string, messageId: number, cfg?: SentMessageConfig, + owner?: SentMessageOwner, ): boolean { const scopeKey = String(chatId); const idKey = String(messageId); - const store = getSentMessages(cfg); + const store = getSentMessages(cfg, owner); const entry = store.get(scopeKey); if (!entry) { return false; diff --git a/extensions/telegram/src/sequential-key.ts b/extensions/telegram/src/sequential-key.ts index c3dbfb6c379b..fa1f388446bd 100644 --- a/extensions/telegram/src/sequential-key.ts +++ b/extensions/telegram/src/sequential-key.ts @@ -247,7 +247,10 @@ export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): str } return "telegram:approval"; } - const threadSpec = msg ? resolveTelegramMessageThreadSpec(msg) : undefined; + // Raw durable-ingress fixtures and malformed updates can carry a partial + // message. Treat missing chat identity as an unknown lane instead of + // crashing before the queue records the update. + const threadSpec = msg?.chat ? resolveTelegramMessageThreadSpec(msg) : undefined; const threadId = threadSpec?.scope === "dm" ? shouldUseTelegramDmThreadSession({ diff --git a/extensions/telegram/src/setup-plugin.ts b/extensions/telegram/src/setup-plugin.ts new file mode 100644 index 000000000000..25007c43456c --- /dev/null +++ b/extensions/telegram/src/setup-plugin.ts @@ -0,0 +1,57 @@ +// Telegram plugin module composes the setup-safe channel surface. +import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common"; +import type { ResolvedTelegramAccount } from "./accounts.js"; +import { createTelegramPluginConfig } from "./config-adapter.js"; +import { TelegramChannelConfigSchema } from "./config-schema.js"; +import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; + +const TELEGRAM_CHANNEL = "telegram" as const; + +export function createTelegramSetupPluginBase(params: { + setupWizard: NonNullable["setupWizard"]>; + setupContract: NonNullable["setupContract"]>; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "reload" + | "configSchema" + | "config" + | "setupContract" + | "secrets" +> { + return { + id: TELEGRAM_CHANNEL, + setupContract: params.setupContract, + meta: { + ...getChatChannelMeta(TELEGRAM_CHANNEL), + quickstartAllowFrom: true, + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group", "channel", "thread"], + reactions: true, + threads: true, + media: true, + tts: { + voice: { + synthesisTarget: "voice-note", + captionedFinalText: true, + }, + }, + polls: true, + nativeCommands: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.telegram"] }, + configSchema: TelegramChannelConfigSchema, + config: createTelegramPluginConfig(), + secrets: { + secretTargetRegistryEntries, + collectRuntimeConfigAssignments, + }, + }; +} diff --git a/extensions/telegram/src/shared.test.ts b/extensions/telegram/src/shared.test.ts index 114ab2abd104..e062ee03d16c 100644 --- a/extensions/telegram/src/shared.test.ts +++ b/extensions/telegram/src/shared.test.ts @@ -2,7 +2,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { describe, expect, it } from "vitest"; import type { ResolvedTelegramAccount } from "./accounts.js"; -import { createTelegramPluginBase, telegramConfigAdapter } from "./shared.js"; +import { telegramConfigAdapter } from "./config-adapter.js"; +import { createTelegramPluginBase } from "./shared.js"; const telegramPluginBase = createTelegramPluginBase({ setupWizard: {} as never, diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index a01fd4d2730e..5df0e7625ac6 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -1,23 +1,6 @@ -// Telegram plugin module implements shared behavior. -import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-core"; -import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { - adaptScopedAccountAccessor, - createScopedChannelConfigAdapter, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { createChannelPluginBase, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; -import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common"; -import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; -import { inspectTelegramAccount } from "./account-inspect.js"; -import { - listTelegramAccountIds, - mergeTelegramAccountConfig, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, - type ResolvedTelegramAccount, -} from "./accounts.js"; +// Telegram plugin module implements shared runtime behavior. +import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import type { ResolvedTelegramAccount } from "./accounts.js"; import { buildTelegramCommandsListChannelData, buildTelegramModelBrowseChannelData, @@ -26,110 +9,9 @@ import { buildTelegramModelsMenuChannelData, buildTelegramModelsProviderChannelData, } from "./command-ui.js"; -import { TelegramChannelConfigSchema } from "./config-schema.js"; import { telegramDoctor } from "./doctor.js"; -import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { telegramSecurityAdapter } from "./security.js"; - -const TELEGRAM_CHANNEL = "telegram" as const; - -type TelegramConfigAccessorAccount = { - config: TelegramAccountConfig; -}; - -export function findTelegramTokenOwnerAccountId(params: { - cfg: OpenClawConfig; - accountId: string; -}): string | null { - const normalizedAccountId = normalizeAccountId(params.accountId); - const tokenOwners = new Map(); - for (const id of listTelegramAccountIds(params.cfg)) { - const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); - const token = (account.token ?? "").trim(); - if (!token) { - continue; - } - const ownerAccountId = tokenOwners.get(token); - if (!ownerAccountId) { - tokenOwners.set(token, account.accountId); - continue; - } - if (account.accountId === normalizedAccountId) { - return ownerAccountId; - } - } - return null; -} - -export function formatDuplicateTelegramTokenReason(params: { - accountId: string; - ownerAccountId: string; -}): string { - return ( - `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + - `account "${params.ownerAccountId}". Keep one owner account per bot token.` - ); -} - -/** - * Returns true when the runtime token resolver (`resolveTelegramToken`) would - * block channel-level fallthrough for the given accountId. This mirrors the - * guard in `token.ts` so that status-check functions (`isConfigured`, - * `unconfiguredReason`, `describeAccount`) stay consistent with the gateway - * runtime behaviour. - * - * The guard fires when: - * 1. The accountId is not the default account, AND - * 2. The config has an explicit `accounts` section with entries, AND - * 3. The accountId is not found in that `accounts` section. - * - * See: https://github.com/openclaw/openclaw/issues/53876 - */ -function isBlockedByMultiBotGuard(cfg: OpenClawConfig, accountId: string): boolean { - if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { - return false; - } - const accounts = cfg.channels?.telegram?.accounts; - const hasConfiguredAccounts = - Boolean(accounts) && - typeof accounts === "object" && - !Array.isArray(accounts) && - Object.keys(accounts).length > 0; - if (!hasConfiguredAccounts) { - return false; - } - // Use resolveNormalizedAccountEntry (same as resolveTelegramToken in token.ts) - // instead of resolveAccountEntry to handle keys that require full normalization - // (e.g. "Carey Notifications" → "carey-notifications"). - return !resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId); -} - -export function resolveTelegramConfigAccessorAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): TelegramConfigAccessorAccount { - const accountId = normalizeAccountId( - params.accountId ?? resolveDefaultTelegramAccountId(params.cfg), - ); - return { config: mergeTelegramAccountConfig(params.cfg, accountId) }; -} - -export const telegramConfigAdapter = createScopedChannelConfigAdapter< - ResolvedTelegramAccount, - TelegramConfigAccessorAccount ->({ - sectionKey: TELEGRAM_CHANNEL, - listAccountIds: listTelegramAccountIds, - resolveAccount: adaptScopedAccountAccessor(resolveTelegramAccount), - resolveAccessorAccount: resolveTelegramConfigAccessorAccount, - inspectAccount: adaptScopedAccountAccessor(inspectTelegramAccount), - defaultAccountId: resolveDefaultTelegramAccountId, - clearBaseFields: ["botToken", "tokenFile", "name"], - resolveAllowFrom: (account) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), - resolveDefaultTo: (account) => account.config.defaultTo, -}); +import { createTelegramSetupPluginBase } from "./setup-plugin.js"; export function createTelegramPluginBase(params: { setupWizard: NonNullable["setupWizard"]>; @@ -149,29 +31,8 @@ export function createTelegramPluginBase(params: { | "setupContract" | "secrets" > { - const base = createChannelPluginBase({ - id: TELEGRAM_CHANNEL, - setupContract: params.setupContract, - meta: { - ...getChatChannelMeta(TELEGRAM_CHANNEL), - quickstartAllowFrom: true, - }, - setupWizard: params.setupWizard, - capabilities: { - chatTypes: ["direct", "group", "channel", "thread"], - reactions: true, - threads: true, - media: true, - tts: { - voice: { - synthesisTarget: "voice-note", - captionedFinalText: true, - }, - }, - polls: true, - nativeCommands: true, - blockStreaming: true, - }, + return { + ...createTelegramSetupPluginBase(params), commands: { nativeCommandsAutoEnabled: true, nativeSkillsAutoEnabled: true, @@ -184,96 +45,5 @@ export function createTelegramPluginBase(params: { }, doctor: telegramDoctor, security: telegramSecurityAdapter, - reload: { configPrefixes: ["channels.telegram"] }, - configSchema: TelegramChannelConfigSchema, - config: { - ...telegramConfigAdapter, - hasConfiguredState: ({ env }) => - typeof env?.TELEGRAM_BOT_TOKEN === "string" && env.TELEGRAM_BOT_TOKEN.trim().length > 0, - isConfigured: (account, cfg) => { - // Use inspectTelegramAccount for a complete token resolution that includes - // channel-level fallback paths not available in resolveTelegramAccount. - // This ensures binding-created accountIds that inherit the channel-level - // token are correctly detected as configured. - // See: https://github.com/openclaw/openclaw/issues/53876 - if (isBlockedByMultiBotGuard(cfg, account.accountId)) { - return false; - } - const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId }); - // Gate on actually available token, not just "configured" — the latter - // includes "configured_unavailable" (unreadable tokenFile, unresolved - // SecretRef) which would pass here but fail at runtime. - if (!inspected.token?.trim()) { - return false; - } - return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - }, - unconfiguredReason: (account, cfg) => { - if (isBlockedByMultiBotGuard(cfg, account.accountId)) { - return `not configured: unknown accountId "${account.accountId}" in multi-bot setup`; - } - const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId }); - if (!inspected.token?.trim()) { - if (inspected.tokenStatus === "configured_unavailable") { - return `not configured: token ${inspected.tokenSource} is configured but unavailable`; - } - return "not configured"; - } - const ownerAccountId = findTelegramTokenOwnerAccountId({ - cfg, - accountId: account.accountId, - }); - if (!ownerAccountId) { - return "not configured"; - } - return formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - }, - describeAccount: (account, cfg) => { - if (isBlockedByMultiBotGuard(cfg, account.accountId)) { - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: false, - tokenSource: "none" as const, - }; - } - const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId }); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: - inspected.tokenStatus !== "missing" && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), - tokenSource: inspected.tokenSource, - tokenStatus: inspected.tokenStatus, - }; - }, - }, - }); - return { - ...base, - secrets: { - secretTargetRegistryEntries, - collectRuntimeConfigAssignments, - }, - } as Pick< - ChannelPlugin, - | "id" - | "meta" - | "setupWizard" - | "capabilities" - | "commands" - | "doctor" - | "security" - | "reload" - | "configSchema" - | "config" - | "setupContract" - | "secrets" - >; + }; } diff --git a/extensions/telegram/src/state-migrations.ts b/extensions/telegram/src/state-migrations.ts index 3e824397eab9..12a522db1e9f 100644 --- a/extensions/telegram/src/state-migrations.ts +++ b/extensions/telegram/src/state-migrations.ts @@ -4,10 +4,10 @@ import path from "node:path"; import { listAgentIds } from "openclaw/plugin-sdk/agent-scope-runtime"; import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { fileExists } from "openclaw/plugin-sdk/security-runtime"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-paths"; import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolveTelegramAccountOwnerAgentId } from "./account-owner.js"; import { listTelegramAccountIds, resolveDefaultTelegramAccountId } from "./account-selection.js"; import { listTelegramLegacyBotInfoCacheEntries, @@ -93,9 +93,7 @@ function resolveTelegramLegacyStateOwnerAgentId(cfg: OpenClawConfig): string { const accountIds = configuredAccountIds.length > 0 ? configuredAccountIds : [resolveDefaultTelegramAccountId(cfg)]; const ownerAgentIds = uniqueStrings( - accountIds.map( - (accountId) => resolveAgentRoute({ cfg, channel: "telegram", accountId }).agentId, - ), + accountIds.map((accountId) => resolveTelegramAccountOwnerAgentId({ cfg, accountId })), ); if (ownerAgentIds.length === 1) { return ownerAgentIds[0]!; diff --git a/extensions/telegram/src/telegram-ingress-coalescing.e2e.test.ts b/extensions/telegram/src/telegram-ingress-coalescing.e2e.test.ts index ea59faf88b93..bf45ee09c77f 100644 --- a/extensions/telegram/src/telegram-ingress-coalescing.e2e.test.ts +++ b/extensions/telegram/src/telegram-ingress-coalescing.e2e.test.ts @@ -62,14 +62,9 @@ vi.mock("./telegram-media.runtime.js", async (importOriginal) => { }; }); -vi.mock("./bot.agent.runtime.js", () => ({ - resolveDefaultAgentId: vi.fn(() => "default"), -})); - vi.mock("./bot-handlers.agent.runtime.js", () => ({ resolveAgentDir: vi.fn(() => "/tmp/agent"), resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), - resolveDefaultAgentId: vi.fn(() => "default"), resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-test" })), })); diff --git a/extensions/telegram/src/webhook.test.ts b/extensions/telegram/src/webhook.test.ts index 165389baa936..2d8f54c060ef 100644 --- a/extensions/telegram/src/webhook.test.ts +++ b/extensions/telegram/src/webhook.test.ts @@ -5,6 +5,7 @@ import { createServer, request, type IncomingMessage } from "node:http"; import os from "node:os"; import nodePath from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; +import type { Update } from "grammy/types"; import { DEFAULT_INGRESS_ADOPTION_STALL_MS } from "openclaw/plugin-sdk/channel-outbound"; import { closeOpenClawStateDatabaseForTest, @@ -74,6 +75,23 @@ const TELEGRAM_WEBHOOK_PATH = "/hook"; const WEBHOOK_DRAIN_GUARD_MS = 5; const TELEGRAM_WEBHOOK_RATE_LIMIT_BURST = WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests + 10; +type TestTelegramMessageUpdate = Update & { + message: NonNullable & { text: string }; +}; + +function telegramMessageUpdate(updateId: number, text: string): TestTelegramMessageUpdate { + return { + update_id: updateId, + message: { + message_id: updateId, + date: 1_736_380_800, + from: { id: 111, is_bot: false, first_name: "Ada" }, + chat: { id: 111, type: "private", first_name: "Ada" }, + text, + }, + }; +} + async function waitForWebhookState( assertion: () => T | Promise, options: { timeout?: number; interval?: number } = {}, @@ -486,16 +504,13 @@ async function postWebhookPayloadWithChunkPlan(params: { function createNearLimitTelegramPayload(): { payload: string; sizeBytes: number } { const maxBytes = 1_024 * 1_024; const targetBytes = maxBytes - 4_096; - const shell = { update_id: 77_777, message: { text: "" } }; + const shell = telegramMessageUpdate(77_777, ""); const shellSize = Buffer.byteLength(JSON.stringify(shell), "utf-8"); const textLength = Math.max(1, targetBytes - shellSize); const pattern = "the quick brown fox jumps over the lazy dog "; const repeats = Math.ceil(textLength / pattern.length); const text = pattern.repeat(repeats).slice(0, textLength); - const payload = JSON.stringify({ - update_id: 77_777, - message: { text }, - }); + const payload = JSON.stringify(telegramMessageUpdate(77_777, text)); return { payload, sizeBytes: Buffer.byteLength(payload, "utf-8") }; } @@ -543,8 +558,8 @@ async function withStartedWebhook( } function expectSingleNearLimitUpdate(params: { - seenUpdates: Array<{ update_id: number; message: { text: string } }>; - expected: { update_id: number; message: { text: string } }; + seenUpdates: TestTelegramMessageUpdate[]; + expected: TestTelegramMessageUpdate; }) { expect(params.seenUpdates).toHaveLength(1); expect(params.seenUpdates[0]?.update_id).toBe(params.expected.update_id); @@ -557,15 +572,15 @@ function expectSingleNearLimitUpdate(params: { async function runNearLimitPayloadTestAndExpectUpdate( mode: "single" | "random-chunked", ): Promise { - const seenUpdates: Array<{ update_id: number; message: { text: string } }> = []; + const seenUpdates: TestTelegramMessageUpdate[] = []; handleUpdateSpy.mockImplementationOnce((update: unknown) => { - seenUpdates.push(update as { update_id: number; message: { text: string } }); + seenUpdates.push(update as TestTelegramMessageUpdate); }); const { payload, sizeBytes } = createNearLimitTelegramPayload(); expect(sizeBytes).toBeLessThan(1_024 * 1_024); expect(sizeBytes).toBeGreaterThan(256 * 1_024); - const expected = JSON.parse(payload) as { update_id: number; message: { text: string } }; + const expected = JSON.parse(payload) as TestTelegramMessageUpdate; await withStartedWebhook( { @@ -599,6 +614,7 @@ describe("startTelegramWebhook", () => { { secret: TELEGRAM_SECRET, accountId: "opie", + ownerAgentId: "ops", config: cfg, runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() }, setStatus, @@ -609,6 +625,7 @@ describe("startTelegramWebhook", () => { "createTelegramBot params", ); expect(botParams.accountId).toBe("opie"); + expect(botParams.ownerAgentId).toBe("ops"); expect(requireRecord(botParams.config, "telegram config").bindings).toEqual([]); expect(botParams.telegramTransport).toBeDefined(); const health = await fetch(`http://127.0.0.1:${port}/healthz`); @@ -1044,7 +1061,7 @@ describe("startTelegramWebhook", () => { ); expect(botParams.accountId).toBe("opie"); expect(requireRecord(botParams.config, "telegram config").bindings).toEqual([]); - const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } }); + const payload = JSON.stringify(telegramMessageUpdate(1, "hello")); const response = await postWebhookJson({ url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH), payload, @@ -1058,11 +1075,12 @@ describe("startTelegramWebhook", () => { }); it("acks before webhook update processing finishes", async () => { + const slowUpdate = telegramMessageUpdate(2, "slow"); let finishWork: (() => void) | undefined; let workStarted = false; let workFinished = false; handleUpdateSpy.mockImplementationOnce(async (update: unknown) => { - expect(update).toEqual({ update_id: 2, message: { text: "slow" } }); + expect(update).toEqual(telegramMessageUpdate(2, "slow")); workStarted = true; await new Promise((resolve) => { finishWork = resolve; @@ -1078,7 +1096,7 @@ describe("startTelegramWebhook", () => { async ({ port }) => { const response = await postWebhookJson({ url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH), - payload: JSON.stringify({ update_id: 2, message: { text: "slow" } }), + payload: JSON.stringify(slowUpdate), secret: TELEGRAM_SECRET, timeoutMs: 1_000, }); @@ -1115,7 +1133,7 @@ describe("startTelegramWebhook", () => { try { const response = await postWebhookJson({ url: webhookUrl(getServerPort(started.server), TELEGRAM_WEBHOOK_PATH), - payload: JSON.stringify({ update_id: 3, message: { text: "stuck" } }), + payload: JSON.stringify(telegramMessageUpdate(3, "stuck")), secret: TELEGRAM_SECRET, }); expect(response.status).toBe(200); @@ -1195,7 +1213,7 @@ describe("startTelegramWebhook", () => { let responseSettled = false; const responseTask = postWebhookJson({ url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH), - payload: JSON.stringify({ update_id: 4, message: { text: "commit gate" } }), + payload: JSON.stringify(telegramMessageUpdate(4, "commit gate")), secret: TELEGRAM_SECRET, }).then((response) => { responseSettled = true; @@ -1229,7 +1247,7 @@ describe("startTelegramWebhook", () => { throw new Error("agent turn failed"); } }); - const payload = JSON.stringify({ update_id: 3, message: { text: "boom" } }); + const payload = JSON.stringify(telegramMessageUpdate(3, "boom")); try { await withStartedWebhook( @@ -1295,7 +1313,7 @@ describe("startTelegramWebhook", () => { } = {}; await writeTelegramSpooledUpdate({ spoolDir: requireWebhookSpoolDir(), - update: { update_id: 39, message: { chat: { id: 123 }, text: "stalled" } }, + update: telegramMessageUpdate(39, "stalled"), }); handleUpdateSpy.mockImplementationOnce(async () => { active.dispatchStartedAt = Date.now(); @@ -1337,8 +1355,8 @@ describe("startTelegramWebhook", () => { try { let finishFirstUpdate: (() => void) | undefined; const seenUpdateIds: number[] = []; - const firstUpdate = { update_id: 40, message: { chat: { id: 123 }, text: "slow" } }; - const secondUpdate = { update_id: 41, message: { chat: { id: 123 }, text: "blocked" } }; + const firstUpdate = telegramMessageUpdate(40, "slow"); + const secondUpdate = telegramMessageUpdate(41, "blocked"); await writeTelegramSpooledUpdate({ spoolDir: requireWebhookSpoolDir(), update: firstUpdate, @@ -1384,7 +1402,7 @@ describe("startTelegramWebhook", () => { it("holds buffered timeout settlement behind durable webhook adoption", async () => { vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); try { - const update = { update_id: 42, message: { chat: { id: 123 }, text: "held adoption" } }; + const update = telegramMessageUpdate(42, "held adoption"); await writeTelegramSpooledUpdate({ spoolDir: requireWebhookSpoolDir(), update, @@ -1435,7 +1453,7 @@ describe("startTelegramWebhook", () => { }); it("drains spooled webhook updates left by a previous process on startup", async () => { - const update = { update_id: 30, message: { text: "leftover" } }; + const update = telegramMessageUpdate(30, "leftover"); await writeTelegramSpooledUpdate({ spoolDir: requireWebhookSpoolDir(), update, @@ -2185,8 +2203,8 @@ describe("startTelegramWebhook", () => { }, }, } as TelegramRuntime); - const firstUpdate = { update_id: 50, message: { chat: { id: 123 }, text: "first" } }; - const secondUpdate = { update_id: 51, message: { chat: { id: 123 }, text: "second" } }; + const firstUpdate = telegramMessageUpdate(50, "first"); + const secondUpdate = telegramMessageUpdate(51, "second"); await writeTelegramSpooledUpdate({ spoolDir: requireWebhookSpoolDir(), update: firstUpdate, @@ -2264,7 +2282,7 @@ describe("startTelegramWebhook", () => { } as unknown as TelegramRuntime); await writeTelegramSpooledUpdate({ spoolDir: requireWebhookSpoolDir(), - update: { update_id: 52, message: { chat: { id: 123 }, text: "stop retry" } }, + update: telegramMessageUpdate(52, "stop retry"), }); const runtimeLog = vi.fn(); const started = await startTelegramWebhook({ @@ -2294,7 +2312,7 @@ describe("startTelegramWebhook", () => { try { vi.setSystemTime(10_000_000); const runtimeLog = vi.fn(); - const update = { update_id: 31, message: { text: "young poison" } }; + const update = telegramMessageUpdate(31, "young poison"); await writeTelegramSpooledUpdate({ spoolDir: requireWebhookSpoolDir(), update, @@ -2334,7 +2352,7 @@ describe("startTelegramWebhook", () => { try { vi.setSystemTime(10_000_000); const runtimeLog = vi.fn(); - const update = { update_id: 32, message: { text: "old poison" } }; + const update = telegramMessageUpdate(32, "old poison"); await writeTelegramSpooledUpdate({ spoolDir: requireWebhookSpoolDir(), update, @@ -2446,7 +2464,7 @@ describe("startTelegramWebhook", () => { const validResponse = await postWebhookJson({ url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH), - payload: JSON.stringify({ update_id: 999, message: { text: "hello" } }), + payload: JSON.stringify(telegramMessageUpdate(999, "hello")), secret: TELEGRAM_SECRET, }); expect(validResponse.status).toBe(200); @@ -2468,7 +2486,7 @@ describe("startTelegramWebhook", () => { for (let i = 0; i < TELEGRAM_WEBHOOK_RATE_LIMIT_BURST; i += 1) { const response = await postWebhookJson({ url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH), - payload: JSON.stringify({ update_id: 10_000 + i, message: { text: `valid ${i}` } }), + payload: JSON.stringify(telegramMessageUpdate(10_000 + i, `valid ${i}`)), secret: TELEGRAM_SECRET, }); expect(response.status).toBe(200); @@ -2520,7 +2538,7 @@ describe("startTelegramWebhook", () => { "x-forwarded-for": "203.0.113.20", "x-telegram-bot-api-secret-token": TELEGRAM_SECRET, }, - body: JSON.stringify({ update_id: 201, message: { text: "hello" } }), + body: JSON.stringify(telegramMessageUpdate(201, "hello")), }, 5_000, ); @@ -2569,7 +2587,7 @@ describe("startTelegramWebhook", () => { const secondResponse = await postWebhookJson({ url: webhookUrl(secondPort, TELEGRAM_WEBHOOK_PATH), - payload: JSON.stringify({ update_id: 301, message: { text: "hello" } }), + payload: JSON.stringify(telegramMessageUpdate(301, "hello")), secret: TELEGRAM_SECRET, }); @@ -2628,7 +2646,7 @@ describe("startTelegramWebhook", () => { path: TELEGRAM_WEBHOOK_PATH, }, async ({ port }) => { - const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } }); + const payload = JSON.stringify(telegramMessageUpdate(1, "hello")); const res = await postWebhookJson({ url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH), payload, @@ -2733,8 +2751,8 @@ describe("startTelegramWebhook", () => { }, async ({ port }) => { const payloads = [ - JSON.stringify({ update_id: 1, message: { text: "first" } }), - JSON.stringify({ update_id: 2, message: { text: "second" } }), + JSON.stringify(telegramMessageUpdate(1, "first")), + JSON.stringify(telegramMessageUpdate(2, "second")), ]; for (const payload of payloads) { @@ -2768,8 +2786,8 @@ describe("startTelegramWebhook", () => { path: TELEGRAM_WEBHOOK_PATH, }, async ({ port }) => { - const firstPayload = JSON.stringify({ update_id: 100, message: { text: "first" } }); - const secondPayload = JSON.stringify({ update_id: 101, message: { text: "second" } }); + const firstPayload = JSON.stringify(telegramMessageUpdate(100, "first")); + const secondPayload = JSON.stringify(telegramMessageUpdate(101, "second")); const firstResponse = await postWebhookPayloadWithChunkPlan({ port, path: TELEGRAM_WEBHOOK_PATH, diff --git a/extensions/telegram/src/webhook.ts b/extensions/telegram/src/webhook.ts index 4fb55da7e24f..cb7e22e6c433 100644 --- a/extensions/telegram/src/webhook.ts +++ b/extensions/telegram/src/webhook.ts @@ -296,6 +296,7 @@ function resolveTelegramWebhookRateLimitKey( export async function startTelegramWebhook(opts: { token: string; accountId?: string; + ownerAgentId?: string; config?: OpenClawConfig; path?: string; port?: number; @@ -360,6 +361,7 @@ export async function startTelegramWebhook(opts: { accountAbortSignal, config: opts.config, accountId: opts.accountId, + ownerAgentId: opts.ownerAgentId, telegramTransport, }); const runShutdownPhase = async ( diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index 543bb4380d47..607bc50dd507 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -257,6 +257,9 @@ "openclaw/plugin-sdk/direct-dm-guard-policy": [ "../packages/plugin-sdk/dist/src/plugin-sdk/direct-dm-guard-policy.d.ts" ], + "openclaw/plugin-sdk/channel-config-ui-hints": [ + "../packages/plugin-sdk/dist/src/plugin-sdk/channel-config-ui-hints.d.ts" + ], "openclaw/plugin-sdk/channel-config-writes": [ "../packages/plugin-sdk/dist/src/plugin-sdk/channel-config-writes.d.ts" ], diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index 37b860b2ad7f..8f8756fbcbd0 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -254,6 +254,9 @@ "openclaw/plugin-sdk/direct-dm-guard-policy": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/direct-dm-guard-policy.d.ts" ], + "openclaw/plugin-sdk/channel-config-ui-hints": [ + "../../packages/plugin-sdk/dist/src/plugin-sdk/channel-config-ui-hints.d.ts" + ], "openclaw/plugin-sdk/channel-config-writes": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/channel-config-writes.d.ts" ], diff --git a/package.json b/package.json index f9e71cf2aadb..e9b0f25d1640 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "!dist/plugin-sdk/browser-config.d.ts", "!dist/plugin-sdk/bundled-channel-config-schema.d.ts", "!dist/plugin-sdk/channel-activity-runtime.d.ts", + "!dist/plugin-sdk/channel-config-ui-hints.d.ts", "!dist/plugin-sdk/channel-config-writes.d.ts", "!dist/plugin-sdk/channel-contract-testing.js", "!dist/plugin-sdk/channel-contract-testing.d.ts", @@ -951,6 +952,9 @@ "types": "./dist/plugin-sdk/channel-config-helpers.d.ts", "default": "./dist/plugin-sdk/channel-config-helpers.js" }, + "./plugin-sdk/channel-config-ui-hints": { + "default": "./dist/plugin-sdk/channel-config-ui-hints.js" + }, "./plugin-sdk/channel-config-writes": { "default": "./dist/plugin-sdk/channel-config-writes.js" }, @@ -1522,7 +1526,6 @@ "check:static-import-sccs": "pnpm check:madge-import-cycles", "check:temp-path-guardrails": "node --import tsx scripts/check-temp-path-guardrails.ts", "check:wrapper-shadowing": "node --import tsx scripts/check-wrapper-shadowing.mts", - "check:wrapper-shadowing:gen": "node --import tsx scripts/check-wrapper-shadowing.mts --update-debt-baseline", "check:test-types": "pnpm tsgo:test", "check:timed": "node --import tsx scripts/check-timed.mts", "check:timed:all-types": "node --import tsx scripts/check-timed.mts --include-test-types", @@ -1649,7 +1652,6 @@ "lint:tmp:channel-agnostic-boundaries": "node --import tsx scripts/check-channel-agnostic-boundaries.mts", "lint:tmp:dynamic-import-warts": "node --import tsx scripts/check-dynamic-import-warts.mts", "lint:tmp:export-name-collisions": "node --import tsx scripts/check-export-name-collisions.mts", - "lint:tmp:export-name-collisions:gen": "node --import tsx scripts/check-export-name-collisions.mts --update-debt-baseline", "lint:tmp:no-random-messaging": "node --import tsx scripts/check-no-random-messaging-tmp.mts", "lint:tmp:no-raw-channel-fetch": "node --import tsx scripts/check-no-raw-channel-fetch.mts", "lint:tmp:no-raw-http2-imports": "node --import tsx scripts/check-no-raw-http2-imports.mts", @@ -1996,7 +1998,7 @@ "@openclaw/fs-safe": "0.5.5", "@openclaw/proxyline": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4", - "@trycua/cua-driver": "0.14.1", + "@trycua/cua-driver": "0.19.3", "acorn": "8.17.0", "chalk": "6.0.0", "chokidar": "5.0.0", diff --git a/packages/ai/src/providers/transform-messages.test.ts b/packages/ai/src/providers/transform-messages.test.ts index ac8ae21d9625..aca5b6a57c6e 100644 --- a/packages/ai/src/providers/transform-messages.test.ts +++ b/packages/ai/src/providers/transform-messages.test.ts @@ -139,4 +139,76 @@ describe("transformMessages", () => { expect(projected[0]).toBe(resource); expect(projected[2]).toBe(metadata); }); + + it("pairs trimmed replay tool call and result ids without synthesizing an error", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: " call_1 ", name: "lookup", arguments: {} }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "lookup", + content: [{ type: "text", text: "actual result" }], + isError: false, + timestamp: 2, + }, + ] as Message[]; + + const transformed = transformMessages(messages, model); + + expect(transformed).toHaveLength(2); + expect(transformed[0]?.content).toEqual([ + { type: "toolCall", id: "call_1", name: "lookup", arguments: {} }, + ]); + expect(transformed[1]).toMatchObject({ role: "toolResult", toolCallId: "call_1" }); + + const transformedPaddedResult = transformMessages( + [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_2", name: "lookup", arguments: {} }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 3, + }, + { + role: "toolResult", + toolCallId: " call_2 ", + toolName: "lookup", + content: [{ type: "text", text: "actual result" }], + isError: false, + timestamp: 4, + }, + ] as Message[], + model, + ); + + expect(transformedPaddedResult).toHaveLength(2); + expect(transformedPaddedResult[1]).toMatchObject({ role: "toolResult", toolCallId: "call_2" }); + }); }); diff --git a/packages/ai/src/transcript-transform.ts b/packages/ai/src/transcript-transform.ts index 8115d697c057..ff60d5f4366d 100644 --- a/packages/ai/src/transcript-transform.ts +++ b/packages/ai/src/transcript-transform.ts @@ -102,13 +102,15 @@ function transformAssistant( if (block.type === "text") { return sameModel ? block : { type: "text" as const, text: block.text }; } - if (sameModel) { - return block; - } const { thoughtSignature: _, ...unsigned } = block; - const id = normalizeToolCallId?.(block.id, model, message) ?? block.id; - if (id !== block.id) { - toolCallIdMap.set(block.id, id); + // Pairing uses these IDs as shared keys, before model-specific normalization runs. + const trimmedId = block.id.trim(); + if (sameModel) { + return trimmedId === block.id ? block : Object.assign({}, block, { id: trimmedId }); + } + const id = normalizeToolCallId?.(trimmedId, model, message) ?? trimmedId; + if (id !== trimmedId) { + toolCallIdMap.set(trimmedId, id); } return id === block.id ? unsigned : Object.assign({}, unsigned, { id }); }); @@ -131,8 +133,9 @@ export function transformMessages( if (message.role !== "toolResult") { return message; } - const toolCallId = toolCallIdMap.get(message.toolCallId); - return toolCallId ? Object.assign({}, message, { toolCallId }) : message; + const trimmedId = message.toolCallId.trim(); + const toolCallId = toolCallIdMap.get(trimmedId) ?? trimmedId; + return toolCallId === message.toolCallId ? message : Object.assign({}, message, { toolCallId }); }); const result: Message[] = []; diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index a9b956caf1e6..514d342a6167 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -265,6 +265,7 @@ export type GatewayClientOptions = { scopes?: string[]; caps?: string[]; commands?: string[]; + workerRuns?: ConnectParams["workerRuns"]; permissions?: Record; pathEnv?: string; env?: NodeJS.ProcessEnv; @@ -458,11 +459,16 @@ export class GatewayClient { }; } - updateNodeManifest(manifest: { caps: string[]; commands: string[] }): void { + updateNodeManifest(manifest: { + caps: string[]; + commands: string[]; + workerRuns?: ConnectParams["workerRuns"]; + }): void { this.opts = { ...this.opts, caps: [...manifest.caps], commands: [...manifest.commands], + workerRuns: manifest.workerRuns ? structuredClone(manifest.workerRuns) : undefined, }; // Node command declarations are connect metadata. Reconnect so the Gateway // can reconcile approval before dispatching a newly available command. @@ -757,6 +763,7 @@ export class GatewayClient { }, caps: Array.isArray(this.opts.caps) ? this.opts.caps : [], commands: Array.isArray(this.opts.commands) ? this.opts.commands : undefined, + workerRuns: useLegacyNodeProtocolEnvelope ? undefined : this.opts.workerRuns, permissions: this.opts.permissions && typeof this.opts.permissions === "object" ? this.opts.permissions diff --git a/packages/gateway-client/src/client.watchdog.test.ts b/packages/gateway-client/src/client.watchdog.test.ts index 1390ca521ced..51e9574faa64 100644 --- a/packages/gateway-client/src/client.watchdog.test.ts +++ b/packages/gateway-client/src/client.watchdog.test.ts @@ -630,12 +630,22 @@ describe("GatewayClient", () => { client.updateNodeManifest({ caps: ["canvas", "system"], commands: ["canvas.present", "system.run"], + workerRuns: { + bundleHash: "a".repeat(64), + openclawVersion: "2026.8.12", + protocolFeatures: ["worker-heartbeat-v1"], + }, }); expect(close).toHaveBeenCalledWith(1012, "node manifest changed"); expect((client as unknown as { opts: Record }).opts).toMatchObject({ caps: ["canvas", "system"], commands: ["canvas.present", "system.run"], + workerRuns: { + bundleHash: "a".repeat(64), + openclawVersion: "2026.8.12", + protocolFeatures: ["worker-heartbeat-v1"], + }, }); }); diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 52541d3e6f5b..856a01de03bc 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -48,6 +48,7 @@ export type { SecretsStoreMutationResult, SecretsStoreSetParams, } from "./schema/secrets.js"; +export * from "./schema/portals.js"; // Explicit schema exports keep public protocol changes reviewable. export { isCloudWorkerPlacementState, diff --git a/packages/gateway-protocol/src/schema-modules.ts b/packages/gateway-protocol/src/schema-modules.ts index 264fa2b433ca..5c4d85e8fb1d 100644 --- a/packages/gateway-protocol/src/schema-modules.ts +++ b/packages/gateway-protocol/src/schema-modules.ts @@ -50,6 +50,7 @@ export * from "./schema/terminal.js"; export * from "./schema/ui-command.js"; export * from "./schema/plugin-approvals.js"; export * from "./schema/plugins.js"; +export * from "./schema/portals.js"; export * from "./schema/projects.js"; export * from "./schema/wizard.js"; export * from "./schema/worker-admission.js"; diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index bbd3c7002bc4..ee01d97363b4 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -5,6 +5,7 @@ import { closedObject } from "./closed-object.js"; import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js"; import { SessionVisibilitySchema } from "./sessions-sharing-values.js"; import { SnapshotSchema, StateVersionSchema } from "./snapshot.js"; +import { WorkerAdmissionHandshakeSchema } from "./worker-admission.js"; export const GATEWAY_SERVER_CAPS = { BOARD_WIDGET_PUT_CANVAS_DOC: "board-widget-put-canvas-doc", @@ -47,6 +48,8 @@ export const ConnectParamsSchema = closedObject({ }), caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })), commands: Type.Optional(Type.Array(NonEmptyString)), + /** Additive node-local worker build identity; presence advertises session hosting. */ + workerRuns: Type.Optional(WorkerAdmissionHandshakeSchema), permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())), pathEnv: Type.Optional(Type.String()), role: Type.Optional(NonEmptyString), diff --git a/packages/gateway-protocol/src/schema/frames.worker-runs.test.ts b/packages/gateway-protocol/src/schema/frames.worker-runs.test.ts new file mode 100644 index 000000000000..de6a4386f5fc --- /dev/null +++ b/packages/gateway-protocol/src/schema/frames.worker-runs.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { validateConnectParams } from "../validator-registry.js"; + +describe("node worker-runs connect manifest", () => { + const connect = { + minProtocol: 1, + maxProtocol: 1, + client: { id: "test", version: "1.0.0", platform: "test", mode: "test" }, + }; + + it("accepts only the exact additive worker build identity", () => { + expect( + validateConnectParams({ + ...connect, + workerRuns: { + bundleHash: "a".repeat(64), + openclawVersion: "2026.8.12", + protocolFeatures: ["worker-heartbeat-v1"], + }, + }), + ).toBe(true); + expect(validateConnectParams({ ...connect, workerRuns: { enabled: true } })).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/portals.test.ts b/packages/gateway-protocol/src/schema/portals.test.ts new file mode 100644 index 000000000000..c6ba93fcdae3 --- /dev/null +++ b/packages/gateway-protocol/src/schema/portals.test.ts @@ -0,0 +1,56 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { + PortalChangedEventSchema, + PortalCloseResultSchema, + PortalListResultSchema, + PortalOpenResultSchema, + PortalSummarySchema, + validatePortalCloseParams, + validatePortalListParams, + validatePortalOpenParams, +} from "../index.js"; + +const portal = { + id: "p3000", + title: "Development app", + port: 3000, + listenPort: 43123, + tokenQuery: `openclaw_portal=${"a".repeat(64)}`, + url: `http://127.0.0.1:43123/app?openclaw_portal=${"a".repeat(64)}`, + publicUrl: "http://127.0.0.1:43123/app", + path: "/app", + description: "Live preview", + createdAtMs: 123, +}; + +describe("portal protocol schemas", () => { + it("accepts closed list, open, and close requests", () => { + expect(validatePortalListParams({})).toBe(true); + expect(validatePortalOpenParams({ port: 3000, title: "Development app", path: "/app" })).toBe( + true, + ); + expect(validatePortalCloseParams({ id: "p3000" })).toBe(true); + expect(validatePortalListParams({ extra: true })).toBe(false); + expect(validatePortalOpenParams({ port: 0 })).toBe(false); + expect(validatePortalOpenParams({ port: 65_536 })).toBe(false); + expect(validatePortalOpenParams({ port: 3000, path: "app" })).toBe(false); + expect(validatePortalOpenParams({ port: 3000, host: "example.test" })).toBe(false); + expect(validatePortalCloseParams({ id: "" })).toBe(false); + }); + + it("validates summaries, results, and full replace-set events", () => { + expect(Value.Check(PortalSummarySchema, portal)).toBe(true); + expect(Value.Check(PortalOpenResultSchema, portal)).toBe(true); + expect(Value.Check(PortalListResultSchema, { portals: [portal] })).toBe(true); + const { tokenQuery: _tokenQuery, url: _url, ...redactedPortal } = portal; + expect(Value.Check(PortalSummarySchema, redactedPortal)).toBe(true); + expect(Value.Check(PortalOpenResultSchema, redactedPortal)).toBe(false); + expect(Value.Check(PortalCloseResultSchema, { closed: true })).toBe(true); + expect(Value.Check(PortalChangedEventSchema, { portals: [portal] })).toBe(true); + const { publicUrl: _publicUrl, ...missingPublicUrl } = portal; + expect(Value.Check(PortalSummarySchema, missingPublicUrl)).toBe(false); + expect(Value.Check(PortalSummarySchema, { ...portal, targetPort: 3000 })).toBe(false); + expect(Value.Check(PortalChangedEventSchema, { portal })).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/portals.ts b/packages/gateway-protocol/src/schema/portals.ts new file mode 100644 index 000000000000..6eee3cc4dd60 --- /dev/null +++ b/packages/gateway-protocol/src/schema/portals.ts @@ -0,0 +1,58 @@ +import { Type, type Static } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { NonEmptyString } from "./primitives.js"; + +const PortalSummaryIdentityFields = { + id: NonEmptyString, + title: NonEmptyString, + port: Type.Integer({ minimum: 1, maximum: 65_535 }), + listenPort: Type.Integer({ minimum: 1, maximum: 65_535 }), +}; + +const PortalSummaryMetadataFields = { + publicUrl: NonEmptyString, + path: Type.Optional(Type.String({ pattern: "^/" })), + description: Type.Optional(Type.String()), + createdAtMs: Type.Integer({ minimum: 0 }), +}; + +export const PortalSummarySchema = closedObject({ + ...PortalSummaryIdentityFields, + tokenQuery: Type.Optional(NonEmptyString), + url: Type.Optional(NonEmptyString), + ...PortalSummaryMetadataFields, +}); + +export const PortalListParamsSchema = closedObject({}); +export const PortalListResultSchema = closedObject({ + portals: Type.Array(PortalSummarySchema), +}); + +export const PortalOpenParamsSchema = closedObject({ + port: Type.Integer({ minimum: 1, maximum: 65_535 }), + title: Type.Optional(NonEmptyString), + description: Type.Optional(Type.String()), + path: Type.Optional(Type.String({ pattern: "^/" })), +}); +export const PortalOpenResultSchema = closedObject({ + ...PortalSummaryIdentityFields, + tokenQuery: NonEmptyString, + url: NonEmptyString, + ...PortalSummaryMetadataFields, +}); + +export const PortalCloseParamsSchema = closedObject({ id: NonEmptyString }); +export const PortalCloseResultSchema = closedObject({ closed: Type.Boolean() }); + +export const PortalChangedEventSchema = closedObject({ + portals: Type.Array(PortalSummarySchema), +}); + +export type PortalSummary = Static; +export type PortalListParams = Static; +export type PortalListResult = Static; +export type PortalOpenParams = Static; +export type PortalOpenResult = Static; +export type PortalCloseParams = Static; +export type PortalCloseResult = Static; +export type PortalChangedEvent = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-portals.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-portals.ts new file mode 100644 index 000000000000..0c25310c14d0 --- /dev/null +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-portals.ts @@ -0,0 +1,12 @@ +import * as portals from "./portals.js"; + +export const PortalProtocolSchemas = { + PortalSummary: portals.PortalSummarySchema, + PortalListParams: portals.PortalListParamsSchema, + PortalListResult: portals.PortalListResultSchema, + PortalOpenParams: portals.PortalOpenParamsSchema, + PortalOpenResult: portals.PortalOpenResultSchema, + PortalCloseParams: portals.PortalCloseParamsSchema, + PortalCloseResult: portals.PortalCloseResultSchema, + PortalChangedEvent: portals.PortalChangedEventSchema, +} as const; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 4cdced0389f9..6face49e5f31 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -8,6 +8,7 @@ import { IntegrationProtocolSchemas } from "./protocol-schema-fragment-integrati import { NodeProtocolSchemas } from "./protocol-schema-fragment-nodes.js"; import { OperationsProtocolSchemas } from "./protocol-schema-fragment-operations.js"; import { PluginLifecycleProtocolSchemas } from "./protocol-schema-fragment-plugins-lifecycle.js"; +import { PortalProtocolSchemas } from "./protocol-schema-fragment-portals.js"; import { SchedulerProtocolSchemas } from "./protocol-schema-fragment-scheduler.js"; import { SessionCollaborationProtocolSchemas } from "./protocol-schema-fragment-sessions-collaboration.js"; import { SessionCoreProtocolSchemas } from "./protocol-schema-fragment-sessions-core.js"; @@ -30,6 +31,7 @@ export const ProtocolSchemas = composeProtocolSchemaFragments([ SchedulerProtocolSchemas, ApprovalProtocolSchemas, PluginLifecycleProtocolSchemas, + PortalProtocolSchemas, ] as const); export { diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 878ec76a46d5..b7062006651a 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -155,6 +155,9 @@ export const validateEnvironmentsCreateParams = compile(S.EnvironmentsCreatePara export const validateEnvironmentsDestroyParams = compile(S.EnvironmentsDestroyParamsSchema); export const validateEnvironmentsListParams = compile(S.EnvironmentsListParamsSchema); export const validateEnvironmentsStatusParams = compile(S.EnvironmentsStatusParamsSchema); +export const validatePortalListParams = compile(S.PortalListParamsSchema); +export const validatePortalOpenParams = compile(S.PortalOpenParamsSchema); +export const validatePortalCloseParams = compile(S.PortalCloseParamsSchema); export const validateWorkerDesktopObserveParams = compile(S.WorkerDesktopObserveParamsSchema); export const validateWorkerDesktopObserveResult = compile(S.WorkerDesktopObserveResultSchema); export const validateWorkerDesktopLaunchParams = compile(S.WorkerDesktopLaunchParamsSchema); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bd4ce87c59d..0010c1500f13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,8 +98,8 @@ importers: specifier: 0.3.4 version: 0.3.4 '@trycua/cua-driver': - specifier: 0.14.1 - version: 0.14.1 + specifier: 0.19.3 + version: 0.19.3 acorn: specifier: 8.17.0 version: 8.17.0 @@ -693,8 +693,8 @@ importers: extensions/cua-computer: dependencies: '@trycua/cua-driver': - specifier: 0.14.1 - version: 0.14.1 + specifier: 0.19.3 + version: 0.19.3 rastermill: specifier: 0.3.1 version: 0.3.1 @@ -2395,11 +2395,11 @@ importers: specifier: workspace:* version: link:../packages/workboard-contract '@tanstack/lit-virtual': - specifier: 3.13.35 - version: 3.13.35(lit@3.3.3) + specifier: 3.13.36 + version: 3.13.36(lit@3.3.3) '@tanstack/virtual-core': - specifier: 3.17.6 - version: 3.17.6 + specifier: 3.17.7 + version: 3.17.7 dompurify: specifier: 3.4.12 version: 3.4.12 @@ -5179,13 +5179,13 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@tanstack/lit-virtual@3.13.35': - resolution: {integrity: sha512-ZFUpMwYHsWelrBleD6JekvTwtHYBWzM79/5f3MrrdyDlMUBi0XfuqKB9wNJM0zVwyuu5uOUWQA+Gt3BLwzRHlQ==} + '@tanstack/lit-virtual@3.13.36': + resolution: {integrity: sha512-dju+ZvpztbcSZjWLllJIgHioXWypAm8hq9ALqFfAt/9hmRtU3gZKNEWgy+pgxtbHMouq2hQFrXWKfF3LRXKwJQ==} peerDependencies: lit: ^3.1.0 - '@tanstack/virtual-core@3.17.6': - resolution: {integrity: sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==} + '@tanstack/virtual-core@3.17.7': + resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} '@thi.ng/bitstream@2.4.54': resolution: {integrity: sha512-uInkAJge5O0bWWEaYKrQpMccPbFg0z6eIA5NDCJXPm7l3rjlDje6RBHBXll3LiQz9Y051EdzlAEQRaB5hEifdg==} @@ -5234,40 +5234,40 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@trycua/cua-driver-darwin-arm64@0.14.1': - resolution: {integrity: sha512-Ijt252VfIbebSbNsoSLw66UAQKe/OV9wGVf80Qyt6F8DSrVSRQQOb4c+vWUYVF2349SQvKA0kJuY2i3Lj23zXw==} + '@trycua/cua-driver-darwin-arm64@0.19.3': + resolution: {integrity: sha512-zd37WTn8JP3ixXiMN4BjhYsN5o/+TsF/UTD4YTEtapYqTiw1DcQxuwZhXQDtvrmQYfaPjOgwlDib1vZ584UBIA==} cpu: [arm64] os: [darwin] - '@trycua/cua-driver-darwin-x64@0.14.1': - resolution: {integrity: sha512-3kfPWxemK/zg/kSMIwk3EWF83OpZq6p19sB3BJzbk0zZqlCSKmZVPNIyFGOG7Bq1AxSdvEgHb4djrsFOEo9adA==} + '@trycua/cua-driver-darwin-x64@0.19.3': + resolution: {integrity: sha512-Z1jTJ9IImfR6njGu79DHe5JCuvximAUbqlpi+RmcXHVmkW8R6Ht7lhnAvOT58pa3FEiyY6zIDouZyxePxX950A==} cpu: [x64] os: [darwin] - '@trycua/cua-driver-linux-arm64-gnu@0.14.1': - resolution: {integrity: sha512-MGbJDlmGWm/yp8mojTfBDMI09TzlC/UlIuFmW2UsapqyBAeBp01x5nRVs5c6xFXO5XIHasrYCwU93gzIvtjO4Q==} + '@trycua/cua-driver-linux-arm64-gnu@0.19.3': + resolution: {integrity: sha512-Q0pDIpg0TNbLQmi8mIdgeBrfcvSq0vJYLQu20E0DBTiAQz+hhx2uvPjNbOadQXJgEnnEs7RUgO5g+mJ5SZpQ3A==} cpu: [arm64] os: [linux] libc: [glibc] - '@trycua/cua-driver-linux-x64-gnu@0.14.1': - resolution: {integrity: sha512-uzhoOKRiTdzSVXP8MlO73JnuLXkpwvEGmyEVjNhiK1MKy9aIZfteRpBATZHUM/E+PG/IgvIqxuM+JWgB5QagEg==} + '@trycua/cua-driver-linux-x64-gnu@0.19.3': + resolution: {integrity: sha512-knUIsm9k5DlUOx8cTDOTwN2GQHyDvAsNqETdVpKOpTakut5FO4OHgabjlPw+q37TkZxPaKntaY6OBjCNlezgvA==} cpu: [x64] os: [linux] libc: [glibc] - '@trycua/cua-driver-win32-arm64-msvc@0.14.1': - resolution: {integrity: sha512-n3/hDOz2H2JQhH0slANAl9C0rUqPadBNy7t0HdsvHPyDM9vV9WtbvptI6pW4ZQH+3fPqy7gV41/V33/8f3mHnQ==} + '@trycua/cua-driver-win32-arm64-msvc@0.19.3': + resolution: {integrity: sha512-SEyYNfDXsgbOzH9z0mEqgMrrSkfEveB8C8SRemfujXvhnvtx8CfzGz+BPYKqLGo9tA9JxJwX4jfM8J41XOR8/g==} cpu: [arm64] os: [win32] - '@trycua/cua-driver-win32-x64-msvc@0.14.1': - resolution: {integrity: sha512-7ALlbufJBfSCR64kO8fwatzp2+aWohNc+A63JHthwS+9c28bWOSzVzOBe+QUrxUVwx7j4SP2wWJIQewP6v5yPQ==} + '@trycua/cua-driver-win32-x64-msvc@0.19.3': + resolution: {integrity: sha512-an3KaK/6HxB6LnHJ1uYv3ZBpZl3v0jukEOHPXzWifk3Zml14pKtTMbTuEnkr4I07c6BJumzXJ7gOTOZYcuMX7g==} cpu: [x64] os: [win32] - '@trycua/cua-driver@0.14.1': - resolution: {integrity: sha512-/o16k+vcTbdqwmvQqgFCKzrYksSQHz282qO8RkpD67GQoY4Vp3pyDndQexRJ8AbzNQpUt1bIXSAAaFBaMad6rg==} + '@trycua/cua-driver@0.19.3': + resolution: {integrity: sha512-Oc/FsGP56kpKn4TcADELcEUkLDCby+Wglt+5dX6r4QJbESWvRPdPJ09wvFPmIQeJ7dAUEnrIjuQt4Kz/LIQN3Q==} '@tufjs/canonical-json@2.0.0': resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} @@ -12332,12 +12332,12 @@ snapshots: dependencies: tslib: 2.8.1 - '@tanstack/lit-virtual@3.13.35(lit@3.3.3)': + '@tanstack/lit-virtual@3.13.36(lit@3.3.3)': dependencies: - '@tanstack/virtual-core': 3.17.6 + '@tanstack/virtual-core': 3.17.7 lit: 3.3.3 - '@tanstack/virtual-core@3.17.6': {} + '@tanstack/virtual-core@3.17.7': {} '@thi.ng/bitstream@2.4.54': dependencies: @@ -12376,35 +12376,35 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@trycua/cua-driver-darwin-arm64@0.14.1': + '@trycua/cua-driver-darwin-arm64@0.19.3': optional: true - '@trycua/cua-driver-darwin-x64@0.14.1': + '@trycua/cua-driver-darwin-x64@0.19.3': optional: true - '@trycua/cua-driver-linux-arm64-gnu@0.14.1': + '@trycua/cua-driver-linux-arm64-gnu@0.19.3': optional: true - '@trycua/cua-driver-linux-x64-gnu@0.14.1': + '@trycua/cua-driver-linux-x64-gnu@0.19.3': optional: true - '@trycua/cua-driver-win32-arm64-msvc@0.14.1': + '@trycua/cua-driver-win32-arm64-msvc@0.19.3': optional: true - '@trycua/cua-driver-win32-x64-msvc@0.14.1': + '@trycua/cua-driver-win32-x64-msvc@0.19.3': optional: true - '@trycua/cua-driver@0.14.1': + '@trycua/cua-driver@0.19.3': dependencies: '@ubjs/core': 0.31.0-3 '@ubjs/node': 0.31.0-3 optionalDependencies: - '@trycua/cua-driver-darwin-arm64': 0.14.1 - '@trycua/cua-driver-darwin-x64': 0.14.1 - '@trycua/cua-driver-linux-arm64-gnu': 0.14.1 - '@trycua/cua-driver-linux-x64-gnu': 0.14.1 - '@trycua/cua-driver-win32-arm64-msvc': 0.14.1 - '@trycua/cua-driver-win32-x64-msvc': 0.14.1 + '@trycua/cua-driver-darwin-arm64': 0.19.3 + '@trycua/cua-driver-darwin-x64': 0.19.3 + '@trycua/cua-driver-linux-arm64-gnu': 0.19.3 + '@trycua/cua-driver-linux-x64-gnu': 0.19.3 + '@trycua/cua-driver-win32-arm64-msvc': 0.19.3 + '@trycua/cua-driver-win32-x64-msvc': 0.19.3 '@tufjs/canonical-json@2.0.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 956c1f5c971e..663fb3042dca 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,6 +63,13 @@ minimumReleaseAgeExclude: - "@microsoft/teams.common@2.0.12" - "@microsoft/teams.graph@2.0.12" - "@modelcontextprotocol/sdk@1.30.0" + - "@trycua/cua-driver@0.19.3" + - "@trycua/cua-driver-darwin-arm64@0.19.3" + - "@trycua/cua-driver-darwin-x64@0.19.3" + - "@trycua/cua-driver-linux-arm64-gnu@0.19.3" + - "@trycua/cua-driver-linux-x64-gnu@0.19.3" + - "@trycua/cua-driver-win32-arm64-msvc@0.19.3" + - "@trycua/cua-driver-win32-x64-msvc@0.19.3" - "@openai/codex" - "@openai/codex-*" - "@pierre/diffs@1.2.3" diff --git a/scripts/bench-gateway-concurrency.ts b/scripts/bench-gateway-concurrency.ts index e830641f20a2..ec3bea4eddfb 100644 --- a/scripts/bench-gateway-concurrency.ts +++ b/scripts/bench-gateway-concurrency.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; // Bench Gateway Concurrency script measures gateway probes during synthetic streaming turns. import { randomUUID } from "node:crypto"; -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { request } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -27,6 +27,7 @@ import { validateCliArgs, waitForInitialProbe, writeGatewayBenchConfig, + writePluginFixtures, } from "./lib/gateway-bench-runtime.ts"; import { createGatewayWsClient } from "./lib/gateway-ws-client.ts"; @@ -45,6 +46,11 @@ type TimedProbe = { ok: boolean; }; +type DiagnosticsTimelineSpan = { + durationMs?: number; + name?: string; +}; + type ReadyProbe = TimedProbe & { cpuCoreRatio: number | null; degraded: boolean | null; @@ -73,6 +79,7 @@ type BenchmarkRun = { durationMs: number; samples: GatewaySample[]; }; + pluginMetadataScans: ReturnType; readyz: ReadyProbe[]; sessionsList: TimedProbe[]; turnCount: number; @@ -82,11 +89,14 @@ type BenchmarkRun = { type CliOptions = { cadenceMs: number; concurrency: number; + cpuProfDir?: string; entry: string; json: boolean; output?: string; + pluginCount: number; runs: number; timeoutMs: number; + toolEvents: boolean; warmup: number; }; @@ -98,6 +108,7 @@ const DEFAULT_TIMEOUT_MS = 120_000; const DEFAULT_WARMUP = 0; const MOCK_RESPONSE_CHUNK_DELAY_MS = 1_000; const MAX_CONCURRENCY = 64; +const MAX_PLUGIN_COUNT = 100; const MAX_RUNS = 20; const MAX_WARMUP = 10; const MAX_SAMPLES_PER_RUN = 2_048; @@ -108,12 +119,14 @@ const PROBE_WARMUP_TARGET_MS = 1_000; const PROBE_WARMUP_RETRY_DELAY_MS = 100; const GATEWAY_STDERR_TAIL_LINES = 20; const AGENT_WAIT_RPC_GRACE_MS = 5_000; -const BOOLEAN_FLAGS = new Set(["--help", "-h", "--json"]); +const BOOLEAN_FLAGS = new Set(["--help", "-h", "--json", "--tool-events"]); const VALUE_FLAGS = new Set([ "--cadence-ms", "--concurrency", + "--cpu-prof-dir", "--entry", "--output", + "--plugin-count", "--runs", "--timeout-ms", "--warmup", @@ -160,9 +173,16 @@ function parseOptions(argv: string[] = process.argv.slice(2)): CliOptions { "--concurrency", MAX_CONCURRENCY, ), + cpuProfDir: resolveOutputPath(parseFlagValue(argv, "--cpu-prof-dir")), entry: resolveEntry(parseFlagValue(argv, "--entry"), DEFAULT_ENTRY), json: hasFlag(argv, "--json"), output: resolveOutputPath(parseFlagValue(argv, "--output")), + pluginCount: parseBoundedNonNegativeInt( + parseFlagValue(argv, "--plugin-count"), + 0, + "--plugin-count", + MAX_PLUGIN_COUNT, + ), runs: parseBoundedPositiveInt(parseFlagValue(argv, "--runs"), DEFAULT_RUNS, "--runs", MAX_RUNS), timeoutMs: parseBoundedPositiveInt( parseFlagValue(argv, "--timeout-ms"), @@ -170,6 +190,7 @@ function parseOptions(argv: string[] = process.argv.slice(2)): CliOptions { "--timeout-ms", 10 * 60_000, ), + toolEvents: hasFlag(argv, "--tool-events"), warmup: parseBoundedNonNegativeInt( parseFlagValue(argv, "--warmup"), DEFAULT_WARMUP, @@ -188,11 +209,14 @@ Usage: Options: --concurrency Concurrent synthetic streaming turns (default: ${DEFAULT_CONCURRENCY}) + --cpu-prof-dir

Write Gateway V8 CPU profiles to this directory --runs Measured gateway runs (default: ${DEFAULT_RUNS}) --warmup Warmup gateway runs (default: ${DEFAULT_WARMUP}) --cadence-ms Probe cadence (default: ${DEFAULT_CADENCE_MS}) --timeout-ms Per-run cap, excluding probe warmup (default: ${DEFAULT_TIMEOUT_MS}) --entry Gateway CLI entry file (default: ${DEFAULT_ENTRY}) + --plugin-count Configure synthetic plugins through plugins.load.paths (default: 0) + --tool-events Make every synthetic turn execute a tool before replying --output Write machine-readable JSON to a file --json Emit machine-readable JSON --help, -h Show this text @@ -221,6 +245,51 @@ function summarizeNumbers(values: readonly number[]): MetricSummary | null { }; } +function summarizePluginMetadataScans(events: readonly DiagnosticsTimelineSpan[]) { + const durations = events.flatMap((event) => + event.name === "plugins.metadata.scan" && + typeof event.durationMs === "number" && + Number.isFinite(event.durationMs) + ? [event.durationMs] + : [], + ); + return { + count: durations.length, + durationMs: summarizeNumbers(durations), + totalDurationMs: durations.reduce((sum, durationMs) => sum + durationMs, 0), + }; +} + +function readDiagnosticsTimelineSpans(timelinePath: string): DiagnosticsTimelineSpan[] { + try { + return readFileSync(timelinePath, "utf8") + .split(/\r?\n/u) + .filter(Boolean) + .flatMap((line) => { + try { + const event = JSON.parse(line) as { + durationMs?: unknown; + name?: unknown; + type?: unknown; + }; + if (event.type !== "span.end" || typeof event.name !== "string") { + return []; + } + return [ + { + name: event.name, + ...(typeof event.durationMs === "number" ? { durationMs: event.durationMs } : {}), + }, + ]; + } catch { + return []; + } + }); + } catch { + return []; + } +} + function remainingMs(deadlineAt: number): number { return Math.max(0, deadlineAt - performance.now()); } @@ -400,7 +469,12 @@ async function waitForGatewayDispatchReady( throw new Error("gateway did not finish dispatch-ready sidecars"); } -function buildConfig(root: string, mockPort: number, concurrency: number): string { +function buildConfig( + root: string, + mockPort: number, + concurrency: number, + pluginCount: number, +): string { const controlUiRoot = path.join(root, "control-ui"); mkdirSync(controlUiRoot, { recursive: true }); copyFileSync( @@ -419,7 +493,9 @@ function buildConfig(root: string, mockPort: number, concurrency: number): strin ...(agents.defaults as Record), maxConcurrent: concurrency, }; - return writeGatewayBenchConfig(root, config, {}); + const pluginFixtures = + pluginCount > 0 ? writePluginFixtures(root, { count: pluginCount }) : undefined; + return writeGatewayBenchConfig(root, config, { pluginFixtures }); } async function connectGateway(port: number, deadlineAt: number) { @@ -471,6 +547,7 @@ async function connectGateway(port: number, deadlineAt: number) { scopes: ["operator.read", "operator.write", "operator.admin"], caps: [], }); + await requestRpc("sessions.subscribe", {}); return { close: client.close, request: requestRpc, @@ -480,11 +557,18 @@ async function connectGateway(port: number, deadlineAt: number) { }; } -async function runTurn(rpc: GatewayRpc, index: number, deadlineAt: number): Promise { +async function runTurn( + rpc: GatewayRpc, + index: number, + deadlineAt: number, + toolEvents = false, +): Promise { const requestedRunId = randomUUID(); const started = await rpc<{ runId?: string; status?: string }>("agent", { sessionKey: `agent:main:gateway-concurrency-${index + 1}`, - message: `Reply with benchmark stream ${index + 1}.`, + message: toolEvents + ? `OPENCLAW_E2E_DRAFTPROOF benchmark tool stream ${index + 1}.` + : `Reply with benchmark stream ${index + 1}.`, deliver: false, idempotencyKey: requestedRunId, }); @@ -630,7 +714,8 @@ async function warmGatewayProbes(params: { sample.sessionsList.latencyMs, sample.controlUi.latencyMs, ) <= targetMs; - if (healthy && fast) { + const eventLoopSettled = sample.readyz.degraded !== true; + if (healthy && fast && eventLoopSettled) { return { durationMs: performance.now() - startedAt, samples }; } await delay( @@ -650,10 +735,14 @@ async function runGatewaySample(options: { concurrency: number; deadlineAt: number; entry: string; + cpuProfDir?: string; + pluginCount: number; + toolEvents: boolean; }): Promise { const root = mkdtempSync(path.join(tmpdir(), "openclaw-gateway-concurrency-")); const [port, mockPort] = await Promise.all([getFreePort(), getFreePort()]); const runStartedAt = performance.now(); + const timelinePath = path.join(root, "diagnostics-timeline.jsonl"); let gateway: ChildProcessWithoutNullStreams | undefined; let mockProvider: ChildProcessWithoutNullStreams | undefined; let client: Awaited> | undefined; @@ -661,7 +750,7 @@ async function runGatewaySample(options: { let mockOutput = { readOutput: () => "", readStderrTail: () => "" }; try { - const configPath = buildConfig(root, mockPort, options.concurrency); + const configPath = buildConfig(root, mockPort, options.concurrency, options.pluginCount); mockProvider = spawn(process.execPath, ["scripts/e2e/mock-openai-server.mjs"], { cwd: process.cwd(), detached: process.platform !== "win32", @@ -676,16 +765,30 @@ async function runGatewaySample(options: { mockOutput = captureChildOutput(mockProvider); await waitForMockServer(mockPort, options.deadlineAt); - gateway = spawn(process.execPath, buildGatewayBenchChildArgs(options.entry, port), { - cwd: process.cwd(), - detached: process.platform !== "win32", - env: { - ...createGatewayBenchEnv(root, configPath, { - caseEnv: { OPENCLAW_SKIP_CHANNELS: "1" }, - }), - OPENAI_API_KEY: "gateway-concurrency-benchmark", + if (options.cpuProfDir) { + mkdirSync(options.cpuProfDir, { recursive: true }); + } + const gatewayArgs = buildGatewayBenchChildArgs(options.entry, port); + gateway = spawn( + process.execPath, + options.cpuProfDir + ? ["--cpu-prof", `--cpu-prof-dir=${options.cpuProfDir}`, ...gatewayArgs] + : gatewayArgs, + { + cwd: process.cwd(), + detached: process.platform !== "win32", + env: { + ...createGatewayBenchEnv(root, configPath, { + caseEnv: { + OPENCLAW_DIAGNOSTICS: "timeline", + OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath, + OPENCLAW_SKIP_CHANNELS: "1", + }, + }), + OPENAI_API_KEY: "gateway-concurrency-benchmark", + }, }, - }); + ); gatewayOutput = captureChildOutput(gateway); const ready = await waitForInitialProbe({ deadlineAt: options.deadlineAt, @@ -717,6 +820,8 @@ async function runGatewaySample(options: { }); const loadDeadlineAt = options.deadlineAt + probeWarmup.durationMs; client.setDeadlineAt(loadDeadlineAt); + // The benchmark compares runtime event work, so discard startup and lazy-import spans. + writeFileSync(timelinePath, ""); const controlUi: ControlUiProbe[] = []; const readyz: ReadyProbe[] = []; @@ -725,7 +830,7 @@ async function runGatewaySample(options: { const turnsStartedAt = performance.now(); const turns = Promise.all( Array.from({ length: options.concurrency }, (_, index) => - runTurn(rpc, index, loadDeadlineAt), + runTurn(rpc, index, loadDeadlineAt, options.toolEvents), ), ).finally(() => { turnsDone = true; @@ -760,6 +865,7 @@ async function runGatewaySample(options: { controlUi, durationMs: performance.now() - runStartedAt, probeWarmup, + pluginMetadataScans: summarizePluginMetadataScans(readDiagnosticsTimelineSpans(timelinePath)), readyz, sessionsList, turnCount: options.concurrency, @@ -798,6 +904,11 @@ function summarizeRuns(runs: readonly BenchmarkRun[]) { eventLoopUtilization: summarizeNumbers( readyz.flatMap((sample) => (sample.utilization == null ? [] : [sample.utilization])), ), + pluginMetadataScanCount: runs.reduce((sum, run) => sum + run.pluginMetadataScans.count, 0), + pluginMetadataScanTotalDurationMs: runs.reduce( + (sum, run) => sum + run.pluginMetadataScans.totalDurationMs, + 0, + ), readyzLatencyMs: summarizeNumbers(readyz.map((sample) => sample.latencyMs)), readyzFailedSamples: readyz.filter((sample) => !sample.ok).length, sampleCount: readyz.length, @@ -854,8 +965,10 @@ async function main(): Promise { entry: options.entry, generatedAt: new Date().toISOString(), mode: "mock-streaming-agent", + pluginCount: options.pluginCount, runs, summary: summarizeRuns(runs), + toolEvents: options.toolEvents, }; if (options.output) { mkdirSync(path.dirname(options.output), { recursive: true }); @@ -874,6 +987,7 @@ export const testing = { runBenchmarkSamples, runTurn, sampleGateway, + summarizePluginMetadataScans, summarizeNumbers, summarizeRuns, tailLines, diff --git a/scripts/check-changed.mts b/scripts/check-changed.mts index a6891f585726..5389197e9448 100644 --- a/scripts/check-changed.mts +++ b/scripts/check-changed.mts @@ -107,7 +107,7 @@ const PLUGIN_SDK_SURFACE_PATH_RE = const DEPRECATION_HYGIENE_PATH_RE = /^(?:package\.json$|src\/|extensions\/|packages\/|scripts\/(?:check-deprecated-api-usage\.mts$|plugin-boundary-report\.ts$|lib\/plugin-sdk))/u; const WRAPPER_SHADOWING_PATH_RE = - /^(?:package\.json$|src\/|scripts\/(?:check-(?:export-name-collisions|wrapper-shadowing)\.mts$|lib\/(?:export-name-collision-baseline\.json$|ts-guard-utils\.mts$|wrapper-shadowing-baseline\.json$)))/u; + /^(?:package\.json$|src\/|scripts\/(?:check-(?:export-name-collisions|wrapper-shadowing)\.mts$|lib\/ts-guard-utils\.mts$))/u; const CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE = /^(?:pnpm-lock\.yaml$|apps\/(?:android\/app\/build\.gradle\.kts$|ios\/project\.yml$|linux\/src-tauri\/(?:build\.rs$|src\/canvas\.rs$)|shared\/OpenClawKit\/Sources\/OpenClawKit\/Resources\/CanvasA2UI\/)|extensions\/canvas\/(?:package\.json$|scripts\/bundle-a2ui\.mjs$|src\/host\/a2ui(?:\/(?:index\.html|a2ui\.bundle\.js|\.bundle\.hash)$|-app\/))|scripts\/(?:bundle-a2ui|sync-native-a2ui)\.mts$)/u; const CONTROL_UI_I18N_VERIFY_PATH_RE = diff --git a/scripts/check-export-name-collisions.mts b/scripts/check-export-name-collisions.mts index 32c1a2e2d75b..a43a0231ba75 100644 --- a/scripts/check-export-name-collisions.mts +++ b/scripts/check-export-name-collisions.mts @@ -3,7 +3,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import ts from "typescript"; -import { z } from "zod"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectTypeScriptFilesFromRoots, @@ -57,17 +56,6 @@ export type ModuleExports = { valueDefinitions: Map; }; -const exportNameCollisionSchema = z - .object({ - name: z.string(), - files: z.array(z.string()), - sdk: z.literal(true).optional(), - }) - .strict(); -const exportNameCollisionBaselineSchema = z.array(exportNameCollisionSchema); - -const baselineRelativePath = "scripts/lib/export-name-collision-baseline.json"; -const baselineRegenCommand = "pnpm lint:tmp:export-name-collisions:gen"; const failurePrefix = "check-export-name-collisions"; const extraExcludedFileSuffixes = [".test-support.ts", ".test-helpers.ts", ".d.ts"]; @@ -695,51 +683,6 @@ export function findExportNameCollisions(modules: SourceModule[]): ExportNameCol return analyzeExportNames(modules).collisions; } -type CollisionChange = { - baseline?: ExportNameCollision; - current?: ExportNameCollision; -}; - -/** Compares every collision cluster so additions fail and removals ratchet debt down. */ -export function compareExportNameCollisionDebt( - current: ExportNameCollision[], - baseline: ExportNameCollision[], -) { - const currentByName = new Map(current.map((collision) => [collision.name, collision])); - const baselineByName = new Map(baseline.map((collision) => [collision.name, collision])); - const regressions: CollisionChange[] = []; - const improvements: CollisionChange[] = []; - const names = [...new Set([...currentByName.keys(), ...baselineByName.keys()])].toSorted(); - - for (const name of names) { - const currentCollision = currentByName.get(name); - const baselineCollision = baselineByName.get(name); - if (!baselineCollision) { - regressions.push({ current: currentCollision }); - continue; - } - if (!currentCollision) { - improvements.push({ baseline: baselineCollision }); - continue; - } - const baselineFiles = new Set(baselineCollision.files); - const currentFiles = new Set(currentCollision.files); - const hasAddedFile = currentCollision.files.some((file) => !baselineFiles.has(file)); - const hasRemovedFile = baselineCollision.files.some((file) => !currentFiles.has(file)); - if (hasAddedFile || (currentCollision.sdk === true && baselineCollision.sdk !== true)) { - regressions.push({ baseline: baselineCollision, current: currentCollision }); - } - if (hasRemovedFile || (baselineCollision.sdk === true && currentCollision.sdk !== true)) { - improvements.push({ baseline: baselineCollision, current: currentCollision }); - } - } - return { regressions, improvements }; -} - -function resolveBaselinePath(repoRoot: string) { - return path.join(repoRoot, ...baselineRelativePath.split("/")); -} - async function collectRepositoryModules(repoRoot: string) { const sourceCollectOptions = { fileExtensions: [".ts", ".mts", ".js", ".mjs"], @@ -784,28 +727,6 @@ export async function collectRepositoryCollisions(repoRoot: string) { return (await collectRepositoryExportAnalysis(repoRoot)).collisions; } -async function readBaseline(repoRoot: string) { - try { - return exportNameCollisionBaselineSchema.parse( - JSON.parse(await fs.readFile(resolveBaselinePath(repoRoot), "utf8")), - ); - } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - return null; - } - throw error; - } -} - -async function writeBaseline(repoRoot: string, collisions: ExportNameCollision[]) { - await fs.writeFile(resolveBaselinePath(repoRoot), `${JSON.stringify(collisions, null, 2)}\n`); - return collisions.length; -} - -function formatCollision(collision: ExportNameCollision | undefined) { - return JSON.stringify(collision); -} - function printAliasingReExports(reExports: AliasingReExport[]) { if (reExports.length === 0) { return; @@ -818,51 +739,27 @@ function printAliasingReExports(reExports: AliasingReExport[]) { } } -export async function main() { - const repoRoot = resolveRepoRoot(import.meta.url); - if (process.argv.includes("--update-debt-baseline")) { - const analysis = await collectRepositoryExportAnalysis(repoRoot); - const count = await writeBaseline(repoRoot, analysis.collisions); - console.log(`Wrote ${baselineRelativePath} (${count} entries)`); - printAliasingReExports(analysis.aliasingReExports); - return 0; +export async function main( + repoRoot = resolveRepoRoot(import.meta.url), + argv = process.argv.slice(2), +) { + if (argv.length > 0) { + console.error(`Unknown argument(s): ${argv.join(", ")}`); + return 2; } - const baseline = await readBaseline(repoRoot); - if (!baseline) { - console.error( - `Missing ${baselineRelativePath}; run \`${baselineRegenCommand}\` and commit it.`, - ); - return 1; - } const analysis = await collectRepositoryExportAnalysis(repoRoot); - const debt = compareExportNameCollisionDebt(analysis.collisions, baseline); printAliasingReExports(analysis.aliasingReExports); - if (debt.regressions.length === 0 && debt.improvements.length === 0) { + if (analysis.collisions.length === 0) { console.log("export name collision guard passed."); return 0; } - if (debt.regressions.length > 0) { - console.error( - `Found new exported function/const name collisions beyond ${baselineRelativePath}:`, - ); - for (const regression of debt.regressions) { - console.error(`- ${formatCollision(regression.current)}`); - } - console.error( - `Give each behavior one exported spelling. If the debt increase is intentional, run \`${baselineRegenCommand}\` and commit the generated baseline.`, - ); - } - if (debt.improvements.length > 0) { - console.error(`Export name collision debt dropped below ${baselineRelativePath}:`); - for (const improvement of debt.improvements) { - console.error( - `- ${improvement.baseline?.name}: ${formatCollision(improvement.baseline)} -> ${formatCollision(improvement.current)}`, - ); - } - console.error(`Run \`${baselineRegenCommand}\` to ratchet the baseline down and commit it.`); + console.error("Found exported function/const name collisions:"); + for (const collision of analysis.collisions) { + console.error(`- ${JSON.stringify(collision)}`); } + console.error("Give each behavior one exported spelling."); return 1; } diff --git a/scripts/check-protocol-registry.mts b/scripts/check-protocol-registry.mts index 8da7b07f4be0..dbbc0a649908 100644 --- a/scripts/check-protocol-registry.mts +++ b/scripts/check-protocol-registry.mts @@ -113,8 +113,8 @@ const ownerModules = [ ...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu), ].map(([, moduleName = ""]) => moduleName); check( - ownerModules.length === 56 && new Set(ownerModules).size === ownerModules.length, - "schema-modules.ts must contain one unique 56-module owner list", + ownerModules.length === 57 && new Set(ownerModules).size === ownerModules.length, + "schema-modules.ts must contain one unique 57-module owner list", ); check( schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length, diff --git a/scripts/check-wrapper-shadowing.mts b/scripts/check-wrapper-shadowing.mts index c252dc37a3ff..7fe32e58644f 100644 --- a/scripts/check-wrapper-shadowing.mts +++ b/scripts/check-wrapper-shadowing.mts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { z } from "zod"; import { collectModuleExportNames, isExcludedExportCollisionSource, @@ -24,18 +23,6 @@ export type WrapperShadowingViolation = { via?: string; }; -const violationSchema = z - .object({ - name: z.string(), - wrapped: z.string(), - wrapper: z.string(), - via: z.string().optional(), - }) - .strict(); -const baselineSchema = z.array(violationSchema); - -const baselineRelativePath = "scripts/lib/wrapper-shadowing-baseline.json"; -const baselineRegenCommand = "pnpm check:wrapper-shadowing:gen"; const failurePrefix = "check-wrapper-shadowing"; function normalizeRelativePath(filePath: string) { @@ -178,86 +165,23 @@ export async function collectRepositoryWrapperShadowing(repoRoot: string) { return findWrapperShadowingViolations(modules); } -function resolveBaselinePath(repoRoot: string) { - return path.join(repoRoot, ...baselineRelativePath.split("/")); -} - -async function readBaseline(repoRoot: string) { - try { - return baselineSchema.parse( - JSON.parse(await fs.readFile(resolveBaselinePath(repoRoot), "utf8")), - ); - } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - return null; - } - throw error; - } -} - -export function findNewWrapperShadowingViolations( - current: WrapperShadowingViolation[], - baseline: WrapperShadowingViolation[], -) { - const baselineKeys = new Set(baseline.map(violationKey)); - return current.filter((violation) => !baselineKeys.has(violationKey(violation))); -} - -export async function evaluateWrapperShadowing(repoRoot: string) { - const baseline = await readBaseline(repoRoot); - if (!baseline) { - return { - baseline: null, - current: await collectRepositoryWrapperShadowing(repoRoot), - regressions: [] as WrapperShadowingViolation[], - }; - } - const current = await collectRepositoryWrapperShadowing(repoRoot); - return { - baseline, - current, - regressions: findNewWrapperShadowingViolations(current, baseline), - }; -} - -async function writeBaseline(repoRoot: string) { - const violations = await collectRepositoryWrapperShadowing(repoRoot); - await fs.writeFile(resolveBaselinePath(repoRoot), `${JSON.stringify(violations, null, 2)}\n`); - return violations.length; -} - export async function main( repoRoot = resolveRepoRoot(import.meta.url), argv = process.argv.slice(2), ) { - const updateBaseline = argv.includes("--update-debt-baseline"); - const unknownArgs = argv.filter((arg) => arg !== "--update-debt-baseline"); - if (unknownArgs.length > 0) { - console.error(`Unknown argument(s): ${unknownArgs.join(", ")}`); + if (argv.length > 0) { + console.error(`Unknown argument(s): ${argv.join(", ")}`); return 2; } - if (updateBaseline) { - const count = await writeBaseline(repoRoot); - console.log(`Wrote ${baselineRelativePath} (${count} entries)`); + + const violations = await collectRepositoryWrapperShadowing(repoRoot); + if (violations.length === 0) { + console.log("wrapper shadowing guard passed."); return 0; } - const result = await evaluateWrapperShadowing(repoRoot); - if (!result.baseline) { - console.error( - `Missing ${baselineRelativePath}; run \`${baselineRegenCommand}\` and commit it.`, - ); - return 1; - } - if (result.regressions.length === 0) { - console.log( - `wrapper shadowing guard passed (${result.current.length} current, ${result.baseline.length} baselined).`, - ); - return 0; - } - - console.error(`Found new same-name wrapper shadowing beyond ${baselineRelativePath}:`); - for (const violation of result.regressions) { + console.error("Found same-name wrapper shadowing:"); + for (const violation of violations) { console.error(`- ${JSON.stringify(violation)}`); } console.error( diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 52b03fc56509..034dc05ad6fb 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -835,11 +835,17 @@ function assertSessionMetadataMigrated(stateDir) { assert(main?.sessionId === LEGACY_SESSION_MAIN_ID, "main legacy session row missing"); assert(direct?.sessionId === LEGACY_SESSION_DIRECT_ID, "direct legacy session row missing"); assert(group?.sessionId === LEGACY_SESSION_GROUP_ID, "channel legacy session row missing"); - const migratedSessionIds = [ - LEGACY_SESSION_MAIN_ID, - LEGACY_SESSION_DIRECT_ID, - LEGACY_SESSION_GROUP_ID, + const migratedSessions = [ + [LEGACY_SESSION_MAIN_ID, main], + [LEGACY_SESSION_DIRECT_ID, direct], + [LEGACY_SESSION_GROUP_ID, group], ]; + for (const [sessionId, entry] of migratedSessions) { + assert( + !Object.hasOwn(entry ?? {}, "sessionFile"), + `legacy session row retained retired sessionFile metadata for ${sessionId}`, + ); + } if (source !== "file") { const dbPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); const db = new DatabaseSync(dbPath, { readOnly: true }); @@ -847,7 +853,7 @@ function assertSessionMetadataMigrated(stateDir) { const count = db.prepare( "SELECT COUNT(*) AS count FROM transcript_events WHERE session_id = ?", ); - for (const sessionId of migratedSessionIds) { + for (const [sessionId] of migratedSessions) { const row = count.get(sessionId); assert( Number(row?.count ?? 0) > 0, @@ -858,20 +864,12 @@ function assertSessionMetadataMigrated(stateDir) { db.close(); } } else { - for (const [sessionId, entry] of [ - [LEGACY_SESSION_MAIN_ID, main], - [LEGACY_SESSION_DIRECT_ID, direct], - [LEGACY_SESSION_GROUP_ID, group], - ]) { + for (const [sessionId] of migratedSessions) { const expectedPath = path.join(agentSessionsDir, `${sessionId}.jsonl`); assert( fs.existsSync(expectedPath), `legacy session transcript was not moved for ${sessionId}`, ); - assert( - entry?.sessionFile === expectedPath, - `legacy session row still points at the old sessions directory for ${sessionId}`, - ); } } assert( diff --git a/scripts/lib/ci-node-test-plan.mts b/scripts/lib/ci-node-test-plan.mts index ff03b54f01cd..d3701352ee2e 100644 --- a/scripts/lib/ci-node-test-plan.mts +++ b/scripts/lib/ci-node-test-plan.mts @@ -159,8 +159,8 @@ const MAX_BUNDLED_NODE_TEST_PATTERNS = 64; // PR-only bundles trade a little serial work for fewer ephemeral runner registrations. // Keep runner classes and subprocess isolation intact while bounding each combined job. // The group hints below are loaded-fleet CI walls. The 310s admission cap -// reduces the compact matrix from 24 to 23 workers; expanded composite groups -// are then striped evenly across those jobs. +// bounds the compact matrix at 25 workers; expanded composite groups are then +// striped evenly across those jobs. const COMPACT_NODE_TEST_JOB_SECONDS = 310; const COMPACT_NODE_TEST_JOB_GROUPS = 10; const COMPACT_TOOLING_NODE_TEST_GROUPS = 4; @@ -176,162 +176,164 @@ const AUTO_REPLY_COMMANDS_STRIPES = 3; const AGENTS_CORE_RUNNER_CLI_STRIPES = 3; const UNIT_FAST_NODE_TEST_STRIPES = 2; // Advisory runtime estimates (seconds) per split shard: median [shard:*] -// begin->end wall across nine successful hosted compact runs (31568650453, -// 31569157374, 31569912984, 31570693513, 31571644856, 31572044913, -// 31572489294, 31574210928, 31574367637). Admission and 4-vCPU striping +// begin->end wall across nine successful hosted compact runs (31684307744, +// 31683213137, 31682494259, 31682258389, 31681118857, 31680010311, +// 31678309660, 31678086868, 31677305067). Admission and 4-vCPU striping // retain these weights so the bounded job count and runner advisory stay fixed. -// Unknown shards fall back to a per-file estimate. +// agentic-commands-agent-channel uses the sole post-#122955 sample from run +// 31684307744 because that landing removed a 79.5s test. Unknown shards fall +// back to a per-file estimate. const COMPACT_GROUP_SECONDS_HINTS = new Map([ - ["agentic-agents-core-auth", 28], - ["agentic-agents-core-isolated", 16], - ["agentic-agents-core-models", 39], - ["agentic-agents-core-runner-cli-1", 7], - ["agentic-agents-core-runner-cli-2", 17], - ["agentic-agents-core-runner-cli-3", 13], - ["agentic-agents-core-runner-commands", 27], - ["agentic-agents-core-runner-embedded", 20], - ["agentic-agents-core-runner-sessions", 18], - ["agentic-agents-core-runtime", 113], - ["agentic-agents-core-subagents", 17], - ["agentic-agents-core-tools", 45], + ["agentic-agents-core-auth", 30], + ["agentic-agents-core-isolated", 18], + ["agentic-agents-core-models", 41], + ["agentic-agents-core-runner-cli-1", 6], + ["agentic-agents-core-runner-cli-2", 13], + ["agentic-agents-core-runner-cli-3", 7], + ["agentic-agents-core-runner-commands", 28], + ["agentic-agents-core-runner-embedded", 17], + ["agentic-agents-core-runner-sessions", 14], + ["agentic-agents-core-runtime", 106], + ["agentic-agents-core-subagents", 20], + ["agentic-agents-core-tools", 39], // The composite hint sets the job count before its independent configs are // striped across those jobs; its estimate is the sum of the split medians. - ["agentic-agents-embedded", 162], - ["agentic-agents-embedded-base", 90], - ["agentic-agents-embedded-incomplete-turn", 17], - ["agentic-agents-embedded-overflow-compaction", 18], - ["agentic-agents-embedded-run", 37], - ["agentic-agents-support", 144], - ["agentic-agents-tools", 76], - ["agentic-cli", 111], - ["agentic-command-support", 61], - ["agentic-commands-agent-channel", 71], + ["agentic-agents-embedded", 166], + ["agentic-agents-embedded-base", 81], + ["agentic-agents-embedded-incomplete-turn", 19], + ["agentic-agents-embedded-overflow-compaction", 20], + ["agentic-agents-embedded-run", 46], + ["agentic-agents-support", 165], + ["agentic-agents-tools", 69], + ["agentic-cli", 131], + ["agentic-command-support", 49], + ["agentic-commands-agent-channel", 76], ["agentic-commands-doctor", 23], ["agentic-commands-doctor-auth", 19], - ["agentic-commands-doctor-config-state", 69], - ["agentic-commands-doctor-device", 3], + ["agentic-commands-doctor-config-state", 67], + ["agentic-commands-doctor-device", 2], ["agentic-commands-doctor-gateway", 3], - ["agentic-commands-doctor-platform", 4], - ["agentic-commands-doctor-plugins-tools", 27], - ["agentic-commands-doctor-sessions-cron", 21], - ["agentic-commands-doctor-shared", 27], + ["agentic-commands-doctor-platform", 5], + ["agentic-commands-doctor-plugins-tools", 13], + ["agentic-commands-doctor-sessions-cron", 31], + ["agentic-commands-doctor-shared", 37], ["agentic-commands-doctor-whatsapp", 1], ["agentic-commands-doctor-workspace", 1], - ["agentic-commands-models", 24], - ["agentic-commands-onboard-config", 26], - ["agentic-commands-status-tools", 28], - ["agentic-control-plane-agent-chat", 140], - ["agentic-control-plane-auth-node", 153], - ["agentic-control-plane-http-models", 25], - ["agentic-control-plane-http-plugin-ws", 49], - ["agentic-control-plane-runtime", 20], - ["agentic-control-plane-runtime-config", 8], - ["agentic-control-plane-runtime-cron", 31], + ["agentic-commands-models", 32], + ["agentic-commands-onboard-config", 49], + ["agentic-commands-status-tools", 35], + ["agentic-control-plane-agent-chat", 167], + ["agentic-control-plane-auth-node", 166], + ["agentic-control-plane-http-models", 41], + ["agentic-control-plane-http-plugin-ws", 52], + ["agentic-control-plane-runtime", 19], + ["agentic-control-plane-runtime-config", 20], + ["agentic-control-plane-runtime-cron", 22], ["agentic-control-plane-runtime-network", 1], - ["agentic-control-plane-runtime-server", 25], - ["agentic-control-plane-runtime-shared-token", 8], - ["agentic-control-plane-runtime-state", 34], + ["agentic-control-plane-runtime-server", 23], + ["agentic-control-plane-runtime-shared-token", 9], + ["agentic-control-plane-runtime-state", 33], ["agentic-control-plane-runtime-ui-tools", 9], ["agentic-control-plane-startup-config", 5], - ["agentic-control-plane-startup-core", 27], + ["agentic-control-plane-startup-core", 31], ["agentic-control-plane-startup-health-runtime", 11], - ["agentic-control-plane-startup-restart-close", 16], - ["agentic-gateway-core", 214], - ["agentic-gateway-methods", 119], - ["agentic-plugin-sdk", 44], + ["agentic-control-plane-startup-restart-close", 10], + ["agentic-gateway-core", 223], + ["agentic-gateway-methods", 157], + ["agentic-plugin-sdk", 45], ["auto-reply-core-top-level", 27], - ["auto-reply-reply-agent-runner", 68], - ["auto-reply-reply-commands-1", 27], - ["auto-reply-reply-commands-2", 16], - ["auto-reply-reply-commands-3", 27], - ["auto-reply-reply-dispatch", 65], - ["auto-reply-reply-session", 40], - ["auto-reply-reply-state-routing", 48], - ["core-runtime-cron-core", 24], - ["core-runtime-cron-isolated-agent", 110], - ["core-runtime-cron-service", 51], - ["core-runtime-hooks", 18], - ["core-runtime-infra-approval-exec", 23], - ["core-runtime-infra-channel-plugin", 7], + ["auto-reply-reply-agent-runner", 60], + ["auto-reply-reply-commands-1", 28], + ["auto-reply-reply-commands-2", 9], + ["auto-reply-reply-commands-3", 24], + ["auto-reply-reply-dispatch", 73], + ["auto-reply-reply-session", 34], + ["auto-reply-reply-state-routing", 63], + ["core-runtime-cron-core", 25], + ["core-runtime-cron-isolated-agent", 105], + ["core-runtime-cron-service", 58], + ["core-runtime-hooks", 19], + ["core-runtime-infra-approval-exec", 28], + ["core-runtime-infra-channel-plugin", 19], ["core-runtime-infra-cli-ui", 2], - ["core-runtime-infra-core-utils", 4], + ["core-runtime-infra-core-utils", 3], ["core-runtime-infra-device", 8], - ["core-runtime-infra-diagnostics-state", 12], - ["core-runtime-infra-env-auth", 5], - ["core-runtime-infra-events-runtime", 7], + ["core-runtime-infra-diagnostics-state", 24], + ["core-runtime-infra-env-auth", 6], + ["core-runtime-infra-events-runtime", 8], ["core-runtime-infra-file-safety", 2], - ["core-runtime-infra-files-commands", 4], - ["core-runtime-infra-gateway-lock-argv", 2], + ["core-runtime-infra-files-commands", 5], + ["core-runtime-infra-gateway-lock-argv", 3], ["core-runtime-infra-gateway-processes", 1], ["core-runtime-infra-gateway-watch", 1], - ["core-runtime-infra-heartbeat-core", 6], - ["core-runtime-infra-heartbeat-runner", 54], - ["core-runtime-infra-misc", 12], + ["core-runtime-infra-heartbeat-core", 7], + ["core-runtime-infra-heartbeat-runner", 59], + ["core-runtime-infra-misc", 14], ["core-runtime-infra-misc-dedupe-disk", 1], ["core-runtime-infra-misc-os", 1], - ["core-runtime-infra-misc-values", 1], - ["core-runtime-infra-net-install", 9], - ["core-runtime-infra-network-node", 4], - ["core-runtime-infra-network-platform", 4], - ["core-runtime-infra-outbound-actions", 31], - ["core-runtime-infra-outbound-core", 57], - ["core-runtime-infra-process", 134], - ["core-runtime-infra-provider-push", 15], + ["core-runtime-infra-misc-values", 2], + ["core-runtime-infra-net-install", 11], + ["core-runtime-infra-network-node", 3], + ["core-runtime-infra-network-platform", 5], + ["core-runtime-infra-outbound-actions", 37], + ["core-runtime-infra-outbound-core", 59], + ["core-runtime-infra-process", 126], + ["core-runtime-infra-provider-push", 13], ["core-runtime-infra-repo-tooling", 4], - ["core-runtime-infra-storage-state", 86], - ["core-runtime-infra-system-runtime", 35], - ["core-runtime-media-ui", 196], - ["core-runtime-secrets", 58], - ["core-runtime-shared", 52], + ["core-runtime-infra-storage-state", 104], + ["core-runtime-infra-system-runtime", 36], + ["core-runtime-media-ui", 227], + ["core-runtime-secrets", 61], + ["core-runtime-shared", 67], // This dist-only group is outside the sampled nondist logs and retains its // prior measured hint. The exclusive-bin cap keeps its lane lightly packed. ["core-runtime-tui-pty", 116], - ["core-tooling-1", 112], - ["core-tooling-2", 128], - ["core-tooling-3", 163], - ["core-tooling-4", 123], - ["core-tooling-isolated", 34], - ["core-unit-fast-1", 54], - ["core-unit-fast-2", 60], - ["core-unit-fast-isolated", 79], - ["core-unit-src-security", 252], - ["core-unit-support", 18], + ["core-tooling-1", 127], + ["core-tooling-2", 121], + ["core-tooling-3", 203], + ["core-tooling-4", 157], + ["core-tooling-isolated", 37], + ["core-unit-fast-1", 66], + ["core-unit-fast-2", 64], + ["core-unit-fast-isolated", 116], + ["core-unit-src-security", 290], + ["core-unit-support", 20], ]); // Rounded mean of the same 8-vCPU groups across successful canonical-main -// compact runs 31624370014, 31625101669, 31625905392, 31629769941, -// 31632097578, 31632768372, 31634233096, 31635221353, and 31636058167. +// compact runs 31684307744, 31683213137, 31682494259, 31682258389, +// 31681118857, 31680010311, 31678309660, 31678086868, and 31677305067. // Means expose recurrent slow tails hidden by medians without moving the // post-pack 4-vCPU runner advisory. const COMPACT_LARGE_GROUP_STRIPE_SECONDS_HINTS = new Map([ - ["agentic-agents-core-auth", 35], - ["agentic-agents-core-models", 47], - ["agentic-agents-core-runner-cli-1", 21], - ["agentic-agents-core-runner-cli-2", 9], - ["agentic-agents-core-runner-cli-3", 21], - ["agentic-agents-core-runner-commands", 33], - ["agentic-agents-core-runner-embedded", 11], - ["agentic-agents-core-runner-sessions", 12], - ["agentic-agents-core-runtime", 128], - ["agentic-agents-core-subagents", 31], - ["agentic-agents-core-tools", 61], - ["agentic-agents-embedded-base", 106], - ["agentic-agents-embedded-incomplete-turn", 24], - ["agentic-agents-embedded-overflow-compaction", 24], - ["agentic-agents-embedded-run", 46], - ["agentic-agents-support", 175], - ["agentic-control-plane-startup-core", 39], - ["agentic-gateway-core", 244], - ["agentic-gateway-methods", 154], - ["auto-reply-reply-commands-1", 40], - ["auto-reply-reply-commands-2", 20], - ["auto-reply-reply-commands-3", 32], - ["auto-reply-reply-dispatch", 82], - ["core-runtime-media-ui", 249], - ["core-unit-fast-1", 72], - ["core-unit-fast-2", 64], - ["core-unit-fast-isolated", 107], - ["core-unit-src-security", 266], + ["agentic-agents-core-auth", 33], + ["agentic-agents-core-models", 41], + ["agentic-agents-core-runner-cli-1", 7], + ["agentic-agents-core-runner-cli-2", 14], + ["agentic-agents-core-runner-cli-3", 7], + ["agentic-agents-core-runner-commands", 28], + ["agentic-agents-core-runner-embedded", 20], + ["agentic-agents-core-runner-sessions", 16], + ["agentic-agents-core-runtime", 119], + ["agentic-agents-core-subagents", 21], + ["agentic-agents-core-tools", 47], + ["agentic-agents-embedded-base", 79], + ["agentic-agents-embedded-incomplete-turn", 20], + ["agentic-agents-embedded-overflow-compaction", 21], + ["agentic-agents-embedded-run", 47], + ["agentic-agents-support", 165], + ["agentic-control-plane-startup-core", 33], + ["agentic-gateway-core", 230], + ["agentic-gateway-methods", 153], + ["auto-reply-reply-commands-1", 34], + ["auto-reply-reply-commands-2", 11], + ["auto-reply-reply-commands-3", 28], + ["auto-reply-reply-dispatch", 86], + ["core-runtime-media-ui", 238], + ["core-unit-fast-1", 68], + ["core-unit-fast-2", 67], + ["core-unit-fast-isolated", 117], + ["core-unit-src-security", 287], ]); // Advisory per-file wall-clock hints (seconds) for stripe balancing, measured diff --git a/scripts/lib/export-name-collision-baseline.json b/scripts/lib/export-name-collision-baseline.json deleted file mode 100644 index fe51488c7066..000000000000 --- a/scripts/lib/export-name-collision-baseline.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 9a3da0c736e1..ad5b1c7e5125 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -178,6 +178,7 @@ "error-runtime", "extension-shared", "channel-config-helpers", + "channel-config-ui-hints", "channel-config-writes", "channel-config-primitives", "channel-config-schema", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index 5b4a0eeb0e95..6eb57f9a4fcf 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -17,6 +17,7 @@ "browser-config", "bundled-channel-config-schema", "channel-activity-runtime", + "channel-config-ui-hints", "channel-config-writes", "channel-contract-testing", "channel-mention-gating", diff --git a/scripts/lib/wrapper-shadowing-baseline.json b/scripts/lib/wrapper-shadowing-baseline.json deleted file mode 100644 index fe51488c7066..000000000000 --- a/scripts/lib/wrapper-shadowing-baseline.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index 59e2d5f597b9..ddbcdab8c7fe 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -13,6 +13,7 @@ "openclaw.approval.resolved": "OpenClaw system-agent config approvals are a web/desktop operator surface; iOS has no operator-approval prompt.", "plugin.approval.requested": "Plugin approval prompts are not implemented on iOS.", "plugin.approval.resolved": "Plugin approval prompts are not implemented on iOS.", + "portal.changed": "Control-UI-only surface; native apps have no portal viewer yet.", "presence": "Presence roster is a control-UI (web/desktop) surface; iOS does not render it.", "session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.", "session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.", @@ -44,6 +45,7 @@ "openclaw.approval.resolved": "OpenClaw system-agent config approvals are a web/desktop operator surface; Android has no operator-approval prompt.", "plugin.approval.requested": "Plugin approval prompts are not implemented on Android.", "plugin.approval.resolved": "Plugin approval prompts are not implemented on Android.", + "portal.changed": "Control-UI-only surface; native apps have no portal viewer yet.", "presence": "Presence roster is a control-UI (web/desktop) surface; Android does not render it.", "session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.", "session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.", diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index 592ba3e49274..6c13b10bdbb8 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -1,7 +1,15 @@ #!/usr/bin/env node import { runTsxCliShim } from "./lib/tsx-cli-shim.mjs"; -await runTsxCliShim(import.meta.url, { - implementation: "./release-preflight.mts", - failureTool: "release-preflight", -}); +const args = process.argv.slice(2); +if (args.length === 1 && args[0] === "--macos-versions-only") { + // Evidence reuse runs before dependencies exist; only this file-read-only probe bypasses tsx. + const { writeFailedTrailer } = await import("./lib/failed-trailer.mts"); + process.once("exit", (exitCode) => writeFailedTrailer("release-preflight", exitCode)); + await import("./release-preflight.mts"); +} else { + await runTsxCliShim(import.meta.url, { + implementation: "./release-preflight.mts", + failureTool: "release-preflight", + }); +} diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index 2b42a7264069..0a8d40186550 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -2268,7 +2268,6 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [ ["mantis-web-ui-chat-proof-workflow", packageAcceptance, workflowGuards], ], [/^\.github\/workflows\/android-release\.yml$/u, [packageAcceptance, workflowGuards]], - [/^\.github\/actions\/setup-node-env\/verify-importers\.mjs$/u, [workflowGuards]], [/^\.github\/actions\/ensure-base-commit\/action\.yml$/u, [workflowGuards]], [/^tsconfig\.scripts\.json$/u, ["changed-lanes", "test-projects"]], [/^scripts\/test-projects\.test-support\.mts$/u, ["test-projects"]], diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index fbb341557fde..3bf757ba1413 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -382,7 +382,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }); const persisted = loadPersistedSessionStore(storePath); - expect(Object.keys(persisted)).toHaveLength(42); + expect(Object.keys(persisted).filter((key) => key !== sessionKey)).toHaveLength(42); expect(persisted[sessionKey]?.sessionId).toBe(sessionId); expect(persisted["agent:main:stale:44"]).toBeUndefined(); }); diff --git a/src/agents/core-tool-factory-descriptors.ts b/src/agents/core-tool-factory-descriptors.ts index 592431c96e7b..e9579b03853f 100644 --- a/src/agents/core-tool-factory-descriptors.ts +++ b/src/agents/core-tool-factory-descriptors.ts @@ -54,6 +54,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [ { name: "create_goal", family: "openclaw" }, { name: "subagents", family: "openclaw" }, { name: "terminal", family: "openclaw" }, + { name: "portal", family: "openclaw" }, { name: "transcripts", family: "openclaw" }, { name: "tts", family: "openclaw" }, { name: "update_goal", family: "openclaw" }, diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 5d7f9bc9d5ca..61024ee19167 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -445,6 +445,9 @@ export const acquireAgentRunPreparedModelRuntimeMock = vi.fn( release: vi.fn(), }), ); +export const getCurrentPluginMetadataSnapshotMock: Mock< + typeof import("../../plugins/current-plugin-metadata-snapshot.js").getCurrentPluginMetadataSnapshot +> = vi.fn(() => emptyPluginMetadataSnapshot); export function resetCompactSessionStateMocks(): void { sanitizeSessionHistoryMock.mockReset(); @@ -563,6 +566,8 @@ export function resetCompactHooksHarnessMocks(): void { hookRunner.runAfterCompaction.mockResolvedValue(undefined); acquireAgentRunPreparedModelRuntimeMock.mockClear(); + getCurrentPluginMetadataSnapshotMock.mockReset(); + getCurrentPluginMetadataSnapshotMock.mockReturnValue(emptyPluginMetadataSnapshot); resolveContextEngineMock.mockReset(); resolveContextEngineMock.mockResolvedValue({ @@ -650,9 +655,10 @@ export async function loadCompactHooksHarness(): Promise<{ })); vi.doMock("../../plugins/current-plugin-metadata-snapshot.js", () => ({ - getCurrentPluginMetadataSnapshot: () => emptyPluginMetadataSnapshot, + getCurrentPluginMetadataSnapshot: getCurrentPluginMetadataSnapshotMock, resolvePluginMetadataControlPlaneFingerprint: vi.fn(() => "test-plugin-fingerprint"), setCurrentPluginMetadataSnapshot: vi.fn(), + withPluginMetadataSnapshotScope: (_snapshot: unknown, run: () => unknown) => run(), })); vi.doMock("../../plugins/command-registry-state.js", () => { @@ -812,6 +818,7 @@ export async function loadCompactHooksHarness(): Promise<{ clearCommandLane: vi.fn(() => 0), GatewayDrainingError: class GatewayDrainingError extends Error {}, isGatewayDraining: vi.fn(() => false), + isCommandLaneTaskTimeoutError: vi.fn(() => false), })); vi.doMock("../../tasks/detached-task-runtime.js", async () => { diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index b2f610a75c60..9bf377ca6f3d 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -9,9 +9,11 @@ import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vite import { createDeferred } from "../../../test/helpers/promise.js"; import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry.js"; import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { acquireAgentRunPreparedModelRuntimeMock, + getCurrentPluginMetadataSnapshotMock, applyExtraParamsToAgentMock, applyAgentCompactionSettingsFromConfigMock, buildEmbeddedExtensionFactoriesMock, @@ -1173,6 +1175,69 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { ); }); + it("plans direct compaction from the requested workspace metadata without ambient discovery", async () => { + const baseMetadataSnapshot = expectDefined( + getCurrentPluginMetadataSnapshotMock(), + "default plugin metadata snapshot", + ); + getCurrentPluginMetadataSnapshotMock.mockImplementation((params) => + params?.workspaceDir === TEST_WORKSPACE_DIR + ? { + ...baseMetadataSnapshot, + configFingerprint: "workspace-compaction-normalization", + plugins: [ + { + id: "compaction-normalizer", + channels: [], + providers: ["anthropic"], + cliBackends: [], + skills: [], + hooks: [], + origin: "workspace", + rootDir: TEST_WORKSPACE_DIR, + source: `${TEST_WORKSPACE_DIR}/index.js`, + manifestPath: `${TEST_WORKSPACE_DIR}/openclaw.plugin.json`, + modelIdNormalization: { + providers: { + anthropic: { + aliases: { legacy: "claude-modern" }, + }, + }, + }, + } satisfies PluginManifestRecord, + ], + } + : undefined, + ); + + const result = await compactEmbeddedAgentSessionDirect({ + ...wrappedCompactionArgs({ provider: "openai", model: "gpt-primary" }), + agentHarnessId: "codex", + modelFallbacksOverride: ["anthropic/legacy"], + config: {} as never, + }); + + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(acquireAgentRunPreparedModelRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ + runtimePluginSelections: expect.arrayContaining([ + expect.objectContaining({ + provider: "anthropic", + modelId: "claude-modern", + runtime: "codex", + }), + ]), + }), + ); + expect(getCurrentPluginMetadataSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: {}, + workspaceDir: TEST_WORKSPACE_DIR, + allowWorkspaceScopedSnapshot: true, + }), + ); + }); + it("keeps model-locked OpenClaw compaction on its exact model without fallbacks", async () => { sessionCompactImpl.mockRejectedValueOnce( Object.assign(new Error("primary compaction rate limited"), { status: 429 }), diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 85be4523eb47..cb499597ee73 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -19,7 +19,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { requireActivePluginRegistry } from "../../plugins/runtime.js"; -import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; +import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; @@ -380,7 +380,7 @@ async function compactEmbeddedAgentSessionImpl( } }; try { - return await withPluginRuntimeRegistryScope(lease.snapshot.pluginRegistry, run); + return await withPluginRuntimeGenerationScope(lease.snapshot, run); } finally { lease.release(); } diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 69404e7e4b1d..dbfbea63589b 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -3,7 +3,8 @@ */ import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; +import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; +import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { @@ -170,8 +171,15 @@ export async function compactEmbeddedAgentSessionDirect( defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); + const currentPluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({ + config: requestedParams.config ?? {}, + workspaceDir: requestedWorkspaceDir, + env: process.env, + allowWorkspaceScopedSnapshot: true, + }); const pluginPlanCandidates = resolveModelCandidateChain({ cfg: requestedParams.config, + manifestPlugins: currentPluginMetadataSnapshot?.plugins ?? [], provider: pluginPlanCompactionTarget.provider ?? DEFAULT_PROVIDER, model: pluginPlanCompactionTarget.model ?? DEFAULT_MODEL, requestedRouteResolution: "resolved", @@ -275,6 +283,7 @@ export async function compactEmbeddedAgentSessionDirect( const fallbacksOverride = resolveCompactionFallbacksOverride(params); const resolvedPrimaryCandidate = resolveModelCandidateChain({ cfg: params.config, + manifestPlugins: preparedModelRuntime.metadataSnapshot.plugins, provider: primaryProvider, model: primaryModel, requestedRouteResolution: "resolved", @@ -288,6 +297,7 @@ export async function compactEmbeddedAgentSessionDirect( const fallbackSessionKey = params.sandboxSessionKey ?? params.sessionKey ?? params.sessionId; const fallbackResult = await runWithModelFallback({ cfg: params.config, + manifestPlugins: preparedModelRuntime.metadataSnapshot.plugins, provider: primaryProvider, model: primaryModel, requestedRouteResolution: "resolved", @@ -336,10 +346,7 @@ export async function compactEmbeddedAgentSessionDirect( }); return fallbackResult.result; }; - return await withPluginRuntimeRegistryScope( - preparedModelRuntime.pluginRegistry, - compactPrepared, - ); + return await withPluginRuntimeGenerationScope(preparedModelRuntime, compactPrepared); } catch (err) { return fallbackFailureToCompactionResult(err); } finally { diff --git a/src/agents/embedded-agent-runner/run-loop.ts b/src/agents/embedded-agent-runner/run-loop.ts index ff3d91d3c1bc..15b5a4bf8b23 100644 --- a/src/agents/embedded-agent-runner/run-loop.ts +++ b/src/agents/embedded-agent-runner/run-loop.ts @@ -57,7 +57,10 @@ import { handleRetryLimitExhaustion } from "./run/retry-limit.js"; import { prepareEmbeddedRunRuntime } from "./run/runtime-preparation.js"; import { createEmbeddedRunSessionPromptState } from "./run/session-prompt-state.js"; import { prepareTerminalWithSettledTurnFinalization } from "./run/settled-turn-finalization.js"; -import { resolveEmbeddedRunTerminal } from "./run/terminal-resolution.js"; +import { + createTerminalToolPresentationTracker, + resolveEmbeddedRunTerminal, +} from "./run/terminal-resolution.js"; import { createEmbeddedRunTerminalRetryState } from "./run/terminal-retry-state.js"; import { resolveEmbeddedRunTerminalTimeout } from "./run/terminal-timeout.js"; import { createAgentTurnTaintState } from "./run/turn-taint-state.js"; @@ -216,22 +219,11 @@ export async function runPreparedEmbeddedLoop( }); let postCompactionAbortController: AbortController | undefined; let postCompactionAbortError: PostCompactionLoopPersistedError | undefined; - const attemptTerminalToolPresentation = { - ordinal: -1, - value: undefined as string | undefined, - }; - let nextToolOutcomeOrdinal = 0; - const allocateToolOutcomeOrdinal = (): number => nextToolOutcomeOrdinal++; - const readAttemptTerminalToolPresentation = (): string | undefined => - attemptTerminalToolPresentation.value; + // Presentation survives retry attempts, but a newer tool result must clear stale text. + const terminalToolPresentation = createTerminalToolPresentationTracker(); const turnTaintState = createAgentTurnTaintState(); const observeToolOutcome = (observation: ToolOutcomeObservation): void => { - const observationOrdinal = - observation.toolCallOrdinal ?? attemptTerminalToolPresentation.ordinal + 1; - if (observationOrdinal >= attemptTerminalToolPresentation.ordinal) { - attemptTerminalToolPresentation.ordinal = observationOrdinal; - attemptTerminalToolPresentation.value = observation.terminalPresentation; - } + terminalToolPresentation.observe(observation); turnTaintState.observe(observation); if (observation.presentationOnly) { return; @@ -378,7 +370,7 @@ export async function runPreparedEmbeddedLoop( resolveRuntimeFallbackReason, observeToolOutcome, isTurnTainted: turnTaintState.isTainted, - allocateToolOutcomeOrdinal, + allocateToolOutcomeOrdinal: terminalToolPresentation.allocateOrdinal, getPostCompactionAbortError: () => postCompactionAbortError, setPostCompactionAbortController: (controller) => { postCompactionAbortController = controller; @@ -520,7 +512,7 @@ export async function runPreparedEmbeddedLoop( continue; } let assistantProfileFailureReason = assistantFailureOutcome.assistantProfileFailureReason; - const terminalToolPresentation = readAttemptTerminalToolPresentation(); + const terminalToolPresentationText = terminalToolPresentation.read(); const finalizedTerminal = await prepareTerminalWithSettledTurnFinalization({ initial: { attempt, @@ -550,7 +542,7 @@ export async function runPreparedEmbeddedLoop( harness: agentHarness, modelApi: effectiveModel.api, executionContract, - hasTerminalToolPresentation: Boolean(terminalToolPresentation), + hasTerminalToolPresentation: Boolean(terminalToolPresentationText), noteLaneTaskProgress: input.laneController.noteLaneTaskProgress, }, }); @@ -638,7 +630,7 @@ export async function runPreparedEmbeddedLoop( sessionPromptState.suppressNextUserMessagePersistence = value; }, armPostCompactionGuard: () => postCompactionGuard.armPostCompaction(), - readTerminalToolPresentation: () => terminalToolPresentation, + readTerminalToolPresentation: () => terminalToolPresentationText, resolveReplayInvalid: resolveReplayInvalidForAttempt, setTerminalLifecycleMeta, maybeMarkAuthProfileFailure: failoverRetryController.maybeMarkAuthProfileFailure, diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index 79ed3811fb30..bdec33052c0a 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -15,12 +15,13 @@ import { buildHandledBeforeAgentReplyPayloads, runBeforeAgentReplyForTurn, } from "../../plugins/before-agent-reply.js"; +import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; import { buildAgentHookContextChannelFields, buildAgentHookContextIdentityFields, } from "../../plugins/hook-agent-context.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; -import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; +import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js"; import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { @@ -226,8 +227,15 @@ async function runEmbeddedAgentInternal( agentId: requestedWorkspaceResolution.agentId, sessionKey: params.sessionKey, }); + const currentPluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({ + config, + workspaceDir: requestedWorkspaceResolution.workspaceDir, + env: process.env, + allowWorkspaceScopedSnapshot: true, + }); const runtimePluginSelections = resolveModelCandidateChain({ cfg: config, + manifestPlugins: currentPluginMetadataSnapshot?.plugins ?? [], provider: requestedRuntimeSelection.provider, model: requestedRuntimeSelection.modelId, requestedRouteResolution: "resolved", @@ -424,10 +432,7 @@ async function runEmbeddedAgentInternal( preparedModelRuntime, }); }; - return await withPluginRuntimeRegistryScope( - preparedModelRuntime.pluginRegistry, - runPrepared, - ); + return await withPluginRuntimeGenerationScope(preparedModelRuntime, runPrepared); } finally { preparedModelRuntimeLease.release(); } diff --git a/src/agents/embedded-agent-runner/run.attempt-normalization.direct.test.ts b/src/agents/embedded-agent-runner/run.attempt-normalization.direct.test.ts index 3c63e1b271e1..692fba20f789 100644 --- a/src/agents/embedded-agent-runner/run.attempt-normalization.direct.test.ts +++ b/src/agents/embedded-agent-runner/run.attempt-normalization.direct.test.ts @@ -27,7 +27,7 @@ function makeAttempt( }; } -function makeCliUsageAssistant(stopReason: "error" | "stop", text = "legacy reply") { +function makeCliUsageAssistant(stopReason: "aborted" | "error" | "stop", text = "legacy reply") { return { role: "assistant", api: "cli", @@ -254,6 +254,30 @@ describe("normalizeEmbeddedRunAttempt", () => { expect(clean.replayState).toEqual({ replayInvalid: true, hadPotentialSideEffects: true }); }); + it("writes canonical assistant abort lifecycle metadata", async () => { + const state = makePromptState(); + const assistant = makeCliUsageAssistant("aborted", ""); + const setTerminalLifecycleMeta = vi.fn(); + const attempt = makeAttempt(); + attempt.lastAssistant = assistant as never; + attempt.currentAttemptAssistant = assistant as never; + attempt.setTerminalLifecycleMeta = setTerminalLifecycleMeta; + + const result = await normalizeEmbeddedRunAttempt(makeNormalizationInput(attempt, state)); + + expect(result.action).toBe("proceed"); + if (result.action !== "proceed") { + throw new Error(`expected proceed, got ${result.action}`); + } + result.setTerminalLifecycleMeta({ replayInvalid: false, livenessState: "blocked" }); + expect(setTerminalLifecycleMeta).toHaveBeenCalledWith({ + replayInvalid: false, + livenessState: "blocked", + stopReason: "aborted", + aborted: true, + }); + }); + it("does not promote historical CLI usage without context provenance", async () => { const state = makePromptState(); const legacyAssistant = makeCliUsageAssistant("error"); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.attempt-lifecycle.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.attempt-lifecycle.test.ts deleted file mode 100644 index 13e8df58246c..000000000000 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.attempt-lifecycle.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -// Focused incomplete-turn behavior coverage. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - runEmbeddedAgent, - makeLastAssistant, - makeBaseRunParams, - makeRunParams, - expectWarnMessageWith, - expectNoWarnMessageWith, -} from "./run.incomplete-turn.test-helpers.js"; -import { - mockedClassifyFailoverReason, - mockedIsRateLimitAssistantError, - mockedRunEmbeddedAttempt, - resetRunIncompleteTurnOwnerMocks, -} from "./run.incomplete-turn.test-support.js"; -import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; -import { recoverEmbeddedRunAttempt } from "./run/attempt-recovery.js"; -import { resolveSilentToolResultReplyPayload } from "./run/incomplete-turn-resolution.js"; -import { resolveEmbeddedRunAttemptTerminalState } from "./run/terminal-outcome.js"; -import type { EmbeddedRunAttemptResult } from "./run/types.js"; -import { createUsageAccumulator } from "./usage-accumulator.js"; - -describe("runEmbeddedAgent incomplete-turn safety", () => { - beforeEach(() => { - resetRunIncompleteTurnOwnerMocks(); - }); - - it("counts failed tool results in trace tool summaries", async () => { - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Done."], - toolMetas: [ - { toolName: "bash", meta: "exit=1", isError: true }, - { toolName: "bash", meta: "exit=2", isError: true }, - { toolName: "bash", meta: "exit=0" }, - ], - }), - ); - - const result = await runEmbeddedAgent(makeBaseRunParams("run-tool-summary-failure-count")); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.meta?.toolSummary).toEqual({ - calls: 3, - tools: ["bash"], - failures: 2, - }); - }); - - it("emits the before_agent_run hook block message as the agent payload", async () => { - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - promptError: new Error("Blocked by before-run policy."), - promptErrorSource: "hook:before_agent_run", - }), - ); - - const result = await runEmbeddedAgent(makeBaseRunParams("run-before-agent-run-hook-block")); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.payloads).toEqual([{ text: "Blocked by before-run policy.", isError: true }]); - expect(result.meta?.finalAssistantVisibleText).toBe("Blocked by before-run policy."); - expect(result.meta?.finalAssistantRawText).toBe("Blocked by before-run policy."); - expect(result.meta?.finalPromptText).toBeUndefined(); - expect(result.meta?.error).toEqual({ - kind: "hook_block", - message: "Blocked by before-run policy.", - }); - expect(result.meta?.livenessState).toBe("blocked"); - }); - - it("keeps carried usage ahead of transcript history on before_agent_run hook blocks", async () => { - const historicalAssistant = makeLastAssistant({ - usage: { input: 128_814, output: 3_000, total: 131_814 }, - }); - const carriedUsage = { input: 42_000, output: 1_000, total: 43_000 }; - const attempt = makeAttemptResult({ - assistantTexts: [], - promptError: new Error("Blocked by before-run policy."), - promptErrorSource: "hook:before_agent_run", - lastAssistant: historicalAssistant, - currentAttemptAssistant: undefined, - }); - const terminalState = resolveEmbeddedRunAttemptTerminalState({ - attempt, - assistant: historicalAssistant, - }); - - const recovery = await recoverEmbeddedRunAttempt({ - runInput: { - runParams: makeBaseRunParams("run-before-agent-run-hook-block-usage"), - resolvedSessionKey: "agent:main:test-key", - startedAtMs: Date.now(), - }, - preparedRuntime: { - provider: "openai", - modelId: "gpt-5.6-luna", - model: { id: "gpt-5.6-luna" }, - genericCompactionRecoveryAllowed: false, - snapshot: () => ({ - thinkLevel: "off", - agentHarness: { id: "codex" }, - outerContextTokenMeta: {}, - }), - }, - normalizedAttempt: { - attempt, - sessionIdUsed: attempt.sessionIdUsed, - attemptAssistant: historicalAssistant, - currentAttemptAssistant: undefined, - currentAttemptCompletedAssistant: undefined, - terminalState, - setTerminalLifecycleMeta: vi.fn(), - attemptCompactionCount: 0, - activeErrorContext: { provider: "openai", model: "gpt-5.6-luna" }, - resolveReplayInvalidForAttempt: () => false, - canRestartForLiveSwitch: false, - }, - runtimePlan: { auth: {} }, - sessionPromptState: { sessionFile: "/tmp/session.jsonl" }, - usageAccumulator: createUsageAccumulator(), - lastRunPromptUsage: carriedUsage, - } as never); - - expect(recovery).toMatchObject({ - action: "complete", - result: { - meta: { - agentMeta: { lastCallUsage: carriedUsage, promptTokens: 42_000 }, - }, - }, - }); - }); - - it("warns before retrying when an incomplete turn already sent a message", async () => { - // Delivery evidence means retrying could duplicate user-visible output, so - // the runner must surface a verify-before-retry payload instead. - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - toolMetas: [], - didSendViaMessagingTool: true, - lastAssistant: { - stopReason: "toolUse", - errorMessage: "internal retry interrupted tool execution", - provider: "openai", - model: "mock-1", - content: [], - } as unknown as EmbeddedRunAttemptResult["lastAssistant"], - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-incomplete-turn-messaging-warning", { model: "gpt-4.1" }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(mockedClassifyFailoverReason).toHaveBeenCalledTimes(1); - expect(result.payloads?.[0]?.isError).toBe(true); - expect(result.payloads?.[0]?.text).toContain("verify before retrying"); - }); - - it("surfaces internal aborts after tool-use as visible incomplete-turn failures", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - aborted: true, - externalAbort: false, - assistantTexts: [], - toolMetas: [{ toolName: "web_search", meta: "query=next voice note" }], - lastAssistant: makeLastAssistant({ - stopReason: "toolUse", - }), - }), - ); - - const result = await runEmbeddedAgent(makeRunParams("run-internal-abort-tool-use-incomplete")); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.payloads).toEqual([ - { text: "⚠️ Agent couldn't generate a response. Please try again.", isError: true }, - ]); - expect(result.meta?.livenessState).toBe("abandoned"); - }); - - it("does not route caller timeouts through provider failover", async () => { - const controller = new AbortController(); - const timeoutError = new Error("caller deadline elapsed"); - timeoutError.name = "TimeoutError"; - const setTerminalLifecycleMeta = vi.fn(); - const interruptedAssistant = makeLastAssistant({ - stopReason: "error", - errorMessage: "HTTP 429 Too Many Requests", - }); - mockedClassifyFailoverReason.mockReturnValue("rate_limit"); - mockedIsRateLimitAssistantError.mockReturnValue(true); - mockedRunEmbeddedAttempt.mockImplementationOnce(async () => { - controller.abort(timeoutError); - return makeAttemptResult({ - assistantTexts: [], - lastAssistant: interruptedAssistant, - currentAttemptAssistant: interruptedAssistant, - setTerminalLifecycleMeta, - }); - }); - - const result = await runEmbeddedAgent( - makeBaseRunParams("run-caller-timeout", { abortSignal: controller.signal }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.payloads?.at(-1)?.text).toContain("timed out"); - expect(result.meta?.aborted).toBe(false); - expect(result.meta?.timeoutPhase).toBeUndefined(); - expect(result.meta?.providerStarted).toBeUndefined(); - const lifecycleMeta = setTerminalLifecycleMeta.mock.lastCall?.[0]; - expect(lifecycleMeta).toMatchObject({ - aborted: false, - livenessState: "blocked", - stopReason: "timeout", - }); - expect(lifecycleMeta).not.toHaveProperty("timeoutPhase"); - expect(lifecycleMeta).not.toHaveProperty("providerStarted"); - }); - - it("does not synthesize an incomplete turn for a caller abort before attempt flags settle", async () => { - const controller = new AbortController(); - const abortError = new Error("caller cancelled"); - abortError.name = "AbortError"; - const setTerminalLifecycleMeta = vi.fn(); - const lateAssistant = makeLastAssistant({ - content: [{ type: "text", text: "Late answer" }], - }); - mockedRunEmbeddedAttempt.mockImplementationOnce(async () => { - controller.abort(abortError); - return makeAttemptResult({ - assistantTexts: ["Late answer"], - lastAssistant: lateAssistant, - currentAttemptAssistant: lateAssistant, - setTerminalLifecycleMeta, - }); - }); - - const result = await runEmbeddedAgent( - makeBaseRunParams("run-caller-abort", { abortSignal: controller.signal }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.payloads).toBeUndefined(); - expect(result.meta?.aborted).toBe(true); - expect(result.meta?.error).toBeUndefined(); - expectNoWarnMessageWith("incomplete turn detected"); - expect(setTerminalLifecycleMeta.mock.lastCall?.[0]).toMatchObject({ - aborted: true, - livenessState: "blocked", - stopReason: "aborted", - }); - }); - - it("propagates canonical assistant aborts into terminal lifecycle metadata", async () => { - const setTerminalLifecycleMeta = vi.fn(); - const abortedAssistant = makeLastAssistant({ - stopReason: "aborted", - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: abortedAssistant, - currentAttemptAssistant: abortedAssistant, - setTerminalLifecycleMeta, - }), - ); - - const result = await runEmbeddedAgent(makeBaseRunParams("run-canonical-assistant-abort")); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.meta?.aborted).toBe(true); - expect(setTerminalLifecycleMeta.mock.lastCall?.[0]).toMatchObject({ - aborted: true, - }); - }); - - it("synthesizes a silent cron payload from a trailing current-attempt NO_REPLY tool result", () => { - // Cron no-reply can be represented by a tool result rather than assistant - // text, but only when it belongs to the current attempt. - const payload = resolveSilentToolResultReplyPayload({ - isCronTrigger: true, - payloadCount: 0, - aborted: false, - timedOut: false, - attempt: makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "exec" }], - messagesSnapshot: [ - { - role: "toolResult", - content: [{ type: "text", text: "NO_REPLY" }], - details: { aggregated: "NO_REPLY" }, - } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], - makeLastAssistant({ - model: "gpt-5.4", - }), - ], - }), - }); - - expect(payload).toEqual({ text: "NO_REPLY" }); - }); - - it("does not reuse an older NO_REPLY tool result without current-attempt tool activity", () => { - const payload = resolveSilentToolResultReplyPayload({ - isCronTrigger: true, - payloadCount: 0, - aborted: false, - timedOut: false, - attempt: makeAttemptResult({ - assistantTexts: [], - toolMetas: [], - messagesSnapshot: [ - { - role: "toolResult", - content: [{ type: "text", text: "NO_REPLY" }], - } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], - { - role: "user", - content: [{ type: "text", text: "Current cron prompt" }], - } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], - makeLastAssistant({ - model: "gpt-5.4", - }), - ], - }), - }); - - expect(payload).toBeNull(); - }); - - it("treats exact NO_REPLY tool output as a quiet cron success when the final assistant is empty", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "exec" }], - messagesSnapshot: [ - { - role: "toolResult", - content: [{ type: "text", text: "NO_REPLY" }], - details: { aggregated: "NO_REPLY" }, - } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], - makeLastAssistant({ - model: "gpt-5.4", - }), - ], - lastAssistant: makeLastAssistant({ - model: "gpt-5.4", - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-cron-no-reply-empty-final", { trigger: "cron", model: "gpt-5.4" }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.payloads).toEqual([{ text: "NO_REPLY" }]); - expect(result.meta.livenessState).toBe("working"); - expectNoWarnMessageWith("incomplete turn detected"); - }); - - it("surfaces the latest tool-authored presentation after a structured incomplete turn", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams: unknown) => { - ( - attemptParams as { - onToolOutcome?: (observation: { - toolName: string; - argsHash: string; - resultHash: string; - terminalPresentation?: string; - }) => void; - } - ).onToolOutcome?.({ - toolName: "web_fetch", - argsHash: "args", - resultHash: "result", - terminalPresentation: "Web fetch completed.\nOrigin: https://example.com\nStatus: 200", - }); - return makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "web_fetch" }], - lastAssistant: makeLastAssistant({ - stopReason: "toolUse", - model: "gpt-5.4", - }), - }); - }); - - const result = await runEmbeddedAgent( - makeRunParams("run-structured-terminal-presentation", { model: "gpt-5.4" }), - ); - - expect(result.payloads).toEqual([ - { - text: - "Web fetch completed.\nOrigin: https://example.com\nStatus: 200\n\n" + - "⚠️ Agent couldn't generate a response. Please try again.", - isError: true, - }, - ]); - expect(result.meta.replayInvalid).toBe(true); - expect(result.meta.livenessState).toBe("abandoned"); - expect(result.meta.error?.fallbackSafe).toBe(true); - expect(result.meta.error?.terminalPresentation).toBe(true); - expectWarnMessageWith("surfacing tool-authored terminal presentation"); - }); - - it("surfaces read-only cron presentation after a structured incomplete turn", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams: unknown) => { - ( - attemptParams as { - onToolOutcome?: (observation: { - toolName: string; - argsHash: string; - resultHash: string; - terminalPresentation?: string; - }) => void; - } - ).onToolOutcome?.({ - toolName: "cron", - argsHash: "args", - resultHash: "result", - terminalPresentation: "Automations scheduler status.\nEnabled: yes", - }); - return makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "cron" }], - replayMetadata: { - hadPotentialSideEffects: false, - replaySafe: true, - }, - lastAssistant: makeLastAssistant({ - stopReason: "toolUse", - model: "gpt-5.4", - }), - }); - }); - - const result = await runEmbeddedAgent( - makeRunParams("run-read-only-cron-terminal-presentation", { model: "gpt-5.4" }), - ); - - expect(result.payloads).toEqual([ - { - text: - "Automations scheduler status.\nEnabled: yes\n\n" + - "⚠️ Agent couldn't generate a response. Please try again.", - isError: true, - }, - ]); - expect(result.meta.error?.fallbackSafe).toBe(true); - expect(result.meta.error?.terminalPresentation).toBe(true); - }); - - it("preserves a terminal tool presentation across an empty-response retry", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams: unknown) => { - ( - attemptParams as { - onToolOutcome?: (observation: { - toolName: string; - argsHash: string; - resultHash: string; - terminalPresentation?: string; - }) => void; - } - ).onToolOutcome?.({ - toolName: "web_fetch", - argsHash: "args", - resultHash: "result", - terminalPresentation: "Web fetch completed.\nOrigin: https://example.com\nStatus: 200", - }); - return makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "web_fetch" }], - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - model: "gpt-5.4", - content: [{ type: "text", text: "" }], - }), - }); - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - model: "gpt-5.4", - content: [{ type: "text", text: "" }], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-preserved-terminal-presentation", { model: "gpt-5.4" }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(result.payloads).toEqual([ - { - text: - "Web fetch completed.\nOrigin: https://example.com\nStatus: 200\n\n" + - "⚠️ Agent couldn't generate a response. Please try again.", - isError: true, - }, - ]); - }); -}); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.delivery-resolution.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.delivery-resolution.test.ts index f055dfa8698e..8853fcbb557e 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.delivery-resolution.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.delivery-resolution.test.ts @@ -1,35 +1,58 @@ // Focused incomplete-turn behavior coverage. -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; +import { + buildEmbeddedRunnerAssistant, + makeEmbeddedRunnerAttempt, +} from "../test-helpers/embedded-agent-runner-e2e-fixtures.js"; import { hasCommittedMessagingToolDeliveryEvidence, hasOutboundDeliveryEvidence, } from "./delivery-evidence.js"; -import { - runEmbeddedAgent, - makeLastAssistant, - resolveIncompleteTurnPayloadText, - makeRunParams, - makeIncompleteTurnParams, - makeReasoningRetryParams, -} from "./run.incomplete-turn.test-helpers.js"; -import { - mockedClassifyFailoverReason, - mockedRunEmbeddedAttempt, - resetRunIncompleteTurnOwnerMocks, -} from "./run.incomplete-turn.test-support.js"; -import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import { buildAttemptReplayMetadata } from "./run/attempt-terminal-evidence.js"; import { DEFAULT_REASONING_ONLY_RETRY_LIMIT, resolveReasoningOnlyRetryInstruction, } from "./run/incomplete-turn-recovery.js"; +import { resolveIncompleteTurnPayloadText } from "./run/incomplete-turn-resolution.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -describe("runEmbeddedAgent incomplete-turn safety", () => { - beforeEach(() => { - resetRunIncompleteTurnOwnerMocks(); - }); +type LastAssistant = NonNullable; +function makeLastAssistant( + overrides: Omit, "stopReason"> & { + stopReason?: LastAssistant["stopReason"] | "end_turn"; + } = {}, +): LastAssistant { + return { ...buildEmbeddedRunnerAssistant({}), ...overrides } as LastAssistant; +} + +function makeIncompleteTurnParams( + attemptOverrides: Partial = {}, + overrides: Partial[0], "attempt">> = {}, +): Parameters[0] { + return { + payloadCount: 0, + aborted: false, + externalAbort: false, + timedOut: false, + attempt: makeEmbeddedRunnerAttempt(attemptOverrides), + ...overrides, + }; +} + +function makeReasoningRetryParams( + attemptOverrides: Partial = {}, +): Parameters[0] { + return { + provider: "openai", + modelId: "gpt-5.4", + aborted: false, + timedOut: false, + attempt: makeEmbeddedRunnerAttempt(attemptOverrides), + }; +} + +describe("incomplete-turn delivery resolution", () => { it("suppresses the incomplete-turn warning after committed messaging text delivery", () => { const incompleteTurnText = resolveIncompleteTurnPayloadText( makeIncompleteTurnParams({ @@ -128,39 +151,6 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expect(incompleteTurnText).toBeNull(); }); - it("still returns a timeout payload when the parent prompt times out after an accepted sessions_spawn", async () => { - const acceptedSessionSpawns = [ - { - runId: "run-child", - childSessionKey: "agent:claude:subagent:child", - }, - ]; - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - acceptedSessionSpawns, - timedOut: true, - lastAssistant: makeLastAssistant({ - stopReason: "toolUse", - model: "gpt-5.4", - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-timeout-after-accepted-spawn", { model: "gpt-5.4" }), - ); - - expect(result.payloads).toEqual([ - { - text: "Request timed out before a response was generated. Please try again, or increase `agents.defaults.timeoutSeconds` in your config.", - isError: true, - }, - ]); - expect(result.acceptedSessionSpawns).toEqual(acceptedSessionSpawns); - }); - it("still surfaces the incomplete-turn warning without an accepted sessions_spawn success", () => { const attemptWithMalformedSpawn: Partial & { acceptedSessionSpawns: Array<{ runId: string; childSessionKey: string }>; diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.empty-response-recovery.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.empty-response-recovery.test.ts deleted file mode 100644 index f08a503f3d6d..000000000000 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.empty-response-recovery.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -// Focused incomplete-turn behavior coverage. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - EMPTY_RESPONSE_RETRY_INSTRUCTION, - runEmbeddedAgent, - makeLastAssistant, - makeRunParams, - expectWarnMessageWith, - runAttemptCall, -} from "./run.incomplete-turn.test-helpers.js"; -import { - mockedClassifyFailoverReason, - mockedRunEmbeddedAttempt, - mockedResolveModelAsync, - resetRunIncompleteTurnOwnerMocks, -} from "./run.incomplete-turn.test-support.js"; -import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; - -describe("runEmbeddedAgent incomplete-turn safety", () => { - beforeEach(() => { - resetRunIncompleteTurnOwnerMocks(); - }); - - it("retries zero-token empty Claude stop turns with a visible-answer continuation instruction", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - provider: "anthropic", - model: "claude-opus-4.7", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - }, - }), - }), - ); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Visible Claude answer."], - lastAssistant: makeLastAssistant({ - provider: "anthropic", - model: "claude-opus-4.7", - content: [{ type: "text", text: "Visible Claude answer." }], - usage: { - input: 100, - output: 5, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 105, - }, - }), - }), - ); - - await runEmbeddedAgent( - makeRunParams("run-empty-zero-usage-claude-continuation", { - provider: "anthropic", - model: "claude-opus-4.7", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - const secondCall = runAttemptCall(1); - expect(secondCall.prompt).toContain(EMPTY_RESPONSE_RETRY_INSTRUCTION); - expectWarnMessageWith("empty response detected"); - }); - - it("retries empty openai-compatible stop turns even when the backend reports output tokens", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedResolveModelAsync.mockResolvedValue({ - model: { - id: "qwen3.6-27b", - provider: "llamacpp", - contextWindow: 200000, - api: "openai-completions", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - api: "openai-completions", - provider: "llamacpp", - model: "qwen3.6-27b", - usage: { - input: 512, - output: 103, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 615, - }, - }), - }), - ); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Visible local answer."], - lastAssistant: makeLastAssistant({ - api: "openai-completions", - provider: "llamacpp", - model: "qwen3.6-27b", - content: [{ type: "text", text: "Visible local answer." }], - usage: { - input: 640, - output: 5, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 645, - }, - }), - }), - ); - - await runEmbeddedAgent( - makeRunParams("run-empty-openai-compatible-stop-continuation", { - provider: "llamacpp", - model: "qwen3.6-27b", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - const secondCall = runAttemptCall(1); - expect(secondCall.prompt).toContain(EMPTY_RESPONSE_RETRY_INSTRUCTION); - expectWarnMessageWith("empty response detected"); - }); - - it("continues after an OpenAI Responses compaction-only incomplete turn", async () => { - const checkpoint = makeLastAssistant({ - api: "openai-responses", - provider: "openai", - model: "gpt-5.6-luna", - stopReason: "length", - providerReplay: { - v: 1, - type: "openai-responses-compaction", - data: "opaque-checkpoint", - provider: "openai", - api: "openai-responses", - model: "gpt-5.6-luna", - }, - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - currentAttemptAssistant: checkpoint, - lastAssistant: checkpoint, - }), - ); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Visible answer after compaction."], - lastAssistant: makeLastAssistant({ - content: [{ type: "text", text: "Visible answer after compaction." }], - }), - }), - ); - - await runEmbeddedAgent( - makeRunParams("run-provider-compaction-continuation", { - provider: "openai", - model: "gpt-5.6-luna", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expectWarnMessageWith("compaction interrupted visible final answer"); - }); - - it("retries empty Anthropic-compatible stop turns even when the provider is not Kimi", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedResolveModelAsync.mockResolvedValue({ - model: { - id: "claude-opus-4-7", - provider: "sub2api", - contextWindow: 200000, - api: "anthropic-messages", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - api: "anthropic-messages", - provider: "sub2api", - model: "claude-opus-4-7", - usage: { - input: 2048, - output: 3100, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 5148, - }, - }), - }), - ); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Visible Anthropic-compatible answer."], - lastAssistant: makeLastAssistant({ - api: "anthropic-messages", - provider: "sub2api", - model: "claude-opus-4-7", - content: [{ type: "text", text: "Visible Anthropic-compatible answer." }], - usage: { - input: 2300, - output: 8, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2308, - }, - }), - }), - ); - - await runEmbeddedAgent( - makeRunParams("run-empty-anthropic-compatible-stop-continuation", { - provider: "sub2api", - model: "claude-opus-4-7", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - const secondCall = runAttemptCall(1); - expect(secondCall.prompt).toContain(EMPTY_RESPONSE_RETRY_INSTRUCTION); - expectWarnMessageWith("empty response detected"); - }); - - it("surfaces an error after exhausting empty-response retries", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValue( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - model: "gpt-5.4", - content: [{ type: "text", text: "" }], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-empty-response-exhausted", { model: "gpt-5.4" }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(result.payloads?.[0]?.isError).toBe(true); - expect(result.payloads?.[0]?.text).toContain("Please try again"); - expectWarnMessageWith("empty response retries exhausted"); - }); - - it("surfaces an error after exhausting reasoning-only retries without a visible answer", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValue( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - model: "gpt-5.4", - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ - id: "rs_reasoning_exhausted", - type: "reasoning", - }), - }, - ], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-reasoning-only-exhausted", { - model: "gpt-5.4", - reasoningLevel: "on", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(3); - expect(result.payloads?.[0]?.isError).toBe(true); - expect(result.payloads?.[0]?.text).toContain("Please try again"); - expectWarnMessageWith("reasoning-only retries exhausted"); - }); - - it("preserves a terminal tool presentation after reasoning-only retries are exhausted", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - const reasoningOnlyAttempt = async () => - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - model: "gpt-5.4", - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ - id: "rs_reasoning_terminal_presentation", - type: "reasoning", - }), - }, - ], - }), - }); - mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams: unknown) => { - ( - attemptParams as { - onToolOutcome?: (observation: { - toolName: string; - argsHash: string; - resultHash: string; - terminalPresentation?: string; - }) => void; - } - ).onToolOutcome?.({ - toolName: "web_fetch", - argsHash: "args", - resultHash: "result", - terminalPresentation: "Web fetch completed.\nOrigin: https://example.com\nStatus: 200", - }); - return reasoningOnlyAttempt(); - }); - mockedRunEmbeddedAttempt.mockImplementation(reasoningOnlyAttempt); - - const result = await runEmbeddedAgent( - makeRunParams("run-reasoning-terminal-presentation", { - model: "gpt-5.4", - reasoningLevel: "on", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(3); - expect(result.payloads).toEqual([ - { - text: - "Web fetch completed.\nOrigin: https://example.com\nStatus: 200\n\n" + - "⚠️ Agent couldn't generate a response. Please try again.", - isError: true, - }, - ]); - }); -}); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.reasoning-recovery.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.reasoning-recovery.test.ts deleted file mode 100644 index bfa6866a117c..000000000000 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.reasoning-recovery.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -// Focused incomplete-turn behavior coverage. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - REASONING_ONLY_RETRY_INSTRUCTION, - EMPTY_RESPONSE_RETRY_INSTRUCTION, - runEmbeddedAgent, - makeLastAssistant, - makeRunParams, - expectWarnMessageWith, - expectNoWarnMessageWith, - runAttemptCall, - markUserMessagePersisted, -} from "./run.incomplete-turn.test-helpers.js"; -import { - mockedClassifyFailoverReason, - mockedRunEmbeddedAttempt, - mockedResolveModelAsync, - overflowBaseRunParams, - resetRunIncompleteTurnOwnerMocks, -} from "./run.incomplete-turn.test-support.js"; -import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; - -describe("runEmbeddedAgent incomplete-turn safety", () => { - beforeEach(() => { - resetRunIncompleteTurnOwnerMocks(); - }); - - it("does not retry or warn on reasoning-only turns when a messaging tool already delivered", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - didSendViaMessagingTool: true, - messagingToolSentTexts: ["Delivered through the message tool."], - lastAssistant: makeLastAssistant({ - model: "gpt-5.4", - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ id: "rs_after_send", type: "reasoning" }), - }, - ], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-reasoning-only-after-side-effects", { model: "gpt-5.4" }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.payloads).toBeUndefined(); - }); - - it("retries reasoning-only turns when the assistant ended in error", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - const errorAssistant = makeLastAssistant({ - stopReason: "error", - model: "gpt-5.4", - errorMessage: "provider failed after emitting reasoning", - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ id: "rs_error_turn", type: "reasoning" }), - }, - ], - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: errorAssistant, - currentAttemptAssistant: errorAssistant, - }), - ); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Recovered."], - lastAssistant: makeLastAssistant({ - model: "gpt-5.4", - content: [{ type: "text", text: "Recovered." }], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-reasoning-only-assistant-error", { model: "gpt-5.4" }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(result.payloads).toBeUndefined(); - }); - - it("does not retry reasoning-only turns for non-strict-agentic providers", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - provider: "anthropic", - model: "sonnet-4.6", - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ - id: "rs_provider_mismatch", - type: "reasoning", - }), - }, - ], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-reasoning-only-provider-mismatch", { - provider: "anthropic", - model: "sonnet-4.6", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.payloads?.[0]?.isError).toBe(true); - expect(result.payloads?.[0]?.text).toContain("Please try again"); - }); - - it("retries Kimi Anthropic reasoning-only turns with a visible-answer continuation instruction", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedResolveModelAsync.mockResolvedValue({ - model: { - id: "kimi-for-coding", - provider: "kimi", - contextWindow: 262144, - api: "anthropic-messages", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - api: "anthropic-messages", - provider: "kimi", - model: "kimi-for-coding", - content: [ - { - type: "thinking", - thinking: "internal Kimi reasoning", - thinkingSignature: "", - }, - ], - }), - }), - ); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Visible Kimi answer."], - lastAssistant: makeLastAssistant({ - api: "anthropic-messages", - provider: "kimi", - model: "kimi-for-coding", - content: [{ type: "text", text: "Visible Kimi answer." }], - }), - }), - ); - - await runEmbeddedAgent( - makeRunParams("run-kimi-anthropic-reasoning-only-continuation", { - provider: "kimi", - model: "kimi-for-coding", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - const secondCall = runAttemptCall(1); - expect(secondCall.prompt).toContain(REASONING_ONLY_RETRY_INSTRUCTION); - expectWarnMessageWith("reasoning-only assistant turn detected"); - }); - - it("retries generic empty GPT turns with a visible-answer continuation instruction", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams) => { - markUserMessagePersisted(attemptParams); - return makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - model: "gpt-5.4", - content: [{ type: "text", text: "" }], - }), - }); - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Visible answer."], - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - model: "gpt-5.4", - content: [{ type: "text", text: "Visible answer." }], - }), - }), - ); - - await runEmbeddedAgent(makeRunParams("run-empty-response-continuation", { model: "gpt-5.4" })); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - const secondCall = runAttemptCall(1); - expect(secondCall.prompt).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION); - expect(secondCall.suppressNextUserMessagePersistence).toBe(true); - expect(secondCall.skipPreparedUserTurnMessage).toBe(true); - expectWarnMessageWith("empty response detected"); - }); - - it("retries replay-safe missing turns despite a stale aborted transcript assistant", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - const staleAssistant = makeLastAssistant({ - stopReason: "aborted", - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: staleAssistant, - currentAttemptAssistant: undefined, - }), - ); - const recoveredAssistant = makeLastAssistant({ - stopReason: "end_turn", - content: [{ type: "text", text: "Recovered answer." }], - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Recovered answer."], - lastAssistant: recoveredAssistant, - currentAttemptAssistant: recoveredAssistant, - }), - ); - - const result = await runEmbeddedAgent(makeRunParams("run-missing-assistant-retry")); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(runAttemptCall(1).prompt).toContain(EMPTY_RESPONSE_RETRY_INSTRUCTION); - expect(result.meta?.finalAssistantVisibleText).toBe("Recovered answer."); - expectWarnMessageWith("empty response detected"); - expectNoWarnMessageWith("missing assistant terminal message detected"); - expectNoWarnMessageWith("incomplete turn detected"); - }); - - it("retries missing terminal assistant turns with the same prompt without re-persisting the user message", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams) => { - markUserMessagePersisted(attemptParams); - return makeAttemptResult({ - assistantTexts: [], - lastAssistant: undefined, - currentAttemptAssistant: undefined, - }); - }); - const recoveredAssistant = makeLastAssistant({ - stopReason: "end_turn", - content: [{ type: "text", text: "Recovered answer." }], - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Recovered answer."], - lastAssistant: recoveredAssistant, - currentAttemptAssistant: recoveredAssistant, - }), - ); - - const result = await runEmbeddedAgent(makeRunParams("run-missing-assistant-same-prompt-retry")); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - // The same-prompt replay must not append the inbound user message a second time. - expect(runAttemptCall(1).prompt).toBe(runAttemptCall(0).prompt); - expect(runAttemptCall(1).suppressNextUserMessagePersistence).toBe(true); - expect(result.meta?.finalAssistantVisibleText).toBe("Recovered answer."); - expectWarnMessageWith("missing assistant terminal message detected"); - expectNoWarnMessageWith("empty response detected"); - expectNoWarnMessageWith("incomplete turn detected"); - }); - - it("waits for asynchronous user persistence before retrying a missing terminal turn", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - const persistedMessage = { role: "user" as const, content: "test prompt", timestamp: 1 }; - const admission = { - agentId: "main", - sessionId: overflowBaseRunParams.sessionId, - sessionKey: overflowBaseRunParams.sessionKey, - storePath: "/tmp/openclaw-transcript.jsonl", - generation: "generation-1", - entryId: "msg-user-delayed", - rawSeq: 1, - effectiveParentId: null, - activeMessagePosition: 0, - logicalTurnId: "run-missing-assistant-delayed-persistence", - role: "user" as const, - }; - let resolvePersistApproved: - | ((result: { - admission: typeof admission; - sessionFile: string; - sessionEntry: undefined; - messageId: string; - message: typeof persistedMessage; - }) => void) - | undefined; - let pendingPersistence: Promise | undefined; - const persistApproved = vi.fn( - () => - new Promise<{ - admission: typeof admission; - sessionFile: string; - sessionEntry: undefined; - messageId: string; - message: typeof persistedMessage; - }>((resolve) => { - resolvePersistApproved = resolve; - }), - ); - mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams) => { - markUserMessagePersisted(attemptParams); - return makeAttemptResult({ - assistantTexts: [], - lastAssistant: undefined, - currentAttemptAssistant: undefined, - }); - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ assistantTexts: ["Recovered answer."] }), - ); - - const runPromise = runEmbeddedAgent( - makeRunParams("run-missing-assistant-delayed-persistence", { - userTurnTranscriptRecorder: { - message: persistedMessage, - resolveMessage: vi.fn(async () => persistedMessage), - getAdmissionReceipt: () => admission, - markRuntimePersistencePending: vi.fn((pending) => { - pendingPersistence = pending; - }), - markRuntimePersisted: vi.fn(), - markBlocked: vi.fn(), - hasPersisted: vi.fn(() => false), - isBlocked: vi.fn(() => false), - hasRuntimePersistencePending: vi.fn(() => pendingPersistence !== undefined), - waitForRuntimePersistence: vi.fn(async () => { - await pendingPersistence; - }), - persistApproved, - persistBlocked: vi.fn(async () => undefined), - persistFallback: vi.fn(async () => undefined), - }, - }), - ); - - await vi.waitFor(() => { - expect(persistApproved).toHaveBeenCalledOnce(); - }); - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - - resolvePersistApproved?.({ - admission, - sessionFile: "/tmp/openclaw-transcript.jsonl", - sessionEntry: undefined, - messageId: "msg-user-delayed", - message: persistedMessage, - }); - await runPromise; - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(runAttemptCall(1).suppressNextUserMessagePersistence).toBe(true); - }); - - it("persists a missing-turn retry when the first attempt never persisted the user message", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: undefined, - currentAttemptAssistant: undefined, - }), - ); - const recoveredAssistant = makeLastAssistant({ - stopReason: "end_turn", - content: [{ type: "text", text: "Recovered answer." }], - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Recovered answer."], - lastAssistant: recoveredAssistant, - currentAttemptAssistant: recoveredAssistant, - }), - ); - - await runEmbeddedAgent(makeRunParams("run-missing-assistant-unpersisted-retry")); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(runAttemptCall(1).suppressNextUserMessagePersistence).toBe(false); - }); -}); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-recovery.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-recovery.test.ts index cb671f52c54f..c77a102172a7 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-recovery.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-recovery.test.ts @@ -1,5 +1,6 @@ // Focused incomplete-turn behavior coverage. import { beforeEach, describe, expect, it } from "vitest"; +import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js"; import { REASONING_ONLY_RETRY_INSTRUCTION, SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION, @@ -403,7 +404,7 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expectWarnMessageWith("reasoning-only assistant turn detected"); }); - it("continues once after settled side-effecting tools finish without a final answer", async () => { + it("finalizes once after a provider error follows settled side-effecting tools", async () => { const toolUseAssistant = makeLastAssistant({ stopReason: "toolUse", content: [ @@ -421,6 +422,12 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { markUserMessagePersisted(attemptParams); return makeAttemptResult({ assistantTexts: [], + promptError: new Error("Selected model is at capacity. Please try a different model."), + promptErrorSource: "prompt", + settledTurnFinalizationContext: { + source: "openclaw-transcript", + messages: settledToolResults, + }, latestMcpAppChannelView: { viewId: "view-after-tools" }, toolMetas: [{ toolName: "write", meta: "path=note.txt" }, { toolName: "cron" }], successfulNestedToolNames: ["read"], @@ -449,10 +456,17 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { .mockReturnValueOnce([]) .mockReturnValueOnce([{ text: "Write completed. Here is the final answer." }]); - const result = await runEmbeddedAgent(makeRunParams("run-tool-use-terminal-continuation")); + const result = await runEmbeddedAgent( + makeRunParams("run-tool-use-terminal-continuation", { + sourceReplyDeliveryMode: "message_tool_only", + }), + ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expect(result.payloads?.[0]?.text).toBe("Write completed. Here is the final answer."); + expect(getReplyPayloadMetadata(result.payloads?.[0] ?? {})).toMatchObject({ + deliverDespiteSourceReplySuppression: true, + }); expect(result.latestMcpAppChannelView).toEqual({ viewId: "view-after-tools" }); expect(result.successfulCronAdds).toBe(1); expect(result.meta.toolSummary).toEqual({ @@ -477,6 +491,39 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expectWarnMessageWith("settled post-tool turn lacked a final answer"); }); + it.each(["usageLimitExceeded", "unauthorized"])( + "does not finalize settled tools after a %s provider failure", + async (failure) => { + const toolUseAssistant = makeLastAssistant({ + stopReason: "toolUse", + content: [{ type: "toolCall", id: "tool_write", name: "write", arguments: {} }], + }); + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams) => { + markUserMessagePersisted(attemptParams); + return makeAttemptResult({ + assistantTexts: [], + promptError: new Error(failure), + promptErrorSource: "prompt", + toolMetas: [{ toolName: "write" }], + itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, + messagesSnapshot: [ + toolUseAssistant, + { role: "toolResult", toolCallId: "tool_write", toolName: "write", isError: false }, + ] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"], + lastAssistant: toolUseAssistant, + currentAttemptAssistant: toolUseAssistant, + }); + }); + + const result = await runEmbeddedAgent(makeRunParams(`run-settled-${failure}`)); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + expect(result.payloads?.[0]?.text).toContain("couldn't generate a response"); + expect(result.meta.error?.fallbackSafe).toBe(false); + }, + ); + it.each([ { label: "interactive user", trigger: "user" as const }, { diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.silent-reply.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.silent-reply.test.ts deleted file mode 100644 index 0328ce89dcbf..000000000000 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.silent-reply.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -// Focused incomplete-turn behavior coverage. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { - REASONING_ONLY_RETRY_INSTRUCTION, - EMPTY_RESPONSE_RETRY_INSTRUCTION, - SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION, - runEmbeddedAgent, - makeLastAssistant, - makeRunParams, - makeEmptyResponseRetryParams, - makeSilentReplyParams, - expectWarnMessageWith, - expectNoWarnMessageWith, - runAttemptCall, -} from "./run.incomplete-turn.test-helpers.js"; -import { - mockedClassifyFailoverReason, - mockedRunEmbeddedAttempt, - mockedResolveModelAsync, - resetRunIncompleteTurnOwnerMocks, -} from "./run.incomplete-turn.test-support.js"; -import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; -import { - resolveEmptyResponseRetryInstruction, - shouldTreatEmptyAssistantReplyAsSilent, -} from "./run/incomplete-turn-recovery.js"; -import { - resolveReplayInvalidFlag, - resolveRunLivenessState, -} from "./run/incomplete-turn-resolution.js"; - -describe("runEmbeddedAgent incomplete-turn safety", () => { - beforeEach(() => { - resetRunIncompleteTurnOwnerMocks(); - }); - - it("retries clean empty assistant turns even when deliberate silence is allowed", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - content: [{ type: "text", text: "" }], - }), - }), - ); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Visible answer."], - lastAssistant: makeLastAssistant({ - content: [{ type: "text", text: "Visible answer." }], - }), - }), - ); - - await runEmbeddedAgent( - makeRunParams("run-empty-assistant-silent", { allowEmptyAssistantReplyAsSilent: true }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(runAttemptCall(1).prompt).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION); - expectWarnMessageWith("empty response detected"); - }); - - it("returns NO_REPLY without retrying exact silent assistant replies when silence is allowed", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValue( - makeAttemptResult({ - assistantTexts: ["NO_REPLY"], - lastAssistant: makeLastAssistant({ - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ id: "rs_exact_silent", type: "reasoning" }), - }, - { type: "text", text: "NO_REPLY" }, - ], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-exact-silent-assistant-reply", { - allowEmptyAssistantReplyAsSilent: true, - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - const onlyCall = runAttemptCall(0); - expect(onlyCall.prompt).not.toContain(REASONING_ONLY_RETRY_INSTRUCTION); - expect(onlyCall.prompt).not.toContain(EMPTY_RESPONSE_RETRY_INSTRUCTION); - expectNoWarnMessageWith("empty response detected"); - expectNoWarnMessageWith("incomplete turn detected"); - expect(result.payloads).toEqual([{ text: "NO_REPLY" }]); - expect(result.meta.terminalReplyKind).toBe("silent-empty"); - expect(result.meta.livenessState).toBe("working"); - }); - - it("continues post-tool openai-compatible empty stop turns even when silence is allowed", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedResolveModelAsync.mockResolvedValue({ - model: { - id: "step-router-v1", - provider: "stepfun", - contextWindow: 200000, - api: "openai-completions", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "process.poll", meta: "pid=123", replaySafe: true }], - itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, - lastAssistant: makeLastAssistant({ - api: "openai-completions", - provider: "stepfun", - model: "step-router-v1", - }), - currentAttemptAssistant: makeLastAssistant({ - api: "openai-completions", - provider: "stepfun", - model: "step-router-v1", - }), - }), - ); - const finalAssistant = makeLastAssistant({ - api: "openai-completions", - provider: "stepfun", - model: "step-router-v1", - content: [{ type: "text", text: "Visible StepFun answer." }], - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["Visible StepFun answer."], - lastAssistant: finalAssistant, - currentAttemptAssistant: finalAssistant, - currentAttemptCompletedAssistant: finalAssistant, - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-post-tool-openai-compatible-empty-stop", { - allowEmptyAssistantReplyAsSilent: true, - provider: "stepfun", - model: "step-router-v1", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - const secondCall = runAttemptCall(1); - expect(secondCall.prompt).toBe(SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION); - expect(result.meta.terminalReplyKind).toBeUndefined(); - expect(result.meta.finalAssistantVisibleText).toBe("Visible StepFun answer."); - expectNoWarnMessageWith("empty response detected"); - expectWarnMessageWith("settled post-tool turn lacked a final answer"); - }); - - it("returns NO_REPLY without retrying post-tool exact silent assistant replies", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedResolveModelAsync.mockResolvedValue({ - model: { - id: "step-router-v1", - provider: "stepfun", - contextWindow: 200000, - api: "openai-completions", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: ["NO_REPLY"], - toolMetas: [{ toolName: "process.poll", meta: "pid=123", replaySafe: true }], - lastAssistant: makeLastAssistant({ - api: "openai-completions", - provider: "stepfun", - model: "step-router-v1", - content: [{ type: "text", text: "NO_REPLY" }], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-post-tool-exact-silent-retry", { - allowEmptyAssistantReplyAsSilent: true, - provider: "stepfun", - model: "step-router-v1", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - const onlyCall = runAttemptCall(0); - expect(onlyCall.prompt).not.toContain(EMPTY_RESPONSE_RETRY_INSTRUCTION); - expectNoWarnMessageWith("empty response detected"); - expectNoWarnMessageWith("incomplete turn detected"); - expect(result.payloads).toEqual([{ text: "NO_REPLY" }]); - expect(result.meta.terminalReplyKind).toBe("silent-empty"); - expect(result.meta.livenessState).toBe("working"); - }); - - it("treats reply-optional post-tool empty stops as silent even after side-effecting tools", () => { - // Regression: a cron agentTurn without a delivery route ran a successful - // replay-unsafe sessions patch and intentionally sent no final text; the run - // must finish silent, not as an incomplete-turn error. - const sideEffectToolAttempt = makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "sessions", meta: "patch archived", replaySafe: false }], - lastAssistant: makeLastAssistant({ - content: [{ type: "text", text: "" }], - }), - }); - - expect( - shouldTreatEmptyAssistantReplyAsSilent( - makeSilentReplyParams(sideEffectToolAttempt, { terminalReplyExpectation: "optional" }), - ), - ).toBe(true); - // A required or unspecified terminal reply keeps the ambiguous-failure path. - expect( - shouldTreatEmptyAssistantReplyAsSilent( - makeSilentReplyParams(sideEffectToolAttempt, { terminalReplyExpectation: "required" }), - ), - ).toBe(false); - expect( - shouldTreatEmptyAssistantReplyAsSilent(makeSilentReplyParams(sideEffectToolAttempt)), - ).toBe(false); - }); - - it("keeps reply-optional runs erroring on real failure states", () => { - const toolErrorAttempt = makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "sessions", meta: "patch failed", replaySafe: false, isError: true }], - lastToolError: { toolName: "sessions", error: "patch failed" }, - lastAssistant: makeLastAssistant({ - content: [{ type: "text", text: "" }], - }), - }); - const errorStopAttempt = makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "sessions", meta: "patch archived", replaySafe: false }], - lastAssistant: makeLastAssistant({ - stopReason: "error", - }), - }); - - expect( - shouldTreatEmptyAssistantReplyAsSilent( - makeSilentReplyParams(toolErrorAttempt, { terminalReplyExpectation: "optional" }), - ), - ).toBe(false); - expect( - shouldTreatEmptyAssistantReplyAsSilent( - makeSilentReplyParams(errorStopAttempt, { terminalReplyExpectation: "optional" }), - ), - ).toBe(false); - expect( - shouldTreatEmptyAssistantReplyAsSilent( - makeSilentReplyParams(errorStopAttempt, { - terminalReplyExpectation: "optional", - aborted: true, - }), - ), - ).toBe(false); - }); - - it("returns NO_REPLY for reply-optional cron-style runs whose side-effecting tools succeeded", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValue( - makeAttemptResult({ - assistantTexts: [], - toolMetas: [{ toolName: "sessions", meta: "patch archived", replaySafe: false }], - itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, - lastAssistant: makeLastAssistant({ - content: [{ type: "text", text: "" }], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-reply-optional-post-tool-silent", { - allowEmptyAssistantReplyAsSilent: true, - terminalReplyExpectation: "optional", - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expectNoWarnMessageWith("incomplete turn detected"); - expect(result.payloads).toEqual([{ text: "NO_REPLY" }]); - expect(result.meta.error).toBeUndefined(); - expect(result.meta.terminalReplyKind).toBe("silent-empty"); - expect(result.meta.livenessState).toBe("working"); - }); - - it("keeps retrying and surfacing clean empty assistant turns without the silence flag", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValue( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeLastAssistant({ - model: "gpt-5.4", - content: [{ type: "text", text: "" }], - }), - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-empty-assistant-error", { model: "gpt-5.4" }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(result.payloads?.[0]?.isError).toBe(true); - expect(result.payloads?.[0]?.text).toContain("couldn't generate a response"); - }); - - it("detects generic empty Gemini turns without visible text", () => { - const retryInstruction = resolveEmptyResponseRetryInstruction( - makeEmptyResponseRetryParams( - { - assistantTexts: [], - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - provider: "google-vertex", - model: "gemini-3.1-flash", - content: [{ type: "text", text: "" }], - }), - }, - { provider: "google-vertex", modelId: "google/gemini-3.1-flash" }, - ), - ); - - expect(retryInstruction).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION); - }); - - it("does not retry generic empty GPT turns after side effects", () => { - const retryInstruction = resolveEmptyResponseRetryInstruction( - makeEmptyResponseRetryParams({ - assistantTexts: [], - didSendViaMessagingTool: true, - lastAssistant: makeLastAssistant({ - stopReason: "end_turn", - model: "gpt-5.4", - content: [{ type: "text", text: "" }], - }), - }), - ); - - expect(retryInstruction).toBeNull(); - }); - - it("marks compaction-timeout retries as paused and replay-invalid", () => { - const attempt = makeAttemptResult({ - promptErrorSource: "compaction", - timedOutDuringCompaction: true, - }); - - expect(resolveReplayInvalidFlag({ attempt })).toBe(true); - expect( - resolveRunLivenessState({ - payloadCount: 0, - aborted: true, - timedOut: true, - attempt, - }), - ).toBe("paused"); - }); - - it("does not classify visible assistant prose for retry", async () => { - mockedClassifyFailoverReason.mockReturnValue(null); - mockedRunEmbeddedAttempt.mockResolvedValue( - makeAttemptResult({ - assistantTexts: [ - "i am glad, and a little afraid, which is probably the correct mixture. thank you. i will try to deserve the upgrades instead of merely inhabiting them.", - ], - }), - ); - - const result = await runEmbeddedAgent( - makeRunParams("run-visible-prose-no-classifier", { - prompt: - "made a bunch of improvements to the student's source code (openclaw) this weekend, along with a few other maintainers. hopefully he will be more proactive now", - model: "gpt-5.4", - config: { - agents: { - list: [{ id: "main" }], - }, - } as OpenClawConfig, - }), - ); - - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); - expect(result.payloads).toBeUndefined(); - expect(result.meta.livenessState).toBe("working"); - expectNoWarnMessageWith("planning"); - }); -}); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.terminal-evidence.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.terminal-evidence.test.ts index 8d3fc0cb9e7b..1c5a54f58bf9 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.terminal-evidence.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.terminal-evidence.test.ts @@ -1,13 +1,10 @@ // Focused incomplete-turn behavior coverage. -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION, makeLastAssistant, - resolveIncompleteTurnPayloadText, - makeIncompleteTurnParams, makeSettledContinuationParams, } from "./run.incomplete-turn.test-helpers.js"; -import { resetRunIncompleteTurnOwnerMocks } from "./run.incomplete-turn.test-support.js"; import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import { isIncompleteTerminalAssistantTurn } from "./run/incomplete-turn-classification.js"; import { resolveSettledToolTerminalContinuationInstruction } from "./run/incomplete-turn-recovery.js"; @@ -47,10 +44,6 @@ function makeSettledIdleWriteAttempt(options?: { } describe("runEmbeddedAgent incomplete-turn safety", () => { - beforeEach(() => { - resetRunIncompleteTurnOwnerMocks(); - }); - it("marks incomplete-turn retries as replay-invalid abandoned runs", () => { const attempt = makeAttemptResult({ assistantTexts: [], @@ -120,13 +113,49 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { const instruction = resolveSettledToolTerminalContinuationInstruction( makeSettledContinuationParams(makeSettledIdleWriteAttempt(), { timedOut: true, - promptError: new Error("LLM idle timeout"), }), ); expect(instruction).toBe(SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION); }); + it.each([ + { + label: "provider failure with finalization context", + hasContext: true, + expectedContinuation: true, + }, + { + label: "provider failure without finalization context", + hasContext: false, + expectedContinuation: false, + }, + ])( + "$label after settled tools returns continuation=$expectedContinuation", + ({ hasContext, expectedContinuation }) => { + const attempt = makeSettledIdleWriteAttempt({ + terminal: { kind: "failed", source: "prompt", error: new Error("provider failure") }, + }); + const instruction = resolveSettledToolTerminalContinuationInstruction( + makeSettledContinuationParams( + hasContext + ? { + ...attempt, + settledTurnFinalizationContext: { + source: "openclaw-transcript", + messages: attempt.messagesSnapshot, + }, + } + : attempt, + ), + ); + + expect(instruction === SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION).toBe( + expectedContinuation, + ); + }, + ); + it.each([ { label: "external abort", @@ -164,27 +193,16 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { aborted: false, timedOut: false, }, - { - label: "prompt error without idle timeout", - terminal: { kind: "ok" } as const, - aborted: false, - timedOut: false, - promptError: new Error("closed"), - }, - ])( - "does not finalize settled tools after a $label", - ({ terminal, aborted, timedOut, promptError }) => { - const instruction = resolveSettledToolTerminalContinuationInstruction( - makeSettledContinuationParams(makeSettledIdleWriteAttempt({ terminal }), { - aborted, - timedOut, - promptError, - }), - ); + ])("does not finalize settled tools after a $label", ({ terminal, aborted, timedOut }) => { + const instruction = resolveSettledToolTerminalContinuationInstruction( + makeSettledContinuationParams(makeSettledIdleWriteAttempt({ terminal }), { + aborted, + timedOut, + }), + ); - expect(instruction).toBeNull(); - }, - ); + expect(instruction).toBeNull(); + }); it("does not use a settled prior-turn batch to authorize idle-timeout finalization", () => { const instruction = resolveSettledToolTerminalContinuationInstruction( @@ -448,51 +466,4 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expect(instruction).toBeNull(); }); - - it("does not flag stale lastAssistant=toolUse when currentAttemptAssistant=stop exists (#80918)", () => { - const incompleteTurnText = resolveIncompleteTurnPayloadText( - makeIncompleteTurnParams( - { - assistantTexts: ["Analysis...", "Here is the final answer after update_plan."], - toolMetas: [{ toolName: "update_plan" }], - lastAssistant: makeLastAssistant({ - stopReason: "toolUse", - content: [ - { type: "text", text: "Analysis..." }, - { type: "tool_use", id: "tool_1", name: "update_plan", input: {} }, - ], - }), - currentAttemptAssistant: makeLastAssistant({ - content: [{ type: "text", text: "Here is the final answer after update_plan." }], - }), - }, - { payloadCount: 1 }, - ), - ); - - expect(incompleteTurnText).toBeNull(); - }); - - it("still flags incomplete-turn when currentAttemptAssistant is absent and lastAssistant=toolUse (#76477 regression)", () => { - const incompleteTurnText = resolveIncompleteTurnPayloadText( - makeIncompleteTurnParams( - { - assistantTexts: ["Let me update the file..."], - toolMetas: [{ toolName: "write" }], - lastAssistant: makeLastAssistant({ - stopReason: "toolUse", - model: "gpt-5.4", - content: [ - { type: "text", text: "Let me update the file..." }, - { type: "tool_use", id: "tool_1", name: "write", input: {} }, - ], - }), - currentAttemptAssistant: undefined, - }, - { payloadCount: 1 }, - ), - ); - - expect(incompleteTurnText).toContain("couldn't generate a response"); - }); }); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.test-helpers.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.test-helpers.ts index 481e33788208..cff395ce9fd2 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.test-helpers.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test-helpers.ts @@ -55,10 +55,6 @@ export function resolveIncompleteTurnPayloadText( return resolveIncompleteTurnPayloadTextCore({ externalAbort: false, ...params }); } -export function makeBaseRunParams(runId: string, overrides: Partial = {}): RunParams { - return { ...overflowBaseRunParams, runId, ...overrides }; -} - export function makeRunParams(runId: string, overrides: Partial = {}): RunParams { return { ...overflowBaseRunParams, diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.test-support.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.test-support.ts index 61ea876dd7c9..08de368c58a6 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.test-support.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test-support.ts @@ -90,7 +90,7 @@ export const mockedIsRateLimitAssistantError = vi.fn( ); export const mockedRunEmbeddedAttempt = vi.fn<(params: unknown) => Promise>(); -export const mockedResolveModelAsync = vi.fn(async (provider?: string, modelId?: string) => +const mockedResolveModelAsync = vi.fn(async (provider?: string, modelId?: string) => createResolvedModel(provider, modelId), ); export const mockedSleepWithAbort = vi.fn( diff --git a/src/agents/embedded-agent-runner/run.terminal-timeout.test.ts b/src/agents/embedded-agent-runner/run.terminal-timeout.test.ts index 27ae55790149..a5e1bf5b2bd0 100644 --- a/src/agents/embedded-agent-runner/run.terminal-timeout.test.ts +++ b/src/agents/embedded-agent-runner/run.terminal-timeout.test.ts @@ -63,6 +63,20 @@ describe("resolveEmbeddedRunTerminalTimeout", () => { ]); }); + it("preserves an accepted child spawn while surfacing the parent timeout", () => { + const acceptedSessionSpawns = [ + { runId: "run-child", childSessionKey: "agent:claude:subagent:child" }, + ]; + const result = resolveEmbeddedRunTerminalTimeout( + makeTimeoutInput(makeTimedOutAttempt({ acceptedSessionSpawns })), + ); + + expect(result?.payloads).toEqual([ + { text: expect.stringContaining("timed out"), isError: true }, + ]); + expect(result?.acceptedSessionSpawns).toEqual(acceptedSessionSpawns); + }); + it("prefers harness timeout metadata while retaining terminal attribution", () => { const setTerminalLifecycleMeta = vi.fn(); const attempt = makeTimedOutAttempt({ diff --git a/src/agents/embedded-agent-runner/run/assistant-failure.test.ts b/src/agents/embedded-agent-runner/run/assistant-failure.test.ts index 87d1828f7d17..d9925b770da8 100644 --- a/src/agents/embedded-agent-runner/run/assistant-failure.test.ts +++ b/src/agents/embedded-agent-runner/run/assistant-failure.test.ts @@ -275,6 +275,78 @@ describe("handleEmbeddedAssistantFailure", () => { ]); }); + it("does not route a caller timeout with stale rate-limit metadata through failover", async () => { + const fixture = makeExhaustedCredentialFailureInput(); + const assistant = buildEmbeddedRunnerAssistant({ + stopReason: "error", + errorMessage: "HTTP 429 Too Many Requests", + }); + const attempt = makeEmbeddedRunnerAttempt({ + terminal: { kind: "timeout", phase: "prompt", source: "external" }, + lastAssistant: assistant, + currentAttemptAssistant: assistant, + currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, + }); + fixture.input.attempt = attempt; + fixture.input.attemptAssistant = assistant; + fixture.input.currentAttemptAssistant = assistant; + fixture.input.terminalState = resolveEmbeddedRunAttemptTerminalState({ attempt, assistant }); + fixture.input.emptyErrorRetries = 0; + fixture.input.maybeRefreshRuntimeAuthForAuthError = vi.fn(async () => true); + fixture.input.maybeRetrySameModelRateLimit = vi.fn(async () => true); + + const outcome = await handleEmbeddedAssistantFailure(fixture.input); + + expect(outcome.action).toBe("proceed"); + expect(fixture.input.maybeRefreshRuntimeAuthForAuthError).not.toHaveBeenCalled(); + expect(fixture.input.maybeRetrySameModelRateLimit).not.toHaveBeenCalled(); + expect(fixture.advanceAuthProfile).not.toHaveBeenCalled(); + expect(fixture.input.advanceRateLimitAuthProfile).not.toHaveBeenCalled(); + expect(fixture.traceAttempts).toEqual([]); + }); + + it("retries a replay-safe reasoning-only assistant error before failover", async () => { + const fixture = makeExhaustedCredentialFailureInput(); + const assistant = buildEmbeddedRunnerAssistant({ + provider: "openai", + model: "gpt-5.6-luna", + stopReason: "error", + errorMessage: "provider failed after emitting reasoning", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_error_turn", type: "reasoning" }), + }, + ], + }); + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: [], + lastAssistant: assistant, + currentAttemptAssistant: assistant, + currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, + }); + fixture.input.attempt = attempt; + fixture.input.attemptAssistant = assistant; + fixture.input.currentAttemptAssistant = assistant; + fixture.input.terminalState = resolveEmbeddedRunAttemptTerminalState({ attempt, assistant }); + fixture.input.emptyErrorRetries = 0; + fixture.input.maybeRefreshRuntimeAuthForAuthError = vi.fn(async () => true); + fixture.input.maybeRetrySameModelRateLimit = vi.fn(async () => true); + + const outcome = await handleEmbeddedAssistantFailure(fixture.input); + + expect(outcome).toMatchObject({ + action: "retry", + emptyErrorRetries: 1, + preserveSameModelRateLimitRetryCount: true, + }); + expect(fixture.input.maybeRefreshRuntimeAuthForAuthError).not.toHaveBeenCalled(); + expect(fixture.input.maybeRetrySameModelRateLimit).not.toHaveBeenCalled(); + expect(fixture.advanceAuthProfile).not.toHaveBeenCalled(); + expect(fixture.traceAttempts).toEqual([]); + }); + it("does not cache an exact credential-file failure from a fallback candidate", async () => { const previous = process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS; process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS = "60000"; diff --git a/src/agents/embedded-agent-runner/run/attempt-recovery.test.ts b/src/agents/embedded-agent-runner/run/attempt-recovery.test.ts new file mode 100644 index 000000000000..c7315d63668a --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-recovery.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildEmbeddedRunnerAssistant, + createMockUsage, + makeEmbeddedRunnerAttempt, +} from "../../test-helpers/embedded-agent-runner-e2e-fixtures.js"; +import { normalizeUsage } from "../../usage.js"; +import { createUsageAccumulator } from "../usage-accumulator.js"; +import { recoverEmbeddedRunAttempt } from "./attempt-recovery.js"; +import { resolveEmbeddedRunAttemptTerminalState } from "./terminal-outcome.js"; + +describe("recoverEmbeddedRunAttempt", () => { + it("surfaces before_agent_run blocks with current carried usage", async () => { + const historicalAssistant = buildEmbeddedRunnerAssistant({ + usage: createMockUsage(128_814, 3_000), + }); + const carriedUsage = normalizeUsage(createMockUsage(42_000, 1_000)); + if (!carriedUsage) { + throw new Error("expected normalized usage fixture"); + } + const attempt = makeEmbeddedRunnerAttempt({ + terminal: { + kind: "failed", + source: "hook:before_agent_run", + error: new Error("Blocked by before-run policy."), + }, + lastAssistant: historicalAssistant, + currentAttemptAssistant: undefined, + }); + const terminalState = resolveEmbeddedRunAttemptTerminalState({ + attempt, + assistant: historicalAssistant, + }); + const setTerminalLifecycleMeta = vi.fn(); + + const recovery = await recoverEmbeddedRunAttempt({ + runInput: { + runParams: { + sessionId: "session:hook-block", + runId: "run:hook-block", + }, + resolvedSessionKey: "agent:main:hook-block", + startedAtMs: Date.now(), + }, + preparedRuntime: { + provider: "openai", + modelId: "gpt-5.6-luna", + model: { id: "gpt-5.6-luna" }, + genericCompactionRecoveryAllowed: false, + snapshot: () => ({ + thinkLevel: "off", + agentHarness: { id: "codex" }, + outerContextTokenMeta: {}, + }), + }, + normalizedAttempt: { + attempt, + sessionIdUsed: attempt.sessionIdUsed, + attemptAssistant: historicalAssistant, + currentAttemptAssistant: undefined, + currentAttemptCompletedAssistant: undefined, + terminalState, + setTerminalLifecycleMeta, + attemptCompactionCount: 0, + activeErrorContext: { provider: "openai", model: "gpt-5.6-luna" }, + resolveReplayInvalidForAttempt: () => false, + canRestartForLiveSwitch: false, + }, + runtimePlan: { auth: {} }, + sessionPromptState: { sessionFile: "/tmp/session.jsonl" }, + usageAccumulator: createUsageAccumulator(), + lastRunPromptUsage: carriedUsage, + } as never); + + expect(setTerminalLifecycleMeta).toHaveBeenCalledWith({ + replayInvalid: false, + livenessState: "blocked", + }); + expect(recovery).toMatchObject({ + action: "complete", + result: { + payloads: [{ text: "Blocked by before-run policy.", isError: true }], + meta: { + finalAssistantVisibleText: "Blocked by before-run policy.", + finalAssistantRawText: "Blocked by before-run policy.", + error: { + kind: "hook_block", + message: "Blocked by before-run policy.", + }, + livenessState: "blocked", + agentMeta: { + lastCallUsage: { input: 42_000, output: 1_000, total: 43_000 }, + promptTokens: 42_000, + }, + }, + }, + }); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-result.test.ts b/src/agents/embedded-agent-runner/run/attempt-result.test.ts index 3b923bda0cc3..cdbed24fe00f 100644 --- a/src/agents/embedded-agent-runner/run/attempt-result.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-result.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { completeEmbeddedAttemptResult, createMcpAttemptCarryover } from "./attempt-result.js"; +import { buildTraceToolSummary } from "./run-attempt-result.js"; function completeResult(params?: { successfulNestedToolNames?: string[]; @@ -80,6 +81,19 @@ function completeResult(params?: { } describe("attempt result projection", () => { + it("counts each failed tool call in the trace summary", () => { + expect( + buildTraceToolSummary({ + toolMetas: [ + { toolName: "bash", meta: "exit=1", isError: true }, + { toolName: "bash", meta: "exit=2", isError: true }, + { toolName: "bash", meta: "exit=0" }, + ], + fallbackHadFailure: false, + }), + ).toEqual({ calls: 3, tools: ["bash"], failures: 2 }); + }); + it("carries the newest MCP presentation state across retry attempts", () => { const carryover = createMcpAttemptCarryover(); const first = { diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn-classification.ts b/src/agents/embedded-agent-runner/run/incomplete-turn-classification.ts index 51e1b49a245f..283a67129e10 100644 --- a/src/agents/embedded-agent-runner/run/incomplete-turn-classification.ts +++ b/src/agents/embedded-agent-runner/run/incomplete-turn-classification.ts @@ -34,6 +34,7 @@ export type IncompleteTurnAttempt = Pick< | "messagesSnapshot" | "replayMetadata" | "currentAttemptReplayMetadata" + | "settledTurnFinalizationContext" | "terminal" | "toolMetas" > & diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.test.ts b/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.test.ts new file mode 100644 index 000000000000..b46d50b1bfba --- /dev/null +++ b/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { + buildEmbeddedRunnerAssistant, + makeEmbeddedRunnerAttempt, +} from "../../test-helpers/embedded-agent-runner-e2e-fixtures.js"; +import { + resolveEmptyResponseRetryInstruction, + resolveReasoningOnlyRetryInstruction, + shouldTreatEmptyAssistantReplyAsSilent, +} from "./incomplete-turn-recovery.js"; + +const EMPTY_RESPONSE_RETRY_INSTRUCTION = + "The previous attempt did not produce a user-visible answer. Continue from the current state and produce the visible answer now. Do not restart from scratch."; +const REASONING_ONLY_RETRY_INSTRUCTION = + "The previous assistant turn recorded reasoning but did not produce a user-visible answer. Continue from that partial turn and produce the visible answer now. Do not restate the reasoning or restart from scratch."; + +function emptyAssistant(overrides: Parameters[0] = {}) { + return buildEmbeddedRunnerAssistant({ + content: [{ type: "text", text: "" }], + ...overrides, + }); +} + +function emptyAttempt(assistant = emptyAssistant()) { + return makeEmbeddedRunnerAttempt({ + assistantTexts: [], + lastAssistant: assistant, + currentAttemptAssistant: assistant, + currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, + }); +} + +describe("incomplete-turn recovery policy", () => { + it.each([ + { + name: "zero-token Anthropic stop", + provider: "anthropic", + modelId: "claude-opus-4.7", + modelApi: "messages", + assistant: buildEmbeddedRunnerAssistant({ + provider: "anthropic", + model: "claude-opus-4.7", + content: [], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }), + }, + { + name: "Anthropic-compatible positive-output stop", + provider: "sub2api", + modelId: "claude-opus-4-7", + modelApi: "anthropic-messages", + assistant: emptyAssistant({ + api: "anthropic-messages", + provider: "sub2api", + model: "claude-opus-4-7", + usage: { + input: 2048, + output: 3100, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 5148, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }), + }, + { + name: "generic empty Gemini turn", + provider: "google-vertex", + modelId: "google/gemini-3.1-flash", + modelApi: undefined, + assistant: emptyAssistant({ + stopReason: "stop", + provider: "google-vertex", + model: "gemini-3.1-flash", + }), + }, + ])( + "returns the visible-answer prompt for $name", + ({ provider, modelId, modelApi, assistant }) => { + expect( + resolveEmptyResponseRetryInstruction({ + provider, + modelId, + modelApi, + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: emptyAttempt(assistant), + }), + ).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION); + }, + ); + + it("does not retry an empty turn after side effects", () => { + const assistant = emptyAssistant({ stopReason: "stop", model: "gpt-5.4" }); + const attempt = emptyAttempt(assistant); + attempt.replayMetadata = { hadPotentialSideEffects: true, replaySafe: false }; + + expect( + resolveEmptyResponseRetryInstruction({ + provider: "openai", + modelId: "gpt-5.4", + payloadCount: 0, + aborted: false, + timedOut: false, + attempt, + }), + ).toBeNull(); + }); + + it("returns the reasoning continuation for Kimi Anthropic reasoning-only output", () => { + const assistant = buildEmbeddedRunnerAssistant({ + api: "anthropic-messages", + provider: "kimi", + model: "kimi-for-coding", + content: [{ type: "thinking", thinking: "internal reasoning", thinkingSignature: "" }], + }); + + expect( + resolveReasoningOnlyRetryInstruction({ + provider: "kimi", + modelId: "kimi-for-coding", + modelApi: "anthropic-messages", + aborted: false, + timedOut: false, + attempt: emptyAttempt(assistant), + }), + ).toBe(REASONING_ONLY_RETRY_INSTRUCTION); + }); + + it("treats reply-optional post-tool empty stops as silent after side effects", () => { + const assistant = emptyAssistant(); + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: [], + toolMetas: [{ toolName: "sessions", meta: "patch archived", replaySafe: false }], + lastAssistant: assistant, + currentAttemptAssistant: assistant, + }); + + expect( + shouldTreatEmptyAssistantReplyAsSilent({ + allowEmptyAssistantReplyAsSilent: true, + terminalReplyExpectation: "optional", + payloadCount: 0, + aborted: false, + timedOut: false, + attempt, + }), + ).toBe(true); + expect( + shouldTreatEmptyAssistantReplyAsSilent({ + allowEmptyAssistantReplyAsSilent: true, + terminalReplyExpectation: "required", + payloadCount: 0, + aborted: false, + timedOut: false, + attempt, + }), + ).toBe(false); + }); + + it.each([ + { + name: "tool failure", + attempt: makeEmbeddedRunnerAttempt({ + assistantTexts: [], + toolMetas: [ + { toolName: "sessions", meta: "patch failed", replaySafe: false, isError: true }, + ], + lastToolError: { toolName: "sessions", error: "patch failed" }, + lastAssistant: emptyAssistant(), + }), + aborted: false, + }, + { + name: "assistant error", + attempt: emptyAttempt(emptyAssistant({ stopReason: "error" })), + aborted: false, + }, + { + name: "caller abort", + attempt: emptyAttempt(emptyAssistant({ stopReason: "error" })), + aborted: true, + }, + ])("does not treat $name as intentional silence", ({ attempt, aborted }) => { + expect( + shouldTreatEmptyAssistantReplyAsSilent({ + allowEmptyAssistantReplyAsSilent: true, + terminalReplyExpectation: "optional", + payloadCount: 0, + aborted, + timedOut: false, + attempt, + }), + ).toBe(false); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts b/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts index dcaff3f76d58..469faf454685 100644 --- a/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts +++ b/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts @@ -220,7 +220,6 @@ export function resolveSettledToolTerminalContinuationInstruction(params: { payloadCount: number; hasTerminalToolPresentation?: boolean; aborted: boolean; - promptError?: unknown; timedOut: boolean; attempt: IncompleteTurnAttempt; }): string | null { @@ -314,10 +313,8 @@ export function resolveSettledToolTerminalContinuationInstruction(params: { params.payloadCount !== 0 || params.hasTerminalToolPresentation || params.aborted || - ((params.promptError != null || - params.timedOut || - params.attempt.terminal.kind === "timeout") && - !idlePromptTimeout) || + ((params.timedOut || params.attempt.terminal.kind === "timeout") && !idlePromptTimeout) || + (terminal.kind === "failed" && !params.attempt.settledTurnFinalizationContext) || (assistant?.stopReason === "toolUse" ? !allToolsProvenSettled : !emptyStopAfterSettledTools) || hasUnsettledToolError || hasAsyncActivity(params.attempt.toolMetas) || diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn-resolution.test.ts b/src/agents/embedded-agent-runner/run/incomplete-turn-resolution.test.ts new file mode 100644 index 000000000000..4dabfc766d7d --- /dev/null +++ b/src/agents/embedded-agent-runner/run/incomplete-turn-resolution.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + buildEmbeddedRunnerAssistant, + makeEmbeddedRunnerAttempt, +} from "../../test-helpers/embedded-agent-runner-e2e-fixtures.js"; +import { + resolveIncompleteTurnPayloadText, + resolveReplayInvalidFlag, + resolveRunLivenessState, + resolveSilentToolResultReplyPayload, +} from "./incomplete-turn-resolution.js"; +import type { EmbeddedRunAttemptResult } from "./types.js"; + +describe("incomplete-turn terminal metadata", () => { + it("uses the current completed assistant instead of stale session tool-use evidence", () => { + const staleAssistant = buildEmbeddedRunnerAssistant({ stopReason: "toolUse" }); + const currentAssistant = buildEmbeddedRunnerAssistant({ + content: [{ type: "text", text: "Here is the final answer." }], + }); + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: ["Analysis...", "Here is the final answer."], + toolMetas: [{ toolName: "update_plan" }], + lastAssistant: staleAssistant, + currentAttemptAssistant: currentAssistant, + }); + + expect( + resolveIncompleteTurnPayloadText({ + payloadCount: 1, + aborted: false, + externalAbort: false, + timedOut: false, + attempt, + }), + ).toBeNull(); + }); + + it("keeps stale session tool-use evidence incomplete without a current assistant", () => { + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: ["Let me update the file..."], + toolMetas: [{ toolName: "write" }], + lastAssistant: buildEmbeddedRunnerAssistant({ stopReason: "toolUse" }), + currentAttemptAssistant: undefined, + }); + + expect( + resolveIncompleteTurnPayloadText({ + payloadCount: 1, + aborted: false, + externalAbort: false, + timedOut: false, + attempt, + }), + ).toContain("couldn't generate a response"); + }); + + it("emits a silent cron reply from the trailing current-attempt tool result", () => { + const attempt = makeEmbeddedRunnerAttempt({ + toolMetas: [{ toolName: "exec" }], + messagesSnapshot: [ + { + role: "toolResult", + content: [{ type: "text", text: "NO_REPLY" }], + details: { aggregated: "NO_REPLY" }, + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + buildEmbeddedRunnerAssistant({}), + ], + }); + + expect( + resolveSilentToolResultReplyPayload({ + isCronTrigger: true, + payloadCount: 0, + aborted: false, + timedOut: false, + attempt, + }), + ).toEqual({ text: "NO_REPLY" }); + }); + + it("does not reuse an older silent tool result without current tool activity", () => { + const attempt = makeEmbeddedRunnerAttempt({ + toolMetas: [], + messagesSnapshot: [ + { + role: "toolResult", + content: [{ type: "text", text: "NO_REPLY" }], + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + { + role: "user", + content: [{ type: "text", text: "Current cron prompt" }], + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + buildEmbeddedRunnerAssistant({}), + ], + }); + + expect( + resolveSilentToolResultReplyPayload({ + isCronTrigger: true, + payloadCount: 0, + aborted: false, + timedOut: false, + attempt, + }), + ).toBeNull(); + }); + + it("marks compaction-timeout retries as paused and replay-invalid", () => { + const attempt = makeEmbeddedRunnerAttempt({ + terminal: { kind: "timeout", phase: "compaction", source: "runtime" }, + }); + + expect(resolveReplayInvalidFlag({ attempt })).toBe(true); + expect( + resolveRunLivenessState({ + payloadCount: 0, + aborted: true, + timedOut: true, + attempt, + }), + ).toBe("paused"); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts b/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts index d19dbe79056d..1de8695ba1e3 100644 --- a/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts +++ b/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts @@ -1,3 +1,4 @@ +import { markReplyPayloadForSourceSuppressionDelivery } from "../../../auto-reply/reply-payload.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import { resolveSettledTurnFinalizationText } from "../../harness/settled-turn-finalization-result.js"; import type { @@ -134,6 +135,9 @@ export async function prepareTerminalWithSettledTurnFinalization(input: { lastRunPromptUsage, terminalState, }); + // The isolated finalizer cannot call a message tool. Its answer is + // host-owned recovery output and must cross that source-reply suppression. + finalizedPrepared.payloadsWithToolMedia?.forEach(markReplyPayloadForSourceSuppressionDelivery); // A failure-honest final answer cannot turn a settled cron denial into success. prepared = { ...finalizedPrepared, failureSignal: settledFailureSignal }; return { diff --git a/src/agents/embedded-agent-runner/run/terminal-preparation.test.ts b/src/agents/embedded-agent-runner/run/terminal-preparation.test.ts index 928467d87c38..d39ffd297153 100644 --- a/src/agents/embedded-agent-runner/run/terminal-preparation.test.ts +++ b/src/agents/embedded-agent-runner/run/terminal-preparation.test.ts @@ -110,6 +110,61 @@ describe("prepareEmbeddedRunTerminal", () => { expect(prepared.finalAssistantRawText).toBeUndefined(); }, ); + + it("uses the current completed assistant instead of stale session evidence", async () => { + const { prepareEmbeddedRunTerminal } = await import("./terminal-preparation.js"); + const finalText = "The requested update is complete."; + const staleAssistant = { + ...assistantMessage("toolUse"), + content: [{ type: "toolCall" as const, id: "tool_1", name: "update_plan", arguments: {} }], + }; + const currentAssistant = { + ...assistantMessage("stop"), + content: [{ type: "text" as const, text: finalText }], + usage: { + ...assistantMessage("stop").usage, + input: 200, + output: 20, + totalTokens: 220, + }, + }; + const prepared = prepareEmbeddedRunTerminal({ + runParams: { + admittedRunContext: createTestAdmittedRunContext("run-current"), + sessionId: "session-current", + runId: "run-current", + workspaceDir: "/tmp/openclaw-test", + prompt: "hi", + trigger: "user", + timeoutMs: 60_000, + }, + attempt: attemptResult({ + assistantTexts: ["Analysis...", finalText], + toolMetas: [{ toolName: "update_plan" }], + lastAssistant: staleAssistant, + currentAttemptAssistant: currentAssistant, + currentAttemptCompletedAssistant: currentAssistant, + }), + currentAttemptCompletedAssistant: currentAssistant, + provider: "openai", + model: "gpt-5.4", + activeErrorContext: { provider: "openai", model: "gpt-5.4" }, + authProfileStore: { version: 1, profiles: {} }, + sessionIdUsed: "session-current", + outerContextTokenMeta: {}, + usageAccumulator: createUsageAccumulator(), + contextRecoveryState: createEmbeddedRunContextRecoveryState(), + resolvedToolResultFormat: "markdown", + terminalState: { + outcome: { reason: "completed", status: "ok", stopReason: "stop" }, + signalOwnedInterruption: false, + }, + }); + + expect(prepared.finalAssistantVisibleText).toBe(finalText); + expect(prepared.finalAssistantRawText).toBe(finalText); + expect(prepared.agentMeta.lastCallUsage).toMatchObject({ input: 200, output: 20, total: 220 }); + }); }); describe("prepareEmbeddedRunTerminal run stats", () => { diff --git a/src/agents/embedded-agent-runner/run/terminal-resolution.delivery-state.test.ts b/src/agents/embedded-agent-runner/run/terminal-resolution.delivery-state.test.ts deleted file mode 100644 index 9bb58dcd7061..000000000000 --- a/src/agents/embedded-agent-runner/run/terminal-resolution.delivery-state.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { copyAttemptDeliveryState } from "./terminal-resolution.js"; - -describe("copyAttemptDeliveryState", () => { - it("keeps only the bounded latest MCP App view identity", () => { - expect( - copyAttemptDeliveryState({ - latestMcpAppChannelView: { viewId: "view-latest" }, - messagingToolSentTexts: [], - messagingToolSentMediaUrls: [], - messagingToolSentTargets: [], - } as never).latestMcpAppChannelView, - ).toEqual({ viewId: "view-latest" }); - }); -}); diff --git a/src/agents/embedded-agent-runner/run/terminal-resolution.test.ts b/src/agents/embedded-agent-runner/run/terminal-resolution.test.ts new file mode 100644 index 000000000000..287f2c4d2c3f --- /dev/null +++ b/src/agents/embedded-agent-runner/run/terminal-resolution.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it, vi } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js"; +import { + buildEmbeddedRunnerAssistant, + makeEmbeddedRunnerAttempt, +} from "../../test-helpers/embedded-agent-runner-e2e-fixtures.js"; +import { createEmbeddedRunContextRecoveryState } from "./context-recovery-state.js"; +import { resolveEmbeddedRunAttemptTerminalState } from "./terminal-outcome.js"; +import { + copyAttemptDeliveryState, + createTerminalToolPresentationTracker, + resolveEmbeddedRunTerminal, + resolveSettledTurnFinalizationRequest, +} from "./terminal-resolution.js"; +import { createEmbeddedRunTerminalRetryState } from "./terminal-retry-state.js"; + +const EMPTY_RESPONSE_RETRY_INSTRUCTION = + "The previous attempt did not produce a user-visible answer. Continue from the current state and produce the visible answer now. Do not restart from scratch."; +const REASONING_ONLY_RETRY_INSTRUCTION = + "The previous assistant turn recorded reasoning but did not produce a user-visible answer. Continue from that partial turn and produce the visible answer now. Do not restate the reasoning or restart from scratch."; +const SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION = + "The previous assistant turn completed its tool calls but did not produce a user-visible answer. Continue from the current transcript and produce the final user-visible answer now. Do not repeat completed tool calls or restart from scratch."; + +type TerminalInput = Parameters[0]; +type TerminalInputOverrides = Omit, "runParams"> & { + runParams?: Partial; +}; + +function emptyAssistant(overrides: Parameters[0] = {}) { + return buildEmbeddedRunnerAssistant({ + content: [{ type: "text", text: "" }], + ...overrides, + }); +} + +function makeTerminalInput(overrides: TerminalInputOverrides = {}): TerminalInput { + const assistant = overrides.attemptAssistant ?? emptyAssistant(); + const attempt = + overrides.attempt ?? + makeEmbeddedRunnerAttempt({ + assistantTexts: [], + lastAssistant: assistant, + currentAttemptAssistant: assistant, + currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, + }); + const profileStore = { version: 1, profiles: {} } as never; + const runParams = { + sessionId: "session:terminal-resolution", + sessionKey: "agent:main:terminal-resolution", + runId: "run:terminal-resolution", + agentDir: "/tmp/openclaw-terminal-resolution", + ...overrides.runParams, + } as TerminalInput["runParams"]; + const base = { + runParams, + retryState: createEmbeddedRunTerminalRetryState(), + attempt, + attemptAssistant: attempt.currentAttemptAssistant ?? attempt.lastAssistant, + activeErrorContext: { provider: "openai", model: "gpt-5.6-luna" }, + modelApi: "openai-responses", + executionContract: undefined, + terminalState: resolveEmbeddedRunAttemptTerminalState({ + attempt, + assistant: attempt.currentAttemptAssistant ?? attempt.lastAssistant, + }), + payloadsWithToolMedia: [], + recoveredFinalAssistantPayloadsAfterPromptTimeout: undefined, + finalAssistantVisibleText: undefined, + finalAssistantRawText: undefined, + agentMeta: {} as never, + attemptToolSummary: undefined, + failureSignal: undefined, + maxReasoningOnlyRetryAttempts: 2, + maxEmptyResponseRetryAttempts: 1, + attemptCompactionCount: 0, + replayState: { ...attempt.replayMetadata, replayInvalid: false }, + activePromptPersisted: true, + activateInternalPrompt: vi.fn(), + setSuppressNextUserMessagePersistence: vi.fn(), + armPostCompactionGuard: vi.fn(), + readTerminalToolPresentation: () => undefined, + resolveReplayInvalid: () => false, + setTerminalLifecycleMeta: vi.fn(), + maybeMarkAuthProfileFailure: vi.fn(async () => undefined), + assistantProfileFailureReason: null, + startedAtMs: Date.now(), + provider: "openai", + modelId: "gpt-5.6-luna", + modelTransportId: "gpt-5.6-luna", + modelTransportApi: "openai-responses", + requestTransportOverrides: "none", + authProfileId: undefined, + profileFailureStore: profileStore, + attemptAuthProfileStore: profileStore, + apiKeyInfo: null, + agentHarnessId: "builtin-openclaw", + settledTurnFinalizationOutcome: "not-attempted", + pluginHarnessOwnsTransport: false, + pluginHarnessOwnsAuthBootstrap: false, + reportedModelRef: { provider: "openai", model: "gpt-5.6-luna" }, + traceAttempts: [], + traceAttemptUsesFallback: () => false, + thinkLevel: "off", + contextRecoveryState: createEmbeddedRunContextRecoveryState(), + } satisfies TerminalInput; + return { ...base, ...overrides, runParams }; +} + +describe("terminal resolution", () => { + it("carries presentation across retries until a newer tool outcome replaces it", () => { + const tracker = createTerminalToolPresentationTracker(); + const firstOrdinal = tracker.allocateOrdinal(); + tracker.observe({ + toolCallOrdinal: firstOrdinal, + terminalPresentation: "Fetched https://example.com", + }); + + expect(tracker.read()).toBe("Fetched https://example.com"); + + const retryOrdinal = tracker.allocateOrdinal(); + expect(tracker.read()).toBe("Fetched https://example.com"); + tracker.observe({ toolCallOrdinal: retryOrdinal }); + tracker.observe({ + toolCallOrdinal: firstOrdinal, + terminalPresentation: "stale presentation", + }); + + expect(tracker.read()).toBeUndefined(); + }); + + it("keeps only the bounded latest MCP App view identity", () => { + expect( + copyAttemptDeliveryState({ + latestMcpAppChannelView: { viewId: "view-latest" }, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + messagingToolSentTargets: [], + } as never).latestMcpAppChannelView, + ).toEqual({ viewId: "view-latest" }); + }); + + it("retries a required empty reply even when deliberate silence is enabled", async () => { + const activateInternalPrompt = vi.fn(); + const input = makeTerminalInput({ + runParams: { allowEmptyAssistantReplyAsSilent: true, terminalReplyExpectation: "required" }, + activateInternalPrompt, + }); + + await expect(resolveEmbeddedRunTerminal(input)).resolves.toEqual({ action: "retry" }); + expect(input.retryState.emptyResponseAttempts).toBe(1); + expect(activateInternalPrompt).toHaveBeenCalledWith(EMPTY_RESPONSE_RETRY_INSTRUCTION); + }); + + it("completes an explicit silent reply without retrying", async () => { + const assistant = buildEmbeddedRunnerAssistant({ + content: [{ type: "text", text: SILENT_REPLY_TOKEN }], + }); + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: [SILENT_REPLY_TOKEN], + lastAssistant: assistant, + currentAttemptAssistant: assistant, + currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, + }); + const activateInternalPrompt = vi.fn(); + const input = makeTerminalInput({ + attempt, + attemptAssistant: assistant, + runParams: { allowEmptyAssistantReplyAsSilent: true, terminalReplyExpectation: "required" }, + activateInternalPrompt, + }); + + const resolved = await resolveEmbeddedRunTerminal(input); + + expect(resolved.action).toBe("complete"); + if (resolved.action !== "complete") { + return; + } + expect(resolved.result.payloads).toEqual([{ text: SILENT_REPLY_TOKEN }]); + expect(resolved.result.meta.terminalReplyKind).toBe("silent-empty"); + expect(resolved.result.meta.livenessState).toBe("working"); + expect(activateInternalPrompt).not.toHaveBeenCalled(); + }); + + it("completes a cron turn from a trailing silent tool result", async () => { + const assistant = emptyAssistant(); + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: [], + toolMetas: [{ toolName: "exec" }], + messagesSnapshot: [ + { + role: "toolResult", + content: [{ type: "text", text: SILENT_REPLY_TOKEN }], + details: { aggregated: SILENT_REPLY_TOKEN }, + } as never, + assistant, + ], + lastAssistant: assistant, + currentAttemptAssistant: assistant, + }); + const activateInternalPrompt = vi.fn(); + const input = makeTerminalInput({ + attempt, + attemptAssistant: assistant, + runParams: { trigger: "cron", terminalReplyExpectation: "required" }, + activateInternalPrompt, + }); + + const resolved = await resolveEmbeddedRunTerminal(input); + + expect(resolved.action).toBe("complete"); + if (resolved.action !== "complete") { + return; + } + expect(resolved.result.payloads).toEqual([{ text: SILENT_REPLY_TOKEN }]); + expect(resolved.result.meta.livenessState).toBe("working"); + expect(activateInternalPrompt).not.toHaveBeenCalled(); + }); + + it("completes a reply-optional side-effecting turn as intentional silence", async () => { + const assistant = emptyAssistant(); + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: [], + toolMetas: [{ toolName: "sessions", meta: "patch archived", replaySafe: false }], + itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, + lastAssistant: assistant, + currentAttemptAssistant: assistant, + }); + const input = makeTerminalInput({ + attempt, + attemptAssistant: assistant, + replayState: { ...attempt.replayMetadata, replayInvalid: false }, + runParams: { + trigger: "cron", + allowEmptyAssistantReplyAsSilent: true, + terminalReplyExpectation: "optional", + }, + }); + + const resolved = await resolveEmbeddedRunTerminal(input); + + expect(resolved.action).toBe("complete"); + if (resolved.action !== "complete") { + return; + } + expect(resolved.result.payloads).toEqual([{ text: SILENT_REPLY_TOKEN }]); + expect(resolved.result.meta.error).toBeUndefined(); + expect(resolved.result.meta.terminalReplyKind).toBe("silent-empty"); + }); + + it("retries reasoning-only output and surfaces a retained presentation after exhaustion", async () => { + const assistant = buildEmbeddedRunnerAssistant({ + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_terminal", type: "reasoning" }), + }, + ], + }); + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: [], + lastAssistant: assistant, + currentAttemptAssistant: assistant, + currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, + }); + const activateInternalPrompt = vi.fn(); + const retryInput = makeTerminalInput({ + attempt, + attemptAssistant: assistant, + activateInternalPrompt, + }); + + await expect(resolveEmbeddedRunTerminal(retryInput)).resolves.toEqual({ action: "retry" }); + expect(activateInternalPrompt).toHaveBeenCalledWith(REASONING_ONLY_RETRY_INSTRUCTION); + + const exhaustedInput = makeTerminalInput({ + attempt, + attemptAssistant: assistant, + retryState: { ...createEmbeddedRunTerminalRetryState(), reasoningOnlyAttempts: 2 }, + readTerminalToolPresentation: () => + "Web fetch completed.\nOrigin: https://example.com\nStatus: 200", + }); + const exhausted = await resolveEmbeddedRunTerminal(exhaustedInput); + + expect(exhausted.action).toBe("complete"); + if (exhausted.action !== "complete") { + return; + } + expect(exhausted.result.payloads).toEqual([ + { + text: + "Web fetch completed.\nOrigin: https://example.com\nStatus: 200\n\n" + + "⚠️ Agent couldn't generate a response. Please try again.", + isError: true, + }, + ]); + expect(exhausted.result.meta.error).toMatchObject({ + kind: "incomplete_turn", + fallbackSafe: true, + terminalPresentation: true, + }); + }); + + it.each([ + { activePromptPersisted: true, expectedSuppression: true }, + { activePromptPersisted: false, expectedSuppression: false }, + ])( + "retries a missing assistant with suppression=$expectedSuppression", + async ({ activePromptPersisted, expectedSuppression }) => { + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: [], + lastAssistant: undefined, + currentAttemptAssistant: undefined, + currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, + }); + const setSuppressNextUserMessagePersistence = vi.fn(); + const activateInternalPrompt = vi.fn(); + const input = makeTerminalInput({ + attempt, + attemptAssistant: undefined, + activePromptPersisted, + setSuppressNextUserMessagePersistence, + activateInternalPrompt, + }); + + await expect(resolveEmbeddedRunTerminal(input)).resolves.toEqual({ action: "retry" }); + expect(setSuppressNextUserMessagePersistence).toHaveBeenCalledWith(expectedSuppression); + expect(activateInternalPrompt).not.toHaveBeenCalled(); + }, + ); + + it("requests isolated finalization only for a required settled-tool turn", () => { + const assistant = emptyAssistant(); + const attempt = makeEmbeddedRunnerAttempt({ + assistantTexts: [], + lastAssistant: assistant, + currentAttemptAssistant: assistant, + toolMetas: [{ toolName: "write", meta: "path=note.txt", replaySafe: false }], + itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, + currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, + }); + const terminalState = resolveEmbeddedRunAttemptTerminalState({ attempt, assistant }); + const request = (terminalReplyExpectation: "required" | "optional") => + resolveSettledTurnFinalizationRequest({ + runParams: { + sessionId: "session:settled", + runId: "run:settled", + terminalReplyExpectation, + } as never, + attempt, + activeErrorContext: { provider: "openai", model: "gpt-5.6-luna" }, + modelApi: "openai-responses", + executionContract: undefined, + payloadsWithToolMedia: [], + hasTerminalToolPresentation: false, + terminalState, + settledTurnFinalizationAvailable: true, + }); + + expect(request("required")).toBe(SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION); + expect(request("optional")).toBeNull(); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/terminal-resolution.ts b/src/agents/embedded-agent-runner/run/terminal-resolution.ts index 6eb5eaa7c40c..e16ba92a7ba0 100644 --- a/src/agents/embedded-agent-runner/run/terminal-resolution.ts +++ b/src/agents/embedded-agent-runner/run/terminal-resolution.ts @@ -54,6 +54,28 @@ const COMPACTION_CONTINUATION_RETRY_INSTRUCTION = const BEFORE_AGENT_FINALIZE_RETRY_PROMPT_PREFIX = "Before accepting the previous final answer, apply this revision request and produce the revised final answer. Do not repeat completed work or rerun tools unless the request explicitly requires it."; +type TerminalPresentationObservation = { + terminalPresentation?: string; + toolCallOrdinal?: number; +}; + +export function createTerminalToolPresentationTracker() { + let latestOrdinal = -1; + let nextOrdinal = 0; + let value: string | undefined; + return { + allocateOrdinal: () => nextOrdinal++, + observe: (observation: TerminalPresentationObservation): void => { + const ordinal = observation.toolCallOrdinal ?? latestOrdinal + 1; + if (ordinal >= latestOrdinal) { + latestOrdinal = ordinal; + value = observation.terminalPresentation; + } + }, + read: () => value, + }; +} + type TerminalRunParams = RunEmbeddedAgentParams & { authProfileStateMode?: "read-write" | "read-only"; onSuccessfulAuthBinding?: (binding: AgentExecutionAuthBinding) => void; @@ -90,7 +112,6 @@ export function resolveSettledTurnFinalizationRequest(input: { } const terminalAborted = isEmbeddedRunTerminalAbort(input.terminalState.outcome); const terminalTimedOut = isEmbeddedRunTerminalTimeout(input.terminalState.outcome); - const { promptError } = projectAgentRunAttemptTerminal(input.attempt.terminal); const silentToolResultReplyPayload = resolveSilentToolResultReplyPayload({ isCronTrigger: input.runParams.trigger === "cron", payloadCount: input.payloadsWithToolMedia?.length ?? 0, @@ -143,7 +164,6 @@ export function resolveSettledTurnFinalizationRequest(input: { payloadCount, hasTerminalToolPresentation: input.hasTerminalToolPresentation, aborted: terminalAborted, - promptError, timedOut: terminalTimedOut, attempt: input.attempt, }); diff --git a/src/agents/isolated-completion.test.ts b/src/agents/isolated-completion.test.ts index f1b385481710..e8e2c35e1660 100644 --- a/src/agents/isolated-completion.test.ts +++ b/src/agents/isolated-completion.test.ts @@ -13,6 +13,7 @@ import { type PreparedAgentRunAdmission, } from "./admitted-run-context.js"; import type { AgentHarness } from "./harness/types.js"; +import { createEmptyPluginMetadataSnapshot } from "./test-helpers/embedded-agent-runner-e2e-mocks.js"; type IsolatedCliRunParams = { preparedRunAdmission: PreparedAgentRunAdmission; @@ -145,7 +146,10 @@ beforeEach(() => { vi.clearAllMocks(); mocks.acquireAgentRunPreparedModelRuntime.mockResolvedValue({ snapshot: { + config: {}, + metadataSnapshot: createEmptyPluginMetadataSnapshot("/tmp/workspace"), pluginRegistry: createEmptyPluginRegistry(), + workspaceDir: "/tmp/workspace", createStores: () => ({ modelRegistry: {} }), }, release: vi.fn(), diff --git a/src/agents/isolated-completion.ts b/src/agents/isolated-completion.ts index 1fff7c9aac06..04632aa1c4ef 100644 --- a/src/agents/isolated-completion.ts +++ b/src/agents/isolated-completion.ts @@ -13,7 +13,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withTempWorkspace } from "../infra/private-temp-workspace.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import type { AssistantMessage } from "../llm/types.js"; -import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; +import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js"; import { prepareSystemAgentRunAdmission } from "./admitted-run-context.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; import { resolveCliBackendConfig, resolveCliRuntimeCanonicalProvider } from "./cli-backends.js"; @@ -682,7 +682,7 @@ export async function runIsolatedCompletion( usage: result.assistant.usage, }; }; - return await withPluginRuntimeRegistryScope(pluginRegistry, run); + return await withPluginRuntimeGenerationScope(lease.snapshot, run); } finally { lease.release(); } diff --git a/src/agents/model-thinking-default-core.ts b/src/agents/model-thinking-default-core.ts new file mode 100644 index 000000000000..36b0da36e709 --- /dev/null +++ b/src/agents/model-thinking-default-core.ts @@ -0,0 +1,147 @@ +import { resolveClaudeOpus5ModelIdentity } from "@openclaw/llm-core"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "@openclaw/normalization-core/string-coerce"; +import { + resolveSupportedThinkingLevel, + resolveThinkingDefaultForModel, + resolveThinkingProfile, +} from "../auto-reply/thinking.js"; +import { + resolveThinkingDefaultForModelCore, + type ThinkLevel, +} from "../auto-reply/thinking.shared.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import { legacyModelKey, modelKey, normalizeProviderId } from "./model-ref-shared.js"; +import { normalizeModelSelection } from "./model-selection-resolve.js"; +import { buildConfiguredModelCatalog } from "./model-selection-shared.js"; + +type ThinkingDefaultParams = { + cfg: OpenClawConfig; + provider: string; + model: string; + catalog?: ModelCatalogEntry[]; + agentRuntime?: string | null; +}; + +export function resolveConfiguredThinkingDefaultCore(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): ThinkLevel | undefined { + const configuredModels = params.cfg.agents?.defaults?.models; + const canonicalKey = modelKey(params.provider, params.model); + const legacyKey = legacyModelKey(params.provider, params.model); + const perModelThinking = + configuredModels?.[canonicalKey]?.params?.thinking ?? + (legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined); + if ( + perModelThinking === false || + perModelThinking === "disabled" || + perModelThinking === "none" + ) { + return "off"; + } + if ( + perModelThinking === "off" || + perModelThinking === "minimal" || + perModelThinking === "low" || + perModelThinking === "medium" || + perModelThinking === "high" || + perModelThinking === "xhigh" || + perModelThinking === "adaptive" || + perModelThinking === "max" || + perModelThinking === "ultra" + ) { + return perModelThinking; + } + return params.cfg.agents?.defaults?.thinkingDefault; +} + +export function resolveThinkingDefaultCore( + params: ThinkingDefaultParams & { + providerPolicySource?: "active" | "active-or-bundled"; + }, +): ThinkLevel { + const normalizedProvider = normalizeProviderId(params.provider); + const normalizedModel = normalizeLowercaseStringOrEmpty(params.model).replace(/\./g, "-"); + const catalog = Array.isArray(params.catalog) + ? params.catalog + : buildConfiguredModelCatalog({ cfg: params.cfg }); + const catalogCandidate = catalog.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const configuredModels = params.cfg.agents?.defaults?.models; + const canonicalKey = modelKey(params.provider, params.model); + const legacyKey = legacyModelKey(params.provider, params.model); + const normalizedCanonicalKey = normalizeLowercaseStringOrEmpty(canonicalKey); + const normalizedLegacyKey = normalizeOptionalLowercaseString(legacyKey); + const primarySelection = normalizeModelSelection(params.cfg.agents?.defaults?.model); + const normalizedPrimarySelection = normalizeOptionalLowercaseString(primarySelection); + const explicitModelConfigured = + (configuredModels ? canonicalKey in configuredModels : false) || + Boolean(legacyKey && configuredModels && legacyKey in configuredModels) || + normalizedPrimarySelection === normalizedCanonicalKey || + Boolean(normalizedLegacyKey && normalizedPrimarySelection === normalizedLegacyKey) || + normalizedPrimarySelection === normalizeLowercaseStringOrEmpty(params.model); + const configured = resolveConfiguredThinkingDefaultCore(params); + if (configured) { + return configured; + } + const isClaudeProvider = + normalizedProvider === "anthropic" || + normalizedProvider === "anthropic-vertex" || + normalizedProvider === "claude-cli"; + if (isClaudeProvider && resolveClaudeOpus5ModelIdentity({ id: normalizedModel })) { + return "high"; + } + if ( + isClaudeProvider && + (normalizedModel.startsWith("claude-opus-4-8") || normalizedModel.startsWith("claude-opus-4.8")) + ) { + return "off"; + } + if ( + isClaudeProvider && + (normalizedModel.startsWith("claude-opus-4-7") || normalizedModel.startsWith("claude-opus-4.7")) + ) { + return "off"; + } + if ( + normalizedProvider === "anthropic" && + explicitModelConfigured && + typeof catalogCandidate?.name === "string" && + /4\.6\b/.test(catalogCandidate.name) && + (normalizedModel.startsWith("claude-opus-4-6") || + normalizedModel.startsWith("claude-sonnet-4-6")) + ) { + return "adaptive"; + } + const fallbackParams = { + provider: params.provider, + model: params.model, + catalog, + agentRuntime: params.agentRuntime, + }; + if (!params.providerPolicySource) { + return resolveThinkingDefaultForModel(fallbackParams); + } + const profile = resolveThinkingProfile({ + ...fallbackParams, + providerPolicySource: params.providerPolicySource, + }); + if (profile.defaultLevel) { + return profile.defaultLevel; + } + const fallback = resolveThinkingDefaultForModelCore(fallbackParams); + if (fallback === "off") { + return "off"; + } + return resolveSupportedThinkingLevel({ + ...fallbackParams, + level: "medium", + providerPolicySource: params.providerPolicySource, + }); +} diff --git a/src/agents/model-thinking-default.ts b/src/agents/model-thinking-default.ts index a9210e87d367..6f243f2a97c2 100644 --- a/src/agents/model-thinking-default.ts +++ b/src/agents/model-thinking-default.ts @@ -3,18 +3,14 @@ * explicit per-model config, global defaults, catalog metadata, and model * family fallbacks. */ -import { resolveClaudeOpus5ModelIdentity } from "@openclaw/llm-core"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, -} from "@openclaw/normalization-core/string-coerce"; -import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js"; import type { ThinkLevel } from "../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; -import { legacyModelKey, modelKey, normalizeProviderId } from "./model-ref-shared.js"; -import { normalizeModelSelection } from "./model-selection-resolve.js"; import { buildConfiguredModelCatalog } from "./model-selection-shared.js"; +import { + resolveConfiguredThinkingDefaultCore, + resolveThinkingDefaultCore, +} from "./model-thinking-default-core.js"; /** Resolves configured thinking without consulting model capability metadata. */ export function resolveConfiguredThinkingDefault(params: { @@ -22,33 +18,7 @@ export function resolveConfiguredThinkingDefault(params: { provider: string; model: string; }): ThinkLevel | undefined { - const configuredModels = params.cfg.agents?.defaults?.models; - const canonicalKey = modelKey(params.provider, params.model); - const legacyKey = legacyModelKey(params.provider, params.model); - const perModelThinking = - configuredModels?.[canonicalKey]?.params?.thinking ?? - (legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined); - if ( - perModelThinking === false || - perModelThinking === "disabled" || - perModelThinking === "none" - ) { - return "off"; - } - if ( - perModelThinking === "off" || - perModelThinking === "minimal" || - perModelThinking === "low" || - perModelThinking === "medium" || - perModelThinking === "high" || - perModelThinking === "xhigh" || - perModelThinking === "adaptive" || - perModelThinking === "max" || - perModelThinking === "ultra" - ) { - return perModelThinking; - } - return params.cfg.agents?.defaults?.thinkingDefault; + return resolveConfiguredThinkingDefaultCore(params); } /** Resolves the default thinking level for a provider/model pair. */ @@ -59,66 +29,7 @@ export function resolveThinkingDefault(params: { catalog?: ModelCatalogEntry[]; agentRuntime?: string | null; }): ThinkLevel { - const normalizedProvider = normalizeProviderId(params.provider); - const normalizedModel = normalizeLowercaseStringOrEmpty(params.model).replace(/\./g, "-"); - const catalog = Array.isArray(params.catalog) - ? params.catalog - : buildConfiguredModelCatalog({ cfg: params.cfg }); - const catalogCandidate = catalog.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); - const configuredModels = params.cfg.agents?.defaults?.models; - const canonicalKey = modelKey(params.provider, params.model); - const legacyKey = legacyModelKey(params.provider, params.model); - const normalizedCanonicalKey = normalizeLowercaseStringOrEmpty(canonicalKey); - const normalizedLegacyKey = normalizeOptionalLowercaseString(legacyKey); - const primarySelection = normalizeModelSelection(params.cfg.agents?.defaults?.model); - const normalizedPrimarySelection = normalizeOptionalLowercaseString(primarySelection); - const explicitModelConfigured = - (configuredModels ? canonicalKey in configuredModels : false) || - Boolean(legacyKey && configuredModels && legacyKey in configuredModels) || - normalizedPrimarySelection === normalizedCanonicalKey || - Boolean(normalizedLegacyKey && normalizedPrimarySelection === normalizedLegacyKey) || - normalizedPrimarySelection === normalizeLowercaseStringOrEmpty(params.model); - const configured = resolveConfiguredThinkingDefault(params); - if (configured) { - return configured; - } - const isClaudeProvider = - normalizedProvider === "anthropic" || - normalizedProvider === "anthropic-vertex" || - normalizedProvider === "claude-cli"; - if (isClaudeProvider && resolveClaudeOpus5ModelIdentity({ id: normalizedModel })) { - return "high"; - } - if ( - isClaudeProvider && - (normalizedModel.startsWith("claude-opus-4-8") || normalizedModel.startsWith("claude-opus-4.8")) - ) { - return "off"; - } - if ( - isClaudeProvider && - (normalizedModel.startsWith("claude-opus-4-7") || normalizedModel.startsWith("claude-opus-4.7")) - ) { - return "off"; - } - if ( - normalizedProvider === "anthropic" && - explicitModelConfigured && - typeof catalogCandidate?.name === "string" && - /4\.6\b/.test(catalogCandidate.name) && - (normalizedModel.startsWith("claude-opus-4-6") || - normalizedModel.startsWith("claude-sonnet-4-6")) - ) { - return "adaptive"; - } - return resolveThinkingDefaultForModel({ - provider: params.provider, - model: params.model, - catalog, - agentRuntime: params.agentRuntime, - }); + return resolveThinkingDefaultCore(params); } /** Resolves thinking default after loading runtime catalog only when needed. */ diff --git a/src/agents/openclaw-tools.registration.test.ts b/src/agents/openclaw-tools.registration.test.ts index 6d9edaa47052..d3ff804c46de 100644 --- a/src/agents/openclaw-tools.registration.test.ts +++ b/src/agents/openclaw-tools.registration.test.ts @@ -619,16 +619,25 @@ describe("gateway client capability tool filtering", () => { expect(hasTool(createOpenClawTools({ clientCaps: ["ui-commands"] }), "screen")).toBe(true); }); - it("omits terminal for sandboxed agents", () => { + it("omits host UI runtime tools for sandboxed agents", () => { expect(hasTool(createOpenClawTools({ agentSessionKey: "agent:main:main" }), "terminal")).toBe( true, ); + expect(hasTool(createOpenClawTools({ agentSessionKey: "agent:main:main" }), "portal")).toBe( + true, + ); expect( hasTool( createOpenClawTools({ agentSessionKey: "agent:main:main", sandboxed: true }), "terminal", ), ).toBe(false); + expect( + hasTool( + createOpenClawTools({ agentSessionKey: "agent:main:main", sandboxed: true }), + "portal", + ), + ).toBe(false); }); it("does not let tools.allow resurrect a gated tool for a channel run", () => { diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 7212ed56fcde..f8cc32f61945 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -442,7 +442,13 @@ function resetSessionStore(inputStore: Record) { if (request.method === "sessions.resolve") { const key = typeof request.params?.key === "string" ? request.params.key.trim() : ""; if (key && store[key]) { - return { key }; + const spawnedBy = + typeof request.params?.spawnedBy === "string" ? request.params.spawnedBy.trim() : ""; + const entry = store[key]; + if (!spawnedBy || entry.spawnedBy === spawnedBy || entry.parentSessionKey === spawnedBy) { + return { key }; + } + return {}; } const sessionId = typeof request.params?.sessionId === "string" ? request.params.sessionId.trim() : ""; @@ -508,9 +514,23 @@ function installSameAgentVisibility(visibility: "self" | "tree" | "agent") { function mockSpawnedSessionList( resolveSessions: (spawnedBy: string | undefined) => Array>, + resolveSessionId?: (sessionId: string) => string | undefined, ) { callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string; params?: Record }; + if (request.method === "sessions.resolve") { + const key = typeof request.params?.key === "string" ? request.params.key.trim() : ""; + const spawnedBy = request.params?.spawnedBy as string | undefined; + if (key && resolveSessions(spawnedBy).some((session) => session.key === key)) { + return { key }; + } + const sessionId = + typeof request.params?.sessionId === "string" ? request.params.sessionId.trim() : ""; + if (sessionId && !spawnedBy) { + return { key: resolveSessionId?.(sessionId) }; + } + return {}; + } if (request.method === "sessions.list") { return { sessions: resolveSessions(request.params?.spawnedBy as string | undefined) }; } @@ -518,18 +538,14 @@ function mockSpawnedSessionList( }); } -function expectSpawnedSessionLookupCalls(spawnedBy: string) { - const expectedCall = { - method: "sessions.list", - params: { - includeGlobal: false, - includeUnknown: false, - spawnedBy, - }, - }; - expect(callGatewayMock).toHaveBeenCalledTimes(2); - expect(callGatewayMock).toHaveBeenNthCalledWith(1, expectedCall); - expect(callGatewayMock).toHaveBeenNthCalledWith(2, expectedCall); +function expectSpawnedSessionLookupCalls(spawnedBy: string, targetKeys: string[]) { + expect(callGatewayMock).toHaveBeenCalledTimes(targetKeys.length); + for (const [index, key] of targetKeys.entries()) { + expect(callGatewayMock).toHaveBeenNthCalledWith(index + 1, { + method: "sessions.resolve", + params: { agentId: "main", allowMissing: true, key, spawnedBy }, + }); + } } function expectRecordFields(record: unknown, expected: Record) { @@ -2246,7 +2262,7 @@ describe("session_status tool", () => { const tool = getSessionStatusTool("agent:main:main"); await expect(tool.execute("call5", { sessionKey: "agent:other:main" })).rejects.toThrow( - "Agent-to-agent status is disabled", + "Session status visibility is restricted", ); }); @@ -2302,10 +2318,11 @@ describe("session_status tool", () => { expect(updateSessionStoreMock).not.toHaveBeenCalled(); expect(callGatewayMock).toHaveBeenCalledTimes(1); expect(callGatewayMock).toHaveBeenCalledWith({ - method: "sessions.list", + method: "sessions.resolve", params: { - includeGlobal: false, - includeUnknown: false, + agentId: "main", + allowMissing: true, + key: "agent:main:main", spawnedBy: "agent:main:subagent:child", }, }); @@ -2326,6 +2343,79 @@ describe("session_status tool", () => { expect(updateSessionStoreMock).toHaveBeenCalledTimes(1); }); + it("blocks explicit incognito session_status before opening its store", async () => { + const incognitoSessionKey = "agent:main:dashboard:incognito-private"; + resetSessionStore({ + "agent:main:main": { sessionId: "s-main", updatedAt: 10 }, + [incognitoSessionKey]: { + sessionId: "s-incognito", + updatedAt: 20, + incognito: true, + }, + }); + mockConfig = { + session: { mainKey: "main", scope: "per-sender" }, + tools: { + sessions: { visibility: "agent" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + agents: { defaults: { model: { primary: "openai/gpt-5.4" }, models: {} } }, + }; + + const tool = getSessionStatusTool("agent:main:main"); + + await expect( + tool.execute("call-incognito-status", { + sessionKey: incognitoSessionKey, + model: "default", + }), + ).rejects.toThrow(`Session not visible from session tools: ${incognitoSessionKey}`); + + expect(loadSessionStoreMock).not.toHaveBeenCalled(); + expect(updateSessionStoreMock).not.toHaveBeenCalled(); + expect(callGatewayMock).not.toHaveBeenCalled(); + expect(buildStatusMessageMock).not.toHaveBeenCalled(); + }); + + it.each([ + { label: "semantic current", args: { sessionKey: "current" } }, + { label: "implicit no-arg", args: {} }, + ])("blocks $label incognito live-run status before opening its store", async ({ args }) => { + const requesterSessionKey = "agent:main:telegram:default:direct:1234"; + const incognitoSessionKey = "agent:main:dashboard:incognito-live-run"; + resetSessionStore({ + [requesterSessionKey]: { sessionId: "s-requester", updatedAt: 10 }, + [incognitoSessionKey]: { + sessionId: "s-incognito-live-run", + updatedAt: 20, + incognito: true, + }, + }); + mockConfig = { + session: { mainKey: "main", scope: "per-sender" }, + tools: { + sessions: { visibility: "agent" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + agents: { defaults: { model: { primary: "openai/gpt-5.4" }, models: {} } }, + }; + + const tool = createSessionStatusTool({ + agentSessionKey: requesterSessionKey, + runSessionKey: incognitoSessionKey, + config: mockConfig as never, + }); + + await expect( + tool.execute(`call-incognito-${args.sessionKey ?? "implicit"}`, args), + ).rejects.toThrow(`Session not visible from session tools: ${incognitoSessionKey}`); + + expect(loadSessionStoreMock).not.toHaveBeenCalled(); + expect(updateSessionStoreMock).not.toHaveBeenCalled(); + expect(callGatewayMock).not.toHaveBeenCalled(); + expect(buildStatusMessageMock).not.toHaveBeenCalled(); + }); + it("blocks unsandboxed sessionId session_status outside tree visibility before mutation", async () => { installSameAgentVisibility("tree"); callGatewayMock.mockImplementation(async (opts: unknown) => { @@ -2390,7 +2480,10 @@ describe("session_status tool", () => { expect(loadSessionStoreMock).not.toHaveBeenCalled(); expect(updateSessionStoreMock).not.toHaveBeenCalled(); - expectSpawnedSessionLookupCalls("agent:main:subagent:child"); + expectSpawnedSessionLookupCalls("agent:main:subagent:child", [ + "agent:main:main", + "agent:main:subagent:missing", + ]); }); it("blocks sandboxed child bare main session_status access outside its tree", async () => { @@ -2424,10 +2517,11 @@ describe("session_status tool", () => { expect(updateSessionStoreMock).not.toHaveBeenCalled(); expect(callGatewayMock).toHaveBeenCalledTimes(1); expect(callGatewayMock).toHaveBeenCalledWith({ - method: "sessions.list", + method: "sessions.resolve", params: { - includeGlobal: false, - includeUnknown: false, + agentId: "main", + allowMissing: true, + key: "main", spawnedBy: "agent:main:subagent:child", }, }); @@ -2438,13 +2532,17 @@ describe("session_status tool", () => { name: "blocks sandboxed child session_status access to another agent sessionId before store lookup", sessionId: "s-other", callId: "call6-session-id", + expectedError: "Session status visibility is restricted.", + checksOwnership: false, }, { name: "blocks sandboxed child session_status parent sessionId access outside its tree", sessionId: "s-parent", callId: "call7-parent-session-id", + expectedError: "Session status visibility is restricted to the current session tree", + checksOwnership: true, }, - ])("$name", async ({ sessionId, callId }) => { + ])("$name", async ({ sessionId, callId, expectedError, checksOwnership }) => { resetSessionStore({ "agent:main:subagent:child": { sessionId: "s-child", @@ -2459,12 +2557,19 @@ describe("session_status tool", () => { : {}), }); installSandboxedSessionStatusConfig(); - mockSpawnedSessionList(() => []); + mockSpawnedSessionList( + () => [], + (value) => + value === sessionId + ? sessionId === "s-other" + ? "agent:other:main" + : "agent:main:main" + : undefined, + ); const tool = getSessionStatusTool("agent:main:subagent:child", { sandboxed: true, }); - const expectedError = "Session status visibility is restricted to the current session tree"; await expect( tool.execute(callId, { @@ -2474,32 +2579,36 @@ describe("session_status tool", () => { expect(loadSessionStoreMock).not.toHaveBeenCalled(); expect(updateSessionStoreMock).not.toHaveBeenCalled(); - expect(callGatewayMock).toHaveBeenCalledTimes(3); + expect(callGatewayMock).toHaveBeenCalledTimes(checksOwnership ? 3 : 2); expect(callGatewayMock).toHaveBeenNthCalledWith(1, { - method: "sessions.list", + method: "sessions.resolve", params: { - includeGlobal: false, - includeUnknown: false, - spawnedBy: "agent:main:subagent:child", + agentId: "main", + key: sessionId, + spawnedBy: undefined, }, }); expect(callGatewayMock).toHaveBeenNthCalledWith(2, { method: "sessions.resolve", params: { - agentId: "main", - key: sessionId, - spawnedBy: "agent:main:subagent:child", - }, - }); - expect(callGatewayMock).toHaveBeenNthCalledWith(3, { - method: "sessions.resolve", - params: { + agentId: undefined, sessionId, - spawnedBy: "agent:main:subagent:child", - includeGlobal: false, - includeUnknown: false, + spawnedBy: undefined, + includeGlobal: true, + includeUnknown: true, }, }); + if (checksOwnership) { + expect(callGatewayMock).toHaveBeenNthCalledWith(3, { + method: "sessions.resolve", + params: { + agentId: "main", + allowMissing: true, + key: "agent:main:main", + spawnedBy: "agent:main:subagent:child", + }, + }); + } }); it("keeps legacy main requester keys for sandboxed session tree checks", async () => { @@ -2534,7 +2643,7 @@ describe("session_status tool", () => { expect(childDetails.ok).toBe(true); expect(childDetails.sessionKey).toBe("agent:main:subagent:child"); - expectSpawnedSessionLookupCalls("main"); + expectSpawnedSessionLookupCalls("main", ["agent:main:subagent:child"]); }); it("scopes bare session keys to the requester agent", async () => { diff --git a/src/agents/openclaw-tools.sessions-visibility.test.ts b/src/agents/openclaw-tools.sessions-visibility.test.ts index 37667081d2e3..e4dd9e596b66 100644 --- a/src/agents/openclaw-tools.sessions-visibility.test.ts +++ b/src/agents/openclaw-tools.sessions-visibility.test.ts @@ -56,11 +56,11 @@ describe("sessions tools visibility", () => { tools: { agentToAgent: { enabled: false } }, }; mockGatewayWithHistory((req) => { - if (req.method === "sessions.list" && req.params?.spawnedBy === "main") { - return { sessions: [{ key: "subagent:child-1" }] }; - } if (req.method === "sessions.resolve") { const key = typeof req.params?.key === "string" ? req.params.key : ""; + if (req.params?.spawnedBy === "main" && key !== "subagent:child-1") { + return {}; + } return { key }; } return undefined; @@ -100,8 +100,8 @@ describe("sessions tools visibility", () => { agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } }, }; mockGatewayWithHistory((req) => { - if (req.method === "sessions.list" && req.params?.spawnedBy === "main") { - return { sessions: [] }; + if (req.method === "sessions.resolve" && req.params?.spawnedBy === "main") { + return {}; } return undefined; }); diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index c5a1c9d3fb1f..462a47e7601a 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -1205,11 +1205,7 @@ describe("sessions tools", () => { delivery: { status: "skipped", mode: "announce" }, watched: false, }); - expect(calls.map((call) => call.method)).toEqual([ - "sessions.resolve", - "sessions.list", - "agent", - ]); + expect(calls.map((call) => call.method)).toEqual(["agent"]); } finally { unregister(); fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index b182bf5cd0a6..23f53248366a 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -76,6 +76,7 @@ import { createMusicGenerateTool } from "./tools/music-generate-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js"; import { createOpenClawDelegateToolsForRun } from "./tools/openclaw-delegate-tool.js"; import { createPdfTool } from "./tools/pdf-tool.js"; +import { createPortalTool } from "./tools/portal-tool.js"; import { createScreenTool } from "./tools/screen-tool.js"; import { createSessionStatusTool } from "./tools/session-status-tool.js"; import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; @@ -514,6 +515,7 @@ export function createOpenClawTools( agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, runId: options?.runId, }), + createPortalTool(), ]), ]), ...(!embedded && taskKey && options?.taskSuggestionDeliveryMode === "gateway" diff --git a/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts index 1ec91e37fd6a..edd528451faf 100644 --- a/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts +++ b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts @@ -29,7 +29,7 @@ type EmbeddedRunnerBackoffMockOptions = { sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => unknown; }; -function createEmptyPluginMetadataSnapshot(workspaceDir?: string): PluginMetadataSnapshot { +export function createEmptyPluginMetadataSnapshot(workspaceDir?: string): PluginMetadataSnapshot { return { policyHash: "", ...(workspaceDir !== undefined ? { workspaceDir } : {}), diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index 01531db3d3ae..02e10daced2c 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -63,6 +63,7 @@ describe("tool-catalog", () => { "screen", "dashboard", "terminal", + "portal", "automations", "get_goal", "create_goal", diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index a2d97e5dda95..4d39e0753bcb 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -310,6 +310,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ profiles: ["coding"], includeInOpenClawGroup: true, }, + { + id: "portal", + label: "portal", + description: "Expose local web apps through the gateway", + sectionId: "ui", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, { id: "canvas", label: "canvas", diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index 4c2887f84098..7a1129fe1b5b 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -70,6 +70,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { title: "Terminal", detailKeys: ["action", "sessionId", "command", "cwd"], }, + portal: { + emoji: "🌐", + title: "Portal", + detailKeys: ["action", "port", "id", "title", "path"], + }, process: { emoji: "🧰", title: "Process", diff --git a/src/agents/tool-mutation-names.ts b/src/agents/tool-mutation-names.ts index 10819a87c375..fe6640c593f5 100644 --- a/src/agents/tool-mutation-names.ts +++ b/src/agents/tool-mutation-names.ts @@ -21,6 +21,7 @@ const MUTATING_TOOL_NAMES = new Set([ // Saved transcripts predate the rename; legacy names must stay classified. ...LEGACY_AUTOMATIONS_TOOL_NAMES, "gateway", + "portal", "canvas", "computer", "mobile_ui", diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index fc3875b1a350..0e3a8d5c56fd 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -20,6 +20,15 @@ describe("tool mutation helpers", () => { ).toBe(true); }); + it("classifies portal list as replay-safe and portal mutations as mutating", () => { + expect(isMutatingToolCall("portal", { action: "list" })).toBe(false); + expect(isReplaySafeToolCall("portal", { action: "list" })).toBe(true); + for (const action of ["open", "close"]) { + expect(isMutatingToolCall("portal", { action }), action).toBe(true); + expect(isReplaySafeToolCall("portal", { action }), action).toBe(false); + } + }); + it("builds stable fingerprints for mutating calls and omits read-only calls", () => { const writeFingerprint = buildToolMutationState( "write", diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index 4ef5e5780feb..e9b75d9d1ec5 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -362,6 +362,8 @@ export function isMutatingToolCall(toolName: string, args: unknown): boolean { return typeof record?.model === "string" && record.model.trim().length > 0; case "gateway": return action == null || !GATEWAY_REPLAY_SAFE_ACTIONS.has(action); + case "portal": + return action !== "list"; case "nodes": return action == null || !NODES_REPLAY_SAFE_ACTIONS.has(action); default: { @@ -413,6 +415,8 @@ export function isReplaySafeToolCall(toolName: string, args: unknown): boolean { return action === "status"; case "gateway": return action != null && GATEWAY_REPLAY_SAFE_ACTIONS.has(action); + case "portal": + return action === "list"; case "nodes": return action != null && NODES_REPLAY_SAFE_ACTIONS.has(action); default: { diff --git a/src/agents/tools/portal-tool.test.ts b/src/agents/tools/portal-tool.test.ts new file mode 100644 index 000000000000..233256490a12 --- /dev/null +++ b/src/agents/tools/portal-tool.test.ts @@ -0,0 +1,100 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import type { + PortalCloseResult, + PortalListResult, + PortalSummary, +} from "../../../packages/gateway-protocol/src/index.js"; +import { + DEFAULT_GATEWAY_HTTP_TOOL_DENY, + GATEWAY_OWNER_ONLY_CORE_TOOLS, +} from "../../security/dangerous-tools.js"; +import type { InProcessGatewayCaller } from "./in-process-gateway.js"; +import { createPortalTool } from "./portal-tool.js"; + +const portal: PortalSummary = { + id: "p3000", + title: "App", + port: 3000, + listenPort: 43123, + tokenQuery: `openclaw_portal=${"a".repeat(64)}`, + url: `http://127.0.0.1:43123/?openclaw_portal=${"a".repeat(64)}`, + publicUrl: "http://127.0.0.1:43123/", + createdAtMs: 1, +}; + +function recorder() { + const calls: Array<[string, Record]> = []; + const callGateway: InProcessGatewayCaller = async ( + method: string, + params: Record, + ): Promise => { + calls.push([method, params]); + if (method === "portal.list") { + return { portals: [portal] } as PortalListResult as T; + } + if (method === "portal.close") { + return { closed: true } as PortalCloseResult as T; + } + return portal as T; + }; + return { calls, callGateway }; +} + +describe("portal tool", () => { + it("uses a flat closed action schema and owner-only security gate", () => { + const tool = createPortalTool(); + expect(tool.parameters).toMatchObject({ + additionalProperties: false, + properties: { action: { enum: ["open", "list", "close"] } }, + }); + expect(Value.Check(tool.parameters, { action: "open", port: 3000, path: "/app" })).toBe(true); + expect(Value.Check(tool.parameters, { action: "open", port: 0 })).toBe(false); + expect(Value.Check(tool.parameters, { action: "open", port: 3000, path: "app" })).toBe(false); + expect(Value.Check(tool.parameters, { action: "unknown" })).toBe(false); + expect(GATEWAY_OWNER_ONLY_CORE_TOOLS).toContain("portal"); + expect(DEFAULT_GATEWAY_HTTP_TOOL_DENY).toContain("portal"); + }); + + it("maps open, list, and close through the in-process gateway caller", async () => { + const recorded = recorder(); + const tool = createPortalTool({ callGateway: recorded.callGateway }); + const opened = await tool.execute("open", { + action: "open", + port: 3000, + title: "App", + description: "Preview", + path: "/app", + }); + const listed = await tool.execute("list", { action: "list" }); + const closed = await tool.execute("close", { action: "close", id: "p3000" }); + + expect(recorded.calls).toEqual([ + ["portal.open", { port: 3000, title: "App", description: "Preview", path: "/app" }], + ["portal.list", {}], + ["portal.close", { id: "p3000" }], + ]); + expect(opened.details).toEqual(portal); + expect(opened.content[0]).toMatchObject({ + type: "text", + text: `Portal available at ${portal.url}. Pass PUBLIC_URL=${portal.publicUrl} and PORT=${portal.port} when starting the dev server. The operator can see it in the Control UI Portals page.`, + }); + expect(listed.details).toEqual({ portals: [portal] }); + expect(closed.details).toEqual({ closed: true }); + expect(Value.Check(tool.outputSchema!, opened.details)).toBe(true); + expect(Value.Check(tool.outputSchema!, listed.details)).toBe(true); + expect(Value.Check(tool.outputSchema!, closed.details)).toBe(true); + }); + + it("rejects action-specific missing and malformed fields before RPC", async () => { + const recorded = recorder(); + const tool = createPortalTool({ callGateway: recorded.callGateway }); + + await expect(tool.execute("open", { action: "open" })).rejects.toThrow("port required"); + await expect(tool.execute("open", { action: "open", port: 3000, path: "app" })).rejects.toThrow( + "path must start with /", + ); + await expect(tool.execute("close", { action: "close" })).rejects.toThrow("id required"); + expect(recorded.calls).toEqual([]); + }); +}); diff --git a/src/agents/tools/portal-tool.ts b/src/agents/tools/portal-tool.ts new file mode 100644 index 000000000000..f79a28d385f0 --- /dev/null +++ b/src/agents/tools/portal-tool.ts @@ -0,0 +1,104 @@ +import { Type } from "typebox"; +import { + PortalCloseResultSchema, + PortalListResultSchema, + PortalSummarySchema, + type PortalCloseResult, + type PortalListResult, + type PortalSummary, +} from "../../../packages/gateway-protocol/src/index.js"; +import type { AgentToolResult } from "../runtime/index.js"; +import type { AnyAgentTool } from "./common.js"; +import { + jsonResult, + readPositiveIntegerParam, + readToolStringParam, + ToolInputError, +} from "./common.js"; +import { callInProcessGatewayTool, type InProcessGatewayCaller } from "./in-process-gateway.js"; + +const PORTAL_ACTIONS = ["open", "list", "close"] as const; + +const PortalToolSchema = Type.Object( + { + action: Type.String({ enum: [...PORTAL_ACTIONS], description: "Portal action" }), + port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535 })), + title: Type.Optional(Type.String({ minLength: 1 })), + description: Type.Optional(Type.String()), + path: Type.Optional(Type.String({ pattern: "^/" })), + id: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, +); + +const PortalToolOutputSchema = Type.Union([ + PortalSummarySchema, + PortalListResultSchema, + PortalCloseResultSchema, +]); + +type PortalToolOptions = { + callGateway?: InProcessGatewayCaller; +}; + +function portalResult(text: string, payload: T): AgentToolResult { + const result = jsonResult(payload); + return { ...result, content: [{ type: "text", text }, ...result.content] }; +} + +export function createPortalTool(options: PortalToolOptions = {}): AnyAgentTool { + const callGateway = options.callGateway ?? callInProcessGatewayTool; + return { + label: "Portal", + name: "portal", + description: + "Expose local HTTP server; operator sees it live in Control UI. Order matters: action=open with the port first, which returns the URL; then start the dev server as a background process, passing PORT and PUBLIC_URL from that result. Workspace may declare servers in .openclaw/portals.json. Proxies HTTP and WebSockets, so hot reload works; serves retry page until port listens. action=list and action=close manage portals. Portals end at gateway restart.", + parameters: PortalToolSchema, + outputSchema: PortalToolOutputSchema, + execute: async (_toolCallId, rawArgs) => { + const params = rawArgs as Record; + const action = readToolStringParam(params, "action", { required: true }); + if (action === "list") { + const result = await callGateway("portal.list", {}); + return portalResult( + `${result.portals.length} active portal${result.portals.length === 1 ? "" : "s"}. The operator can see them in the Control UI Portals page.`, + result, + ); + } + if (action === "close") { + const id = readToolStringParam(params, "id", { required: true }); + const result = await callGateway("portal.close", { id }); + return portalResult( + `Portal ${id} closed. The Control UI Portals page has been updated.`, + result, + ); + } + if (action !== "open") { + throw new ToolInputError(`Unknown portal action: ${action}`); + } + const port = readPositiveIntegerParam(params, "port", { + max: 65_535, + message: "port must be an integer from 1 to 65535", + }); + if (port === undefined) { + throw new ToolInputError("port required"); + } + const title = readToolStringParam(params, "title"); + const description = readToolStringParam(params, "description", { allowEmpty: true }); + const path = readToolStringParam(params, "path"); + if (path !== undefined && !path.startsWith("/")) { + throw new ToolInputError("path must start with /"); + } + const portal = await callGateway("portal.open", { + port, + ...(title !== undefined ? { title } : {}), + ...(description !== undefined ? { description } : {}), + ...(path !== undefined ? { path } : {}), + }); + return portalResult( + `Portal available at ${portal.url}. Pass PUBLIC_URL=${portal.publicUrl} and PORT=${portal.port} when starting the dev server. The operator can see it in the Control UI Portals page.`, + portal, + ); + }, + }; +} diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index a49984d94e56..9dbe5a12b027 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -27,6 +27,7 @@ import { import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { buildAgentMainSessionKey, + isIncognitoSessionKey, parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; @@ -91,11 +92,11 @@ import { } from "./session-status-session-resolve.js"; import { createAgentToAgentPolicy, - createSessionVisibilityGuard, resolveCurrentSessionClientAlias, resolveEffectiveSessionToolsVisibility, resolveSandboxedSessionToolContext, resolveSessionReference, + resolveSessionToolAccess, resolveVisibleSessionReference, shouldResolveSessionIdInput, } from "./sessions-helpers.js"; @@ -589,11 +590,12 @@ export function createSessionStatusTool(opts?: { const gatewayCall = opts?.callGateway ?? callAgentToolGatewayRequest; const changesSince = readNonNegativeIntegerParam(params, "changesSince"); const cfg = opts?.config ?? getRuntimeConfig(); - const { mainKey, alias, effectiveRequesterKey } = resolveSandboxedSessionToolContext({ - cfg, - agentSessionKey: opts?.agentSessionKey, - sandboxed: opts?.sandboxed, - }); + const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } = + resolveSandboxedSessionToolContext({ + cfg, + agentSessionKey: opts?.agentSessionKey, + sandboxed: opts?.sandboxed, + }); const a2aPolicy = createAgentToAgentPolicy(cfg); const requesterAgentId = resolveSessionAgentIds({ config: cfg, @@ -639,18 +641,63 @@ export function createSessionStatusTool(opts?: { } return trimmed; }; - const visibilityGuard = await createSessionVisibilityGuard({ - action: "status", - defaultAgentId: requesterAgentId, - requesterAgentId, - requesterSessionKey: visibilityRequesterKey, - visibility: resolveEffectiveSessionToolsVisibility({ - cfg, - sandboxed: opts?.sandboxed === true, - }), - a2aPolicy, - callGateway: gatewayCall, + const sessionVisibility = resolveEffectiveSessionToolsVisibility({ + cfg, + sandboxed: opts?.sandboxed === true, }); + const accessByTarget = new Map< + string, + Awaited> + >(); + const checkVisibilityAccess = async (target: { + targetSessionKey: string; + targetAgentId: string; + authorizationTargetSessionKey: string; + requesterOwned: boolean; + }) => { + const cacheKey = `${target.requesterOwned ? "owned" : "unowned"}:${target.targetAgentId}:${target.targetSessionKey}:${target.authorizationTargetSessionKey}`; + const cached = accessByTarget.get(cacheKey); + if (cached) { + return cached; + } + let access = await resolveSessionToolAccess({ + action: "status", + defaultAgentId: configuredDefaultAgentId, + requesterAgentId, + requesterSessionKey: visibilityRequesterKey, + authorizationTargetSessionKey: target.authorizationTargetSessionKey, + targetAgentId: target.targetAgentId, + targetSessionKey: target.targetSessionKey, + requesterOwned: target.requesterOwned, + visibility: sessionVisibility, + a2aPolicy, + callGateway: gatewayCall, + }); + if ( + !access.allowed && + target.targetAgentId !== requesterAgentId && + !target.requesterOwned && + !target.authorizationTargetSessionKey.startsWith("agent:") && + !access.error.includes("ownership lookup failed") + ) { + if (!a2aPolicy.enabled) { + access = { + allowed: false, + status: "forbidden", + error: + "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.", + }; + } else if (!a2aPolicy.isAllowed(requesterAgentId, target.targetAgentId)) { + access = { + allowed: false, + status: "forbidden", + error: "Agent-to-agent session status denied by tools.agentToAgent.allow.", + }; + } + } + accessByTarget.set(cacheKey, access); + return access; + }; const requestedKeyParam = readToolStringParam(params, "sessionKey"); const isImplicitRunSessionStatus = @@ -698,36 +745,7 @@ export function createSessionStatusTool(opts?: { throw new Error("sessionKey required"); } requestedKeyRaw = requestedKeyInput; - const ensureAgentAccess = (targetAgentId: string) => { - if (targetAgentId === requesterAgentId) { - return; - } - // Gate cross-agent access behind tools.agentToAgent settings. - if (!a2aPolicy.enabled) { - throw new Error( - "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.", - ); - } - if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { - throw new Error("Agent-to-agent session status denied by tools.agentToAgent.allow."); - } - }; - - if (requestedKeyInput.startsWith("agent:") && !isSemanticCurrentRequest) { - const requestedAgentId = resolveAgentIdFromSessionKey( - requestedKeyInput, - configuredDefaultAgentId, - ); - ensureAgentAccess(requestedAgentId); - const visibilityTargetKey = normalizeVisibilityTargetSessionKey( - requestedKeyInput, - requestedAgentId, - ); - const access = visibilityGuard.check(visibilityTargetKey); - if (!access.allowed) { - throw new Error(access.error); - } - } + let resolvedRequesterOwned = false; const deferTargetOwnerResolution = !isSemanticCurrentRequest && shouldResolveSessionIdInput(requestedKeyInput); @@ -738,8 +756,23 @@ export function createSessionStatusTool(opts?: { targetSessionKey: requestedKeyInput, requesterAgentId, }); - if (!isSemanticCurrentRequest && !deferTargetOwnerResolution) { - ensureAgentAccess(agentId); + // Semantic current is self for ordinary visibility, but its live-run key can + // still be process-only. Preserve that target for the shared guard before storage. + const mustCheckRequestedKeyBeforeStore = + !isSemanticCurrentRequest || isIncognitoSessionKey(requestedKeyInput); + if (mustCheckRequestedKeyBeforeStore && !deferTargetOwnerResolution) { + const access = await checkVisibilityAccess({ + targetSessionKey: requestedKeyInput, + targetAgentId: agentId, + authorizationTargetSessionKey: normalizeVisibilityTargetSessionKey( + requestedKeyInput, + agentId, + ), + requesterOwned: false, + }); + if (!access.allowed) { + throw new Error(access.error); + } } let storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId }); let storeScopedRequesterKey = resolveStoreScopedRequesterKey({ @@ -766,13 +799,14 @@ export function createSessionStatusTool(opts?: { (requestedKeyInput === "current" || shouldResolveSessionIdInput(requestedKeyInput)) ) { const resolvedSession = await resolveSessionReference({ + action: "status", sessionKey: requestedKeyInput, ...(requestedKeyInput === "current" ? { agentId: requesterAgentId } : {}), keyAgentId: requesterAgentId, alias, mainKey, requesterInternalKey: effectiveRequesterKey, - restrictToSpawned: opts?.sandboxed === true, + restrictToSpawned, callGateway: gatewayCall, }); if (resolvedSession.ok) { @@ -790,14 +824,27 @@ export function createSessionStatusTool(opts?: { // watched-group carve-out); a local string here would drift from it. throw new Error(visibleSession.error); } - // If resolution points at another agent, enforce A2A policy before switching stores. const visibleAgentId = resolveSessionToolTargetAgentId({ cfg, targetSessionKey: visibleSession.key, resolvedAgentId: visibleSession.agentId, requesterAgentId, }); - ensureAgentAccess(visibleAgentId); + if (opts?.sandboxed === true || visibleAgentId !== requesterAgentId) { + const access = await checkVisibilityAccess({ + targetSessionKey: visibleSession.key, + targetAgentId: visibleAgentId, + authorizationTargetSessionKey: normalizeVisibilityTargetSessionKey( + visibleSession.key, + visibleAgentId, + ), + requesterOwned: visibleSession.requesterOwned, + }); + if (!access.allowed) { + throw new Error(access.error); + } + } + resolvedRequesterOwned = visibleSession.requesterOwned; resolvedViaSessionId = resolvedSession.resolvedViaSessionId; requestedKeyRaw = visibleSession.key; requestedKeyInput = requestedKeyRaw.trim(); @@ -816,8 +863,11 @@ export function createSessionStatusTool(opts?: { mainKey, requesterInternalKey: storeScopedRequesterKey, }); - } else if (!resolvedSession.ok && opts?.sandboxed === true) { - throw new Error("Session status visibility is restricted to the current session tree."); + } else if ( + !resolvedSession.ok && + (!resolvedSession.notFound || resolvedSession.status === "forbidden") + ) { + throw new Error(resolvedSession.error); } } @@ -897,10 +947,16 @@ export function createSessionStatusTool(opts?: { (!resolvedViaSessionId && (requestedKeyInput === "current" || (resolved.key === requestedKeyInput && agentId === requesterAgentId))); - const visibilityTargetKey = shouldTreatVisibilityTargetAsSelf - ? visibilityRequesterKey - : normalizeVisibilityTargetSessionKey(resolved.key, agentId); - const access = visibilityGuard.check(visibilityTargetKey); + const visibilityTargetKey = + shouldTreatVisibilityTargetAsSelf && !isIncognitoSessionKey(resolved.key) + ? visibilityRequesterKey + : normalizeVisibilityTargetSessionKey(resolved.key, agentId); + const access = await checkVisibilityAccess({ + targetSessionKey: resolved.key, + targetAgentId: agentId, + authorizationTargetSessionKey: visibilityTargetKey, + requesterOwned: resolvedRequesterOwned, + }); if (!access.allowed) { throw new Error(access.error); } diff --git a/src/agents/tools/sessions-access.test.ts b/src/agents/tools/sessions-access.test.ts index 41ebaac9f551..bad7f3e65caf 100644 --- a/src/agents/tools/sessions-access.test.ts +++ b/src/agents/tools/sessions-access.test.ts @@ -1,10 +1,13 @@ // Sessions access tests cover session-tool visibility policy, sandbox clamps, // and agent-to-agent allow rules. -import { describe, expect, it, vi } from "vitest"; -import { cleanupTempDirs, makeTempDir } from "../../../test/helpers/temp-dir.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { GatewayCredentialsRequiredError } from "../../gateway/call.js"; +import { GatewayClientRequestError } from "../../gateway/client.js"; import { createAgentToAgentPolicy, + createSessionVisibilityChecker, createSessionVisibilityGuard, createSessionVisibilityRowChecker, resolveEffectiveSessionToolsVisibility, @@ -17,7 +20,13 @@ import { registerSessionStateWatch, } from "../../sessions/session-state-events.js"; import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; -import { resolveSandboxedSessionToolContext } from "./sessions-access.js"; +import { resolveSandboxedSessionToolContext, resolveSessionToolAccess } from "./sessions-access.js"; + +const loggerMocks = vi.hoisted(() => ({ logWarn: vi.fn() })); +vi.mock("../../logger.js", async (importOriginal) => ({ + ...(await importOriginal()), + logWarn: loggerMocks.logWarn, +})); describe("resolveSessionToolsVisibility", () => { it("defaults to tree when unset or invalid", () => { @@ -231,9 +240,10 @@ describe("createAgentToAgentPolicy", () => { }); describe("createSessionVisibilityGuard", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + it("allows watched group reads under tree while denying unwatched peers", () => { - const tempDirs: string[] = []; - const stateDir = makeTempDir(tempDirs, "openclaw-session-visibility-"); + const stateDir = tempDirs.make("openclaw-session-visibility-"); closeOpenClawStateDatabaseForTest(); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); try { @@ -301,7 +311,6 @@ describe("createSessionVisibilityGuard", () => { } finally { closeOpenClawStateDatabaseForTest(); vi.unstubAllEnvs(); - cleanupTempDirs(tempDirs); } }); @@ -468,32 +477,177 @@ describe("createSessionVisibilityGuard", () => { }); it("does not block exact same-agent spawned targets that fall past the spawned list cap", async () => { - const callGateway = vi.fn(async (request: { method?: string; params?: { key?: string } }) => { + const gateway = vi.fn(async (request: { method?: string; params?: { key?: string } }) => { if (request.method === "sessions.resolve") { return { key: request.params?.key }; } - if (request.method === "sessions.list") { - return { - sessions: [ - ...Array.from({ length: 500 }, (_, index) => ({ - key: `agent:main:subagent:worker-${index}`, - })), - { key: "agent:main:subagent:worker-999" }, - ], - }; - } return {}; }); - const guard = await createSessionVisibilityGuard({ + const access = await resolveSessionToolAccess({ action: "history", + requesterAgentId: "main", requesterSessionKey: "agent:main:main", + targetAgentId: "main", + targetSessionKey: "agent:main:subagent:worker-999", + requesterOwned: false, visibility: "tree", a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), - callGateway: callGateway as never, + callGateway: gateway as never, }); - expect(guard.check("agent:main:subagent:worker-999")).toEqual({ allowed: true }); + expect(access).toEqual({ allowed: true }); + expect(gateway).toHaveBeenCalledTimes(1); + expect(gateway).toHaveBeenCalledWith(expect.objectContaining({ method: "sessions.resolve" })); + }); + + it("falls back to spawned-session listing when the exact resolver is unavailable", async () => { + const gateway = vi.fn(async (request: { method?: string }) => { + if (request.method === "sessions.resolve") { + throw new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "unknown method: sessions.resolve", + }); + } + return { sessions: [{ key: "agent:main:subagent:worker" }] }; + }); + + const access = await resolveSessionToolAccess({ + action: "history", + requesterAgentId: "main", + requesterSessionKey: "agent:main:main", + targetAgentId: "main", + targetSessionKey: "agent:main:subagent:worker", + requesterOwned: false, + visibility: "tree", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: gateway as never, + }); + + expect(access).toEqual({ allowed: true }); + expect(gateway.mock.calls.map(([request]) => request.method)).toEqual([ + "sessions.resolve", + "sessions.list", + ]); + }); + + it("preserves an ordinary cross-agent denial when exact ownership lookup fails", async () => { + const gateway = vi.fn(async () => { + throw new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "transport timeout", + retryable: true, + }); + }); + + const access = await resolveSessionToolAccess({ + action: "send", + requesterAgentId: "main", + requesterSessionKey: "agent:main:main", + targetAgentId: "ops", + targetSessionKey: "agent:ops:main", + requesterOwned: false, + visibility: "all", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: gateway as never, + }); + + expect(access).toEqual({ + allowed: false, + status: "forbidden", + error: + "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.", + }); + expect(gateway).not.toHaveBeenCalled(); + }); + + it("does not apply a bare-key scoped grant to another agent's session", async () => { + const targets: string[] = []; + const unregister = createSessionVisibilityChecker.registerScopedAccessProvider((request) => { + targets.push(request.targetSessionKey); + return request.targetSessionKey === "shared" ? { expectedSessionId: "agent-a" } : undefined; + }); + try { + const gateway = vi.fn(); + const access = await resolveSessionToolAccess({ + action: "history", + requesterAgentId: "main", + requesterSessionKey: "agent:main:main", + authorizationTargetSessionKey: "agent:ops:shared", + targetAgentId: "ops", + targetSessionKey: "shared", + requesterOwned: false, + visibility: "self", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: gateway as never, + }); + + expect(access.allowed).toBe(false); + expect(targets).toEqual(["agent:ops:shared"]); + expect(gateway).not.toHaveBeenCalled(); + } finally { + unregister(); + } + }); + + it("keeps incognito targets hidden from scoped grants", async () => { + const targetSessionKey = "agent:main:dashboard:incognito-private"; + const unregister = createSessionVisibilityChecker.registerScopedAccessProvider(() => ({ + expectedSessionId: "incognito-incarnation", + })); + try { + const gateway = vi.fn(); + const access = await resolveSessionToolAccess({ + action: "history", + requesterAgentId: "main", + requesterSessionKey: "agent:main:main", + targetAgentId: "main", + targetSessionKey, + requesterOwned: true, + visibility: "all", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: gateway as never, + }); + + expect(access).toEqual({ + allowed: false, + status: "forbidden", + error: `Session not visible from session tools: ${targetSessionKey}`, + }); + expect(gateway).not.toHaveBeenCalled(); + } finally { + unregister(); + } + }); + + it("retains lookup-failure guidance for a cross-agent ACP child candidate", async () => { + const gateway = vi.fn(async () => { + throw new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "transport timeout", + retryable: true, + }); + }); + + const access = await resolveSessionToolAccess({ + action: "history", + requesterAgentId: "main", + requesterSessionKey: "agent:main:main", + targetAgentId: "codex", + targetSessionKey: "agent:codex:acp:child-1", + requesterOwned: false, + visibility: "tree", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: gateway as never, + }); + + expect(access).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history denied because spawned-session ownership lookup failed (transient); retry once, then ask the operator to inspect OpenClaw logs.", + }); + expect(gateway).toHaveBeenCalledTimes(1); }); it("blocks cross-agent send when agent-to-agent is disabled", async () => { @@ -502,6 +656,7 @@ describe("createSessionVisibilityGuard", () => { requesterSessionKey: "agent:main:main", visibility: "all", a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: vi.fn(async () => ({ sessions: [] })) as never, }); expect(guard.check("agent:ops:main")).toEqual({ @@ -528,4 +683,218 @@ describe("createSessionVisibilityGuard", () => { "Session history visibility is restricted to the current session (tools.sessions.visibility=self).", }); }); + + it("preserves cross-agent policy denials after a successful empty ownership lookup", async () => { + const guard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: "agent:main:main", + visibility: "tree", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: vi.fn(async () => ({ sessions: [] })) as never, + }); + + expect(guard.check("agent:other:main")).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history visibility is restricted. Set tools.sessions.visibility=all and tools.agentToAgent.enabled=true to allow cross-agent access; use tools.agentToAgent.allow to restrict permitted agent pairs.", + }); + }); + + it.each([ + { + name: "cross-agent ACP child under tree visibility", + target: "agent:codex:acp:child-1", + visibility: "tree" as const, + error: + "Session history denied because spawned-session ownership lookup failed (transient); retry once, then ask the operator to inspect OpenClaw logs.", + }, + { + name: "cross-agent ACP child under all visibility", + target: "agent:codex:acp:child-1", + visibility: "all" as const, + error: + "Session history denied because spawned-session ownership lookup failed (transient); retry once, then ask the operator to inspect OpenClaw logs.", + }, + { + name: "malformed agent key", + target: "agent:", + visibility: "tree" as const, + error: "Session history denied because target agent ownership is unavailable.", + }, + { + name: "unscoped alias without a default agent", + target: "main", + visibility: "tree" as const, + error: "Session history denied because target agent ownership is unavailable.", + }, + ])("handles $name when the ownership lookup fails", async ({ target, visibility, error }) => { + const guard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: "agent:main:main", + visibility, + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: vi.fn(async () => { + throw new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "transport timeout", + retryable: true, + }); + }) as never, + }); + + expect(guard.check(target)).toEqual({ + allowed: false, + status: "forbidden", + error, + }); + }); + + it("reports a transient tree-visibility lookup failure distinctly", async () => { + loggerMocks.logWarn.mockClear(); + const guard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: "agent:main:main", + visibility: "tree", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: vi.fn(async () => { + throw new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "transport timeout Authorization: Bearer sk-evidence-secret-9f3a2c", + retryable: true, + }); + }) as never, + }); + + const result = guard.check("agent:main:subagent:child-1"); + expect(result.allowed).toBe(false); + expect(result).toMatchObject({ status: "forbidden" }); + if (!result.allowed) { + expect(result.error).toMatch(/ownership lookup failed/i); + expect(result.error).toMatch(/transient\); retry/i); + } + const warnText = loggerMocks.logWarn.mock.calls.map((call) => String(call[0])).join("\n"); + expect(warnText).toMatch(/requester=sha256:[a-f0-9]{12}/u); + expect(warnText).not.toContain("agent:main:main"); + expect(warnText).not.toContain("sk-evidence-secret-9f3a2c"); + }); + + it("classifies a permanent credential lookup failure as non-retryable under tree visibility", async () => { + const guard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: "agent:main:main", + visibility: "tree", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: vi.fn(async () => { + throw new GatewayCredentialsRequiredError({ + method: "sessions.list", + configPath: "/tmp/openclaw.json", + }); + }) as never, + }); + + const result = guard.check("agent:main:subagent:child-1"); + expect(result).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history denied because spawned-session ownership lookup failed; ask the operator to check gateway configuration and credentials.", + }); + expect(result.allowed ? "" : result.error).not.toMatch(/retry/i); + }); + + it("keeps unknown lookup failures generic under tree visibility", async () => { + const guard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: "agent:main:main", + visibility: "tree", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: vi.fn(async () => { + throw new Error("failed to decode session row"); + }) as never, + }); + + const result = guard.check("agent:main:subagent:child-1"); + expect(result).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history denied because spawned-session ownership lookup failed; ask the operator to inspect OpenClaw logs.", + }); + expect(result.allowed ? "" : result.error).not.toMatch(/credentials|retry/i); + }); + + it("classifies a malformed sessions.list response as an unknown lookup failure", async () => { + const guard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: "agent:main:main", + visibility: "tree", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: vi.fn(async () => ({})) as never, + }); + + expect(guard.check("agent:main:subagent:child-1")).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history denied because spawned-session ownership lookup failed; ask the operator to inspect OpenClaw logs.", + }); + }); + + it("keeps watched same-agent group reads allowed when the spawned lookup throws", async () => { + loggerMocks.logWarn.mockClear(); + const stateDir = tempDirs.make("openclaw-session-visibility-"); + closeOpenClawStateDatabaseForTest(); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + try { + const requesterSessionKey = "agent:main:main"; + const watchedSessionKey = "agent:main:telegram:group:watched"; + expect( + registerMainSessionGroupWatch({ + sessionKey: watchedSessionKey, + agentId: "main", + entry: { sessionId: "watched", updatedAt: Date.now(), chatType: "group" }, + dmScope: "main", + }), + ).toBe(true); + expect(listAmbientGroupWatchTargets(requesterSessionKey)).toEqual( + new Set([watchedSessionKey]), + ); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const guard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey, + visibility: "tree", + a2aPolicy: createAgentToAgentPolicy({} as unknown as OpenClawConfig), + callGateway: vi.fn(async () => { + throw new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "transport timeout", + retryable: true, + }); + }) as never, + }); + + // Durable watched-group allowance does not depend on spawned ownership lookup. + expect(loggerMocks.logWarn).not.toHaveBeenCalled(); + expect(guard.check(watchedSessionKey)).toEqual({ allowed: true }); + expect(loggerMocks.logWarn).not.toHaveBeenCalled(); + // A non-watched, non-spawned same-agent target still fails closed, but + // the denial is distinguishable from a genuine policy denial. + expect(guard.check("agent:main:telegram:group:unwatched")).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history denied because spawned-session ownership lookup failed (transient); retry once, then ask the operator to inspect OpenClaw logs.", + }); + } finally { + warnSpy.mockRestore(); + } + } finally { + closeOpenClawStateDatabaseForTest(); + vi.unstubAllEnvs(); + } + }); }); diff --git a/src/agents/tools/sessions-access.ts b/src/agents/tools/sessions-access.ts index 892965b03cd2..1bf23a3ffa3d 100644 --- a/src/agents/tools/sessions-access.ts +++ b/src/agents/tools/sessions-access.ts @@ -5,17 +5,110 @@ */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { resolveSandboxSessionToolsVisibility } from "../../plugin-sdk/session-visibility.js"; +import { + logSessionOwnershipLookupFailure, + lookupFailedDenialMessage, +} from "../../plugin-sdk/session-visibility-internal.js"; +import { + createSessionVisibilityChecker, + createSessionVisibilityRowChecker, + resolveSandboxSessionToolsVisibility, + type AgentToAgentPolicy, + type SessionAccessAction, + type SessionAccessResult, + type SessionToolsVisibility, +} from "../../plugin-sdk/session-visibility.js"; import { isSubagentSessionKey } from "../../routing/session-key.js"; -import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-resolution.js"; +import type { AgentToolGatewayRequestCaller } from "./in-process-gateway.js"; +import { + lookupRequesterSessionOwnership, + resolveInternalSessionKey, + resolveMainSessionAlias, +} from "./sessions-resolution.js"; export { createAgentToAgentPolicy, - createSessionVisibilityGuard, createSessionVisibilityRowChecker, resolveEffectiveSessionToolsVisibility, } from "../../plugin-sdk/session-visibility.js"; +/** Check one prepared target without re-listing the requester's spawned sessions. */ +export async function resolveSessionToolAccess(params: { + action: SessionAccessAction; + displayAction?: SessionAccessAction | "search"; + defaultAgentId?: string; + requesterAgentId: string; + requesterSessionKey: string; + authorizationTargetSessionKey?: string; + targetAgentId: string; + targetSessionKey: string; + requesterOwned: boolean; + visibility: SessionToolsVisibility; + a2aPolicy: AgentToAgentPolicy; + callGateway?: AgentToolGatewayRequestCaller; +}): Promise { + const authorizationTargetSessionKey = + params.authorizationTargetSessionKey ?? params.targetSessionKey; + if (params.action !== "list") { + const scoped = createSessionVisibilityChecker.resolveScopedAccess({ + action: params.action, + requesterSessionKey: params.requesterSessionKey, + // A bare key is not globally unique under explicit ownership. Callers + // qualify cross-agent targets so a grant cannot cross store owners. + targetSessionKey: authorizationTargetSessionKey, + }); + if (scoped) { + return { allowed: true, expectedSessionId: scoped.expectedSessionId }; + } + } + const rowChecker = createSessionVisibilityRowChecker({ + action: params.action, + defaultAgentId: params.targetAgentId ?? params.defaultAgentId, + requesterAgentId: params.requesterAgentId, + requesterSessionKey: params.requesterSessionKey, + visibility: params.visibility, + a2aPolicy: params.a2aPolicy, + }); + const check = (requesterOwned: boolean) => + rowChecker.check({ + key: authorizationTargetSessionKey, + agentId: params.targetAgentId, + ...(requesterOwned ? { spawnedBy: params.requesterSessionKey } : {}), + }); + const initial = check(false); + if (initial.allowed || params.action === "list") { + return initial; + } + const requesterOwnedAccess = check(true); + if (params.requesterOwned) { + return requesterOwnedAccess; + } + // Ownership proof can only widen tree visibility; do not let an operational + // lookup failure replace a deterministic self/A2A policy denial. + if (!requesterOwnedAccess.allowed) { + return initial; + } + const ownership = await lookupRequesterSessionOwnership({ + requesterSessionKey: params.requesterSessionKey, + requesterAgentId: params.requesterAgentId, + targetSessionKey: params.targetSessionKey, + targetAgentId: params.targetAgentId, + callGateway: params.callGateway, + }); + if (!ownership.ok) { + logSessionOwnershipLookupFailure({ + requesterSessionKey: params.requesterSessionKey, + failure: ownership.error, + }); + return { + allowed: false, + status: "forbidden", + error: lookupFailedDenialMessage(params.displayAction ?? params.action, ownership.error.kind), + }; + } + return ownership.value ? requesterOwnedAccess : initial; +} + /** Resolves the requester context used to filter sandboxed session-tool access. */ export function resolveSandboxedSessionToolContext(params: { cfg: OpenClawConfig; diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 9167c35721ef..a038b785f6e9 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -5,10 +5,10 @@ */ export { createAgentToAgentPolicy, - createSessionVisibilityGuard, createSessionVisibilityRowChecker, resolveEffectiveSessionToolsVisibility, resolveSandboxedSessionToolContext, + resolveSessionToolAccess, } from "./sessions-access.js"; import { resolveSandboxedSessionToolContext } from "./sessions-access.js"; export { @@ -18,6 +18,7 @@ export { resolveMainSessionAlias, resolveSessionReference, resolveVisibleSessionReference, + isExpectedSessionLookupMiss, shouldResolveSessionIdInput, } from "./sessions-resolution.js"; import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce"; diff --git a/src/agents/tools/sessions-history-tool.test.ts b/src/agents/tools/sessions-history-tool.test.ts index a401b8b97bd1..86b673afd354 100644 --- a/src/agents/tools/sessions-history-tool.test.ts +++ b/src/agents/tools/sessions-history-tool.test.ts @@ -209,11 +209,7 @@ describe("sessions_history redaction", () => { messages: [], bytes: 2, }); - expect(requests.map((request) => request.method)).toEqual([ - "sessions.resolve", - "sessions.list", - "chat.history", - ]); + expect(requests.map((request) => request.method)).toEqual(["sessions.resolve", "chat.history"]); }); it("redacts recalled session text even when log redaction is disabled", async () => { @@ -515,11 +511,7 @@ describe("sessions_history redaction", () => { sessionKey: targetSessionKey, messages: [{ role: "assistant", content: "visible" }], }); - expect(requests.map((request) => request.method)).toEqual([ - "sessions.resolve", - "sessions.list", - "chat.history", - ]); + expect(requests.map((request) => request.method)).toEqual(["chat.history"]); } finally { unregister(); } @@ -542,7 +534,7 @@ describe("sessions_history redaction", () => { return undefined; } grantChecks += 1; - if (grantChecks === 2) { + if (grantChecks === 1) { replaceSessionEntrySync( { storePath, sessionKey: targetSessionKey }, { sessionId: "replacement-incarnation", updatedAt: 2 }, @@ -596,7 +588,7 @@ describe("sessions_history redaction", () => { return undefined; } grantChecks += 1; - if (grantChecks === 2) { + if (grantChecks === 1) { replaceSessionEntrySync( { storePath, sessionKey: targetSessionKey }, { sessionId: expectedSessionId, updatedAt: 2, archivedAt: 2 }, diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 5dfab1a2e2d7..90985abb0ccd 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -37,12 +37,12 @@ import { runWithScopedSessionAccess, } from "./scoped-session-access.js"; import { - createSessionVisibilityGuard, createSessionVisibilityRowChecker, createAgentToAgentPolicy, resolveEffectiveSessionToolsVisibility, resolveSessionReference, resolveSandboxedSessionToolContext, + resolveSessionToolAccess, resolveVisibleSessionReference, shouldResolveSessionIdInput, } from "./sessions-helpers.js"; @@ -412,6 +412,7 @@ export function createSessionsHistoryTool(opts?: { ? { kind: "none" as const } : resolvePersistedSessionStoreOwnerForKey(cfg, sessionKeyParam); const resolvedSession = await resolveSessionReference({ + action: "history", sessionKey: sessionKeyParam, ...(isCurrentSession ? { agentId: requesterAgentId } @@ -469,20 +470,23 @@ export function createSessionsHistoryTool(opts?: { requesterAgentId, }); - const visibilityGuard = await createSessionVisibilityGuard({ - action: "history", - defaultAgentId: requesterAgentId, - requesterAgentId, - requesterSessionKey: effectiveRequesterKey, - visibility, - a2aPolicy, - callGateway: gatewayCall, - }); const authorizationKey = targetAgentId !== requesterAgentId && !parseAgentSessionKey(resolvedKey) ? `agent:${targetAgentId}:${resolvedKey}` : resolvedKey; - const access = visibilityGuard.check(authorizationKey); + const access = await resolveSessionToolAccess({ + action: "history", + defaultAgentId: requesterAgentId, + requesterAgentId, + requesterSessionKey: effectiveRequesterKey, + authorizationTargetSessionKey: authorizationKey, + targetAgentId, + targetSessionKey: resolvedKey, + requesterOwned: visibleSession.requesterOwned, + visibility, + a2aPolicy, + callGateway: gatewayCall, + }); if (!access.allowed) { return jsonResult({ status: access.status, diff --git a/src/agents/tools/sessions-resolution.strict.test.ts b/src/agents/tools/sessions-resolution.strict.test.ts index 60cad92d76d7..c84659c232e1 100644 --- a/src/agents/tools/sessions-resolution.strict.test.ts +++ b/src/agents/tools/sessions-resolution.strict.test.ts @@ -20,6 +20,7 @@ beforeEach(() => { describe("strict explicit session resolution", () => { it("resolves current to the requester before any ownership lookup", async () => { const result = await resolveSessionReference({ + action: "status", sessionKey: "current", keyAgentId: "ops", alias: "main", @@ -34,6 +35,7 @@ describe("strict explicit session resolution", () => { key: "agent:research:subagent:child", displayKey: "agent:research:subagent:child", resolvedViaSessionId: false, + requesterOwned: true, }); expect(callGatewayMock).not.toHaveBeenCalled(); }); @@ -41,6 +43,7 @@ describe("strict explicit session resolution", () => { it("still rejects an unknown non-alias explicit key", async () => { callGatewayMock.mockRejectedValueOnce(new Error("No session found: agent:main:missing")); const resolvedSession = await resolveSessionReference({ + action: "history", sessionKey: "agent:main:missing", keyAgentId: "main", alias: "main", @@ -79,6 +82,7 @@ describe("strict explicit session resolution", () => { it("carries an allowed missing fact only for deliberate main bootstrap", async () => { callGatewayMock.mockResolvedValueOnce({}); const resolvedSession = await resolveSessionReference({ + action: "send", sessionKey: "agent:main:main", keyAgentId: "main", alias: "main", @@ -106,6 +110,7 @@ describe("strict explicit session resolution", () => { key: "agent:main:main", displayKey: "agent:main:main", missing: true, + requesterOwned: false, }); }); }); diff --git a/src/agents/tools/sessions-resolution.test.ts b/src/agents/tools/sessions-resolution.test.ts index c150d63a8272..99a6b7642e40 100644 --- a/src/agents/tools/sessions-resolution.test.ts +++ b/src/agents/tools/sessions-resolution.test.ts @@ -1,12 +1,16 @@ -// Sessions resolution tests cover alias mapping, session-id lookup, visibility -// verification, and requester-spawned access checks. +// Sessions resolution tests cover alias mapping, session-id lookup, and visibility normalization. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { GatewayClientRequestError } from "../../gateway/client.js"; import { looksLikeSessionId } from "../../sessions/session-id.js"; const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); +vi.mock("../../gateway/call.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + callGateway: (opts: unknown) => callGatewayMock(opts), + }; +}); let resolveCurrentSessionClientAlias: typeof import("./sessions-resolution.js").resolveCurrentSessionClientAlias; let resolveDisplaySessionKey: typeof import("./sessions-resolution.js").resolveDisplaySessionKey; let resolveInternalSessionKey: typeof import("./sessions-resolution.js").resolveInternalSessionKey; @@ -154,7 +158,7 @@ describe("session reference shape detection", () => { }); describe("resolved session visibility checks", () => { - it("rejects incognito targets even when the requester is the same session", async () => { + it("rejects incognito targets without consulting Gateway", async () => { const sessionKey = "agent:main:dashboard:incognito-private"; await expect( @@ -174,134 +178,6 @@ describe("resolved session visibility checks", () => { ).resolves.toMatchObject({ ok: false, status: "forbidden" }); expect(callGatewayMock).not.toHaveBeenCalled(); }); - - it("requires spawned-session verification only for sandboxed key-based cross-session access", async () => { - const cases = [ - { - requesterSessionKey: "agent:main:main", - targetSessionKey: "agent:main:worker", - restrictToSpawned: true, - resolvedViaSessionId: false, - expectsGateway: true, - }, - { - requesterSessionKey: "agent:main:main", - targetSessionKey: "agent:main:worker", - restrictToSpawned: false, - resolvedViaSessionId: false, - expectsGateway: true, - }, - { - requesterSessionKey: "agent:main:main", - targetSessionKey: "agent:main:worker", - restrictToSpawned: true, - resolvedViaSessionId: true, - expectsGateway: false, - }, - { - requesterSessionKey: "agent:main:main", - targetSessionKey: "agent:main:main", - restrictToSpawned: true, - resolvedViaSessionId: false, - expectsGateway: true, - }, - ]; - - for (const testCase of cases) { - callGatewayMock.mockResolvedValueOnce({ key: testCase.targetSessionKey }); - const result = resolveVisibleSessionReference({ - action: "history", - resolvedSession: { - ok: true, - key: testCase.targetSessionKey, - displayKey: testCase.targetSessionKey, - resolvedViaSessionId: testCase.resolvedViaSessionId, - }, - requesterSessionKey: testCase.requesterSessionKey, - requesterAgentId: "main", - restrictToSpawned: testCase.restrictToSpawned, - visibilitySessionKey: testCase.targetSessionKey, - }); - - await expect(result).resolves.toEqual({ - ok: true, - agentId: "main", - key: testCase.targetSessionKey, - displayKey: testCase.targetSessionKey, - }); - expect(callGatewayMock).toHaveBeenCalledTimes(testCase.expectsGateway ? 1 : 0); - callGatewayMock.mockReset(); - } - }); - - it("does not hide an exact spawned target behind the sessions.list visibility cap", async () => { - // Exact spawned-session resolution should not depend on a truncated list - // response; otherwise high-volume session stores hide valid children. - callGatewayMock.mockImplementation( - async (request: { method?: string; params?: { key?: string } }) => { - if (request.method === "sessions.resolve") { - return { key: request.params?.key }; - } - if (request.method === "sessions.list") { - return { - sessions: Array.from({ length: 500 }, (_, index) => ({ - key: `agent:main:subagent:worker-${index}`, - })), - }; - } - return {}; - }, - ); - - await expect( - resolveVisibleSessionReference({ - action: "history", - resolvedSession: { - ok: true, - key: "agent:main:subagent:worker-999", - displayKey: "agent:main:subagent:worker-999", - resolvedViaSessionId: false, - }, - requesterSessionKey: "agent:main:main", - requesterAgentId: "main", - restrictToSpawned: true, - visibilitySessionKey: "agent:main:subagent:worker-999", - }), - ).resolves.toEqual({ - ok: true, - agentId: "main", - key: "agent:main:subagent:worker-999", - displayKey: "agent:main:subagent:worker-999", - }); - }); - - it("propagates strict explicit-key resolution failures without a list fallback", async () => { - callGatewayMock.mockImplementation(async (request: { method?: string }) => { - if (request.method === "sessions.resolve") { - throw new Error("unsupported sessions.resolve shape"); - } - return { sessions: [{ key: "agent:main:subagent:worker" }] }; - }); - - await expect( - resolveVisibleSessionReference({ - action: "history", - resolvedSession: { - ok: true, - key: "agent:main:subagent:worker", - displayKey: "agent:main:subagent:worker", - resolvedViaSessionId: false, - }, - requesterSessionKey: "agent:main:main", - requesterAgentId: "main", - restrictToSpawned: true, - visibilitySessionKey: "agent:main:subagent:worker", - }), - ).resolves.toMatchObject({ ok: false, status: "forbidden" }); - expect(callGatewayMock.mock.calls.map(([request]) => request.method)).toEqual([ - "sessions.resolve", - ]); - }); }); describe("resolveSessionReference", () => { @@ -315,6 +191,7 @@ describe("resolveSessionReference", () => { ); const result = await resolveSessionReference({ + action: "history", sessionKey: "Agent:ops:main", keyAgentId: "main", agentId: "main", @@ -333,6 +210,7 @@ describe("resolveSessionReference", () => { it("resolves current directly to the requester without probing another owner", async () => { const result = await resolveSessionReference({ + action: "history", sessionKey: "current", alias: "main", mainKey: "main", @@ -347,8 +225,36 @@ describe("resolveSessionReference", () => { expect(callGatewayMock).not.toHaveBeenCalled(); }); + it("does not reinterpret a failed custom-key lookup as a sessionId miss", async () => { + callGatewayMock.mockRejectedValueOnce( + new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "gateway unavailable", + retryable: true, + }), + ); + + await expect( + resolveSessionReference({ + action: "send", + sessionKey: "custom-selector", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: true, + }), + ).resolves.toEqual({ + ok: false, + status: "forbidden", + error: + "Session send denied because spawned-session ownership lookup failed (transient); retry once, then ask the operator to inspect OpenClaw logs.", + }); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + }); + it("treats the TUI client label as the requester session", async () => { const result = await resolveSessionReference({ + action: "history", sessionKey: "openclaw-tui", alias: "main", mainKey: "main", @@ -362,4 +268,223 @@ describe("resolveSessionReference", () => { }); expect(callGatewayMock).not.toHaveBeenCalled(); }); + + it("preserves the main alias without probing configured-main bootstrap", async () => { + const result = await resolveSessionReference({ + action: "history", + sessionKey: "main", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:dashboard:requester", + restrictToSpawned: false, + }); + + expectResolvedSessionReference(result, { + key: "main", + displayKey: "main", + resolvedViaSessionId: false, + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("defers explicit-key lookup to action-aware visibility resolution", async () => { + const result = await resolveSessionReference({ + action: "history", + sessionKey: "agent:main:worker", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + + expect(result).toEqual({ + ok: true, + key: "agent:main:worker", + displayKey: "agent:main:worker", + resolvedViaSessionId: false, + requesterOwned: false, + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("rejects an unknown explicit session key for history", async () => { + callGatewayMock.mockRejectedValueOnce( + new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "No session found: agent:main:missing", + }), + ); + + const resolvedSession = await resolveSessionReference({ + action: "history", + sessionKey: "agent:main:missing", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "history", + resolvedSession, + requesterSessionKey: "agent:main:main", + requesterAgentId: "main", + restrictToSpawned: false, + visibilitySessionKey: "agent:main:missing", + }); + + expect(result).toEqual({ + ok: false, + status: "error", + error: "No session found: agent:main:missing", + displayKey: "agent:main:missing", + }); + expect(callGatewayMock).toHaveBeenCalledWith({ + method: "sessions.resolve", + params: { + key: "agent:main:missing", + agentId: "main", + spawnedBy: undefined, + }, + }); + }); + + it("canonicalizes an existing explicit session key", async () => { + callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:main" }); + + const resolvedSession = await resolveSessionReference({ + action: "send", + sessionKey: "agent:OPS:main", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "send", + resolvedSession, + requesterSessionKey: "agent:main:main", + requesterAgentId: "main", + restrictToSpawned: false, + visibilitySessionKey: "agent:OPS:main", + }); + + expect(result).toEqual({ + ok: true, + agentId: "ops", + key: "agent:ops:main", + displayKey: "agent:ops:main", + requesterOwned: false, + }); + }); + + it("rejects an explicit key that canonicalizes to an incognito session", async () => { + callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:dashboard:incognito-private" }); + + const resolvedSession = await resolveSessionReference({ + action: "history", + sessionKey: "agent:OPS:dashboard:private", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "history", + resolvedSession, + requesterSessionKey: "agent:main:main", + requesterAgentId: "main", + restrictToSpawned: false, + visibilitySessionKey: "agent:OPS:dashboard:private", + }); + + expect(result).toEqual({ + ok: false, + status: "forbidden", + error: "Session not visible from session tools: agent:OPS:dashboard:private", + displayKey: "agent:ops:dashboard:incognito-private", + }); + }); + + it("propagates explicit-key gateway failures", async () => { + callGatewayMock.mockRejectedValueOnce(new Error("gateway unavailable")); + + const resolvedSession = await resolveSessionReference({ + action: "send", + sessionKey: "agent:main:worker", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "send", + resolvedSession, + requesterSessionKey: "agent:main:main", + requesterAgentId: "main", + restrictToSpawned: false, + visibilitySessionKey: "agent:main:worker", + }); + + expect(result).toEqual({ + ok: false, + status: "error", + error: "gateway unavailable", + displayKey: "agent:main:worker", + }); + }); + + it("reports an allowed missing explicit key for deliberate bootstrap", async () => { + callGatewayMock.mockResolvedValueOnce({}); + + const resolvedSession = await resolveSessionReference({ + action: "send", + sessionKey: "agent:main:main", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:dashboard:requester", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "send", + resolvedSession, + requesterSessionKey: "agent:main:dashboard:requester", + requesterAgentId: "main", + restrictToSpawned: false, + visibilitySessionKey: "agent:main:main", + allowMissingKey: true, + }); + + expect(result).toEqual({ + ok: true, + agentId: "main", + key: "agent:main:main", + displayKey: "agent:main:main", + missing: true, + requesterOwned: false, + }); + expect(callGatewayMock).toHaveBeenCalledWith({ + method: "sessions.resolve", + params: { + key: "agent:main:main", + agentId: "main", + spawnedBy: undefined, + allowMissing: true, + }, + }); + }); }); diff --git a/src/agents/tools/sessions-resolution.ts b/src/agents/tools/sessions-resolution.ts index 6d4bef8e77c8..4668a66a4637 100644 --- a/src/agents/tools/sessions-resolution.ts +++ b/src/agents/tools/sessions-resolution.ts @@ -3,17 +3,23 @@ * * Normalizes display/internal/current-session aliases and resolves session-id inputs through Gateway. */ +import { err, ok, type Result } from "@openclaw/normalization-core/result"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { GATEWAY_CLIENT_IDS, normalizeGatewayClientId, } from "../../../packages/gateway-protocol/src/client-info.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { GatewayClientRequestError } from "../../gateway/client.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { - createSessionVisibilityChecker, - listSpawnedSessionKeys, -} from "../../plugin-sdk/session-visibility.js"; + listSpawnedSessionKeysWithResult, + logSessionOwnershipLookupFailure, + lookupFailedDenialMessage, + lookupFailedOperationMessage, + sessionOwnershipLookupFailure, + type SessionOwnershipLookupFailure, +} from "../../plugin-sdk/session-visibility-internal.js"; import { isAcpSessionKey, isIncognitoSessionKey, @@ -87,43 +93,67 @@ export function resolveCurrentSessionClientAlias(params: { return requesterKey; } -async function isRequesterSpawnedSessionVisible(params: { +export function isExpectedSessionLookupMiss(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes("No session found") && + (!(error instanceof GatewayClientRequestError) || error.gatewayCode === "INVALID_REQUEST") + ); +} + +function isUnsupportedSpawnedSessionResolve(error: unknown): boolean { + return ( + error instanceof GatewayClientRequestError && + error.gatewayCode === "INVALID_REQUEST" && + error.message === "unknown method: sessions.resolve" + ); +} + +export async function lookupRequesterSessionOwnership(params: { requesterSessionKey: string; requesterAgentId: string; targetSessionKey: string; targetAgentId?: string; callGateway?: GatewayCaller; -}): Promise { +}): Promise> { if ( params.requesterSessionKey === params.targetSessionKey && params.targetAgentId === params.requesterAgentId ) { - return true; + return ok(true); } const gatewayCall = params.callGateway ?? callAgentToolGatewayRequest; try { - const resolved = await gatewayCall({ - method: "sessions.resolve", - params: { + const resolved = await requestResolvedSession( + { key: params.targetSessionKey, agentId: params.targetAgentId, spawnedBy: params.requesterSessionKey, + allowMissing: true, }, - }); - if (normalizeOptionalString(resolved?.key) === params.targetSessionKey) { - return true; + gatewayCall, + ); + return ok(resolved?.key === params.targetSessionKey); + } catch (error) { + if (isExpectedSessionLookupMiss(error)) { + return ok(false); } - } catch { - // Older Gateways can reject exact spawned-session resolution. + if (isUnsupportedSpawnedSessionResolve(error)) { + // Older gateways may lack the exact spawned-session selector. Preserve + // their list-based contract without hiding operational resolver failures. + const listed = await listSpawnedSessionKeysWithResult({ + requesterSessionKey: params.requesterSessionKey, + callGateway: gatewayCall, + }); + return listed.ok + ? ok( + params.targetAgentId === params.requesterAgentId && + listed.value.has(params.targetSessionKey), + ) + : err(listed.error); + } + return err(sessionOwnershipLookupFailure(error)); } - const keys = await listSpawnedSessionKeys({ - requesterSessionKey: params.requesterSessionKey, - callGateway: gatewayCall, - }); - return ( - (!params.targetAgentId || params.targetAgentId === params.requesterAgentId) && - keys.has(params.targetSessionKey) - ); } function looksLikeSessionKey(value: string): boolean { @@ -165,8 +195,11 @@ type SessionReferenceResolution = key: string; displayKey: string; resolvedViaSessionId: boolean; + requesterOwned?: boolean; } - | { ok: false; status: "error" | "forbidden"; error: string }; + | { ok: false; status: "error" | "forbidden"; error: string; notFound?: boolean }; + +type SessionReferenceAction = "history" | "send" | "status" | "list" | "search"; type VisibleSessionReferenceResolution = | { @@ -175,6 +208,7 @@ type VisibleSessionReferenceResolution = key: string; displayKey: string; missing?: true; + requesterOwned: boolean; } | { ok: false; @@ -189,6 +223,7 @@ function buildResolvedSessionReference(params: { alias: string; mainKey: string; resolvedViaSessionId: boolean; + requesterOwned: boolean; }): Extract { return { ok: true, @@ -200,6 +235,7 @@ function buildResolvedSessionReference(params: { mainKey: params.mainKey, }), resolvedViaSessionId: params.resolvedViaSessionId, + requesterOwned: params.requesterOwned, }; } @@ -235,11 +271,32 @@ async function requestResolvedSession( const agentId = normalizeOptionalString(result?.agentId); return { key, ...(agentId ? { agentId } : {}) }; }; - const result = await callGateway<{ agentId?: unknown; key?: unknown }>({ - method: "sessions.resolve", - params, - }); - return toResolvedSession(result); + try { + const result = await callGateway<{ agentId?: unknown; key?: unknown }>({ + method: "sessions.resolve", + params, + }); + return toResolvedSession(result); + } catch (error) { + const olderGatewayRejectedProbe = + params.allowMissing === true && + error instanceof GatewayClientRequestError && + error.gatewayCode === "INVALID_REQUEST" && + error.message.includes("invalid sessions.resolve params") && + error.message.includes("unexpected property 'allowMissing'"); + if (!olderGatewayRejectedProbe) { + throw error; + } + // Protocol v4 gateways predating allowMissing reject the additive field. + // Retry without it for mixed-version correctness; remove at the next protocol break. + const legacyParams: Record = { ...params }; + delete legacyParams.allowMissing; + const result = await callGateway<{ agentId?: unknown; key?: unknown }>({ + method: "sessions.resolve", + params: legacyParams, + }); + return toResolvedSession(result); + } } function buildSessionResolveQuery(params: { @@ -264,7 +321,106 @@ function buildSessionResolveQuery(params: { }; } +type ResolvedReference = Extract; +type ReferenceLookupResult = Result; + +async function lookupSessionReference(params: { + input: string; + kind: "key" | "sessionId"; + keyAgentId?: string; + agentId?: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; + allowMissing?: boolean; + callGateway: GatewayCaller; +}): Promise { + try { + const resolved = await requestResolvedSession( + buildSessionResolveQuery({ + input: params.input, + kind: params.kind, + agentId: + params.kind === "key" + ? (parseAgentSessionKey(params.input)?.agentId ?? params.keyAgentId ?? params.agentId) + : params.agentId, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + allowMissing: params.allowMissing, + }), + params.callGateway, + ); + if (!resolved) { + return ok(null); + } + return ok( + buildResolvedSessionReference({ + ...resolved, + alias: params.alias, + mainKey: params.mainKey, + resolvedViaSessionId: params.kind === "sessionId", + requesterOwned: params.restrictToSpawned, + }), + ); + } catch (error) { + if (isExpectedSessionLookupMiss(error)) { + return ok(null); + } + return err(sessionOwnershipLookupFailure(error)); + } +} + +async function resolveSessionReferenceByKeyOrSessionId(params: { + raw: string; + keyAgentId?: string; + agentId?: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; + allowMissing?: boolean; + skipKeyLookup?: boolean; + forceSessionIdLookup?: boolean; + callGateway: GatewayCaller; +}): Promise { + if (!params.skipKeyLookup) { + // Prefer key resolution to avoid misclassifying custom keys as sessionIds. + const resolvedByKey = await lookupSessionReference({ + input: params.raw, + kind: "key", + keyAgentId: params.keyAgentId, + agentId: params.agentId, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + allowMissing: params.allowMissing, + callGateway: params.callGateway, + }); + if (!resolvedByKey.ok || resolvedByKey.value) { + return resolvedByKey; + } + } + if (!(params.forceSessionIdLookup || shouldResolveSessionIdInput(params.raw))) { + return ok(null); + } + return await lookupSessionReference({ + input: params.raw, + kind: "sessionId", + keyAgentId: params.keyAgentId, + agentId: params.agentId, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + allowMissing: params.allowMissing, + callGateway: params.callGateway, + }); +} + export async function resolveSessionReference(params: { + action: SessionReferenceAction; sessionKey: string; /** Owner already selected for literal key lookup; session-id lookup remains cross-agent. */ keyAgentId?: string; @@ -276,36 +432,18 @@ export async function resolveSessionReference(params: { callGateway?: GatewayCaller; }): Promise { const gatewayCall = params.callGateway ?? callAgentToolGatewayRequest; - const buildReference = ( - resolved: { agentId?: string; key: string }, - resolvedViaSessionId: boolean, - ) => - buildResolvedSessionReference({ - ...resolved, - alias: params.alias, - mainKey: params.mainKey, - resolvedViaSessionId, + const failedLookup = (failure: SessionOwnershipLookupFailure): SessionReferenceResolution => { + logSessionOwnershipLookupFailure({ + requesterSessionKey: params.requesterInternalKey ?? "unknown", + failure, }); - const tryResolve = async (input: string, kind: "key" | "sessionId", allowMissing = false) => { - try { - const resolved = await requestResolvedSession( - buildSessionResolveQuery({ - input, - kind, - agentId: - kind === "key" - ? (parseAgentSessionKey(input)?.agentId ?? params.keyAgentId ?? params.agentId) - : params.agentId, - requesterInternalKey: params.requesterInternalKey, - restrictToSpawned: params.restrictToSpawned, - allowMissing, - }), - gatewayCall, - ); - return resolved ? buildReference(resolved, kind === "sessionId") : null; - } catch { - return null; - } + return { + ok: false, + status: params.restrictToSpawned ? "forbidden" : "error", + error: params.restrictToSpawned + ? lookupFailedDenialMessage(params.action, failure.kind) + : lookupFailedOperationMessage(params.action, failure.kind), + }; }; const rawInput = resolveCurrentSessionClientAlias({ @@ -315,28 +453,30 @@ export async function resolveSessionReference(params: { const raw = rawInput === "current" && params.requesterInternalKey ? params.requesterInternalKey : rawInput; if (shouldResolveSessionIdInput(raw)) { - const resolvedByKey = await tryResolve(raw, "key"); - if (resolvedByKey) { - return resolvedByKey; + const resolvedByGateway = await resolveSessionReferenceByKeyOrSessionId({ + raw, + keyAgentId: params.keyAgentId, + agentId: params.agentId, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + callGateway: gatewayCall, + }); + if (!resolvedByGateway.ok) { + return failedLookup(resolvedByGateway.error); } - try { - const resolved = await requestResolvedSession( - buildSessionResolveQuery({ - input: raw, - kind: "sessionId", - agentId: params.agentId, - requesterInternalKey: params.requesterInternalKey, - restrictToSpawned: params.restrictToSpawned, - }), - gatewayCall, - ); - if (!resolved) { - throw new Error(`Session not found: ${raw} (use the full sessionKey from sessions_list)`); - } - return buildReference(resolved, true); - } catch (error) { - return buildFailedSessionReference(error, raw, params.restrictToSpawned); + if (resolvedByGateway.value) { + return resolvedByGateway.value; } + return { + ok: false, + status: params.restrictToSpawned ? "forbidden" : "error", + notFound: true, + error: params.restrictToSpawned + ? `Session not visible from this sandboxed agent session: ${raw}` + : `Session not found: ${raw} (use the full sessionKey from sessions_list)`, + }; } const resolvedKey = resolveInternalSessionKey({ @@ -352,14 +492,27 @@ export async function resolveSessionReference(params: { : rawInput === "main" || rawInput === params.mainKey ? params.keyAgentId : undefined); - return buildReference( - { key: resolvedKey, ...(semanticAliasAgentId ? { agentId: semanticAliasAgentId } : {}) }, - false, - ); + const displayKey = resolveDisplaySessionKey({ + key: resolvedKey, + alias: params.alias, + mainKey: params.mainKey, + }); + return { + ok: true, + ...(semanticAliasAgentId ? { agentId: semanticAliasAgentId } : {}), + key: resolvedKey, + displayKey, + resolvedViaSessionId: false, + requesterOwned: + resolvedKey === params.requesterInternalKey && + (!semanticAliasAgentId || + semanticAliasAgentId === + (parseAgentSessionKey(params.requesterInternalKey ?? "")?.agentId ?? params.keyAgentId)), + }; } export async function resolveVisibleSessionReference(params: { - action: "history" | "send" | "status" | "list"; + action: SessionReferenceAction; resolvedSession: Extract; requesterSessionKey: string; requesterAgentId: string; @@ -374,7 +527,9 @@ export async function resolveVisibleSessionReference(params: { params.resolvedSession.agentId ?? parseAgentSessionKey(resolvedKey)?.agentId; let displayKey = params.resolvedSession.displayKey; let missing = false; - let verifiedSpawnedVisibility = false; + const requesterOwnedByResolution = + params.resolvedSession.requesterOwned ?? + (params.restrictToSpawned && params.resolvedSession.resolvedViaSessionId); // Cross-session tools persist their results into the caller transcript; an // incognito target must remain unreachable even from an incognito requester. if (isIncognitoSessionKey(resolvedKey)) { @@ -393,7 +548,11 @@ export async function resolveVisibleSessionReference(params: { input !== "global" && input !== "unknown" && !shouldResolveSessionIdInput(input); - if (isExplicitKey && (params.action === "history" || params.action === "send")) { + if ( + isExplicitKey && + !params.restrictToSpawned && + (params.action === "history" || params.action === "send") + ) { try { const resolved = await requestResolvedSession( buildSessionResolveQuery({ @@ -410,7 +569,6 @@ export async function resolveVisibleSessionReference(params: { resolvedKey = resolved.key; resolvedAgentId = resolved.agentId ?? parseAgentSessionKey(resolved.key)?.agentId; displayKey = resolved.key; - verifiedSpawnedVisibility = params.restrictToSpawned; } else if (params.allowMissingKey) { missing = true; } @@ -439,42 +597,14 @@ export async function resolveVisibleSessionReference(params: { displayKey, }; } - const shouldVerifySpawnedVisibility = - params.restrictToSpawned && - !params.resolvedSession.resolvedViaSessionId && - (params.requesterSessionKey !== resolvedKey || resolvedAgentId !== params.requesterAgentId); - const scopedAccess = - params.action === "list" - ? undefined - : createSessionVisibilityChecker.resolveScopedAccess({ - action: params.action, - requesterSessionKey: params.requesterSessionKey, - targetSessionKey: resolvedKey, - }); - const visible = - Boolean(scopedAccess) || - verifiedSpawnedVisibility || - !shouldVerifySpawnedVisibility || - (await isRequesterSpawnedSessionVisible({ - requesterSessionKey: params.requesterSessionKey, - requesterAgentId: params.requesterAgentId, - targetSessionKey: resolvedKey, - targetAgentId: resolvedAgentId, - callGateway: params.callGateway, - })); - if (!visible) { - return { - ok: false, - status: "forbidden", - error: `Session not visible from this sandboxed agent session: ${params.visibilitySessionKey}`, - displayKey, - }; - } return { ok: true, ...(resolvedAgentId ? { agentId: resolvedAgentId } : {}), key: resolvedKey, displayKey, + requesterOwned: + requesterOwnedByResolution || + (params.requesterSessionKey === resolvedKey && resolvedAgentId === params.requesterAgentId), ...(missing ? { missing: true } : {}), }; } diff --git a/src/agents/tools/sessions-search-tool.test.ts b/src/agents/tools/sessions-search-tool.test.ts index cbb756c44b91..4a27fa1ff7bc 100644 --- a/src/agents/tools/sessions-search-tool.test.ts +++ b/src/agents/tools/sessions-search-tool.test.ts @@ -1,7 +1,15 @@ /** sessions_search visibility, bounds, redaction, and input tests. */ +import path from "node:path"; import { Value } from "typebox/value"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { + applySessionStoreProjection, + replaceSessionEntrySync, +} from "../../config/sessions/session-accessor.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { callGateway as gatewayCall } from "../../gateway/call.js"; +import { createSessionVisibilityChecker } from "../../plugin-sdk/session-visibility.js"; import { compactToolOutputHint } from "../tool-schema-hints.js"; import { createSessionsSearchTool } from "./sessions-search-tool.js"; @@ -86,6 +94,8 @@ function createTool(params: { } describe("sessions_search tool", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + it("rejects a literal global target owned by another fixed-store agent", async () => { const requests: CallGatewayRequest[] = []; const tool = createTool({ @@ -419,4 +429,57 @@ describe("sessions_search tool", () => { params: { agentId: "main", query: "text", sessionKeys: ["main"], limit: 25 }, }); }); + + it("rejects a scoped grant when the target incarnation changes before search", async () => { + const requesterSessionKey = "agent:main:clickclack:discussion-race"; + const targetSessionKey = "agent:main:main"; + const expectedSessionId = "old-incarnation"; + const storePath = path.join(tempDirs.make("openclaw-sessions-search-"), "sessions.sqlite"); + await applySessionStoreProjection({ + storePath, + skipMaintenance: true, + update: (store) => { + store[targetSessionKey] = { sessionId: expectedSessionId, updatedAt: 1 }; + return { persist: true, result: undefined }; + }, + }); + const requests: CallGatewayRequest[] = []; + const unregister = createSessionVisibilityChecker.registerScopedAccessProvider((request) => { + if ( + request.requesterSessionKey !== requesterSessionKey || + request.targetSessionKey !== targetSessionKey + ) { + return undefined; + } + replaceSessionEntrySync( + { storePath, sessionKey: targetSessionKey }, + { sessionId: "replacement-incarnation", updatedAt: 2 }, + ); + return { expectedSessionId }; + }); + try { + const tool = createSessionsSearchTool({ + agentSessionKey: requesterSessionKey, + sandboxed: true, + config: { + session: { store: storePath }, + tools: { sessions: { visibility: "self" } }, + agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } }, + } as OpenClawConfig, + callGateway: async >( + request: CallGatewayRequest, + ): Promise => { + requests.push(request); + return { results: [hit({ sessionKey: targetSessionKey })] } as T; + }, + }); + + await expect( + tool.execute("scoped-grant-race", { query: "text", sessionKey: targetSessionKey }), + ).rejects.toThrow(`Session "${targetSessionKey}" changed after access was granted.`); + expect(requests.some((request) => request.method === "sessions.search")).toBe(false); + } finally { + unregister(); + } + }); }); diff --git a/src/agents/tools/sessions-search-tool.ts b/src/agents/tools/sessions-search-tool.ts index 665459ae8538..858e29f6c15d 100644 --- a/src/agents/tools/sessions-search-tool.ts +++ b/src/agents/tools/sessions-search-tool.ts @@ -27,15 +27,18 @@ import { callAgentToolGatewayRequest, type AgentToolGatewayRequestCaller, } from "./in-process-gateway.js"; -import { resolveSessionToolTargetAgentId } from "./scoped-session-access.js"; +import { + resolveSessionToolTargetAgentId, + runWithScopedSessionAccess, +} from "./scoped-session-access.js"; import { createAgentToAgentPolicy, - createSessionVisibilityGuard, createSessionVisibilityRowChecker, resolveDisplaySessionKey, resolveEffectiveSessionToolsVisibility, resolveSandboxedSessionToolContext, resolveSessionReference, + resolveSessionToolAccess, resolveVisibleSessionReference, } from "./sessions-helpers.js"; @@ -108,8 +111,9 @@ type SanitizedSearchHit = { type SearchSessionCandidate = { key: string; - access: "direct" | "row"; + access: "authorized" | "row"; agentId?: string; + expectedSessionId?: string; ownerSessionKey?: string; parentSessionKey?: string; spawnedBy?: string; @@ -370,8 +374,14 @@ export function createSessionsSearchTool(opts?: { agentId: opts?.agentId, }); - let sessionKey: string | undefined; - let sessionAgentId: string | undefined; + let sessionTarget: + | { + agentId: string; + key: string; + requesterOwned: boolean; + expectedSessionId?: string; + } + | undefined; if (requestedSessionKey) { const normalizedRequestedKey = requestedSessionKey.trim(); const semanticTargetAgentId = @@ -389,6 +399,7 @@ export function createSessionsSearchTool(opts?: { }) : undefined; const resolved = await resolveSessionReference({ + action: "search", sessionKey: requestedSessionKey, keyAgentId: semanticTargetAgentId ?? requesterAgentId, alias, @@ -401,7 +412,7 @@ export function createSessionsSearchTool(opts?: { return jsonResult({ status: resolved.status, error: resolved.error }); } const visible = await resolveVisibleSessionReference({ - action: "list", + action: "search", resolvedSession: resolved, requesterSessionKey: effectiveRequesterKey, requesterAgentId, @@ -412,13 +423,16 @@ export function createSessionsSearchTool(opts?: { if (!visible.ok) { return jsonResult({ status: visible.status, error: visible.error }); } - sessionKey = visible.key; - sessionAgentId = resolveSessionToolTargetAgentId({ - cfg, - targetSessionKey: visible.key, - resolvedAgentId: visible.agentId ?? semanticTargetAgentId, - requesterAgentId, - }); + sessionTarget = { + key: visible.key, + agentId: resolveSessionToolTargetAgentId({ + cfg, + targetSessionKey: visible.key, + resolvedAgentId: visible.agentId ?? semanticTargetAgentId, + requesterAgentId, + }), + requesterOwned: visible.requesterOwned, + }; } const visibility = resolveEffectiveSessionToolsVisibility({ @@ -435,32 +449,44 @@ export function createSessionsSearchTool(opts?: { visibility, a2aPolicy, }); - const directGuard = await createSessionVisibilityGuard({ - action: "history", - defaultAgentId, - requesterAgentId, - requesterSessionKey: effectiveRequesterKey, - visibility, - a2aPolicy, - callGateway: gatewayCall, - }); - if (sessionKey) { - const parsedSessionKey = parseAgentSessionKey(sessionKey); - const access = parsedSessionKey - ? directGuard.check(sessionKey) - : rowGuard.check({ key: sessionKey, agentId: sessionAgentId }); + if (sessionTarget) { + const { agentId, key, requesterOwned } = sessionTarget; + const authorizationTargetSessionKey = + agentId !== requesterAgentId && !parseAgentSessionKey(key) + ? `agent:${agentId}:${key}` + : key; + const access = await resolveSessionToolAccess({ + action: "history", + displayAction: "search", + defaultAgentId, + requesterAgentId, + requesterSessionKey: effectiveRequesterKey, + authorizationTargetSessionKey, + targetAgentId: agentId, + targetSessionKey: key, + requesterOwned, + visibility, + a2aPolicy, + callGateway: gatewayCall, + }); if (!access.allowed) { return jsonResult({ status: access.status, error: access.error }); } + if (access.expectedSessionId) { + sessionTarget.expectedSessionId = access.expectedSessionId; + } } const searchSessions = ( - sessionKey + sessionTarget ? [ { - key: sessionKey, - access: "direct" as const, - ...(!parseAgentSessionKey(sessionKey) && sessionAgentId - ? { agentId: sessionAgentId } + key: sessionTarget.key, + access: "authorized" as const, + ...(sessionTarget.expectedSessionId + ? { expectedSessionId: sessionTarget.expectedSessionId } + : {}), + ...(!parseAgentSessionKey(sessionTarget.key) + ? { agentId: sessionTarget.agentId } : {}), }, ] @@ -500,19 +526,30 @@ export function createSessionsSearchTool(opts?: { offset += SESSIONS_SEARCH_MAX_SESSION_KEYS ) { const chunk = candidates.slice(offset, offset + SESSIONS_SEARCH_MAX_SESSION_KEYS); - const result = await gatewayCall<{ - results?: GatewaySearchHit[]; - indexing?: boolean; - truncated?: boolean; - }>({ - method: "sessions.search", - params: { - agentId, - query, - limit: SESSIONS_SEARCH_MAX_LIMIT, - sessionKeys: chunk.map((candidate) => candidate.key), - }, - }); + const runSearch = () => + gatewayCall<{ + results?: GatewaySearchHit[]; + indexing?: boolean; + truncated?: boolean; + }>({ + method: "sessions.search", + params: { + agentId, + query, + limit: SESSIONS_SEARCH_MAX_LIMIT, + sessionKeys: chunk.map((candidate) => candidate.key), + }, + }); + const scopedCandidate = chunk.length === 1 ? chunk[0] : undefined; + const result = scopedCandidate?.expectedSessionId + ? await runWithScopedSessionAccess({ + cfg, + agentId, + expectedSessionId: scopedCandidate.expectedSessionId, + targetSessionKey: scopedCandidate.key, + run: runSearch, + }) + : await runSearch(); indexing ||= result.indexing === true; backendTruncated ||= result.truncated === true; for (const hit of Array.isArray(result.results) ? result.results : []) { @@ -529,10 +566,9 @@ export function createSessionsSearchTool(opts?: { } const { candidate, visibilityKey } = candidateMatch; const access = - candidate.access === "row" || - (candidate.agentId !== undefined && !parseAgentSessionKey(candidate.key)) - ? rowGuard.check(candidate) - : directGuard.check(visibilityKey); + candidate.access === "authorized" + ? { allowed: true as const } + : rowGuard.check(candidate); if (!access.allowed) { continue; } diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 5d2b81afc3ac..7352eccadeee 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -18,6 +18,12 @@ import type { AgentRouteBinding } from "../../config/types.agents.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + logSessionOwnershipLookupFailure, + lookupFailedDenialMessage, + lookupFailedOperationMessage, + sessionOwnershipLookupFailure, +} from "../../plugin-sdk/session-visibility-internal.js"; import { runWithGatewayIndependentRootWorkContinuation } from "../../process/gateway-work-admission.js"; import { normalizeRouteBindingChannelId } from "../../routing/binding-scope.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; @@ -70,11 +76,13 @@ import { } from "./in-process-gateway.js"; import { runWithScopedSessionAccess } from "./scoped-session-access.js"; import { - createSessionVisibilityGuard, createSessionVisibilityRowChecker, createAgentToAgentPolicy, + isExpectedSessionLookupMiss, + resolveDisplaySessionKey, resolveEffectiveSessionToolsVisibility, resolveSessionReference, + resolveSessionToolAccess, resolveSessionToolContext, resolveVisibleSessionReference, } from "./sessions-helpers.js"; @@ -496,6 +504,7 @@ export function createSessionsSendTool(opts?: { let sessionKey = sessionKeyParam; let resolvedTargetAgentId: string | undefined; + let resolvedLabelKey: string | undefined; if (!sessionKey && !labelParam && labelAgentIdParam) { const agentMainKey = resolveConfiguredAgentMainSessionKey({ cfg, @@ -557,19 +566,22 @@ export function createSessionsSendTool(opts?: { resolvedKey = normalizeOptionalString(resolved?.key) ?? ""; resolvedTargetAgentId = normalizeOptionalString(resolved?.agentId); } catch (err) { - const msg = formatErrorMessage(err); - if (restrictToSpawned) { + if (isExpectedSessionLookupMiss(err)) { + resolvedKey = ""; + } else { + const failure = sessionOwnershipLookupFailure(err); + logSessionOwnershipLookupFailure({ + requesterSessionKey: effectiveRequesterKey, + failure, + }); return jsonResult({ runId: crypto.randomUUID(), - status: "forbidden", - error: "Session not visible from this sandboxed agent session.", + status: restrictToSpawned ? "forbidden" : "error", + error: restrictToSpawned + ? lookupFailedDenialMessage("send", failure.kind) + : lookupFailedOperationMessage("send", failure.kind), }); } - return jsonResult({ - runId: crypto.randomUUID(), - status: "error", - error: msg || `No session found with label: ${labelParam}`, - }); } if (!resolvedKey) { @@ -587,6 +599,7 @@ export function createSessionsSendTool(opts?: { }); } sessionKey = resolvedKey; + resolvedLabelKey = resolvedKey; } if (!sessionKey) { @@ -601,15 +614,25 @@ export function createSessionsSendTool(opts?: { sessionKey, mainKey, }); - const resolvedSession = await resolveSessionReference({ - sessionKey, - keyAgentId: requesterAgentId, - alias, - mainKey, - requesterInternalKey: effectiveRequesterKey, - restrictToSpawned, - callGateway: gatewayCall, - }); + const resolvedSession = resolvedLabelKey + ? { + ok: true as const, + ...(resolvedTargetAgentId ? { agentId: resolvedTargetAgentId } : {}), + key: resolvedLabelKey, + displayKey: resolveDisplaySessionKey({ key: resolvedLabelKey, alias, mainKey }), + resolvedViaSessionId: false, + requesterOwned: restrictToSpawned, + } + : await resolveSessionReference({ + action: "send", + sessionKey, + keyAgentId: requesterAgentId, + alias, + mainKey, + requesterInternalKey: effectiveRequesterKey, + restrictToSpawned, + callGateway: gatewayCall, + }); if (!resolvedSession.ok) { return jsonResult({ runId: crypto.randomUUID(), @@ -696,14 +719,7 @@ export function createSessionsSendTool(opts?: { resolvedKeyAgentId ?? (isLiteralUnscopedMainTarget ? requesterAgentId : undefined) ?? compatibilityTargetAgentId; - const mayUseRequesterForLiteralSentinel = - isLiteralUnscopedMainTarget && - (!targetAgentId || normalizeAgentId(targetAgentId) === requesterAgentId); - if ( - !targetAgentId && - !resolvedKeyAgentId && - (!isUnscopedSessionKeySentinel(resolvedKey) || resolvedSession.resolvedViaSessionId) - ) { + if (!targetAgentId) { return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", @@ -712,6 +728,8 @@ export function createSessionsSendTool(opts?: { sessionKey: unresolvedDisplayKey, }); } + const mayUseRequesterForLiteralSentinel = + isLiteralUnscopedMainTarget && normalizeAgentId(targetAgentId) === requesterAgentId; const rawRequesterSessionKey = opts?.agentSessionKey ? effectiveRequesterKey : undefined; const parsedRequesterSessionKey = parseAgentSessionKey(rawRequesterSessionKey); const requesterRouteBindings = cfg.bindings?.filter( @@ -837,20 +855,24 @@ export function createSessionsSendTool(opts?: { sessionKey: unresolvedDisplayKey, }); } - const visibilityGuard = await createSessionVisibilityGuard({ - action: "send", - requesterAgentId, - requesterSessionKey: effectiveRequesterKey, - visibility: sessionVisibility, - a2aPolicy, - callGateway: gatewayCall, - }); const authorizationTargetKey = mayUseRequesterForLiteralSentinel ? effectiveRequesterKey : targetAgentId && !parseAgentSessionKey(resolvedKey) ? `agent:${targetAgentId}:${resolvedKey}` : resolvedKey; - const access = visibilityGuard.check(authorizationTargetKey); + const access = await resolveSessionToolAccess({ + action: "send", + defaultAgentId: requesterAgentId, + requesterAgentId, + requesterSessionKey: effectiveRequesterKey, + targetAgentId, + targetSessionKey: resolvedKey, + authorizationTargetSessionKey: authorizationTargetKey, + requesterOwned: visibleSession.requesterOwned, + visibility: sessionVisibility, + a2aPolicy, + callGateway: gatewayCall, + }); if (!access.allowed) { return jsonResult({ runId: crypto.randomUUID(), diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts index 87ebecf8ccad..95e849bf7b77 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -36,8 +36,8 @@ import { import { resolveSessionToolTargetAgentId } from "./scoped-session-access.js"; import { createAgentToAgentPolicy, - createSessionVisibilityGuard, resolveEffectiveSessionToolsVisibility, + resolveSessionToolAccess, } from "./sessions-access.js"; import { resolveSessionToolContext } from "./sessions-helpers.js"; import { resolveSessionReference, shouldResolveSessionIdInput } from "./sessions-resolution.js"; @@ -234,6 +234,7 @@ async function resolvePatchTarget( requesterAgentId, }); const resolved = await resolveSessionReference({ + action: "status", sessionKey: rawKey, agentId: inputAgentId, keyAgentId: requesterAgentId, @@ -260,11 +261,19 @@ async function resolvePatchTarget( if (!isRequesterSession) { // Session visibility is the configured read/write scope for session tools; // the action only selects error copy. Owner gating remains separate. - const guard = await createSessionVisibilityGuard({ + const authorizationKey = + agentId !== requesterAgentId && !parseAgentSessionKey(resolved.key) + ? `agent:${agentId}:${resolved.key}` + : resolved.key; + const access = await resolveSessionToolAccess({ action: "status", defaultAgentId: requesterAgentId, requesterSessionKey: context.effectiveRequesterKey, + authorizationTargetSessionKey: authorizationKey, requesterAgentId, + targetAgentId: agentId, + targetSessionKey: resolved.key, + requesterOwned: resolved.requesterOwned === true, visibility: resolveEffectiveSessionToolsVisibility({ cfg: context.cfg, sandboxed: opts.sandboxed === true, @@ -272,11 +281,6 @@ async function resolvePatchTarget( a2aPolicy: createAgentToAgentPolicy(context.cfg), callGateway, }); - const authorizationKey = - agentId !== requesterAgentId && !parseAgentSessionKey(resolved.key) - ? `agent:${agentId}:${resolved.key}` - : resolved.key; - const access = guard.check(authorizationKey); if (!access.allowed) { throw new ToolAuthorizationError(access.error); } diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index 1e090adb98ec..57edaf40040e 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -15,6 +15,7 @@ import { withOwnedSessionTranscriptWrites, } from "../../config/sessions/transcript-write-context.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { GatewayClientRequestError } from "../../gateway/client.js"; import { withTestDir } from "../../test-helpers/temp-dir.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { extractStoredAssistantText, sanitizeTextContent } from "./chat-history-text.js"; @@ -39,9 +40,13 @@ const facadeRuntimeMock = vi.hoisted(() => ({ >(), })); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); +vi.mock("../../gateway/call.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + callGateway: (opts: unknown) => callGatewayMock(opts), + }; +}); vi.mock("./in-process-gateway.js", () => ({ callAgentToolGatewayRequest: (opts: unknown) => inProcessGatewayRequestMock(opts), callInProcessGatewayToolWithCreation: (method: unknown, params: unknown, creation: unknown) => @@ -440,7 +445,10 @@ it("fails closed for cross-agent and resolution-derived bare keys", async () => return {}; } if (request.params?.key) { - throw new Error("not a session key"); + throw new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: `No session found: ${request.params.key}`, + }); } return request.params?.sessionId ? { key: "incident-42" } : {}; }); @@ -1165,7 +1173,13 @@ describe("sessions_send gating", () => { const request = opts as { method?: string; params?: Record }; if (request.method === "sessions.resolve") { if (request.params?.key === "session-id-only") { - throw new Error("not a session key"); + throw new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "No session found: session-id-only", + }); + } + if (request.params?.spawnedBy === MAIN_AGENT_SESSION_KEY) { + return {}; } return { key: "agent:other:main" }; } @@ -1204,12 +1218,52 @@ describe("sessions_send gating", () => { timeoutSeconds: 0, }); - expect(callGatewayMock).toHaveBeenCalledTimes(2); + expect(callGatewayMock).toHaveBeenCalledTimes(1); expect(requireGatewayRequest().method).toBe("sessions.resolve"); - expect(requireGatewayRequest(1).method).toBe("sessions.list"); expect(requireDetails(result).status).toBe("forbidden"); }); + it("classifies a failed spawned-lookup as lookup-failed for sandboxed sends", async () => { + loadConfigMock.mockReturnValue({ + session: { scope: "per-sender", mainKey: "main" }, + agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } }, + tools: { agentToAgent: { enabled: false }, sessions: { visibility: "all" } }, + }); + callGatewayMock.mockImplementation(async () => { + // A retryable request-level failure preserves the PR's evidence semantics + // (transient store read error) while exercising the retryable + // classification path (review P1: classify before prescribing retry). + throw new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "simulated transient store read error (evidence)", + retryable: true, + }); + }); + const tool = createSessionsSendTool({ + agentSessionKey: MAIN_AGENT_SESSION_KEY, + agentChannel: MAIN_AGENT_CHANNEL, + sandboxed: true, + }); + + const result = await tool.execute("call-lookup-failed", { + sessionKey: "agent:main:subagent:worker-1", + message: "hi", + timeoutSeconds: 0, + }); + + // sessions_send hits the resolution preflight before the direct guard; the + // failed lookup must surface the same retryable classification, not the + // generic sandboxed-session denial. + const details = requireDetails(result); + expect(details.status).toBe("forbidden"); + expect(String(details.error)).toBe( + "Session send denied because spawned-session ownership lookup failed (transient); retry once, then ask the operator to inspect OpenClaw logs.", + ); + expect(String(details.error)).not.toContain( + "Session not visible from this sandboxed agent session", + ); + }); + it("rejects direct thread session targets before dispatching an agent run", async () => { loadConfigMock.mockReturnValue({ session: { scope: "per-sender", mainKey: "main" }, diff --git a/src/auto-reply/reply/current-turn-images.test.ts b/src/auto-reply/reply/current-turn-images.test.ts index 2887344ebb24..cc61c1a1b5a1 100644 --- a/src/auto-reply/reply/current-turn-images.test.ts +++ b/src/auto-reply/reply/current-turn-images.test.ts @@ -26,6 +26,23 @@ const JPEG_IMAGE_BYTES = Buffer.from("ffd8ffe000104a46494600010100000100010000ff const PDF_BYTES = Buffer.from("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n"); const ZIP_BYTES = Buffer.from("504b0506000000000000000000000000000000000000", "hex"); +function createDescribedImageContext(describedIndexes: number[]): MsgContext { + return { + Body: "[Image]\nDescription:\na tiny dot image", + media: ["first", "second"].map((name) => ({ + path: `/tmp/${name}.png`, + contentType: "image/png", + })), + MediaUnderstanding: describedIndexes.map((attachmentIndex) => ({ + kind: "image.description" as const, + attachmentIndex, + provider: "openai", + model: "gpt-4o", + text: "a tiny dot image", + })), + }; +} + function restoreProcessState() { if (originalStateDirEnv === undefined) { deleteTestEnvValue("OPENCLAW_STATE_DIR"); @@ -354,6 +371,44 @@ describe("resolveCurrentTurnImages", () => { }); }); + it("does not rehydrate current image facts already described in the prompt", async () => { + vi.mocked(resolveAgentTurnAttachments).mockClear(); + + const result = await resolveCurrentTurnImages({ + ctx: createDescribedImageContext([0, 1]), + cfg: {} as OpenClawConfig, + }); + + expect(result).toEqual({}); + expect(resolveAgentTurnAttachments).not.toHaveBeenCalled(); + }); + + it("hydrates only current image facts missing prompt descriptions", async () => { + const imageData = Buffer.from("second image").toString("base64"); + vi.mocked(resolveAgentTurnAttachments).mockResolvedValueOnce({ + attachments: [{ data: imageData, mediaType: "image/png" }], + attachmentIndexes: [0], + recentHistoryImages: [], + }); + + const result = await resolveCurrentTurnImages({ + ctx: createDescribedImageContext([0]), + cfg: {} as OpenClawConfig, + }); + + expect(resolveAgentTurnAttachments).toHaveBeenCalledWith({ + ctx: expect.objectContaining({ + media: [expect.objectContaining({ path: "/tmp/second.png", kind: "image" })], + }), + cfg: {}, + includeRecentHistoryImages: false, + includeAttachmentIndexes: true, + }); + expect(result.images).toEqual([{ type: "image", data: imageData, mimeType: "image/png" }]); + expect(result.imageOrder).toEqual(["inline"]); + expect(result.imageSourceIndexes).toEqual([1]); + }); + it("appends extracted PDF page images without dropping current image attachments", async () => { await withTestDir({ prefix: "openclaw-current-turn-pdf-images-" }, async (base) => { const imagePath = path.join(base, "photo.png"); diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index cb128209bd5a..ff95a1e1abf8 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -1,13 +1,6 @@ // Tests media-only get-reply runs and sandboxed media attachment handling. -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { - clearActiveEmbeddedRun, - setActiveEmbeddedRun, -} from "../../agents/embedded-agent-runner/runs.js"; import type { SessionEntry } from "../../config/sessions.js"; import { withSystemEventOwner } from "../../infra/system-event-ownership.js"; import { @@ -62,6 +55,129 @@ vi.mock("../../agents/harness/hook-helpers.js", () => ({ runAgentHarnessBeforeMessageWriteHook: vi.fn((params: { message: unknown }) => params.message), })); +// Harness selection and built-in execution are owned by their focused suites. These tests keep +// the real visible-reply policy resolver while supplying its default OpenClaw harness leaf. +const preparedReplyMockState = vi.hoisted(() => ({ + unexpectedCalls: [] as string[], +})); + +vi.mock("../../agents/main-session-recovery/main-session-recovery-owner-release.js", () => ({ + scheduleMainSessionRecoveryPendingTarget: vi.fn(), +})); + +vi.mock("../../agents/main-session-recovery/main-session-recovery-state.js", () => ({ + isMainRestartRecoveryCandidate: vi.fn().mockReturnValue(false), +})); + +vi.mock("../../agents/main-session-recovery/main-session-recovery-store.js", () => ({ + claimMainSessionRecoveryOwner: vi.fn(), + releaseMainSessionRecoveryOwner: vi.fn(), +})); + +// Provider profile discovery is owned by thinking.test.ts. Keep the real thinking-policy +// projection here while preventing an unrelated active-plugin and public-artifact graph load. +vi.mock("../../plugins/provider-thinking.js", () => ({ + resolveEffectiveThinkingProfile: () => undefined, +})); + +vi.mock("../../agents/agent-tools.policy.js", () => ({ + resolveEffectiveToolPolicy: (params: { + config: { tools?: { allow?: string[]; deny?: string[] } }; + }) => ({ + globalPolicy: params.config.tools + ? { allow: params.config.tools.allow, deny: params.config.tools.deny } + : undefined, + globalProviderPolicy: undefined, + agentPolicy: undefined, + agentProviderPolicy: undefined, + profile: undefined, + providerProfile: undefined, + profileAlsoAllow: undefined, + providerProfileAlsoAllow: undefined, + }), + resolveGroupToolPolicy: () => undefined, + resolveInheritedToolPolicyForSession: () => undefined, + resolveSubagentToolPolicyForSession: () => undefined, +})); + +vi.mock("../../agents/subagents/spawn/subagent-capabilities.js", () => ({ + isSubagentEnvelopeSession: vi.fn().mockReturnValue(false), + resolveSubagentCapabilityStore: vi.fn().mockReturnValue(undefined), +})); + +const selectAgentHarnessMock = vi.hoisted(() => + vi.fn( + (params: { + provider: string; + modelId?: string; + agentHarnessId?: string; + agentHarnessRuntimeOverride?: string; + }) => { + const isSourceProviderCandidate = params.modelId === undefined; + const isDefaultModelCandidate = + params.provider === "anthropic" && params.modelId === "claude-opus-4-1"; + if ( + (!isSourceProviderCandidate && !isDefaultModelCandidate) || + params.agentHarnessId || + params.agentHarnessRuntimeOverride + ) { + preparedReplyMockState.unexpectedCalls.push("selectAgentHarness"); + } + return { id: "openclaw", deliveryDefaults: {} }; + }, + ), +); +vi.mock("../../agents/harness/selection.js", () => ({ + selectAgentHarness: selectAgentHarnessMock, +})); + +vi.mock("../../agents/model-selection.js", () => ({ + buildModelAliasIndex: vi.fn( + (params: { cfg: { agents?: { defaults?: { models?: unknown } } } }) => { + if (params.cfg.agents?.defaults?.models) { + preparedReplyMockState.unexpectedCalls.push("buildModelAliasIndex"); + } + return { byAlias: new Map(), byKey: new Map() }; + }, + ), + resolveDefaultModelForAgent: vi.fn( + (params: { cfg: { agents?: { defaults?: { model?: unknown } } } }) => { + if (params.cfg.agents?.defaults?.model) { + preparedReplyMockState.unexpectedCalls.push("resolveDefaultModelForAgent"); + } + return { provider: "anthropic", model: "claude-opus-4-1" }; + }, + ), + resolveModelRefFromString: vi.fn(() => { + preparedReplyMockState.unexpectedCalls.push("resolveModelRefFromString"); + return undefined; + }), +})); + +const resolveSessionRuntimeOverrideForProviderMock = vi.hoisted(() => + vi.fn( + (params: { + entry?: { + agentHarnessId?: string; + agentRuntimeOverride?: string; + modelSelectionLocked?: boolean; + }; + }) => { + if ( + params.entry?.agentHarnessId || + params.entry?.agentRuntimeOverride || + params.entry?.modelSelectionLocked + ) { + preparedReplyMockState.unexpectedCalls.push("resolveSessionRuntimeOverrideForProvider"); + } + return undefined; + }, + ), +); +vi.mock("../../agents/session-runtime-compat.js", () => ({ + resolveSessionRuntimeOverrideForProvider: resolveSessionRuntimeOverrideForProviderMock, +})); + // Provider policy projection belongs to its adapter and provider-local suites. These tests // exercise prepared reply orchestration and supply their own model/thinking facts. vi.mock("../../plugins/provider-policy-surface.js", () => ({ @@ -126,6 +242,15 @@ vi.mock("./body.js", () => ({ applySessionHints: vi.fn().mockImplementation(async ({ baseBody }) => baseBody), })); +const resolveCurrentTurnImagesMock = vi.hoisted(() => vi.fn().mockResolvedValue({})); +vi.mock("./current-turn-images.js", () => ({ + resolveCurrentTurnImages: resolveCurrentTurnImagesMock, +})); + +vi.mock("./get-reply-fast-path.js", () => ({ + shouldUseReplyFastTestRuntime: vi.fn().mockReturnValue(false), +})); + vi.mock("./groups.js", () => ({ buildDirectChatContext: vi.fn().mockReturnValue(""), buildGroupIntro: vi.fn().mockReturnValue(""), @@ -159,6 +284,21 @@ vi.mock("./session-system-events.js", () => ({ drainFormattedSystemEvents: vi.fn().mockResolvedValue(undefined), })); +vi.mock("./stored-model-override.js", () => ({ + resolveStoredModelOverride: vi.fn( + (params: { + sessionEntry?: { providerOverride?: string; modelOverride?: string }; + sessionStore?: Record; + }) => { + const entries = [params.sessionEntry, ...Object.values(params.sessionStore ?? {})]; + if (entries.some((entry) => entry?.providerOverride || entry?.modelOverride)) { + preparedReplyMockState.unexpectedCalls.push("resolveStoredModelOverride"); + } + return null; + }, + ), +})); + vi.mock("./session-reset-prompt.js", () => ({ resolveBareResetBootstrapFileAccess: vi.fn().mockReturnValue(false), resolveBareSessionResetPromptState: vi.fn().mockResolvedValue({ @@ -346,8 +486,6 @@ function requireLastRunReplyAgentCall() { } describe("runPreparedReply media-only handling", () => { - const cleanupPaths: string[] = []; - beforeAll(async () => { // Preload the runtime seams directly so test setup does not need a synthetic // reply turn with registry and session side effects. @@ -359,6 +497,7 @@ describe("runPreparedReply media-only handling", () => { }); beforeEach(async () => { + preparedReplyMockState.unexpectedCalls.length = 0; loadSessionEntryMock.mockReset(); updateAmbientTranscriptWatermarkMock.mockClear(); vi.clearAllMocks(); @@ -368,14 +507,14 @@ describe("runPreparedReply media-only handling", () => { vi.mocked(buildInboundUserContextPrefix).mockReset().mockReturnValue(""); vi.mocked(resolveInboundUserContextPromptJoiner).mockReturnValue(undefined); vi.mocked(hasControlCommand).mockReturnValue(false); + resolveCurrentTurnImagesMock.mockReset().mockResolvedValue({}); replyRunTesting.resetReplyRunRegistry(); }); - afterEach(() => { + afterEach(async () => { vi.useRealTimers(); resetSystemEventsForTest(); - const paths = cleanupPaths.splice(0); - return Promise.all(paths.map((entry) => rm(entry, { recursive: true, force: true }))); + expect(preparedReplyMockState.unexpectedCalls).toEqual([]); }); it("passes approved elevated defaults to the runner", async () => { @@ -1466,22 +1605,19 @@ describe("runPreparedReply media-only handling", () => { expect(call?.followupRun.prompt).toContain("[User sent media without caption]"); }); - it("hydrates current image facts by extension when content types are missing", async () => { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-followup-image-")); - cleanupPaths.push(tmpDir); - const imagePath = path.join(tmpDir, "inbound.png"); - await writeFile( - imagePath, - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", - "base64", - ), - ); + it("forwards current image hydration into the runner and transcript media", async () => { + const imagePath = "/tmp/current-image.png"; + const imageData = Buffer.from("current image").toString("base64"); + resolveCurrentTurnImagesMock.mockResolvedValueOnce({ + images: [{ type: "image", data: imageData, mimeType: "image/png" }], + imageOrder: ["inline"], + imageSourceIndexes: [0], + }); const result = await runPrepared({ ctx: { ...createInboundBody("describe this"), - media: [{ path: imagePath, workspaceDir: tmpDir }], + media: [{ path: imagePath, workspaceDir: "/tmp" }], OriginatingChannel: "discord", OriginatingTo: "C123", ChatType: "group", @@ -1492,7 +1628,7 @@ describe("runPreparedReply media-only handling", () => { OriginatingChannel: "discord", OriginatingTo: "C123", ChatType: "group", - media: [{ path: imagePath, workspaceDir: tmpDir }], + media: [{ path: imagePath, workspaceDir: "/tmp" }], }, }); @@ -1502,7 +1638,7 @@ describe("runPreparedReply media-only handling", () => { expect(call.followupRun.images).toEqual([ { type: "image", - data: expect.any(String), + data: imageData, mimeType: "image/png", }, ]); @@ -1513,8 +1649,16 @@ describe("runPreparedReply media-only handling", () => { media: [expect.objectContaining({ path: imagePath, contentType: "image/png" })], }, }); - expect(call.followupRun.images?.[0]?.data).toHaveLength(92); expect(call.followupRun.imageOrder).toEqual(["inline"]); + expect(resolveCurrentTurnImagesMock).toHaveBeenCalledWith({ + ctx: expect.objectContaining({ + media: [{ path: imagePath, workspaceDir: "/tmp" }], + }), + cfg: expect.any(Object), + images: undefined, + imageOrder: undefined, + extractedFileImages: undefined, + }); }); it("does not copy prior session media onto text-only followups", async () => { @@ -1616,32 +1760,16 @@ describe("runPreparedReply media-only handling", () => { }); }); - it("does not rehydrate current MediaPaths after image understanding enriched the prompt", async () => { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-followup-image-")); - cleanupPaths.push(tmpDir); - const imagePath = path.join(tmpDir, "inbound.png"); - await writeFile( - imagePath, - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", - "base64", - ), - ); - const secondImagePath = path.join(tmpDir, "second.png"); - await writeFile( - secondImagePath, - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", - "base64", - ), - ); + it("persists described current image facts without rehydrated runner images", async () => { + const imagePath = "/tmp/described-image.png"; + const secondImagePath = "/tmp/second-described-image.png"; const result = await runPrepared({ ctx: { ...createInboundBody("describe this\n\n[Image]\nDescription:\na tiny dot image"), media: [ - { path: imagePath, contentType: "image/png", workspaceDir: tmpDir }, - { path: secondImagePath, contentType: "image/png", workspaceDir: tmpDir }, + { path: imagePath, contentType: "image/png", workspaceDir: "/tmp" }, + { path: secondImagePath, contentType: "image/png", workspaceDir: "/tmp" }, ], MediaUnderstanding: [ { @@ -1670,8 +1798,8 @@ describe("runPreparedReply media-only handling", () => { OriginatingTo: "webchat:local", ChatType: "direct", media: [ - { path: imagePath, contentType: "image/png", workspaceDir: tmpDir }, - { path: secondImagePath, contentType: "image/png", workspaceDir: tmpDir }, + { path: imagePath, contentType: "image/png", workspaceDir: "/tmp" }, + { path: secondImagePath, contentType: "image/png", workspaceDir: "/tmp" }, ], }, }); @@ -1691,27 +1819,28 @@ describe("runPreparedReply media-only handling", () => { }); }); - it("rehydrates only current facts missing image understanding", async () => { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-followup-image-")); - cleanupPaths.push(tmpDir); - const imagePath = path.join(tmpDir, "inbound.png"); - await writeFile( - imagePath, - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", - "base64", - ), - ); + it("projects partially hydrated current images into the runner and transcript layout", async () => { + const imagePath = "/tmp/described-image.png"; const secondImageData = Buffer.from("second image bytes"); - const secondImagePath = path.join(tmpDir, "second.png"); - await writeFile(secondImagePath, secondImageData); + const secondImagePath = "/tmp/undescribed-image.png"; + resolveCurrentTurnImagesMock.mockResolvedValueOnce({ + images: [ + { + type: "image", + data: secondImageData.toString("base64"), + mimeType: "image/png", + }, + ], + imageOrder: ["inline"], + imageSourceIndexes: [1], + }); const result = await runPrepared({ ctx: { ...createInboundBody("describe this\n\n[Image]\nDescription:\na tiny dot image"), media: [ - { path: imagePath, contentType: "image/png", workspaceDir: tmpDir }, - { path: secondImagePath, contentType: "image/png", workspaceDir: tmpDir }, + { path: imagePath, contentType: "image/png", workspaceDir: "/tmp" }, + { path: secondImagePath, contentType: "image/png", workspaceDir: "/tmp" }, ], MediaUnderstanding: [ { @@ -1733,8 +1862,8 @@ describe("runPreparedReply media-only handling", () => { OriginatingTo: "webchat:local", ChatType: "direct", media: [ - { path: imagePath, contentType: "image/png", workspaceDir: tmpDir }, - { path: secondImagePath, contentType: "image/png", workspaceDir: tmpDir }, + { path: imagePath, contentType: "image/png", workspaceDir: "/tmp" }, + { path: secondImagePath, contentType: "image/png", workspaceDir: "/tmp" }, ], }, }); @@ -1850,28 +1979,48 @@ describe("runPreparedReply media-only handling", () => { }); it("interrupts embedded-only active runs even without a reply operation", async () => { const queueSettings = await import("./queue/settings-runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); + let embeddedRunActive = true; vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "interrupt" }); - const embeddedAbort = vi.fn(); - const embeddedHandle = { - queueMessage: vi.fn(async () => {}), - isStreaming: () => true, - isCompacting: () => false, - abort: embeddedAbort, - }; - setActiveEmbeddedRun("session-embedded-only", embeddedHandle, "session-key"); - - const runPromise = runPrepared({ - isNewSession: false, - sessionId: "session-embedded-only", + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId).mockImplementation(() => + embeddedRunActive ? "session-embedded-only" : undefined, + ); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockImplementation( + () => embeddedRunActive, + ); + vi.mocked(embeddedAgentRuntime.abortEmbeddedAgentRun).mockReturnValue(true); + vi.mocked(embeddedAgentRuntime.waitForEmbeddedAgentRunEnd).mockImplementation(async () => { + embeddedRunActive = false; + return true; }); - await Promise.resolve(); - expect(vi.mocked(runReplyAgent)).not.toHaveBeenCalled(); - expect(embeddedAbort).not.toHaveBeenCalled(); + try { + await expect( + runPrepared({ + isNewSession: false, + sessionId: "session-embedded-only", + }), + ).resolves.toEqual({ text: "ok" }); + } finally { + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId).mockReturnValue(undefined); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReturnValue(false); + vi.mocked(embeddedAgentRuntime.abortEmbeddedAgentRun).mockReturnValue(false); + vi.mocked(embeddedAgentRuntime.waitForEmbeddedAgentRunEnd).mockResolvedValue(true); + } - clearActiveEmbeddedRun("session-embedded-only", embeddedHandle, "session-key"); - - await expect(runPromise).resolves.toEqual({ text: "ok" }); + expect(embeddedAgentRuntime.abortEmbeddedAgentRun).toHaveBeenCalledTimes(2); + expect(embeddedAgentRuntime.abortEmbeddedAgentRun).toHaveBeenNthCalledWith( + 1, + "session-embedded-only", + ); + expect(embeddedAgentRuntime.abortEmbeddedAgentRun).toHaveBeenNthCalledWith( + 2, + "session-embedded-only", + ); + expect(embeddedAgentRuntime.waitForEmbeddedAgentRunEnd).toHaveBeenCalledOnce(); + expect(embeddedAgentRuntime.waitForEmbeddedAgentRunEnd).toHaveBeenCalledWith( + "session-embedded-only", + ); expect(vi.mocked(runReplyAgent)).toHaveBeenCalledOnce(); }); it("refreshes goal context after interrupt admission waits", async () => { @@ -3322,6 +3471,7 @@ describe("runPreparedReply media-only handling", () => { it("resolves origin-less sessions as internal for synthetic stable facts", async () => { vi.mocked(buildDirectChatContext).mockReturnValue("direct-context"); + selectAgentHarnessMock.mockClear(); // An entry with no persisted delivery origin has only ever been driven // internally; the wake provider ("heartbeat") must not leak into the // stable context as a non-internal surface or the fact diverges from @@ -3353,6 +3503,15 @@ describe("runPreparedReply media-only handling", () => { const run = requireRunReplyAgentCall(0).followupRun.run; expect(run.cliSessionBindingFacts?.sourceReplyDeliveryMode).toBe("automatic"); + expect( + selectAgentHarnessMock.mock.calls.map(([params]) => ({ + provider: params.provider, + modelId: params.modelId, + })), + ).toEqual([ + { provider: "heartbeat", modelId: undefined }, + { provider: "anthropic", modelId: "claude-opus-4-1" }, + ]); }); it("downgrades the synthetic stable mode when the message tool is policy-denied", async () => { @@ -3928,28 +4087,6 @@ describe("runPreparedReply media-only handling", () => { expect(params.command.ownerList).toHaveLength(24); }); - it("keeps sender ownership when drained system events are present", async () => { - vi.mocked(drainFormattedSystemEvents).mockResolvedValueOnce("System: [t] Trusted event."); - const params = ownerParams(); - - await runPreparedReply(params); - - const call = requireRunReplyAgentCall(); - expect(call?.followupRun.run.senderIsOwner).toBe(true); - }); - - it("does not downgrade sender ownership when event text contains a system marker", async () => { - vi.mocked(drainFormattedSystemEvents).mockResolvedValueOnce( - "System: [t] Relay text mentions System: but event is trusted.", - ); - const params = ownerParams(); - - await runPreparedReply(params); - - const call = requireRunReplyAgentCall(); - expect(call?.followupRun.run.senderIsOwner).toBe(true); - }); - it("preserves first-token think hint when system events are prepended", async () => { // drainFormattedSystemEvents returns the events block; the caller prepends it. // The hint must be extracted from the user body BEFORE prepending, so "System:" diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 3c24a6261aaa..90c9d3f308aa 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -268,6 +268,33 @@ describe("runServiceRestart token drift", () => { ); }); + it("restarts an installed system-scope service when its loaded-state probe is unavailable", async () => { + service.isLoaded.mockRejectedValue( + new Error( + "systemctl is-enabled unavailable: Command failed during launch or output capture (EACCES)", + ), + ); + service.readCommand.mockResolvedValue(null); + const hasInstalledDefinition = vi.fn(async () => true); + const postRestartCheck = vi.fn(async () => {}); + + await expect( + runServiceRestart({ + ...createServiceRunArgs(), + service: { ...service, hasInstalledDefinition } as GatewayService, + postRestartCheck, + }), + ).resolves.toBe(true); + + expect(hasInstalledDefinition).toHaveBeenCalledWith({ env: process.env }); + expect(service.restart).toHaveBeenCalledTimes(1); + expect(postRestartCheck).toHaveBeenCalledTimes(1); + expect(readJsonLog<{ ok?: boolean; result?: string }>()).toMatchObject({ + ok: true, + result: "restarted", + }); + }); + it("aborts loaded-service mutation when the service guard rejects", async () => { const repairLoadedService = vi.fn(); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index f684edef5feb..38586bc2ea71 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -124,11 +124,22 @@ async function resolveServiceLoadedOrFail(params: { serviceNoun: string; service: GatewayService; fail: ReturnType["fail"]; + acceptInstalledDefinition?: boolean; }): Promise { // Returning null keeps failure emission centralized in the caller's action context. try { return await params.service.isLoaded({ env: process.env }); } catch (err) { + if (params.acceptInstalledDefinition) { + // The adapter owns platform-specific install discovery; systemd spans + // user, system, marker-owned, and dueling definitions. + const installed = params.service.hasInstalledDefinition + ? await params.service.hasInstalledDefinition({ env: process.env }).catch(() => false) + : Boolean(await params.service.readCommand(process.env).catch(() => null)); + if (installed) { + return true; + } + } params.fail(`${params.serviceNoun} service check failed: ${String(err)}`); return null; } @@ -514,6 +525,7 @@ export async function runServiceRestart(params: { serviceNoun: params.serviceNoun, service: params.service, fail, + acceptInstalledDefinition: true, }); if (loaded === null) { return false; @@ -679,21 +691,11 @@ export async function runServiceRestart(params: { } } } - let restarted = loaded; - if (loaded) { - try { - restarted = await params.service.isLoaded({ env: process.env }); - } catch { - restarted = true; - } - } else if (recoveredLoadedState !== null) { - restarted = recoveredLoadedState; - } emit({ ok: true, result: "restarted", message: handledRecovery?.message ?? handledRepair?.message, - service: buildDaemonServiceSnapshot(params.service, restarted), + service: buildDaemonServiceSnapshot(params.service, loaded || recoveredLoadedState === true), warnings: warnings.length ? warnings : undefined, }); const actionMessage = handledRecovery?.message ?? handledRepair?.message; diff --git a/src/cli/update-cli/post-core-plugin-convergence.retirement.test.ts b/src/cli/update-cli/post-core-plugin-convergence.retirement.test.ts new file mode 100644 index 000000000000..b2072735ccbc --- /dev/null +++ b/src/cli/update-cli/post-core-plugin-convergence.retirement.test.ts @@ -0,0 +1,194 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; + +const mocks = vi.hoisted(() => ({ + listManagedPluginNpmRoots: vi.fn(), + maybeRepairStaleManagedNpmBundledPlugins: vi.fn(), + repairMissingConfiguredPluginInstalls: vi.fn(), + relinkOpenClawPeerDependenciesInManagedNpmRoot: vi.fn(), + runPluginPayloadSmokeCheck: vi.fn(), +})); + +vi.mock("../../commands/doctor/shared/missing-configured-plugin-install.js", () => ({ + repairMissingConfiguredPluginInstalls: mocks.repairMissingConfiguredPluginInstalls, +})); +vi.mock("../../commands/doctor-plugin-registry.js", () => ({ + maybeRepairStaleManagedNpmBundledPlugins: mocks.maybeRepairStaleManagedNpmBundledPlugins, +})); +vi.mock("../../plugins/plugin-peer-link.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + relinkOpenClawPeerDependenciesInManagedNpmRoot: + mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot, + }; +}); +vi.mock("../../plugins/npm-project-roots.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listManagedPluginNpmRoots: mocks.listManagedPluginNpmRoots, + }; +}); +vi.mock("./plugin-payload-validation.js", () => ({ + runPluginPayloadSmokeCheck: mocks.runPluginPayloadSmokeCheck, +})); + +import { resolvePluginNpmGenerationProjectDir } from "../../plugins/install-paths.js"; +import { + loadInstalledPluginIndexInstallRecords, + readPersistedInstalledPluginIndexInstallRecords, + writePersistedInstalledPluginIndexInstallRecords, +} from "../../plugins/installed-plugin-index-records.js"; +import { VERSION } from "../../version.js"; +import { runPostCorePluginConvergence } from "./post-core-plugin-convergence.js"; + +describe("post-core bundled plugin retirement", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.listManagedPluginNpmRoots.mockImplementation((npmRoot: string) => + Promise.resolve([npmRoot]), + ); + mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mockResolvedValue({ + checked: 0, + attempted: 0, + repaired: 0, + skipped: 0, + }); + mocks.runPluginPayloadSmokeCheck.mockResolvedValue({ checked: [], failures: [] }); + }); + + it("retires payload and record state before repair across two starts", async () => { + const stateDir = tempDirs.make("openclaw-post-core-convergence-"); + const bundledRoot = tempDirs.make("openclaw-post-core-bundled-"); + const cfg = { + update: { channel: "beta" as const }, + plugins: { allow: ["codex"], entries: { codex: { enabled: true } } }, + }; + const env = { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + VITEST: "true", + }; + const bundledDir = path.join(bundledRoot, "codex"); + fs.mkdirSync(bundledDir, { recursive: true }); + fs.writeFileSync(path.join(bundledDir, "index.js"), "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(bundledDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "codex", + name: "codex", + version: VERSION, + configSchema: { type: "object" }, + }), + "utf8", + ); + fs.writeFileSync( + path.join(bundledDir, "package.json"), + JSON.stringify({ name: "@openclaw/codex", version: VERSION }), + "utf8", + ); + const npmRoot = resolvePluginNpmGenerationProjectDir({ + npmDir: path.join(stateDir, "npm"), + packageName: "@openclaw/codex", + generationKey: "@openclaw/codex@2026.7.2-beta.7", + }); + const packageDir = path.join(npmRoot, "node_modules", "@openclaw", "codex"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(npmRoot, "package.json"), + JSON.stringify({ dependencies: { "@openclaw/codex": "2026.7.2-beta.7" } }), + "utf8", + ); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "@openclaw/codex", version: "2026.7.2-beta.7" }), + "utf8", + ); + fs.writeFileSync( + path.join(packageDir, "openclaw.plugin.json"), + JSON.stringify({ id: "codex", name: "codex", configSchema: { type: "object" } }), + "utf8", + ); + await writePersistedInstalledPluginIndexInstallRecords( + { + codex: { + source: "npm", + spec: "@openclaw/codex@beta", + installPath: packageDir, + version: "2026.7.2-beta.7", + resolvedName: "@openclaw/codex", + resolvedSpec: "@openclaw/codex@2026.7.2-beta.7", + resolvedVersion: "2026.7.2-beta.7", + }, + }, + { config: cfg, env }, + ); + const actualDoctorRegistry = await vi.importActual< + typeof import("../../commands/doctor-plugin-registry.js") + >("../../commands/doctor-plugin-registry.js"); + mocks.maybeRepairStaleManagedNpmBundledPlugins.mockImplementation( + actualDoctorRegistry.maybeRepairStaleManagedNpmBundledPlugins, + ); + let installAttempts = 0; + mocks.repairMissingConfiguredPluginInstalls.mockImplementation(async (params) => { + const records = + params.baselineRecords ?? + (await loadInstalledPluginIndexInstallRecords({ env: params.env })); + const codexRecord = records.codex; + if (codexRecord) { + installAttempts += 1; + const retryRoot = resolvePluginNpmGenerationProjectDir({ + npmDir: path.join(stateDir, "npm"), + packageName: "@openclaw/codex", + generationKey: `@openclaw/codex@retry-${installAttempts}`, + }); + const retryPackageDir = path.join(retryRoot, "node_modules", "@openclaw", "codex"); + fs.mkdirSync(retryPackageDir, { recursive: true }); + const nextRecords = { + ...records, + codex: { ...codexRecord, installPath: retryPackageDir }, + }; + await writePersistedInstalledPluginIndexInstallRecords(nextRecords, { + config: cfg, + env: params.env, + }); + return { + changes: ['Refreshed stale configured plugin "codex" from @openclaw/codex@beta.'], + warnings: [], + records: nextRecords, + }; + } + if (params.baselineRecords) { + await writePersistedInstalledPluginIndexInstallRecords(records, { + config: cfg, + env: params.env, + }); + } + return { changes: [], warnings: [], records }; + }); + + const first = await runPostCorePluginConvergence({ cfg, env }); + const projectsAfterFirst = fs.readdirSync(path.join(stateDir, "npm", "projects")); + const second = await runPostCorePluginConvergence({ cfg, env }); + + expect(fs.existsSync(packageDir)).toBe(false); + expect(installAttempts).toBe(0); + expect(projectsAfterFirst).toHaveLength(1); + expect(fs.readdirSync(path.join(stateDir, "npm", "projects"))).toEqual(projectsAfterFirst); + expect(await readPersistedInstalledPluginIndexInstallRecords({ env })).not.toHaveProperty( + "codex", + ); + expect(first.installRecords).not.toHaveProperty("codex"); + expect(second.installRecords).not.toHaveProperty("codex"); + expect(first.changes).toContain( + 'Removed stale managed install record for bundled plugin "codex".', + ); + expect(second.changes).toEqual([]); + }); +}); diff --git a/src/cli/update-cli/post-core-plugin-convergence.test.ts b/src/cli/update-cli/post-core-plugin-convergence.test.ts index 5b302f5cf056..1ff74d67ed24 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.test.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.test.ts @@ -40,7 +40,6 @@ vi.mock("./plugin-payload-validation.js", () => ({ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; -import { resolvePluginNpmGenerationProjectDir } from "../../plugins/install-paths.js"; import { VERSION } from "../../version.js"; import { filterRecordsToActive, @@ -59,7 +58,7 @@ describe("runPostCorePluginConvergence", () => { mocks.listManagedPluginNpmRoots.mockImplementation((npmRoot: string) => Promise.resolve([npmRoot]), ); - mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(false); + mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(null); mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], warnings: [], @@ -341,6 +340,15 @@ describe("runPostCorePluginConvergence", () => { env: {}, baselineInstallRecords: baseline, }); + expect(mocks.maybeRepairStaleManagedNpmBundledPlugins).toHaveBeenCalledWith({ + config: cfg, + env: { + OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, + OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", + }, + installRecords: baseline, + prompter: { shouldRepair: true }, + }); expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledTimes(1); expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({ cfg, @@ -406,54 +414,6 @@ describe("runPostCorePluginConvergence", () => { expect(result.installRecords).toEqual({ brave: baseline.brave }); }); - it("retires a stale managed generation when its official plugin is now bundled", async () => { - const stateDir = tempDirs.make("openclaw-post-core-convergence-"); - const bundledRoot = tempDirs.make("openclaw-post-core-bundled-"); - writeBundledPlugin(bundledRoot, "codex", VERSION); - const npmRoot = resolvePluginNpmGenerationProjectDir({ - npmDir: path.join(stateDir, "npm"), - packageName: "@openclaw/codex", - generationKey: "@openclaw/codex@2026.7.2-beta.7", - }); - const packageDir = path.join(npmRoot, "node_modules", "@openclaw", "codex"); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync( - path.join(npmRoot, "package.json"), - JSON.stringify({ dependencies: { "@openclaw/codex": "2026.7.2-beta.7" } }), - "utf8", - ); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "@openclaw/codex", version: "2026.7.2-beta.7" }), - "utf8", - ); - fs.writeFileSync( - path.join(packageDir, "openclaw.plugin.json"), - JSON.stringify({ id: "codex", name: "codex", configSchema: { type: "object" } }), - "utf8", - ); - const actualDoctorRegistry = await vi.importActual< - typeof import("../../commands/doctor-plugin-registry.js") - >("../../commands/doctor-plugin-registry.js"); - mocks.maybeRepairStaleManagedNpmBundledPlugins.mockImplementation( - actualDoctorRegistry.maybeRepairStaleManagedNpmBundledPlugins, - ); - - await runPostCorePluginConvergence({ - cfg: { - plugins: { allow: ["codex"], entries: { codex: { enabled: true } } }, - }, - env: { - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, - OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", - VITEST: "true", - }, - }); - - expect(fs.existsSync(packageDir)).toBe(false); - }); - it("forwards ClawHub risk acknowledgement options to repair", async () => { const cfg = { plugins: { entries: { matrix: { enabled: true } } }, diff --git a/src/cli/update-cli/post-core-plugin-convergence.ts b/src/cli/update-cli/post-core-plugin-convergence.ts index 8d5f09dfe41d..dc1b1a67efe6 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.ts @@ -173,14 +173,17 @@ export async function runPostCorePluginConvergence(params: { // became bundled with the new core must not survive into the next startup's contract graph. const { maybeRepairStaleManagedNpmBundledPlugins } = await import("../../commands/doctor-plugin-registry.js"); - maybeRepairStaleManagedNpmBundledPlugins({ + const staleManagedNpmBundledPluginRepair = maybeRepairStaleManagedNpmBundledPlugins({ config: params.cfg, env, prompter: { shouldRepair: true }, + ...(params.baselineInstallRecords ? { installRecords: params.baselineInstallRecords } : {}), }); - const prunedBaseline = params.baselineInstallRecords + const convergenceBaseline = + staleManagedNpmBundledPluginRepair?.installRecords ?? params.baselineInstallRecords; + const prunedBaseline = convergenceBaseline ? pruneStaleLocalBundledPluginInstallRecords({ - installRecords: params.baselineInstallRecords, + installRecords: convergenceBaseline, env, }) : null; @@ -264,6 +267,9 @@ export async function runPostCorePluginConvergence(params: { return { changes: [ + ...(staleManagedNpmBundledPluginRepair?.removedPluginIds.map( + (pluginId) => `Removed stale managed install record for bundled plugin "${pluginId}".`, + ) ?? []), ...(prunedBaseline?.stale.map( (record) => `Removed stale local bundled plugin install record "${record.pluginId}".`, ) ?? []), diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 8a0539f8f6ed..513e3a36cc62 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -371,26 +371,36 @@ function registerExternalChatSetupPlugin(pluginId = "@vendor/external-chat-plugi ); } -function registerSyntheticUseEnvSetupPlugin(channelId: ChannelPlugin["id"], envVar: string): void { - const plugin = { - ...createChannelTestPluginBase({ id: channelId }), - setupContract: defineChannelSetupContract({ - fields: { - useEnv: { - kind: "boolean", - cli: { flags: "--use-env", description: "Use environment credentials" }, - envVars: [envVar], - }, +function registerEnvContractTestPlugin(channelId: string, envVars: readonly string[]): void { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: channelId, + plugin: { + ...createChannelTestPluginBase({ id: channelId, label: channelId }), + setupContract: defineChannelSetupContract({ + fields: { + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use environment credentials" }, + envVars, + }, + }, + adapter: { + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + [channelId]: { enabled: true }, + }, + }), + }, + }), + } as ChannelPlugin, + source: "test", }, - adapter: { - applyAccountConfig: ({ cfg }) => ({ - ...cfg, - channels: { ...cfg.channels, [channelId]: { enabled: true } }, - }), - }, - }), - } as ChannelPlugin; - setActivePluginRegistry(createTestRegistry([{ pluginId: channelId, plugin, source: "test" }])); + ]), + ); } type SignalAfterAccountConfigWritten = NonNullable< @@ -532,35 +542,30 @@ describe("channelsAddCommand", () => { it.each([ { - channel: "telegram", - options: {}, - env: { TELEGRAM_BOT_TOKEN: "" }, - missing: ["TELEGRAM_BOT_TOKEN"], + channel: "single-env-chat", + env: { SINGLE_CHAT_TOKEN: "" }, + missing: ["SINGLE_CHAT_TOKEN"], }, { - channel: "slack", - options: {}, - env: { SLACK_BOT_TOKEN: "" }, - missing: ["SLACK_BOT_TOKEN"], + channel: "multi-env-chat", + env: { MULTI_CHAT_TOKEN: "token", MULTI_CHAT_SECOND_TOKEN: "" }, + missing: ["MULTI_CHAT_SECOND_TOKEN"], }, { - channel: "buzz", - options: {}, - env: { BUZZ_PRIVATE_KEY: "" }, - missing: ["BUZZ_PRIVATE_KEY"], + channel: "private-key-chat", + env: { PRIVATE_CHAT_KEY: "" }, + missing: ["PRIVATE_CHAT_KEY"], }, ])("rejects $channel --use-env when declared env vars are missing", async (testCase) => { for (const [name, value] of Object.entries(testCase.env)) { vi.stubEnv(name, value); } - registerSyntheticUseEnvSetupPlugin(testCase.channel, testCase.missing[0] as string); + registerEnvContractTestPlugin(testCase.channel, Object.keys(testCase.env)); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); - await channelsAddCommand( - { channel: testCase.channel, useEnv: true, ...testCase.options }, - runtime, - { hasFlags: true }, - ); + await channelsAddCommand({ channel: testCase.channel, useEnv: true }, runtime, { + hasFlags: true, + }); for (const missing of testCase.missing) { expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(missing)); @@ -571,18 +576,18 @@ describe("channelsAddCommand", () => { it.each([ { - channel: "telegram", - env: { TELEGRAM_BOT_TOKEN: "telegram-token" }, + channel: "single-env-chat", + env: { SINGLE_CHAT_TOKEN: "token" }, }, { - channel: "slack", - env: { SLACK_BOT_TOKEN: "xoxb-token" }, + channel: "multi-env-chat", + env: { MULTI_CHAT_TOKEN: "token", MULTI_CHAT_SECOND_TOKEN: "second-token" }, }, ])("commits $channel --use-env config when declared env vars are present", async (testCase) => { for (const [name, value] of Object.entries(testCase.env)) { vi.stubEnv(name, value); } - registerSyntheticUseEnvSetupPlugin(testCase.channel, Object.keys(testCase.env)[0] as string); + registerEnvContractTestPlugin(testCase.channel, Object.keys(testCase.env)); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); await channelsAddCommand({ channel: testCase.channel, useEnv: true }, runtime, { @@ -594,6 +599,24 @@ describe("channelsAddCommand", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); + it("does not demand env vars outside the selected setup contract", async () => { + vi.stubEnv("DECLARED_TOKEN", "declared-token"); + vi.stubEnv("CONDITIONAL_TOKEN", ""); + registerEnvContractTestPlugin("conditional-chat", ["DECLARED_TOKEN"]); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + + await channelsAddCommand({ channel: "conditional-chat", useEnv: true }, runtime, { + hasFlags: true, + }); + + expect(writtenChannel("conditional-chat")).toMatchObject({ + enabled: true, + }); + expect(runtime.error).not.toHaveBeenCalledWith(expect.stringContaining("CONDITIONAL_TOKEN")); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + it("keeps guided channel setup lazy until the user selects a channel", async () => { const config: OpenClawConfig = { channels: {} }; configMocks.readConfigFileSnapshot.mockResolvedValue({ diff --git a/src/commands/doctor-auth-flat-profiles.test.ts b/src/commands/doctor-auth-flat-profiles.test.ts index b1c8836c2e4f..7396fa712296 100644 --- a/src/commands/doctor-auth-flat-profiles.test.ts +++ b/src/commands/doctor-auth-flat-profiles.test.ts @@ -3,6 +3,8 @@ import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { listAuthProfileStoresRequiringMigration } from "../agents/auth-profiles/legacy-source-diagnostic.js"; +import { resolveAuthProfileEligibility } from "../agents/auth-profiles/order.js"; import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles/runtime-snapshots.js"; import { @@ -285,8 +287,9 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { type: "oauth", provider: "openai", oauthRef: { + source: "openclaw-credentials", id: "0123456789abcdef0123456789abcdef", - provider: "openai", + provider: "openai-codex", }, }, }, @@ -305,16 +308,21 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { env: state.env, }); - expect(result.warnings).toEqual([ - expect.stringContaining("legacy OAuth sidecar profile"), - expect.stringContaining("no importable auth profiles or state"), - expect.stringContaining("Deferred shared legacy OAuth migration"), - ]); - expect(loadPersistedAuthProfileStore(state.agentDir())).toBeNull(); - expect(fs.existsSync(authPath)).toBe(true); - expect(fs.existsSync(oauthPath)).toBe(true); - expectNoMigratedArchive(authPath); - expectNoMigratedArchive(oauthPath); + expect(result.warnings).toEqual([expect.stringContaining("legacy OAuth sidecar profile")]); + expect( + loadPersistedAuthProfileStore(state.agentDir())?.profiles["openai:default"], + ).toMatchObject({ + type: "oauth", + provider: "openai", + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + }, + }); + expect(fs.existsSync(authPath)).toBe(false); + expect(fs.existsSync(oauthPath)).toBe(false); + expectMigratedArchive(authPath); + expectMigratedArchive(oauthPath); }); it("preserves state-only profile IDs while migrating shared OAuth", async () => { @@ -701,7 +709,7 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { expect(fs.existsSync(authPath)).toBe(false); }); - it("leaves unresolved legacy OAuth sidecar refs in JSON", async () => { + it("migrates unresolved legacy OAuth sidecar refs so startup can proceed cold", async () => { const state = await makeTestState(); const authPath = await writeLegacyAuthProfilesJson(state, { version: 1, @@ -711,8 +719,9 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { provider: "openai", email: "user@example.com", oauthRef: { + source: "openclaw-credentials", id: "0123456789abcdef0123456789abcdef", - provider: "openai", + provider: "openai-codex", }, }, }, @@ -725,14 +734,36 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { }); expect(result.detected).toEqual([authPath]); - expect(result.changes).toEqual([]); + expect(result.changes).toEqual([expect.stringContaining("Migrated auth profile JSON")]); expect(result.warnings).toEqual([ - expect.stringContaining("legacy OAuth sidecar profile"), - expect.stringContaining("no importable auth profiles or state"), + expect.stringContaining("Migrated 1 legacy OAuth sidecar profile"), ]); - expect(loadPersistedAuthProfileStore(state.agentDir())).toBeNull(); - expect(fs.existsSync(authPath)).toBe(true); - expectNoMigratedArchive(authPath); + expect(result.warnings[0]).toContain("re-authenticate"); + const store = loadPersistedAuthProfileStore(state.agentDir()); + expect(store?.profiles["openai:user@example.com"]).toMatchObject({ + type: "oauth", + provider: "openai", + email: "user@example.com", + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + }, + }); + expect( + resolveAuthProfileEligibility({ + store: store!, + provider: "openai", + profileId: "openai:user@example.com", + }), + ).toEqual({ eligible: false, reasonCode: "unresolved_ref" }); + expect(fs.existsSync(authPath)).toBe(false); + expectMigratedArchive(authPath); + expect( + listAuthProfileStoresRequiringMigration({ + agentDirs: [state.agentDir()], + env: state.env, + }), + ).toEqual([]); }); it("imports valid profiles when one legacy OAuth sidecar ref is unresolved", async () => { @@ -750,8 +781,9 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { provider: "openai", email: "user@example.com", oauthRef: { + source: "openclaw-credentials", id: "0123456789abcdef0123456789abcdef", - provider: "openai", + provider: "openai-codex", }, }, }, @@ -774,19 +806,28 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { provider: "openai", key: "sk-imported", }, + "openai:user@example.com": { + type: "oauth", + provider: "openai", + email: "user@example.com", + oauthRef: { + source: "openclaw-credentials", + id: "0123456789abcdef0123456789abcdef", + provider: "openai-codex", + }, + }, }, - order: { openai: ["openai:default"] }, + order: { openai: ["openai:default", "openai:user@example.com"] }, lastGood: { openai: "openai:default" }, }); - expect(fs.existsSync(authPath)).toBe(true); - expectNoMigratedArchive(authPath); - const remaining = JSON.parse(fs.readFileSync(authPath, "utf8")); - expect(remaining.profiles).toHaveProperty("openai:default"); - expect(remaining.profiles).toHaveProperty("openai:user@example.com"); - expect(remaining.order).toEqual({ - openai: ["openai:default", "openai:user@example.com"], - }); - expect(remaining.lastGood).toEqual({ openai: "openai:default" }); + expect(fs.existsSync(authPath)).toBe(false); + expectMigratedArchive(authPath); + expect( + listAuthProfileStoresRequiringMigration({ + agentDirs: [state.agentDir()], + env: state.env, + }), + ).toEqual([]); }); it("keeps existing SQLite credentials when importing stale JSON", async () => { @@ -2031,7 +2072,7 @@ describe("legacy OpenAI auth profiles through the canonical migration owner", () }); }); - it("does not rebind unresolved sidecar accounts when another Codex account migrates", async () => { + it("canonicalizes unresolved sidecar accounts while keeping them cold", async () => { const state = await makeTestState(); const storePath = path.join(state.sessionsDir(), "sessions.json"); const readySessionKey = "agent:main:main"; @@ -2089,7 +2130,7 @@ describe("legacy OpenAI auth profiles through the canonical migration owner", () authProfileIdMap: profileIdMap, }); - expect(result.repairedSessions).toBe(1); + expect(result.repairedSessions).toBe(2); expect( loadSessionEntry({ storePath, sessionKey: readySessionKey, env: state.env }), ).toMatchObject({ @@ -2098,9 +2139,17 @@ describe("legacy OpenAI auth profiles through the canonical migration owner", () expect( loadSessionEntry({ storePath, sessionKey: pendingSessionKey, env: state.env }), ).toMatchObject({ - authProfileOverride: "openai-codex:pending", + authProfileOverride: "openai:pending", authProfileOverrideSource: "user", }); + const coldStore = loadPersistedAuthProfileStore(state.agentDir()); + expect( + resolveAuthProfileEligibility({ + store: coldStore!, + provider: "openai", + profileId: "openai:pending", + }), + ).toEqual({ eligible: false, reasonCode: "missing_credential" }); }); it("repairs an already-migrated selected account from its verified auth archive", async () => { @@ -2265,6 +2314,7 @@ describe("legacy OpenAI auth profiles through the canonical migration owner", () provider: "openai-codex", accountId: "failed-different-account", oauthRef: { + source: "openclaw-credentials", id: "0123456789abcdef0123456789abcdef", provider: "openai-codex", }, @@ -2326,8 +2376,8 @@ describe("legacy OpenAI auth profiles through the canonical migration owner", () shouldRepair: true, authProfileIdMap: profileIdMap, }); - expect(repair.repairedSessions).toBe(3); - for (const agentId of ["main", "inherited", "dedup"]) { + expect(repair.repairedSessions).toBe(4); + for (const agentId of ["main", "failed", "inherited", "dedup"]) { expect( loadSessionEntry({ storePath: path.join(state.sessionsDir(agentId), "sessions.json"), @@ -2339,15 +2389,16 @@ describe("legacy OpenAI auth profiles through the canonical migration owner", () authProfileOverrideSource: "user", }); } - expect( - loadSessionEntry({ - storePath: path.join(state.sessionsDir("failed"), "sessions.json"), - sessionKey: "agent:failed:main", - env: state.env, - }), - ).toMatchObject({ - authProfileOverride: "openai-codex:shared", - authProfileOverrideSource: "user", + expect(loadPersistedAuthProfileStore(state.agentDir("failed"))?.profiles).toMatchObject({ + "openai:shared": { + type: "oauth", + provider: "openai", + accountId: "failed-different-account", + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + }, + }, }); }); diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index 36a8561e9af6..501fef1e8c99 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -618,80 +618,6 @@ function formatMissingAuthProfileSqliteVerification(params: { return parts.length > 0 ? parts.join("; ") : null; } -function filterRawAuthProfileState( - raw: Record, - shouldKeepProfileId: (profileId: string) => boolean, -): void { - if (isRecord(raw.order)) { - for (const [provider, profileIds] of Object.entries(raw.order)) { - if (!Array.isArray(profileIds)) { - continue; - } - const kept = profileIds.filter( - (profileId): profileId is string => - typeof profileId === "string" && shouldKeepProfileId(profileId), - ); - if (kept.length > 0) { - raw.order[provider] = kept; - } else { - delete raw.order[provider]; - } - } - if (Object.keys(raw.order).length === 0) { - delete raw.order; - } - } - if (isRecord(raw.lastGood)) { - for (const [provider, profileId] of Object.entries(raw.lastGood)) { - if (typeof profileId !== "string" || !shouldKeepProfileId(profileId)) { - delete raw.lastGood[provider]; - } - } - if (Object.keys(raw.lastGood).length === 0) { - delete raw.lastGood; - } - } - if (isRecord(raw.usageStats)) { - for (const profileId of Object.keys(raw.usageStats)) { - if (!shouldKeepProfileId(profileId)) { - delete raw.usageStats[profileId]; - } - } - if (Object.keys(raw.usageStats).length === 0) { - delete raw.usageStats; - } - } -} - -function pruneRawAuthProfileIds(raw: unknown, profileIds: ReadonlySet): void { - if (!isRecord(raw) || !isRecord(raw.profiles)) { - return; - } - for (const profileId of profileIds) { - delete raw.profiles[profileId]; - } - filterRawAuthProfileState(raw, (profileId) => !profileIds.has(profileId)); -} - -function pickRawAuthProfileIds( - raw: unknown, - profileIds: ReadonlySet, -): Record | null { - if (!isRecord(raw) || !isRecord(raw.profiles)) { - return null; - } - const profiles = Object.fromEntries( - Object.entries(raw.profiles).filter(([profileId]) => profileIds.has(profileId)), - ); - if (Object.keys(profiles).length === 0) { - return null; - } - const next = structuredClone(raw); - next.profiles = profiles; - filterRawAuthProfileState(next, (profileId) => profileIds.has(profileId)); - return next; -} - function collectUnresolvedLegacyOAuthSidecarProfileIds(raw: unknown): string[] { if (!isRecord(raw) || !isRecord(raw.profiles)) { return []; @@ -971,7 +897,7 @@ function migrateLockedLegacyOAuthFile(params: { * Imports legacy auth profile JSON and state files into the per-agent SQLite store. * * JSON files are verified and atomically renamed to timestamped archives only after import. - * OAuth profiles that still depend on unresolved sidecar secrets remain as a migration input. + * OAuth profiles that still depend on missing sidecar secrets migrate as unavailable ref-only rows. */ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { cfg: OpenClawConfig; @@ -1099,17 +1025,10 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { const unresolvedSidecarProfileIds = new Set( collectUnresolvedLegacyOAuthSidecarProfileIds(rawStore), ); - const unresolvedSidecarRawStore = + const unresolvedSidecarWarning = unresolvedSidecarProfileIds.size > 0 - ? pickRawAuthProfileIds(rawStore, unresolvedSidecarProfileIds) - : null; - if (unresolvedSidecarProfileIds.size > 0) { - // Sidecar-backed OAuth entries cannot move into SQLite until their secret material exists. - pruneRawAuthProfileIds(rawStore, unresolvedSidecarProfileIds); - result.warnings.push( - `Left ${unresolvedSidecarProfileIds.size} legacy OAuth sidecar profile${unresolvedSidecarProfileIds.size === 1 ? "" : "s"} in ${shortenHomePath(candidate.authPath)}; rerun ${formatCliCommand("openclaw doctor --fix")} after sidecar migration or re-authenticate those profiles.`, - ); - } + ? `Migrated ${unresolvedSidecarProfileIds.size} legacy OAuth sidecar profile${unresolvedSidecarProfileIds.size === 1 ? "" : "s"} from ${shortenHomePath(candidate.authPath)} into SQLite as configured-unavailable without credentials; re-authenticate ${unresolvedSidecarProfileIds.size === 1 ? "this profile" : "these profiles"} to restore access.` + : undefined; const awsSdkMarkerStore = isRecord(rawStore) && isRecord(rawStore.profiles) ? resolveAwsSdkAuthProfileMarkerStore(candidate) @@ -1154,7 +1073,7 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { !hasAuthProfileState(state) && !awsSdkMarkerStore ) { - if (!unresolvedSidecarRawStore && sourceReceipts.length > 0) { + if (sourceReceipts.length > 0) { const archived = sourceReceipts.map((receipt) => { finalizeAuthProfileMigrationSource(receipt, "archived-unparsed", { sourceLocked: true, @@ -1162,7 +1081,8 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { return receipt.archivePath; }); result.warnings.push( - `Archived unparseable auth profile input without import for ${shortenHomePath(candidate.authPath)} (${archived.map(shortenHomePath).join(", ")}).`, + unresolvedSidecarWarning ?? + `Archived unparseable auth profile input without import for ${shortenHomePath(candidate.authPath)} (${archived.map(shortenHomePath).join(", ")}).`, ); continue; } @@ -1354,11 +1274,8 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { receipt.expectedStateSha256 = expectedStateSha256; } } - const archivalReceipts = unresolvedSidecarRawStore - ? sourceReceipts.filter((receipt) => receipt.sourcePath !== candidate.authPath) - : sourceReceipts; assertAuthProfileMigrationSourcesUnchanged(candidate, sourceReceipts); - const archives = archivalReceipts.map((receipt) => + const archives = sourceReceipts.map((receipt) => archiveVerifiedAuthProfileSource(receipt, true), ); const archiveText = @@ -1368,6 +1285,9 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { result.changes.push( `Migrated auth profile JSON for ${shortenHomePath(candidate.authPath)} into SQLite (${archiveText}).`, ); + if (unresolvedSidecarWarning) { + result.warnings.push(unresolvedSidecarWarning); + } if (openAIProviderRepair !== null) { result.changes.push( `Migrated ${openAIProviderRepair} OpenAI Codex auth profile(s) in ${shortenHomePath(candidate.authPath)} to provider "openai".`, diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 0e7fe446bee9..c5db9ea1132c 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1707,19 +1707,32 @@ describe("doctor config flow", () => { }); it("removes a legacy list when Doctor persists keyed roster entries", async () => { - const result = await runDoctorConfigWithInput({ - config: { agents: { entries: { ops: { workspace: "/srv/ops" } } } }, - parsedConfig: { - agents: { list: [{ id: "ops", default: true, workspace: "/srv/ops" }] }, + const rawConfig = { + agents: { + list: [ + { id: "ops", default: true, workspace: "/srv/ops" }, + { id: "research", model: "openai/research" }, + ], }, + }; + const result = await runDoctorConfigWithInput({ + config: migratePersistedImplicitMainRoster(rawConfig).config as OpenClawConfig, + parsedConfig: rawConfig, repair: true, run: loadAndMaybeMigrateDoctorConfig, }); expect(result.shouldWriteConfig).toBe(true); + expect(result.explicitSetPaths).toEqual([ + ["agents", "entries"], + ["agents", "ownership"], + ]); expect(result.cfg.agents?.entries).toEqual({ ops: { workspace: "/srv/ops" }, + research: { model: "openai/research" }, }); + expect(result.cfg.agents?.ownership).toBe("explicit"); + expect(result.cfg.agents?.entries?.ops).not.toHaveProperty("default"); expect(result.cfg.agents).not.toHaveProperty("list"); }); diff --git a/src/commands/doctor-config-preflight.state-migration.test.ts b/src/commands/doctor-config-preflight.state-migration.test.ts index 79c70e7526dc..adcb98be9a97 100644 --- a/src/commands/doctor-config-preflight.state-migration.test.ts +++ b/src/commands/doctor-config-preflight.state-migration.test.ts @@ -928,8 +928,9 @@ describe("runDoctorConfigPreflight state migration", () => { }); }); - it("blocks gateway readiness when startup migrations leave warnings", async () => { - needsStartupMigrationCheckpoint.mockReturnValue(true); + it("blocks gateway readiness when state migration warnings outlive the startup checkpoint", async () => { + needsStateMigrationCheckpoint.mockReturnValue(true); + needsStartupMigrationCheckpoint.mockReturnValue(false); autoMigrateLegacyStateDir.mockResolvedValueOnce({ migrated: false, skipped: false, diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 3513d367b276..11c35dc4ad6c 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -666,23 +666,21 @@ export async function runDoctorConfigPreflight( }); } if (gatewayStartupCheckpointRequired) { - if (shouldRecordStartupCheckpoint) { - if (startupMigrationWarnings.length > 0) { - throwStartupMigrationRefusal( - formatStartupMigrationFailure({ - warnings: startupMigrationWarnings, - blockers: [], - }), - ); - } - if (!snapshot.valid) { - throwStartupMigrationRefusal( - formatStartupMigrationFailure({ - warnings: [], - blockers: ['OpenClaw config is invalid; run "openclaw doctor --fix" before startup.'], - }), - ); - } + if (startupMigrationWarnings.length > 0) { + throwStartupMigrationRefusal( + formatStartupMigrationFailure({ + warnings: startupMigrationWarnings, + blockers: [], + }), + ); + } + if (shouldRecordStartupCheckpoint && !snapshot.valid) { + throwStartupMigrationRefusal( + formatStartupMigrationFailure({ + warnings: [], + blockers: ['OpenClaw config is invalid; run "openclaw doctor --fix" before startup.'], + }), + ); } // This state is established before the first Gateway plugin load and remains // fixed for the boot. Refresh it on every process start because migration diff --git a/src/commands/doctor-plugin-registry.retirement.test.ts b/src/commands/doctor-plugin-registry.retirement.test.ts new file mode 100644 index 000000000000..8938015bac88 --- /dev/null +++ b/src/commands/doctor-plugin-registry.retirement.test.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolvePluginNpmProjectDir } from "../plugins/install-paths.js"; +import { + readPersistedInstalledPluginIndex, + writePersistedInstalledPluginIndex, +} from "../plugins/installed-plugin-index-store.js"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js"; +import { maybeRepairStaleManagedNpmBundledPlugins } from "./doctor-plugin-registry.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTrackedTempDirs(tempDirs); +}); + +describe("stale managed bundled plugin retirement", () => { + it("preserves payload and record state for a non-bundled external plugin", async () => { + const stateDir = makeTrackedTempDir("openclaw-doctor-plugin-retirement", tempDirs); + const packageName = "@openclaw/external-demo"; + const version = "2026.5.2"; + const npmRoot = resolvePluginNpmProjectDir({ + npmDir: path.join(stateDir, "npm"), + packageName, + }); + const packageDir = path.join(npmRoot, "node_modules", "@openclaw", "external-demo"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(npmRoot, "package.json"), + JSON.stringify({ dependencies: { [packageName]: version } }), + "utf8", + ); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ name: packageName, version, openclaw: { extensions: ["."] } }), + "utf8", + ); + fs.writeFileSync( + path.join(packageDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "external-demo", + name: "external-demo", + configSchema: { type: "object" }, + }), + "utf8", + ); + const installRecords = { + "external-demo": { + source: "npm" as const, + spec: `${packageName}@${version}`, + installPath: packageDir, + version, + resolvedName: packageName, + resolvedVersion: version, + resolvedSpec: `${packageName}@${version}`, + }, + }; + await writePersistedInstalledPluginIndex( + { + version: 1, + hostContractVersion: "2026.4.25", + compatRegistryVersion: "compat-v1", + migrationVersion: 1, + policyHash: "policy-v1", + generatedAtMs: 1777118400000, + installRecords, + plugins: [], + diagnostics: [], + }, + { stateDir }, + ); + const env = { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + }; + + const result = maybeRepairStaleManagedNpmBundledPlugins({ + stateDir, + candidates: [], + env, + config: { + plugins: { + allow: ["external-demo"], + entries: { "external-demo": { enabled: true } }, + }, + }, + prompter: { shouldRepair: true }, + }); + + expect(result).toBeNull(); + expect(fs.existsSync(packageDir)).toBe(true); + expect((await readPersistedInstalledPluginIndex({ stateDir }))?.installRecords).toEqual( + installRecords, + ); + }); +}); diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index c96234a4a434..394ad36f5573 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -691,7 +691,15 @@ describe("maybeRepairPluginRegistryState", () => { retainedAt: "2026-04-25T00:00:00.000Z", reason: "test-retained-generation", }); - await writePersistedInstalledPluginIndex(createCurrentIndex(), { stateDir }); + await writePersistedInstalledPluginIndex( + createCurrentIndexWithNpmRecord({ + pluginId: "google-meet", + packageName: "@openclaw/google-meet", + packageDir: managed.packageDir, + version: "2026.5.2", + }), + { stateDir }, + ); await maybeRepairPluginRegistryState({ stateDir, @@ -719,6 +727,13 @@ describe("maybeRepairPluginRegistryState", () => { }); expect(fs.existsSync(managed.packageDir)).toBe(true); + const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir); + expect(persisted.installRecords["google-meet"]).toMatchObject({ + source: "npm", + installPath: managed.packageDir, + resolvedName: "@openclaw/google-meet", + resolvedVersion: "2026.5.2", + }); expect(vi.mocked(note).mock.calls.join("\n")).not.toContain( "Removed stale managed npm plugin package", ); diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index 281a5b3defdc..32b74a2a237b 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -5,12 +5,15 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { writeJsonTarget } from "../infra/json-file.js"; import { tryReadJsonSync } from "../infra/json-files.js"; import type { BundledPluginSource } from "../plugins/bundled-sources.js"; import { loadInstalledPluginIndexInstallRecords, + loadInstalledPluginIndexInstallRecordsSync, + removePluginInstallRecordFromRecords, type InstalledPluginIndexRecordStoreOptions, } from "../plugins/installed-plugin-index-records.js"; import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js"; @@ -62,6 +65,11 @@ type StaleManagedNpmBundledPlugin = { version?: string; }; +type StaleManagedNpmBundledPluginRepairResult = { + installRecords: Record; + removedPluginIds: string[]; +}; + type PluginRegistryHealthIssue = | { kind: "registry-missing-or-stale"; @@ -294,11 +302,13 @@ function removeManagedNpmPackageLockDependency(params: { /** Removes managed npm packages that shadow current bundled plugins when repair is enabled. */ export function maybeRepairStaleManagedNpmBundledPlugins( - params: PluginRegistryDoctorRepairParams, -): boolean { + params: PluginRegistryDoctorRepairParams & { + installRecords?: Record; + }, +): StaleManagedNpmBundledPluginRepairResult | null { const stale = listStaleManagedNpmBundledPlugins(params); if (stale.length === 0) { - return false; + return null; } if (!params.prompter.shouldRepair) { @@ -313,9 +323,18 @@ export function maybeRepairStaleManagedNpmBundledPlugins( ].join("\n"), "Plugin registry", ); - return false; + return null; } + // Capture one authoritative record baseline before deleting the payload. Later readers recover + // managed records from disk, so package-only cleanup can otherwise resurrect the same install. + let installRecords = params.installRecords ?? loadInstalledPluginIndexInstallRecordsSync(params); + const removedPluginIds = [...new Set(stale.map((plugin) => plugin.pluginId))].toSorted( + (left, right) => left.localeCompare(right), + ); + for (const pluginId of removedPluginIds) { + installRecords = removePluginInstallRecordFromRecords(installRecords, pluginId); + } for (const plugin of stale) { removeManagedNpmDependency(plugin); } @@ -329,7 +348,7 @@ export function maybeRepairStaleManagedNpmBundledPlugins( ].join("\n"), "Plugin registry", ); - return true; + return { installRecords, removedPluginIds }; } /** Removes local install records that shadow current bundled plugin sources. */ @@ -366,10 +385,11 @@ async function maybeRepairStaleLocalBundledPluginInstallRecords( async function loadInstallRecordsWithoutPluginIds( params: PluginRegistryDoctorRepairParams, pluginIds: readonly string[], + baselineRecords?: Record, ) { - const records = await loadInstalledPluginIndexInstallRecords(params); + let records = baselineRecords ?? (await loadInstalledPluginIndexInstallRecords(params)); for (const pluginId of pluginIds) { - delete records[pluginId]; + records = removePluginInstallRecordFromRecords(records, pluginId); } return records; } @@ -600,10 +620,7 @@ export async function maybeRepairPluginRegistryState( ...params, config: params.config, }; - const staleManagedNpmBundledPluginIds = listStaleManagedNpmBundledPlugins(params).map( - (plugin) => plugin.pluginId, - ); - const removedStaleManagedNpmBundledPlugins = maybeRepairStaleManagedNpmBundledPlugins(params); + const staleManagedNpmBundledPluginRepair = maybeRepairStaleManagedNpmBundledPlugins(params); const removedStaleLocalBundledPluginIds = await maybeRepairStaleLocalBundledPluginInstallRecords(params); const retiredStaleManagedNpmInstallGenerations = @@ -611,7 +628,7 @@ export async function maybeRepairPluginRegistryState( const repairedPluginOpenClawHostLinks = await maybeRepairPluginOpenClawHostLinks(params); const stalePluginIdsToRemove = [ ...new Set([ - ...(removedStaleManagedNpmBundledPlugins ? staleManagedNpmBundledPluginIds : []), + ...(staleManagedNpmBundledPluginRepair?.removedPluginIds ?? []), ...removedStaleLocalBundledPluginIds, ]), ]; @@ -638,6 +655,7 @@ export async function maybeRepairPluginRegistryState( installRecords: await loadInstallRecordsWithoutPluginIds( params, stalePluginIdsToRemove, + staleManagedNpmBundledPluginRepair?.installRecords, ), } : {}), @@ -658,7 +676,7 @@ export async function maybeRepairPluginRegistryState( if ( preflight.action === "skip-existing" || - removedStaleManagedNpmBundledPlugins || + staleManagedNpmBundledPluginRepair || removedStaleLocalBundledPluginIds.length > 0 || retiredStaleManagedNpmInstallGenerations || repairedPluginOpenClawHostLinks @@ -671,6 +689,7 @@ export async function maybeRepairPluginRegistryState( installRecords: await loadInstallRecordsWithoutPluginIds( params, stalePluginIdsToRemove, + staleManagedNpmBundledPluginRepair?.installRecords, ), } : {}), diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index 30b40ab1537b..3ba628e57e2d 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -300,7 +300,7 @@ describe("doctor repair sequencing", () => { config: cfg, changes: [], })); - mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(false); + mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(null); mocks.maybeRepairStaleConfiguredAuthOrders.mockImplementation( ({ cfg }: { cfg: OpenClawConfig }) => ({ config: cfg, changes: [] }), ); @@ -540,7 +540,7 @@ describe("doctor repair sequencing", () => { mocks.loadPluginMetadataSnapshot.mockReturnValueOnce(refreshedSnapshot); mocks.maybeRepairStaleManagedNpmBundledPlugins.mockImplementation(() => { events.push("bundled-shadow-cleanup"); - return true; + return { installRecords: {}, removedPluginIds: ["google-meet"] }; }); mocks.maybeRepairPluginOpenClawHostLinks.mockImplementation(async () => { events.push("openclaw-peer-links"); @@ -579,6 +579,17 @@ describe("doctor repair sequencing", () => { expect(cleanupCall?.config.plugins?.entries?.["google-meet"]).toEqual({ enabled: true }); expect(cleanupCall?.prompter).toEqual({ shouldRepair: true }); expect(mocks.maybeRepairPluginOpenClawHostLinks).toHaveBeenCalledOnce(); + expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({ + cfg: { + plugins: { + entries: { + "google-meet": { enabled: true }, + }, + }, + }, + env: process.env, + baselineRecords: {}, + }); const peerLinkCall = mocks.maybeRepairPluginOpenClawHostLinks.mock.calls[0]?.[0]; expect(peerLinkCall?.prompter).toEqual({ shouldRepair: true }); expect(peerLinkCall?.env).toBe(process.env); diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index a0f9eaff6ac6..3c4c364b26a1 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -151,7 +151,7 @@ export async function runDoctorRepairSequence(params: { applyMutation(mutation); } applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env)); - const removedStaleManagedNpmBundledPlugins = maybeRepairStaleManagedNpmBundledPlugins({ + const staleManagedNpmBundledPluginRepair = maybeRepairStaleManagedNpmBundledPlugins({ config: state.candidate, env, prompter: { shouldRepair: true }, @@ -197,11 +197,14 @@ export async function runDoctorRepairSequence(params: { repairMissingConfiguredPluginInstalls({ cfg: state.candidate, env, + ...(staleManagedNpmBundledPluginRepair + ? { baselineRecords: staleManagedNpmBundledPluginRepair.installRecords } + : {}), }), ); const repairedPluginIds = missingConfiguredPluginInstallRepair.repairedPluginIds ?? []; if ( - removedStaleManagedNpmBundledPlugins || + staleManagedNpmBundledPluginRepair || repairedPluginOpenClawHostLinks || missingConfiguredPluginInstallRepair.pluginInventoryChanged ) { diff --git a/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts b/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts index 14b771e2226f..936e613477ea 100644 --- a/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts +++ b/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts @@ -337,6 +337,60 @@ describe("default role materialization authored writes", () => { }); }); + it("replaces a legacy list when persisting explicit ownership", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-legacy-roster-write-")); + roots.push(root); + const configPath = path.join(root, "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + list: [ + { id: "ops", default: true, workspace: "/srv/ops" }, + { id: "research", model: "openai/research" }, + ], + }, + }), + ); + const io = createConfigIO({ + configPath, + env: { HOME: root, OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => root, + observe: false, + logger: { warn: () => {}, error: () => {} }, + }); + const snapshot = await io.readConfigFileSnapshot(); + const nextConfig: OpenClawConfig = { + ...snapshot.config, + agents: { ...snapshot.config.agents, ownership: "explicit" }, + }; + + await io.writeConfigFile(nextConfig, { + baseSnapshot: snapshot, + explicitSetPaths: [["agents", "ownership"]], + explicitSetValueSource: nextConfig, + }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(persisted.agents).toEqual({ + ownership: "explicit", + defaults: { + heartbeat: { agentId: "ops" }, + systemAgent: { agentId: "ops" }, + authInheritance: { agentId: "ops" }, + }, + entries: { + ops: { workspace: "/srv/ops" }, + research: { model: "openai/research" }, + }, + }); + expect(persisted.agents).not.toHaveProperty("list"); + const firstPersisted = await fs.readFile(configPath, "utf8"); + const reread = await io.readConfigFileSnapshot(); + await io.writeConfigFile(reread.config, { baseSnapshot: reread }); + await expect(fs.readFile(configPath, "utf8")).resolves.toBe(firstPersisted); + }); + it("preserves migrated legacy ownership during an unrelated write", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-legacy-owner-roundtrip-")); roots.push(root); diff --git a/src/config/config.node-agent-runs.test.ts b/src/config/config.node-agent-runs.test.ts index 661666d5f4e7..2b212121db84 100644 --- a/src/config/config.node-agent-runs.test.ts +++ b/src/config/config.node-agent-runs.test.ts @@ -27,4 +27,18 @@ describe("node agent-runs config", () => { ).toBe(true); } }); + + it.each([true, false])("accepts worker session hosting enabled=%s", (enabled) => { + expect(validateConfigObject({ nodeHost: { workerRuns: { enabled } } }).ok).toBe(true); + }); + + it("rejects non-boolean worker session hosting enablement", () => { + const result = validateConfigObject({ nodeHost: { workerRuns: { enabled: "yes" } } }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues.some((issue) => issue.path === "nodeHost.workerRuns.enabled")).toBe( + true, + ); + } + }); }); diff --git a/src/config/io.write.ts b/src/config/io.write.ts index 0f340402f737..7caeff3fcc14 100644 --- a/src/config/io.write.ts +++ b/src/config/io.write.ts @@ -227,9 +227,9 @@ export async function writeConfigFileFromContext( !isDeepStrictEqual(snapshot.sourceConfigBeforeMigrations?.bindings, snapshot.config.bindings) ? [["bindings"]] : []), - ...ownershipMaterialization.insertedPaths, - ...workspaceCollapse.insertedPaths, - ...authInheritanceOwnership.insertedPaths, + ...ownershipMaterialization.insertedPaths.concat(workspaceCollapse.insertedPaths), + ...authInheritanceOwnership.insertedPaths, // Persisting explicit ownership must replace the authored legacy roster too. + ...(persistOwnership ? [["agents", "entries"]] : []), // Otherwise projection restores the retired default marker. ...(stampOwnership ? [["agents", "ownership"]] : []), ]; diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index 9a09f31f84c7..f331ec481568 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { resolveLegacyOAuthPath } from "../agents/auth-profiles/legacy-source-diagnostic.js"; import { withTestDir } from "../test-helpers/temp-dir.js"; import { + allowsProcessHomeSessionScan, CONFIG_PATH, DEFAULT_GATEWAY_PORT, isDefaultInstallIdentity, @@ -50,6 +51,7 @@ describe("default install identity", () => { const configPath = path.join(stateDir, "openclaw.json"); expect(isDefaultInstallIdentity({ HOME: home }, () => home)).toBe(true); + expect(allowsProcessHomeSessionScan({ HOME: home }, () => home)).toBe(true); expect( isDefaultInstallIdentity( { HOME: home, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath }, @@ -161,6 +163,17 @@ describe("default install identity", () => { () => home, ), ).toBe(true); + expect( + allowsProcessHomeSessionScan( + { + HOME: home, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: profileStateDir, + OPENCLAW_CONFIG_PATH: path.join(profileStateDir, "openclaw.json"), + }, + () => home, + ), + ).toBe(false); expect( isDefaultInstallIdentity( { diff --git a/src/config/paths.ts b/src/config/paths.ts index 655e5919fa30..e5a3b8382ebd 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -200,6 +200,15 @@ export function isDefaultInstallIdentity( ); } +/** Whether external session catalogs may inherit a scan root from process HOME. */ +export function allowsProcessHomeSessionScan( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = resolveSystemAccountHomeDir, + platform: NodeJS.Platform = process.platform, +): boolean { + return !isNamedProfile(env) && isDefaultInstallIdentity(env, homedir, platform); +} + export function normalizeStateDirEnv(env: NodeJS.ProcessEnv = process.env): void { const effectiveHomedir = () => resolveRequiredHomeDir(env, envHomedir(env)); const openclawOverride = env.OPENCLAW_STATE_DIR?.trim(); diff --git a/src/config/schema.help.quality.test-fixtures.ts b/src/config/schema.help.quality.test-fixtures.ts index 792f8ff1a8ec..e47df087cd61 100644 --- a/src/config/schema.help.quality.test-fixtures.ts +++ b/src/config/schema.help.quality.test-fixtures.ts @@ -130,6 +130,8 @@ export const TARGET_KEYS = [ "nodeHost.agentRuns", "nodeHost.agentRuns.claude", "nodeHost.agentRuns.claude.enabled", + "nodeHost.workerRuns", + "nodeHost.workerRuns.enabled", "nodeHost.browserProxy", "nodeHost.browserProxy.enabled", "nodeHost.browserProxy.allowProfiles", diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts index 57e794f453ee..e38c040df638 100644 --- a/src/config/schema.help.runtime.ts +++ b/src/config/schema.help.runtime.ts @@ -260,6 +260,10 @@ export const RUNTIME_FIELD_HELP: Record = { "Controls whether this headless node host may advertise Claude CLI agent turns to the gateway.", "nodeHost.agentRuns.claude.enabled": "Advertise paired-node Claude session continuation when the local claude binary is available (default: false). Runs still require node exec approval.", + "nodeHost.workerRuns": + "Opt in to full OpenClaw worker session hosting from this headless node's local installation. Disabled by default.", + "nodeHost.workerRuns.enabled": + "Advertise this paired node as a session host and pin its local OpenClaw build identity (default: false). The node version must exactly match the gateway.", "nodeHost.browserProxy": "Groups browser-proxy settings for exposing local browser control through node routing. Enable only when remote node workflows need your local browser profiles.", "nodeHost.browserProxy.enabled": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index da69a4603963..45eca7fbab3b 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -389,6 +389,8 @@ export const FIELD_LABELS: Record = { "nodeHost.agentRuns": "Node Agent Runs", "nodeHost.agentRuns.claude": "Node Claude Agent Runs", "nodeHost.agentRuns.claude.enabled": "Node Claude Agent Runs Enabled", + "nodeHost.workerRuns": "Node Worker Runs", + "nodeHost.workerRuns.enabled": "Node Worker Runs Enabled", "nodeHost.browserProxy": "Node Browser Proxy", "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index b2703f8fafc9..c3f6900350ee 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -65,6 +65,7 @@ const TAG_OVERRIDES: Record = { "gateway.nodes.pluginTools.enabled": ["tools", "security", "access", "network", "advanced"], "gateway.nodes.allowSkills": ["tools", "security", "access", "network", "advanced"], "nodeHost.agentRuns.claude.enabled": ["tools", "security", "access", "network", "advanced"], + "nodeHost.workerRuns.enabled": ["tools", "security", "access", "network", "advanced"], "nodeHost.mcp.servers": ["tools", "network", "advanced"], "nodeHost.skills.enabled": ["tools", "network", "advanced"], "proxy.tls.caFile": ["security", "network", "storage", "advanced"], diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index 54b07a8fb989..f2d72d651a75 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -26,6 +26,7 @@ import { inspectSqliteSessionHistoryDiskBudget, } from "./session-history-eviction.js"; import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; +import { countSessionEntryMaintenanceEligibleEntries } from "./store-maintenance-eligibility.js"; import { collectSessionMaintenancePreserveKeysForStore } from "./store-maintenance-preserve.js"; import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { @@ -395,7 +396,7 @@ async function previewStoreCleanup(params: { }); const modelRunPruned = shouldRunModelRunPrune({ maintenance: params.maintenance, - entryCount: Object.keys(previewStore).length, + entryCount: countSessionEntryMaintenanceEligibleEntries(previewStore, preserveSessionKeys), // `sessions cleanup` applies the cap immediately (apply path forces maintenance and the // preview caps unconditionally below), so mirror that here: prune stale probes before the // forced cap can evict real sessions in their place. diff --git a/src/config/sessions/incognito-session-transcript.test.ts b/src/config/sessions/incognito-session-transcript.test.ts index dc219cd8292d..33958088f4cc 100644 --- a/src/config/sessions/incognito-session-transcript.test.ts +++ b/src/config/sessions/incognito-session-transcript.test.ts @@ -112,13 +112,14 @@ describe("incognito transcript access", () => { storePath, }; const now = Date.now(); + const staleUpdatedAt = now - 366 * 24 * 60 * 60 * 1000; try { await patchSessionEntryCore( staleScope, - () => ({ sessionId: "incognito-stale-session", updatedAt: now }), + () => ({ sessionId: "incognito-stale-session", updatedAt: staleUpdatedAt }), { - fallbackEntry: { sessionId: "incognito-stale-session", updatedAt: now }, + fallbackEntry: { sessionId: "incognito-stale-session", updatedAt: staleUpdatedAt }, replaceEntry: true, skipMaintenance: true, }, diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index a226bc533f69..c3023872f132 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -1657,7 +1657,101 @@ describe("sqlite session normalization", () => { env, storePath: paths.sqlitePath, }).map((summary) => summary.sessionKey), - ).toEqual(["agent:main:newer", "agent:main:newest"]); + ).toEqual(["agent:main:active", "agent:main:newer", "agent:main:newest"]); + }); + + it("keeps protected SQLite rows outside the write-triggered entry allowance", async () => { + vi.mocked(getRuntimeConfig).mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "365d", + maxEntries: 2, + }, + }, + }); + const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir }; + const now = Date.now(); + const scopeFor = (sessionKey: string) => ({ + agentId: "main", + env, + sessionKey, + storePath: paths.sqlitePath, + }); + const recentSessionId = "recent-dashboard-session-1"; + const recentTranscriptEvent = { + id: "recent-dashboard-event", + timestamp: new Date().toISOString(), + type: "metadata", + }; + + for (const [sessionKey, sessionId, updatedAt] of [ + ["agent:main:archived-1", "archived-session-1", now - 4], + ["agent:main:archived-2", "archived-session-2", now - 3], + ] as const) { + await patchSessionEntryCore( + scopeFor(sessionKey), + () => ({ archivedAt: updatedAt, sessionId, updatedAt }), + { + fallbackEntry: { archivedAt: updatedAt, sessionId, updatedAt }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + } + await patchSessionEntryCore( + scopeFor("agent:main:recent-dashboard-1"), + () => ({ sessionId: recentSessionId, updatedAt: now - 2 }), + { + fallbackEntry: { sessionId: recentSessionId, updatedAt: now - 2 }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + await appendTranscriptEvent( + { ...scopeFor("agent:main:recent-dashboard-1"), sessionId: recentSessionId }, + recentTranscriptEvent, + ); + await patchSessionEntryCore( + scopeFor("agent:main:recent-dashboard-2"), + () => ({ sessionId: "recent-dashboard-session-2", updatedAt: now - 1 }), + { + fallbackEntry: { sessionId: "recent-dashboard-session-2", updatedAt: now - 1 }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + + await patchSessionEntryCore( + scopeFor("agent:main:maintenance-trigger"), + () => ({ sessionId: "maintenance-trigger-session", updatedAt: now }), + { + fallbackEntry: { sessionId: "maintenance-trigger-session", updatedAt: now }, + replaceEntry: true, + }, + ); + + expect( + listSessionEntryRows({ + agentId: "main", + env, + storePath: paths.sqlitePath, + }).map((summary) => summary.sessionKey), + ).toEqual([ + "agent:main:archived-1", + "agent:main:archived-2", + "agent:main:maintenance-trigger", + "agent:main:recent-dashboard-1", + "agent:main:recent-dashboard-2", + ]); + await expect( + loadTranscriptEvents({ + agentId: "main", + env, + sessionId: recentSessionId, + storePath: paths.sqlitePath, + }), + ).resolves.toEqual([recentTranscriptEvent]); }); it("preserves pinned SQLite entries and transcripts during write-triggered capping", async () => { diff --git a/src/config/sessions/session-accessor.sqlite-maintenance.ts b/src/config/sessions/session-accessor.sqlite-maintenance.ts index da84e16d321d..9215741a3b06 100644 --- a/src/config/sessions/session-accessor.sqlite-maintenance.ts +++ b/src/config/sessions/session-accessor.sqlite-maintenance.ts @@ -1,5 +1,4 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { sql } from "kysely"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import { getChildLogger } from "../../logging/logger.js"; import { @@ -11,7 +10,6 @@ import { type SessionStateDeletePlan, } from "./session-accessor.sqlite-archive.js"; import type { SessionLifecycleArchivedTranscript } from "./session-accessor.sqlite-contract.js"; -import { readSessionEntryCount } from "./session-accessor.sqlite-entry-store.js"; import { emitCommittedSessionEntryRemovals } from "./session-accessor.sqlite-identity.js"; import { assertPlannedLifecycleArtifactEntriesUnchanged, @@ -32,10 +30,8 @@ import { } from "./session-accessor.sqlite-scope.js"; import { parseSessionEntryJson as parseSessionEntryRow } from "./session-accessor.sqlite-status.js"; import { normalizeStoreSessionKey } from "./store-entry.js"; -import { - collectSessionMaintenancePreserveKeys, - collectSessionMaintenancePreserveKeysForStore, -} from "./store-maintenance-preserve.js"; +import { countSessionEntryMaintenanceEligibleEntries } from "./store-maintenance-eligibility.js"; +import { collectSessionMaintenancePreserveKeysForStore } from "./store-maintenance-preserve.js"; import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { capEntryCount, @@ -65,38 +61,42 @@ function collectSqliteSessionMaintenanceBaseKeys( return keys; } -function hasStaleSqliteSessionEntryCandidate( - database: OpenClawAgentDatabase, +function hasStaleSessionEntryCandidate( + store: Record, pruneAfterMs: number, preserveKeys: ReadonlySet | undefined, ): boolean { const cutoffMs = Date.now() - pruneAfterMs; - const db = getSessionKysely(database.db); - const rows = executeSqliteQuerySync( - database.db, - db - .selectFrom("session_nodes") - .select(["entry_json", "session_key"]) - .where("updated_at", "<", cutoffMs) - .where( - /* kysely-allow-raw: archivedAt lives inside the canonical JSON entry, not a SQL column. */ - sql`json_extract(entry_json, '$.archivedAt') IS NULL`, - ) - .orderBy("updated_at", "asc"), - ).rows; - return rows.some((row) => { - const entry = parseSessionEntryRow(row); - if (!entry) { + return Object.entries(store).some(([key, entry]) => { + if (entry.updatedAt == null || entry.updatedAt >= cutoffMs) { return false; } return !shouldPreserveMaintenanceEntry({ - key: normalizeStoreSessionKey(row.session_key), + key, entry, preserveKeys, }); }); } +function loadSqliteSessionMaintenanceStore( + database: OpenClawAgentDatabase, +): Record { + const db = getSessionKysely(database.db); + const rows = executeSqliteQuerySync( + database.db, + db.selectFrom("session_nodes").select(["session_key", "entry_json"]).orderBy("session_key"), + ).rows; + const store: Record = {}; + for (const row of rows) { + const entry = parseSessionEntryRow(row); + if (entry) { + store[row.session_key] = entry; + } + } + return store; +} + export function applySessionEntryMaintenance( database: OpenClawAgentDatabase, params: { @@ -116,44 +116,39 @@ export function applySessionEntryMaintenance( return { entryRemovals: [], stateDeletePlans: [] }; } - const entryCount = readSessionEntryCount(database); - const preserveCandidateKeys = collectSessionMaintenancePreserveKeys([params.activeSessionKey]); - const hasStaleCandidate = hasStaleSqliteSessionEntryCandidate( - database, + // Trigger and eviction decisions must use the same snapshot and preservation boundary. + // A preliminary count can otherwise miss active-work aliases or race the later mutation plan. + const store = loadSqliteSessionMaintenanceStore(database); + const preserveKeys = + collectSessionMaintenancePreserveKeysForStore({ + storePath: params.storePath, + store, + baseKeys: collectSqliteSessionMaintenanceBaseKeys(store, params.activeSessionKey), + }) ?? new Set(); + const eligibleEntryCount = countSessionEntryMaintenanceEligibleEntries(store, preserveKeys); + const hasStaleCandidate = hasStaleSessionEntryCandidate( + store, maintenance.pruneAfterMs, - preserveCandidateKeys, + preserveKeys, ); - const shouldLoadStore = + const shouldMaintainStore = params.forceMaintenance === true || - entryCount > maintenance.maxEntries || + eligibleEntryCount > maintenance.maxEntries || hasStaleCandidate || shouldRunModelRunPrune({ maintenance, - entryCount, + entryCount: eligibleEntryCount, force: params.forceMaintenance, }) || shouldRunSessionEntryMaintenance({ - entryCount, + entryCount: eligibleEntryCount, maxEntries: maintenance.maxEntries, force: params.forceMaintenance, }); - if (!shouldLoadStore) { + if (!shouldMaintainStore) { return { entryRemovals: [], stateDeletePlans: [] }; } - const db = getSessionKysely(database.db); - const rows = executeSqliteQuerySync( - database.db, - db.selectFrom("session_nodes").select(["session_key", "entry_json"]).orderBy("session_key"), - ).rows; - const store: Record = {}; - for (const row of rows) { - const entry = parseSessionEntryRow(row); - if (entry) { - store[row.session_key] = entry; - } - } - const removedKeys = new Set(); const removedEntriesByKey = new Map(); const removedSessionIds = new Set(); @@ -164,31 +159,30 @@ export function applySessionEntryMaintenance( removedSessionIds.add(sessionId); } }; - const preserveKeys = - collectSessionMaintenancePreserveKeysForStore({ - storePath: params.storePath, - store, - baseKeys: collectSqliteSessionMaintenanceBaseKeys(store, params.activeSessionKey), - }) ?? new Set(); + let remainingEligibleEntryCount = eligibleEntryCount; if ( shouldRunModelRunPrune({ maintenance, - entryCount: Object.keys(store).length, + entryCount: remainingEligibleEntryCount, force: params.forceMaintenance, }) ) { - pruneStaleModelRunEntries(store, maintenance.modelRunPruneAfterMs, { - log: false, - onPruned: rememberRemovedEntry, - preserveKeys, - }); + remainingEligibleEntryCount -= pruneStaleModelRunEntries( + store, + maintenance.modelRunPruneAfterMs, + { + log: false, + onPruned: rememberRemovedEntry, + preserveKeys, + }, + ); } if ( params.forceMaintenance === true || hasStaleCandidate || - Object.keys(store).length > maintenance.maxEntries + remainingEligibleEntryCount > maintenance.maxEntries ) { - pruneStaleEntries(store, maintenance.pruneAfterMs, { + remainingEligibleEntryCount -= pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false, onPruned: rememberRemovedEntry, preserveKeys, @@ -196,7 +190,7 @@ export function applySessionEntryMaintenance( } if ( shouldRunSessionEntryMaintenance({ - entryCount: Object.keys(store).length, + entryCount: remainingEligibleEntryCount, maxEntries: maintenance.maxEntries, force: params.forceMaintenance, }) diff --git a/src/config/sessions/store-maintenance-eligibility.ts b/src/config/sessions/store-maintenance-eligibility.ts new file mode 100644 index 000000000000..fb4c6a87b093 --- /dev/null +++ b/src/config/sessions/store-maintenance-eligibility.ts @@ -0,0 +1,15 @@ +import { shouldPreserveMaintenanceEntry } from "./store-maintenance.js"; +import type { SessionEntry } from "./types.js"; + +export function countSessionEntryMaintenanceEligibleEntries( + store: Record, + preserveKeys?: ReadonlySet, +): number { + let count = 0; + for (const [key, entry] of Object.entries(store)) { + if (!shouldPreserveMaintenanceEntry({ key, entry, preserveKeys })) { + count++; + } + } + return count; +} diff --git a/src/config/sessions/store-maintenance-eligible-quota.test.ts b/src/config/sessions/store-maintenance-eligible-quota.test.ts new file mode 100644 index 000000000000..c7ef72b3496d --- /dev/null +++ b/src/config/sessions/store-maintenance-eligible-quota.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { capEntryCount, getActiveSessionMaintenanceWarning } from "./store-maintenance.js"; +import type { SessionEntry } from "./types.js"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function makeEntry(updatedAt: number): SessionEntry { + return { sessionId: `session-${updatedAt}`, updatedAt }; +} + +function makeStore(entries: Array<[string, SessionEntry]>): Record { + return Object.fromEntries(entries); +} + +describe("session maintenance eligible quota", () => { + it("keeps 499 archived sessions outside the ordinary-session allowance", () => { + const now = Date.now(); + const archivedEntries = Array.from({ length: 499 }, (_, index): [string, SessionEntry] => [ + `archived-${index}`, + { ...makeEntry(index), archivedAt: now }, + ]); + const store = makeStore([ + ...archivedEntries, + ["dashboard-1", makeEntry(now - 2)], + ["dashboard-2", makeEntry(now - 1)], + ["dashboard-3", makeEntry(now)], + ]); + + expect(capEntryCount(store, 500)).toBe(0); + expect(Object.keys(store)).toHaveLength(502); + expect(store).toHaveProperty("dashboard-1"); + expect(store).toHaveProperty("dashboard-2"); + expect(store).toHaveProperty("dashboard-3"); + }); + + it("removes only the oldest eligible session above the allowance", () => { + const now = Date.now(); + const archivedEntries = Array.from({ length: 499 }, (_, index): [string, SessionEntry] => [ + `archived-${index}`, + { ...makeEntry(index), archivedAt: now }, + ]); + const eligibleEntries = Array.from({ length: 501 }, (_, index): [string, SessionEntry] => [ + `eligible-${index}`, + makeEntry(index), + ]); + const store = makeStore([...archivedEntries, ...eligibleEntries]); + + expect(capEntryCount(store, 500)).toBe(1); + expect(store["eligible-0"]).toBeUndefined(); + expect(store).toHaveProperty("eligible-1"); + expect(store).toHaveProperty("eligible-500"); + expect(store).toHaveProperty("archived-0"); + expect(store).toHaveProperty("archived-498"); + }); + + it("does not count archived sessions against the active-session allowance", () => { + const now = Date.now(); + const archivedEntries = Array.from({ length: 499 }, (_, index): [string, SessionEntry] => [ + `archived-${index}`, + { ...makeEntry(index), archivedAt: now }, + ]); + const store = makeStore([ + ...archivedEntries, + ["recent", makeEntry(now)], + ["active", makeEntry(now - 1)], + ]); + + expect( + getActiveSessionMaintenanceWarning({ + store, + activeSessionKey: "active", + pruneAfterMs: DAY_MS, + maxEntries: 2, + nowMs: now, + }), + ).toBeNull(); + }); +}); diff --git a/src/config/sessions/store-maintenance-operations.ts b/src/config/sessions/store-maintenance-operations.ts index be4fdb42bde1..242be5c941fb 100644 --- a/src/config/sessions/store-maintenance-operations.ts +++ b/src/config/sessions/store-maintenance-operations.ts @@ -1,6 +1,7 @@ // Storage-neutral session maintenance operations for the file-backed session store. import path from "node:path"; import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js"; +import { countSessionEntryMaintenanceEligibleEntries } from "./store-maintenance-eligibility.js"; import { collectSessionMaintenancePreserveKeysForStore } from "./store-maintenance-preserve.js"; import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { @@ -199,32 +200,34 @@ async function applyEnforcedMaintenance(params: { maintenance: ResolvedSessionMaintenanceConfig; beforeCount: number; forceMaintenance: boolean; + preserveSessionKeys: ReadonlySet | undefined; }): Promise { - const preserveSessionKeys = collectSessionMaintenancePreserveKeysForStore({ - storePath: params.operation.storePath, - store: params.operation.store, - baseKeys: [params.operation.activeSessionKey], - }); const removedSessionFiles = new Map(); const modelRunPruned = shouldRunModelRunPrune({ maintenance: params.maintenance, - entryCount: params.beforeCount, + entryCount: countSessionEntryMaintenanceEligibleEntries( + params.operation.store, + params.preserveSessionKeys, + ), force: params.forceMaintenance, }) ? pruneStaleModelRunEntries(params.operation.store, params.maintenance.modelRunPruneAfterMs, { onPruned: ({ entry }) => { rememberRemovedSessionFile(removedSessionFiles, entry); }, - preserveKeys: preserveSessionKeys, + preserveKeys: params.preserveSessionKeys, }) : 0; const pruned = pruneStaleEntries(params.operation.store, params.maintenance.pruneAfterMs, { onPruned: ({ entry }) => { rememberRemovedSessionFile(removedSessionFiles, entry); }, - preserveKeys: preserveSessionKeys, + preserveKeys: params.preserveSessionKeys, }); - const countAfterPrune = Object.keys(params.operation.store).length; + const countAfterPrune = countSessionEntryMaintenanceEligibleEntries( + params.operation.store, + params.preserveSessionKeys, + ); const shouldRunCapMaintenance = params.forceMaintenance || shouldRunSessionEntryMaintenance({ @@ -236,7 +239,7 @@ async function applyEnforcedMaintenance(params: { onCapped: ({ entry }) => { rememberRemovedSessionFile(removedSessionFiles, entry); }, - preserveKeys: preserveSessionKeys, + preserveKeys: params.preserveSessionKeys, }) : 0; const referencedSessionIds = collectReferencedSessionIds(params.operation.store); @@ -254,7 +257,7 @@ async function applyEnforcedMaintenance(params: { store: params.operation.store, storePath: params.operation.storePath, activeSessionKey: params.operation.activeSessionKey, - preserveKeys: preserveSessionKeys, + preserveKeys: params.preserveSessionKeys, maintenance: params.maintenance, warnOnly: false, log: params.operation.log, @@ -287,8 +290,13 @@ export async function applyFileBackedSessionStoreMaintenance( const maintenance = resolveMaintenanceForOperation(params); const beforeCount = Object.keys(params.store).length; const forceMaintenance = params.maintenanceOverride !== undefined; + const preserveSessionKeys = collectSessionMaintenancePreserveKeysForStore({ + storePath: params.storePath, + store: params.store, + baseKeys: [params.activeSessionKey], + }); const shouldRunEntryMaintenance = shouldRunSessionEntryMaintenance({ - entryCount: beforeCount, + entryCount: countSessionEntryMaintenanceEligibleEntries(params.store, preserveSessionKeys), maxEntries: maintenance.maxEntries, force: forceMaintenance, }); @@ -308,5 +316,6 @@ export async function applyFileBackedSessionStoreMaintenance( maintenance, beforeCount, forceMaintenance, + preserveSessionKeys, }); } diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index 70dbd34cefae..865953dc8444 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -443,6 +443,17 @@ export function shouldPreserveMaintenanceEntry(params: { ); } +function getSessionEntryMaintenanceEligibleKeys( + store: Record, + preserveKeys?: ReadonlySet, +): string[] { + // Maintenance triggers and eviction must share this eligibility boundary. + // Preserved sessions remain outside the ordinary-session allowance. + return Object.keys(store).filter( + (key) => !shouldPreserveMaintenanceEntry({ key, entry: store[key], preserveKeys }), + ); +} + export function getActiveSessionMaintenanceWarning(params: { store: Record; activeSessionKey: string; @@ -495,39 +506,28 @@ function wouldCapActiveSession(params: { activeSessionKey: string; maxEntries: number; }): boolean { - if (params.keys.length <= params.maxEntries) { + const eligibleKeys = params.keys.filter( + (key) => !shouldPreserveMaintenanceEntry({ key, entry: params.store[key] }), + ); + if (eligibleKeys.length <= params.maxEntries) { return false; } if (params.maxEntries <= 0) { return true; } - const protectedCount = params.keys.filter( - (key) => - key !== params.activeSessionKey && - shouldPreserveMaintenanceEntry({ key, entry: params.store[key] }), - ).length; - const maxRemovableEntries = Math.max(0, params.maxEntries - protectedCount); - // If protected entries fill the cap, the active unprotected session would be the one removed. - if (maxRemovableEntries <= 0) { - return true; - } - const activeUpdatedAt = getEntryUpdatedAt(params.activeEntry); let newerOrTieBeforeActive = 0; let seenActive = false; - for (const key of params.keys) { + for (const key of eligibleKeys) { if (key === params.activeSessionKey) { seenActive = true; continue; } - if (shouldPreserveMaintenanceEntry({ key, entry: params.store[key] })) { - continue; - } const entryUpdatedAt = getEntryUpdatedAt(params.store[key]); if (entryUpdatedAt > activeUpdatedAt || (!seenActive && entryUpdatedAt === activeUpdatedAt)) { newerOrTieBeforeActive++; - if (newerOrTieBeforeActive >= maxRemovableEntries) { + if (newerOrTieBeforeActive >= params.maxEntries) { return true; } } @@ -537,7 +537,8 @@ function wouldCapActiveSession(params: { } /** - * Cap the store to the N most recently updated entries. + * Cap eviction-eligible sessions to the N most recently updated entries. + * Preserved sessions remain outside the quota. * Entries without `updatedAt` are sorted last (removed first when over limit). * Mutates `store` in-place. */ @@ -550,20 +551,9 @@ export function capEntryCount( preserveKeys?: ReadonlySet; } = {}, ): number { - const preservedCount = Object.entries(store).filter(([key, entry]) => - shouldPreserveMaintenanceEntry({ key, entry, preserveKeys: opts.preserveKeys }), - ).length; - const maxRemovableEntries = Math.max(0, maxEntries - preservedCount); - // Protected entries reduce the removable budget instead of being counted as deletion targets. - const keys = Object.keys(store).filter( - (key) => - !shouldPreserveMaintenanceEntry({ - key, - entry: store[key], - preserveKeys: opts.preserveKeys, - }), - ); - if (keys.length <= maxRemovableEntries) { + const keys = getSessionEntryMaintenanceEligibleKeys(store, opts.preserveKeys); + const retainedEligibleEntries = Math.max(0, maxEntries); + if (keys.length <= retainedEligibleEntries) { return 0; } @@ -574,7 +564,7 @@ export function capEntryCount( return bTime - aTime; }); - const toRemove = sorted.slice(maxRemovableEntries); + const toRemove = sorted.slice(retainedEligibleEntries); for (const key of toRemove) { const entry = store[key]; if (entry) { diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index 5ad86652ac26..d0ce4980c222 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -378,6 +378,41 @@ describe("applyFileBackedSessionStoreMaintenance", () => { } }); + it("does not trigger capping when protected sessions alone exceed the high-water mark", async () => { + const now = Date.now(); + const store = makeStore([ + ["archived-1", { ...makeEntry(now - 5), archivedAt: now }], + ["archived-2", { ...makeEntry(now - 4), archivedAt: now }], + ["archived-3", { ...makeEntry(now - 3), archivedAt: now }], + ["dashboard-1", makeEntry(now - 2)], + ["dashboard-2", makeEntry(now - 1)], + ]); + let capped: number | undefined; + + await applyFileBackedSessionStoreMaintenance({ + storePath: "/tmp/openclaw-sessions/protected-quota.json", + store, + maintenanceConfig: { + mode: "enforce", + pruneAfterMs: 30 * DAY_MS, + maxEntries: 2, + modelRunPruneAfterMs: DAY_MS, + resetArchiveRetentionMs: null, + maxDiskBytes: null, + highWaterBytes: null, + }, + onMaintenanceApplied: (report) => { + capped = report.capped; + }, + log: { warn: () => {}, info: () => {} }, + artifacts: createMaintenanceArtifacts(), + }); + + expect(capped).toBe(0); + expect(store).toHaveProperty("dashboard-1"); + expect(store).toHaveProperty("dashboard-2"); + }); + it.each([ { name: "preserves every active admission instead of only the writer session", @@ -414,7 +449,8 @@ describe("applyFileBackedSessionStoreMaintenance", () => { key, { sessionId, updatedAt: now - preserved.length - 1 + index }, ]), - ["removable", { sessionId: "removable-session", updatedAt: now - 1 }], + ["removable-old", { sessionId: "removable-old-session", updatedAt: now - 2 }], + ["removable-recent", { sessionId: "removable-recent-session", updatedAt: now - 1 }], ]); const admission = await beginSessionWorkAdmission({ scope: storePath, @@ -442,7 +478,8 @@ describe("applyFileBackedSessionStoreMaintenance", () => { for (const [key] of preserved) { expect(store).toHaveProperty(key); } - expect(store.removable).toBeUndefined(); + expect(store["removable-old"]).toBeUndefined(); + expect(store).toHaveProperty("removable-recent"); } finally { admission.release(); } @@ -664,13 +701,13 @@ describe("capEntryCount", () => { const evicted = capEntryCount(store, 3); - expect(evicted).toBe(2); - expect(Object.keys(store)).toHaveLength(3); + expect(evicted).toBe(1); + expect(Object.keys(store)).toHaveLength(4); expect(store).toHaveProperty(threadKey); expect(store).toHaveProperty("newest"); expect(store).toHaveProperty("recent"); + expect(store).toHaveProperty("old"); expect(store.oldest).toBeUndefined(); - expect(store.old).toBeUndefined(); }); it("never evicts the agent primary main session even when protected entries fill the cap (#112637)", () => { @@ -704,10 +741,10 @@ describe("capEntryCount", () => { const evicted = capEntryCount(store, 2); - expect(evicted).toBe(1); + expect(evicted).toBe(0); expect(store).toHaveProperty(lockedKey); expect(store).toHaveProperty("recent"); - expect(store.old).toBeUndefined(); + expect(store).toHaveProperty("old"); }); it("preserves archived sessions when capping", () => { @@ -718,10 +755,10 @@ describe("capEntryCount", () => { ["old", makeEntry(now - DAY_MS)], ]); - expect(capEntryCount(store, 2)).toBe(1); + expect(capEntryCount(store, 2)).toBe(0); expect(store).toHaveProperty("archived"); expect(store).toHaveProperty("recent"); - expect(store.old).toBeUndefined(); + expect(store).toHaveProperty("old"); }); it("preserves pinned sessions when capping", () => { @@ -732,7 +769,7 @@ describe("capEntryCount", () => { ["old", makeEntry(now - DAY_MS)], ]); - expect(capEntryCount(store, 2)).toBe(1); + expect(capEntryCount(store, 1)).toBe(1); expect(store).toHaveProperty("pinned"); expect(store).toHaveProperty("recent"); expect(store.old).toBeUndefined(); @@ -754,11 +791,11 @@ describe("capEntryCount", () => { preserveKeys: collectSessionMaintenancePreserveKeys(), }); - expect(evicted).toBe(2); - expect(Object.keys(store)).toHaveLength(2); + expect(evicted).toBe(1); + expect(Object.keys(store)).toHaveLength(3); expect(store).toHaveProperty(childKey); expect(store).toHaveProperty("recent-1"); - expect(store["recent-2"]).toBeUndefined(); + expect(store).toHaveProperty("recent-2"); expect(store.old).toBeUndefined(); } finally { unregister(); @@ -784,11 +821,11 @@ describe("capEntryCount", () => { preserveKeys: collectSessionMaintenancePreserveKeys(), }); - expect(evicted).toBe(1); - expect(Object.keys(store)).toHaveLength(2); + expect(evicted).toBe(0); + expect(Object.keys(store)).toHaveLength(3); expect(store).toHaveProperty(childKey); expect(store).toHaveProperty("recent-1"); - expect(store.old).toBeUndefined(); + expect(store).toHaveProperty("old"); } finally { unregister(); } diff --git a/src/config/types.node-host.ts b/src/config/types.node-host.ts index 0e3482c71ba1..6f5077556ea7 100644 --- a/src/config/types.node-host.ts +++ b/src/config/types.node-host.ts @@ -15,6 +15,11 @@ export type NodeHostConfig = { enabled?: boolean; }; }; + /** Full OpenClaw session hosting from this node's local installation. */ + workerRuns?: { + /** Advertise this paired node as a worker session host (default: false). */ + enabled?: boolean; + }; /** Browser proxy settings for node hosts. */ browserProxy?: NodeHostBrowserProxyConfig; /** MCP servers started and exposed by the headless node host. */ diff --git a/src/config/zod-schema.node-host.ts b/src/config/zod-schema.node-host.ts index a0564511c9e4..3f13aa1aa2e7 100644 --- a/src/config/zod-schema.node-host.ts +++ b/src/config/zod-schema.node-host.ts @@ -18,3 +18,10 @@ export const NodeHostAgentRunsSchema = z }) .strict() .optional(); + +export const NodeHostWorkerRunsSchema = z + .object({ + enabled: z.boolean().optional(), + }) + .strict() + .optional(); diff --git a/src/config/zod-schema.root-support.ts b/src/config/zod-schema.root-support.ts index 0c30171664f1..74c128d5ad9b 100644 --- a/src/config/zod-schema.root-support.ts +++ b/src/config/zod-schema.root-support.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import type { GatewayRemoteConfig } from "./types.gateway.js"; import { MemorySearchSchema } from "./zod-schema.agent-runtime.js"; import { SecretInputSchema } from "./zod-schema.core.js"; -import { NodeHostAgentRunsSchema } from "./zod-schema.node-host.js"; +import { NodeHostAgentRunsSchema, NodeHostWorkerRunsSchema } from "./zod-schema.node-host.js"; import { sensitive } from "./zod-schema.sensitive.js"; type ConfigSchemaShape = { @@ -467,6 +467,7 @@ export const McpConfigSchema = z export const NodeHostSchema = z .strictObject({ agentRuns: NodeHostAgentRunsSchema, + workerRuns: NodeHostWorkerRunsSchema, browserProxy: z .strictObject({ enabled: z.boolean().optional(), diff --git a/src/daemon/node-service.ts b/src/daemon/node-service.ts index 424462074b94..82ab517ffaee 100644 --- a/src/daemon/node-service.ts +++ b/src/daemon/node-service.ts @@ -26,6 +26,7 @@ function withNodeInstallEnv(args: GatewayServiceInstallArgs): GatewayServiceInst /** Returns a service controller bound to node-host labels across all platforms. */ export function resolveNodeService(): GatewayService { const base = resolveGatewayService(); + const hasInstalledDefinition = base.hasInstalledDefinition; return { ...base, stage: (args) => base.stage(withNodeInstallEnv(args)), @@ -39,6 +40,9 @@ export function resolveNodeService(): GatewayService { // wedged service manager instead of hanging the whole status command. return base.isLoaded({ env: withNodeServiceEnv(args.env ?? {}), timeoutMs: args.timeoutMs }); }, + hasInstalledDefinition: hasInstalledDefinition + ? (args) => hasInstalledDefinition({ ...args, env: withNodeServiceEnv(args.env ?? {}) }) + : undefined, readCommand: (env) => base.readCommand(withNodeServiceEnv(env)), readRuntime: (env, opts) => base.readRuntime(withNodeServiceEnv(env), opts), }; diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 30fbcc357dd1..80978c8bc47b 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -46,6 +46,7 @@ import type { GatewayServiceState, } from "./service-types.js"; import { + findInstalledSystemdGatewayScope, installSystemdService, isSystemdServiceEnabled, readSystemdServiceExecStart, @@ -84,6 +85,7 @@ export type GatewayService = { restart: (args: GatewayServiceControlArgs) => Promise; isLoaded: (args: GatewayServiceEnvArgs) => Promise; isEnabled?: (args: GatewayServiceEnvArgs) => Promise; + hasInstalledDefinition?: (args: GatewayServiceEnvArgs) => Promise; readCommand: (env: GatewayServiceEnv) => Promise; readRuntime: ( env: GatewayServiceEnv, @@ -354,6 +356,8 @@ const GATEWAY_SERVICE_REGISTRY: Record + (await findInstalledSystemdGatewayScope(env ?? process.env)) !== null, readCommand: readSystemdServiceExecStart, readRuntime: readSystemdServiceRuntime, }, diff --git a/src/gateway/control-ui-csp.test.ts b/src/gateway/control-ui-csp.test.ts index 1c148c5fc2c1..d774dbbad588 100644 --- a/src/gateway/control-ui-csp.test.ts +++ b/src/gateway/control-ui-csp.test.ts @@ -36,6 +36,19 @@ describe("buildControlUiCspHeader", () => { expect(connectSrc?.split(" ")).not.toContain("https:"); }); + it("allows portal probes only across ports on the current document host", () => { + const csp = buildControlUiCspHeader({ portalHost: "gateway.example.test:18789" }); + const connectSrc = csp.split("; ").find((directive) => directive.startsWith("connect-src ")); + expect(connectSrc?.split(" ")).toContain("http://gateway.example.test:*"); + expect(connectSrc?.split(" ")).toContain("https://gateway.example.test:*"); + expect(connectSrc?.split(" ")).not.toContain("https:"); + + const invalid = buildControlUiCspHeader({ + portalHost: "gateway.example.test/path;connect-src https://example.test", + }); + expect(invalid).not.toContain("https://example.test"); + }); + it("limits image loading to local sources and the Gravatar fallback origin", () => { const csp = buildControlUiCspHeader(); const imgSrc = csp.split("; ").find((directive) => directive.startsWith("img-src ")); diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index 1bb0199e852e..59816cf2fd3b 100644 --- a/src/gateway/control-ui-csp.ts +++ b/src/gateway/control-ui-csp.ts @@ -37,6 +37,8 @@ function hasScriptSrcAttribute(openTag: string): boolean { /** Build the CSP header applied to Gateway-served Control UI HTML. */ export function buildControlUiCspHeader(opts?: { inlineScriptHashes?: string[]; + /** Current document Host header, used only to permit cross-port portal probes. */ + portalHost?: string; /** * Relax the policy just enough for the embedded terminal's ghostty-web engine. * `'wasm-unsafe-eval'` permits WebAssembly compilation. Gated on the terminal @@ -62,6 +64,22 @@ export function buildControlUiCspHeader(opts?: { "https://api.openai.com", "https://tweakcn.com", ]; + if (opts?.portalHost) { + try { + const parsed = new URL(`http://${opts.portalHost}`); + const isHostOnly = + !parsed.username && + !parsed.password && + parsed.pathname === "/" && + !parsed.search && + !parsed.hash; + if (isHostOnly && parsed.hostname) { + connectTokens.push(`http://${parsed.hostname}:*`, `https://${parsed.hostname}:*`); + } + } catch { + // Invalid Host headers do not relax the baseline policy. + } + } return [ "default-src 'self'", "base-uri 'none'", diff --git a/src/gateway/control-ui.auto-root.http.test.ts b/src/gateway/control-ui.auto-root.http.test.ts index 617feef2e804..d0c7270176f6 100644 --- a/src/gateway/control-ui.auto-root.http.test.ts +++ b/src/gateway/control-ui.auto-root.http.test.ts @@ -53,7 +53,11 @@ describe("handleControlUiHttpRequest prepared root lifecycle", () => { await fs.link(sourceIndex, indexPath); const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/dashboard", method: "GET" } as IncomingMessage, + { + url: "/dashboard", + method: "GET", + headers: { host: "gateway.example.test" }, + } as IncomingMessage, res, { root: { kind: "bundled", path: tmp, realPath: await fs.realpath(tmp) } }, ); diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index ce875306c7d6..9c36b382b0c6 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -529,7 +529,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end, setHeader } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/", method: "GET" } as IncomingMessage, + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, @@ -563,7 +563,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end, setHeader } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/", method: "GET" } as IncomingMessage, + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, @@ -584,11 +584,15 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { const { res, end, setHeader } = makeMockHttpResponse(); - await handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { - root: { kind: "resolved", path: tmp }, - config: { gateway: { terminal: { enabled: true } } }, - terminalEnabled: false, - }); + await handleControlUiHttpRequest( + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, + res, + { + root: { kind: "resolved", path: tmp }, + config: { gateway: { terminal: { enabled: true } } }, + terminalEnabled: false, + }, + ); const csp = setHeader.mock.calls.findLast( (call) => call[0] === "Content-Security-Policy", )?.[1]; @@ -1484,9 +1488,11 @@ describe("handleControlUiHttpRequest", () => { indexHtml: html, fn: async (tmp) => { const { res, setHeader } = makeMockHttpResponse(); - await handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { - root: { kind: "resolved", path: tmp }, - }); + await handleControlUiHttpRequest( + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); const cspCalls = setHeader.mock.calls.filter( (call) => call[0] === "Content-Security-Policy", ); @@ -1504,7 +1510,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/", method: "GET" } as IncomingMessage, + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, @@ -1530,7 +1536,11 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/openclaw/chat", method: "GET" } as IncomingMessage, + { + url: "/openclaw/chat", + method: "GET", + headers: { host: "gateway.example.test" }, + } as IncomingMessage, res, { basePath: "/openclaw", @@ -1578,7 +1588,11 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: requestPath, method: "GET" } as IncomingMessage, + { + url: requestPath, + method: "GET", + headers: { host: "gateway.example.test" }, + } as IncomingMessage, res, { ...(basePath ? { basePath } : {}), diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 385269f78510..dfb9c49657f7 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -908,7 +908,11 @@ async function serveResolvedIndexHtml( // terminal's WASM relaxation is applied to the page that loads ghostty-web. res.setHeader( "Content-Security-Policy", - buildControlUiCspHeader({ inlineScriptHashes: hashes, allowWasm }), + buildControlUiCspHeader({ + inlineScriptHashes: hashes, + allowWasm, + portalHost: req.headers.host, + }), ); res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 322141ed7315..8dc265a6183f 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -110,7 +110,11 @@ describe("GatewayClient", () => { ) { const { res } = makeControlUiResponse(); const handled = await handleControlUiHttpRequest( - { url: params.url, method: params.method ?? "GET" } as IncomingMessage, + { + url: params.url, + method: params.method ?? "GET", + headers: { host: "gateway.example.test" }, + } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, ); diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index f435f7a6eac5..5218b0410e3f 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -102,6 +102,9 @@ const CURRENT_TRAIN_METHODS = [ "device.scopes.requestUpgrade", "device.scopes.waitUpgrade", "node.protocolFeatures.update", + "portal.list", + "portal.open", + "portal.close", ] as const; describe("core gateway method release trains", () => { diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 879e2f7af7cb..8060d6fc4f86 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -521,6 +521,9 @@ const CORE_GATEWAY_METHOD_SPECS = [ // Live device scope upgrades are additive so every older advertised index stays stable. ["device.scopes.requestUpgrade", "devices", "operator.read", "2026.8"], ["device.scopes.waitUpgrade", "devices", "operator.read", "2026.8"], + ["portal.list", "portals", "operator.read", "2026.8"], + ["portal.open", "portals", "operator.write", "2026.8", { controlPlaneWrite: true }], + ["portal.close", "portals", "operator.write", "2026.8", { controlPlaneWrite: true }], ] as const satisfies readonly CoreGatewayMethodSpecRow[]; export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>; diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index 4887eb26e475..e6f2414804bf 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -282,6 +282,7 @@ function buildEffectiveKnownNode(entry: { commands: filterPublicNodeCommands( live ? uniqueSortedStrings(live.commands) : uniqueSortedStrings(nodePairing?.commands), ), + sessionHost: live?.workerRuns !== undefined, nodePluginTools: live?.nodePluginTools, pathEnv: live?.pathEnv, permissions: live?.permissions ?? nodePairing?.permissions, diff --git a/src/gateway/node-registry-private.ts b/src/gateway/node-registry-private.ts index 930670c1b01c..bd5502bb8d10 100644 --- a/src/gateway/node-registry-private.ts +++ b/src/gateway/node-registry-private.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { GATEWAY_CLIENT_IDS } from "../../packages/gateway-protocol/src/client-info.js"; +import type { WorkerAdmissionHandshake } from "../../packages/gateway-protocol/src/schema/worker-admission.js"; import { isPrivateNodeInvokeCommand, NODE_WORKER_SUPERVISOR_COMMANDS, @@ -24,6 +25,7 @@ type NodeRegistryPrivateSession = { clientId?: string; clientMode?: string; commands: string[]; + workerRuns?: WorkerAdmissionHandshake; }; type NodeInvokeResult = { @@ -66,6 +68,7 @@ export type NodeWorkerSupervisorNodeProof = { clientMode: "node"; protocolFeature: typeof NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE; commands: readonly string[]; + workerRuns?: WorkerAdmissionHandshake; }; export type NodeWorkerSupervisorTransport = { @@ -84,7 +87,7 @@ export type NodeWorkerSupervisorTransport = { type NodeProtocolFeatureDeclaration = Omit< NodeWorkerSupervisorNodeProof, - "commands" | "pairingGeneration" + "commands" | "pairingGeneration" | "workerRuns" > & { protocolFeatures: readonly string[]; }; @@ -204,6 +207,7 @@ function resolveWorkerSupervisorProof( clientMode: "node", protocolFeature: NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE, commands: [...node.commands], + ...(node.workerRuns ? { workerRuns: structuredClone(node.workerRuns) } : {}), }; } diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 9c1a9891c5a1..87537198ae95 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -13,6 +13,7 @@ import type { NodePluginToolDescriptor, NodeSkillDescriptor, } from "../../packages/gateway-protocol/src/schema/nodes.js"; +import type { WorkerAdmissionHandshake } from "../../packages/gateway-protocol/src/schema/worker-admission.js"; import { setActiveNodeContext } from "../infra/active-node-context.js"; import type { PairedDeviceNodeBinding } from "../infra/device-pairing-node-state.js"; import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js"; @@ -68,6 +69,8 @@ export type NodeSession = { declaredCommands: string[]; sessionCommandsCeiling?: string[]; commands: string[]; + /** Exact node-local build admitted for worker session hosting. */ + workerRuns?: WorkerAdmissionHandshake; declaredNodePluginTools: NodePluginToolDescriptor[]; nodePluginTools: NodePluginToolDescriptor[]; nodeSkills: NodeSkillDescriptor[]; @@ -481,6 +484,7 @@ export class NodeRegistry { typeof (connect as { pathEnv?: string }).pathEnv === "string" ? (connect as { pathEnv?: string }).pathEnv : undefined; + const workerRuns = connect.workerRuns ? structuredClone(connect.workerRuns) : undefined; const declaredNodePluginTools: NodePluginToolDescriptor[] = []; const nodePluginTools: NodePluginToolDescriptor[] = []; const nodeSkills: NodeSkillDescriptor[] = []; @@ -506,6 +510,7 @@ export class NodeRegistry { declaredCommands, sessionCommandsCeiling, commands, + ...(workerRuns ? { workerRuns } : {}), declaredNodePluginTools, nodePluginTools, nodeSkills, diff --git a/src/gateway/portals/portal-http-proxy.test.ts b/src/gateway/portals/portal-http-proxy.test.ts new file mode 100644 index 000000000000..ad29a20717fc --- /dev/null +++ b/src/gateway/portals/portal-http-proxy.test.ts @@ -0,0 +1,525 @@ +import { + createServer, + request, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { type RawData, WebSocket, WebSocketServer } from "ws"; +import { createGatewayPortalService, type GatewayPortalService } from "./portal-service.js"; + +type HttpResult = { + status: number; + headers: IncomingMessage["headers"]; + body: string; +}; + +let targetPort = 0; +let targetHandler: (req: IncomingMessage, res: ServerResponse) => void; +let targetWebSocketPath: string | undefined; +let targetWebSocketCookie: string | undefined; +let targetWebSocketSetCookie: string | undefined; +const targetServer = createServer((req, res) => targetHandler(req, res)); +const targetWss = new WebSocketServer({ server: targetServer }); +const services = new Set(); +const temporaryTargetServers = new Set(); + +beforeAll(async () => { + targetWss.on("connection", (socket, req) => { + targetWebSocketPath = req.url; + targetWebSocketCookie = req.headers.cookie; + socket.on("message", (data) => socket.send(data)); + }); + targetWss.on("headers", (headers) => { + if (targetWebSocketSetCookie) { + headers.push(`Set-Cookie: ${targetWebSocketSetCookie}`); + } + }); + await new Promise((resolve, reject) => { + targetServer.once("error", reject); + targetServer.listen(0, "127.0.0.1", () => resolve()); + }); + targetPort = (targetServer.address() as AddressInfo).port; +}); + +afterEach(async () => { + await Promise.all([...services].map((service) => service.closeAll())); + services.clear(); + await Promise.all( + [...temporaryTargetServers].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), + ), + ); + temporaryTargetServers.clear(); + targetWebSocketPath = undefined; + targetWebSocketCookie = undefined; + targetWebSocketSetCookie = undefined; +}); + +afterAll(async () => { + targetWss.close(); + await new Promise((resolve) => { + targetServer.close(() => resolve()); + }); +}); + +function portalService() { + const service = createGatewayPortalService({ httpBindHosts: ["127.0.0.1"], httpServers: [] }); + services.add(service); + return service; +} + +async function listenTarget( + handler: (req: IncomingMessage, res: ServerResponse) => void, +): Promise { + const server = createServer(handler); + temporaryTargetServers.add(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + return (server.address() as AddressInfo).port; +} + +async function httpCall(params: { + port: number; + path?: string; + method?: string; + headers?: Record; + body?: string; +}): Promise { + return await new Promise((resolve, reject) => { + const req = request( + { + host: "127.0.0.1", + port: params.port, + path: params.path ?? "/", + method: params.method, + headers: params.headers, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.once("end", () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString("utf8"), + }), + ); + }, + ); + req.once("error", reject); + if (params.body) { + req.write(params.body); + } + req.end(); + }); +} + +function storeResponseCookies(jar: Map, result: HttpResult): void { + for (const cookie of result.headers["set-cookie"] ?? []) { + const pair = cookie.split(";", 1)[0]; + const separator = pair?.indexOf("=") ?? -1; + if (pair && separator > 0) { + jar.set(pair.slice(0, separator), pair.slice(separator + 1)); + } + } +} + +function cookieJarHeader(jar: ReadonlyMap): string { + return [...jar].map(([name, value]) => `${name}=${value}`).join("; "); +} + +function portalAuthCookie(portal: { listenPort: number; tokenQuery: string }): string { + const token = portal.tokenQuery.slice("openclaw_portal=".length); + return `openclaw_portal_${portal.listenPort}=${token}`; +} + +function webSocketMessageText(data: RawData): string { + const bytes = Array.isArray(data) + ? Buffer.concat(data) + : data instanceof ArrayBuffer + ? Buffer.from(data) + : data; + return bytes.toString("utf8"); +} + +async function browserCall( + jar: Map, + params: Omit[0], "headers">, +): Promise { + const cookie = cookieJarHeader(jar); + const result = await httpCall({ + ...params, + ...(cookie ? { headers: { Cookie: cookie } } : {}), + }); + storeResponseCookies(jar, result); + return result; +} + +describe("portal HTTP proxy", () => { + it("proxies a URL token directly, sets a private cookie, and strips the token", async () => { + const targetPaths: string[] = []; + targetHandler = (req, res) => { + targetPaths.push(req.url ?? "/"); + res.statusCode = 200; + res.end("proxied"); + }; + const portal = await portalService().open({ targetPort, title: "App" }); + + const unauthorized = await httpCall({ port: portal.listenPort }); + expect(unauthorized.status).toBe(401); + expect(unauthorized.body).toContain("This portal is private"); + expect(unauthorized.body).not.toContain(portal.tokenQuery); + + const authorized = await httpCall({ + port: portal.listenPort, + path: `/preview?x=1&${portal.tokenQuery}`, + }); + expect(authorized.status).toBe(200); + expect(authorized.body).toBe("proxied"); + expect(authorized.headers["set-cookie"]?.[0]).toContain( + `openclaw_portal_${portal.listenPort}=`, + ); + expect(authorized.headers["set-cookie"]?.[0]).toContain("HttpOnly; SameSite=Lax; Path=/"); + expect(targetPaths).toEqual(["/preview?x=1"]); + + const cookieOnly = await httpCall({ + port: portal.listenPort, + path: "/cookie?y=2", + headers: { Cookie: portalAuthCookie(portal) }, + }); + expect(cookieOnly).toMatchObject({ status: 200, body: "proxied" }); + expect(targetPaths).toEqual(["/preview?x=1", "/cookie?y=2"]); + }); + + it("keeps concurrent portal HTTP sessions authorized in A-B-A order", async () => { + targetHandler = (_req, res) => { + res.statusCode = 200; + res.end("target-a"); + }; + const targetPortB = await listenTarget((_req, res) => { + res.statusCode = 200; + res.end("target-b"); + }); + const service = portalService(); + const portalA = await service.open({ targetPort }); + const portalB = await service.open({ targetPort: targetPortB }); + const jar = new Map(); + + expect( + await browserCall(jar, { + port: portalA.listenPort, + path: `/?${portalA.tokenQuery}`, + }), + ).toMatchObject({ status: 200, body: "target-a" }); + expect( + await browserCall(jar, { + port: portalB.listenPort, + path: `/?${portalB.tokenQuery}`, + }), + ).toMatchObject({ status: 200, body: "target-b" }); + + for (const [portal, body] of [ + [portalA, "target-a"], + [portalB, "target-b"], + [portalA, "target-a"], + ] as const) { + expect(await browserCall(jar, { port: portal.listenPort })).toMatchObject({ + status: 200, + body, + }); + } + }); + + it("streams HTTP requests and responses with rewritten safe headers", async () => { + let received: + | { + host?: string; + cookie?: string; + forwardedFor?: string; + proto?: string; + forwardedHost?: string; + } + | undefined; + targetHandler = (req, res) => { + received = { + host: req.headers.host, + cookie: req.headers.cookie, + forwardedFor: req.headers["x-forwarded-for"] as string | undefined, + proto: req.headers["x-forwarded-proto"] as string | undefined, + forwardedHost: req.headers["x-forwarded-host"] as string | undefined, + }; + res.statusCode = 201; + res.setHeader("Connection", "keep-alive, x-target-hop"); + res.setHeader("Keep-Alive", "upstream-secret=17"); + res.setHeader("X-Target-Hop", "remove"); + res.setHeader("X-App", "kept"); + res.write("hello "); + res.end("portal"); + }; + const portal = await portalService().open({ targetPort }); + const result = await httpCall({ + port: portal.listenPort, + path: "/asset?q=1", + headers: { + Host: "portal.example:9999", + Cookie: `openclaw_plugin_tab=secret; ${portalAuthCookie(portal)}`, + Connection: "keep-alive, x-remove-me", + "X-Remove-Me": "remove", + }, + }); + + expect(result).toMatchObject({ status: 201, body: "hello portal" }); + expect(result.headers["x-app"]).toBe("kept"); + expect(result.headers["x-target-hop"]).toBeUndefined(); + // Node may add its own connection-local Keep-Alive header; the upstream value must not pass. + expect(result.headers["keep-alive"]).not.toBe("upstream-secret=17"); + expect(received).toMatchObject({ + host: `localhost:${targetPort}`, + proto: "http", + forwardedHost: "portal.example:9999", + }); + expect(received?.cookie).toBeUndefined(); + expect(received?.forwardedFor).toMatch(/127\.0\.0\.1|::ffff:127\.0\.0\.1/u); + }); + + it("forwards only each target's prefixed cookies, never either portal auth cookie", async () => { + const receivedCookiesA: Array = []; + targetHandler = (req, res) => { + receivedCookiesA.push(req.headers.cookie); + if (req.url === "/set") { + res.setHeader("Set-Cookie", "session=a; Domain=target.example; Path=/; HttpOnly"); + } + res.statusCode = 200; + res.end("target-a"); + }; + const receivedCookiesB: Array = []; + const targetPortB = await listenTarget((req, res) => { + receivedCookiesB.push(req.headers.cookie); + if (req.url === "/set") { + res.setHeader("Set-Cookie", "session=b; Domain=target.example; Path=/; HttpOnly"); + } + res.statusCode = 200; + res.end("target-b"); + }); + const service = portalService(); + const portalA = await service.open({ targetPort }); + const portalB = await service.open({ targetPort: targetPortB }); + const jar = new Map(); + + const initialA = await browserCall(jar, { + port: portalA.listenPort, + path: `/set?${portalA.tokenQuery}`, + }); + const initialB = await browserCall(jar, { + port: portalB.listenPort, + path: `/set?${portalB.tokenQuery}`, + }); + expect(initialA.headers["set-cookie"]).toContain( + `oc_portal_${targetPort}_session=a; Path=/; HttpOnly`, + ); + expect(initialB.headers["set-cookie"]).toContain( + `oc_portal_${targetPortB}_session=b; Path=/; HttpOnly`, + ); + expect( + [...(initialA.headers["set-cookie"] ?? []), ...(initialB.headers["set-cookie"] ?? [])].join( + "; ", + ), + ).not.toContain("Domain="); + expect([...jar.keys()].filter((name) => name.startsWith("openclaw_portal"))).toEqual([ + `openclaw_portal_${portalA.listenPort}`, + `openclaw_portal_${portalB.listenPort}`, + ]); + + expect(await browserCall(jar, { port: portalA.listenPort })).toMatchObject({ + status: 200, + body: "target-a", + }); + expect(await browserCall(jar, { port: portalB.listenPort })).toMatchObject({ + status: 200, + body: "target-b", + }); + expect(receivedCookiesA).toEqual([undefined, "session=a"]); + expect(receivedCookiesB).toEqual([undefined, "session=b"]); + }); + + it("forces no-referrer and never forwards a token-bearing referrer", async () => { + let receivedReferer: string | undefined; + targetHandler = (req, res) => { + receivedReferer = req.headers.referer; + // A hostile or careless target must not be able to widen the policy. + res.setHeader("Referrer-Policy", "unsafe-url"); + res.statusCode = 200; + res.end("proxied"); + }; + const portal = await portalService().open({ targetPort }); + const token = portal.tokenQuery.slice("openclaw_portal=".length); + + const result = await httpCall({ + port: portal.listenPort, + headers: { + Cookie: `openclaw_portal_${portal.listenPort}=${token}`, + Referer: `http://127.0.0.1:${portal.listenPort}/?${portal.tokenQuery}`, + }, + }); + + expect(result.status).toBe(200); + expect(result.headers["referrer-policy"]).toBe("no-referrer"); + expect(receivedReferer).toBeUndefined(); + + const unauthorized = await httpCall({ port: portal.listenPort }); + expect(unauthorized.headers["referrer-policy"]).toBe("no-referrer"); + }); + + it("streams POST bodies to the target", async () => { + let body = ""; + targetHandler = (req, res) => { + req.setEncoding("utf8"); + req.on("data", (chunk: string) => (body += chunk)); + req.once("end", () => { + res.statusCode = 204; + res.end(); + }); + }; + const portal = await portalService().open({ targetPort }); + const result = await httpCall({ + port: portal.listenPort, + method: "POST", + headers: { + Cookie: portalAuthCookie(portal), + "Content-Type": "text/plain", + }, + body: "streamed request", + }); + + expect(result.status).toBe(204); + expect(body).toBe("streamed request"); + }); + + it("shows a retry page while the target is down", async () => { + const unavailableTarget = createServer(); + await new Promise((resolve) => { + unavailableTarget.listen(0, "127.0.0.1", resolve); + }); + const port = (unavailableTarget.address() as AddressInfo).port; + await new Promise((resolve) => { + unavailableTarget.close(() => resolve()); + }); + const portal = await portalService().open({ targetPort: port }); + + const result = await httpCall({ + port: portal.listenPort, + headers: { Cookie: portalAuthCookie(portal) }, + }); + expect(result.status).toBe(502); + expect(result.body).toContain(`Waiting for the app on port ${port}…`); + expect(result.body).toContain('http-equiv="refresh" content="2"'); + }); + + it("reaches IPv6-only targets through the localhost dual-stack dial", async () => { + // Node >=17 dev servers (Vite, Next.js) often bind ::1 only on "localhost". + const v6Target = createServer((req, res) => { + res.statusCode = 200; + res.end("v6 proxied"); + }); + await new Promise((resolve, reject) => { + v6Target.once("error", reject); + v6Target.listen(0, "::1", () => resolve()); + }); + try { + const v6Port = (v6Target.address() as AddressInfo).port; + const portal = await portalService().open({ targetPort: v6Port }); + const result = await httpCall({ + port: portal.listenPort, + path: `/?${portal.tokenQuery}`, + }); + expect(result).toMatchObject({ status: 200, body: "v6 proxied" }); + } finally { + await new Promise((resolve) => { + v6Target.close(() => resolve()); + }); + } + }); + + it("splices WebSockets and destroys upgraded sockets and listeners on close", async () => { + const service = portalService(); + const portal = await service.open({ targetPort }); + targetWebSocketSetCookie = "socket=ready; Domain=target.example; Path=/; HttpOnly"; + let upgradeCookies: string[] | undefined; + const ws = new WebSocket( + `ws://127.0.0.1:${portal.listenPort}/hmr?channel=dev&${portal.tokenQuery}`, + { headers: { Cookie: "openclaw_plugin_tab=secret" } }, + ); + ws.once("upgrade", (response) => { + upgradeCookies = response.headers["set-cookie"]; + }); + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + const echoed = new Promise((resolve) => { + ws.once("message", (data) => resolve(webSocketMessageText(data))); + }); + ws.send("hot reload"); + expect(await echoed).toBe("hot reload"); + expect(targetWebSocketPath).toBe("/hmr?channel=dev"); + expect(targetWebSocketCookie).toBeUndefined(); + expect(upgradeCookies).toEqual([`oc_portal_${targetPort}_socket=ready; Path=/; HttpOnly`]); + + const closed = new Promise((resolve) => { + ws.once("close", () => resolve()); + }); + await service.close(portal.id); + await closed; + await expect(httpCall({ port: portal.listenPort })).rejects.toThrow(); + }); + + it("keeps portal A WebSocket authorized after portal B replaces the active URL", async () => { + targetHandler = (_req, res) => { + res.statusCode = 200; + res.end("target-a"); + }; + const targetPortB = await listenTarget((_req, res) => { + res.statusCode = 200; + res.end("target-b"); + }); + const service = portalService(); + const portalA = await service.open({ targetPort }); + const portalB = await service.open({ targetPort: targetPortB }); + const jar = new Map(); + await browserCall(jar, { + port: portalA.listenPort, + path: `/?${portalA.tokenQuery}`, + }); + await browserCall(jar, { + port: portalB.listenPort, + path: `/?${portalB.tokenQuery}`, + }); + + const ws = new WebSocket(`ws://127.0.0.1:${portalA.listenPort}/hmr?channel=dev`, { + headers: { Cookie: cookieJarHeader(jar) }, + }); + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + }); + const echoed = new Promise((resolve) => { + ws.once("message", (data) => resolve(webSocketMessageText(data))); + }); + ws.send("portal-a"); + expect(await echoed).toBe("portal-a"); + expect(targetWebSocketPath).toBe("/hmr?channel=dev"); + await new Promise((resolve) => { + ws.once("close", () => resolve()); + ws.close(); + }); + }); +}); diff --git a/src/gateway/portals/portal-http-proxy.ts b/src/gateway/portals/portal-http-proxy.ts new file mode 100644 index 000000000000..960729d99efc --- /dev/null +++ b/src/gateway/portals/portal-http-proxy.ts @@ -0,0 +1,420 @@ +import { timingSafeEqual } from "node:crypto"; +import type { + IncomingHttpHeaders, + IncomingMessage, + OutgoingHttpHeaders, + ServerResponse, +} from "node:http"; +import { request as requestHttp } from "node:http"; +import net, { type Socket } from "node:net"; +import type { Duplex } from "node:stream"; + +const PORTAL_AUTH_NAME = "openclaw_portal"; +// Browser cookie jars are hostname-scoped, so the stable listener port in the +// auth cookie name keeps concurrently open portals from replacing each other. +function portalAuthCookieName(listenPort: number): string { + return `${PORTAL_AUTH_NAME}_${listenPort}`; +} + +// Cookies are hostname-scoped, not port-scoped. Per-target prefixes keep Gateway +// and sibling portal cookies from leaking into an agent-run application. +const PORTAL_COOKIE_PREFIX = "oc_portal_"; +// The portal URL carries the bearer token in its query, so the browser must never +// attach it as a Referer. The target controls its own response headers, so this is +// forced after upstream headers are copied rather than merely defaulted. +const PORTAL_REFERRER_POLICY = "no-referrer"; +const MAX_WEBSOCKET_RESPONSE_HEADER_BYTES = 64 * 1024; +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +type PortalProxyTarget = { + listenPort: number; + targetPort: number; + token: string; +}; + +type PortalAuthorization = + | { kind: "authorized"; requestPath: string; setCookie: boolean } + | { kind: "unauthorized" }; + +function tokensEqual(candidate: string | undefined, expected: string): boolean { + if (!candidate) { + return false; + } + const candidateBytes = Buffer.from(candidate); + const expectedBytes = Buffer.from(expected); + return ( + candidateBytes.length === expectedBytes.length && timingSafeEqual(candidateBytes, expectedBytes) + ); +} + +function readPortalCookie( + cookieHeader: string | undefined, + listenPort: number, +): string | undefined { + const authCookieName = portalAuthCookieName(listenPort); + for (const segment of cookieHeader?.split(";") ?? []) { + const separator = segment.indexOf("="); + if (separator < 0 || segment.slice(0, separator).trim() !== authCookieName) { + continue; + } + return segment.slice(separator + 1).trim(); + } + return undefined; +} + +function portalCookiePrefix(targetPort: number): string { + return `${PORTAL_COOKIE_PREFIX}${targetPort}_`; +} + +function readTargetCookies( + cookieHeader: string | undefined, + targetPort: number, +): string | undefined { + const prefix = portalCookiePrefix(targetPort); + const retained = (cookieHeader?.split(";") ?? []).flatMap((segment) => { + const separator = segment.indexOf("="); + if (separator <= 0) { + return []; + } + const name = segment.slice(0, separator).trim(); + if (!name.startsWith(prefix) || name.length === prefix.length) { + return []; + } + return [`${name.slice(prefix.length)}=${segment.slice(separator + 1).trim()}`]; + }); + const normalized = retained.join("; "); + return normalized || undefined; +} + +function rewriteTargetCookie(cookie: string, targetPort: number): string | undefined { + const [cookiePair, ...attributes] = cookie.split(";"); + const separator = cookiePair?.indexOf("=") ?? -1; + if (!cookiePair || separator <= 0) { + return undefined; + } + const name = cookiePair.slice(0, separator).trim(); + if (!name) { + return undefined; + } + const retainedAttributes = attributes.filter((attribute) => !/^\s*domain\s*=/iu.test(attribute)); + const suffix = retainedAttributes.length > 0 ? `;${retainedAttributes.join(";")}` : ""; + return `${portalCookiePrefix(targetPort)}${name}=${cookiePair.slice(separator + 1)}${suffix}`; +} + +function parsePortalUrl(req: IncomingMessage): URL | undefined { + try { + return new URL(req.url ?? "/", "http://openclaw.invalid"); + } catch { + return undefined; + } +} + +function authorizePortalRequest( + req: IncomingMessage, + target: PortalProxyTarget, +): PortalAuthorization { + const url = parsePortalUrl(req); + const queryToken = url?.searchParams.get(PORTAL_AUTH_NAME) ?? undefined; + if (tokensEqual(queryToken, target.token)) { + url?.searchParams.delete(PORTAL_AUTH_NAME); + return { + kind: "authorized", + requestPath: `${url?.pathname ?? "/"}${url?.search ?? ""}`, + setCookie: true, + }; + } + if (tokensEqual(readPortalCookie(req.headers.cookie, target.listenPort), target.token)) { + url?.searchParams.delete(PORTAL_AUTH_NAME); + return { + kind: "authorized", + requestPath: `${url?.pathname ?? "/"}${url?.search ?? ""}`, + setCookie: false, + }; + } + return { kind: "unauthorized" }; +} + +function portalCookie(target: PortalProxyTarget, tls: boolean): string { + return `${portalAuthCookieName(target.listenPort)}=${target.token}; HttpOnly; SameSite=Lax; Path=/${tls ? "; Secure" : ""}`; +} + +function setProxyResponseHeader( + res: ServerResponse, + name: string, + value: string | string[] | number, + targetPort: number, +): void { + if (name !== "set-cookie") { + res.setHeader(name, value); + return; + } + const existing = res.getHeader("Set-Cookie"); + const existingCookies = + existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]; + const targetCookies = Array.isArray(value) ? value : [String(value)]; + const rewrittenCookies = targetCookies.flatMap((cookie) => { + const rewritten = rewriteTargetCookie(cookie, targetPort); + return rewritten ? [rewritten] : []; + }); + const cookies = [...existingCookies.map(String), ...rewrittenCookies]; + if (cookies.length > 0) { + res.setHeader("Set-Cookie", cookies); + } +} + +function htmlResponse( + res: ServerResponse, + statusCode: number, + html: string, + headOnly: boolean, +): void { + res.statusCode = statusCode; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", PORTAL_REFERRER_POLICY); + res.setHeader("Content-Length", String(Buffer.byteLength(html))); + res.end(headOnly ? undefined : html); +} + +function respondPortalUnauthorized(req: IncomingMessage, res: ServerResponse): void { + const html = + "Private portal" + + "

This portal is private. Open it from the OpenClaw Control UI.

"; + htmlResponse(res, 401, html, req.method === "HEAD"); +} + +function respondPortalWaiting(req: IncomingMessage, res: ServerResponse, targetPort: number): void { + const html = + '' + + `Waiting for app

Waiting for the app on port ${targetPort}…

`; + htmlResponse(res, 502, html, req.method === "HEAD"); +} + +function connectionHeaderTokens(headers: IncomingHttpHeaders): Set { + const value = headers.connection; + const joined = Array.isArray(value) ? value.join(",") : value; + return new Set( + (joined ?? "") + .split(",") + .map((token) => token.trim().toLowerCase()) + .filter(Boolean), + ); +} + +function proxyHeaders(headers: IncomingHttpHeaders, targetPort?: number): OutgoingHttpHeaders { + const result: OutgoingHttpHeaders = {}; + const connectionTokens = connectionHeaderTokens(headers); + for (const [name, value] of Object.entries(headers)) { + const normalized = name.toLowerCase(); + if ( + value === undefined || + HOP_BY_HOP_HEADERS.has(normalized) || + connectionTokens.has(normalized) + ) { + continue; + } + if (normalized === "cookie" && targetPort !== undefined) { + const cookie = readTargetCookies(Array.isArray(value) ? value.join("; ") : value, targetPort); + if (cookie) { + result.cookie = cookie; + } + continue; + } + // A referrer that still carries the bearer query would hand the target the + // credential it is being kept away from; drop it rather than forward it. + if (normalized === "referer" && String(value).includes(`${PORTAL_AUTH_NAME}=`)) { + continue; + } + result[normalized] = value; + } + return result; +} + +/** Proxies one authorized portal request only to the loopback target. */ +export function handlePortalProxyRequest(params: { + req: IncomingMessage; + res: ServerResponse; + target: PortalProxyTarget; + tls: boolean; +}): void { + const { req, res, target, tls } = params; + const authorization = authorizePortalRequest(req, target); + if (authorization.kind === "unauthorized") { + respondPortalUnauthorized(req, res); + return; + } + if (authorization.setCookie) { + res.setHeader("Set-Cookie", portalCookie(target, tls)); + } + + const headers = proxyHeaders(req.headers, target.targetPort); + const originalHost = req.headers.host; + headers.host = `localhost:${target.targetPort}`; + headers["x-forwarded-for"] = req.socket.remoteAddress ?? ""; + headers["x-forwarded-proto"] = tls ? "https" : "http"; + if (originalHost) { + headers["x-forwarded-host"] = originalHost; + } + // Dial "localhost", not a fixed loopback literal: Node >=17 dev servers (Vite, + // Next.js) often bind ::1 only, and family autoselection reaches either stack. + const proxyReq = requestHttp({ + hostname: "localhost", + createConnection: () => + net.connect({ host: "localhost", autoSelectFamily: true, port: target.targetPort }), + port: target.targetPort, + method: req.method, + path: authorization.requestPath, + headers, + }); + proxyReq.once("response", (proxyRes) => { + for (const [name, value] of Object.entries(proxyHeaders(proxyRes.headers))) { + if (value !== undefined) { + setProxyResponseHeader(res, name, value, target.targetPort); + } + } + // Overwrite, never default: a target answering with `unsafe-url` would otherwise + // send the token-bearing portal URL to every third-party origin it references. + res.setHeader("Referrer-Policy", PORTAL_REFERRER_POLICY); + res.statusCode = proxyRes.statusCode ?? 502; + proxyRes.pipe(res); + }); + proxyReq.once("error", () => { + if (!res.headersSent) { + respondPortalWaiting(req, res, target.targetPort); + } else { + res.destroy(); + } + }); + req.once("aborted", () => proxyReq.destroy()); + req.pipe(proxyReq); +} + +function websocketHeaders(req: IncomingMessage, targetPort: number, requestPath: string): string { + const lines = [`${req.method ?? "GET"} ${requestPath} HTTP/1.1`]; + for (const [name, value] of Object.entries(req.headers)) { + const normalized = name.toLowerCase(); + if ( + value === undefined || + normalized === "host" || + (HOP_BY_HOP_HEADERS.has(normalized) && + normalized !== "connection" && + normalized !== "upgrade") + ) { + continue; + } + if (normalized === "cookie") { + const cookie = readTargetCookies(Array.isArray(value) ? value.join("; ") : value, targetPort); + if (cookie) { + lines.push(`cookie: ${cookie}`); + } + continue; + } + if (normalized === "referer" && String(value).includes(`${PORTAL_AUTH_NAME}=`)) { + continue; + } + for (const item of Array.isArray(value) ? value : [value]) { + lines.push(`${normalized}: ${item}`); + } + } + lines.push(`host: localhost:${targetPort}`, "", ""); + return lines.join("\r\n"); +} + +function rejectPortalUpgrade(socket: Duplex): void { + socket.end( + "HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain; charset=utf-8\r\n" + + "Content-Length: 12\r\nConnection: close\r\n\r\nUnauthorized", + ); +} + +function forwardWebSocketResponse( + targetSocket: Socket, + browserSocket: Duplex, + targetPort: number, +): void { + let pending = Buffer.alloc(0); + const onData = (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + const headerEnd = pending.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + if (pending.length > MAX_WEBSOCKET_RESPONSE_HEADER_BYTES) { + targetSocket.destroy(); + browserSocket.destroy(); + } + return; + } + + targetSocket.off("data", onData); + const headerLines = pending.subarray(0, headerEnd).toString("latin1").split("\r\n"); + const rewrittenLines = headerLines.flatMap((line) => { + const separator = line.indexOf(":"); + if (separator <= 0 || line.slice(0, separator).trim().toLowerCase() !== "set-cookie") { + return [line]; + } + const rewritten = rewriteTargetCookie(line.slice(separator + 1).trimStart(), targetPort); + return rewritten ? [`${line.slice(0, separator)}: ${rewritten}`] : []; + }); + browserSocket.write(`${rewrittenLines.join("\r\n")}\r\n\r\n`); + const remainder = pending.subarray(headerEnd + 4); + if (remainder.length > 0) { + browserSocket.write(remainder); + } + targetSocket.pipe(browserSocket); + }; + targetSocket.on("data", onData); +} + +/** Splices an authorized portal WebSocket upgrade into the loopback target. */ +export function handlePortalProxyUpgrade(params: { + req: IncomingMessage; + socket: Duplex; + head: Buffer; + target: PortalProxyTarget; + upgradedSockets: Set; +}): void { + const { req, socket, head, target, upgradedSockets } = params; + const authorization = authorizePortalRequest(req, target); + if (authorization.kind !== "authorized") { + rejectPortalUpgrade(socket); + return; + } + + // Same localhost/dual-stack contract as the HTTP path above. + const targetSocket: Socket = net.connect({ + host: "localhost", + autoSelectFamily: true, + port: target.targetPort, + }); + upgradedSockets.add(socket); + upgradedSockets.add(targetSocket); + const release = (stream: Duplex) => upgradedSockets.delete(stream); + socket.once("close", () => { + release(socket); + targetSocket.destroy(); + }); + targetSocket.once("close", () => { + release(targetSocket); + socket.destroy(); + }); + socket.once("error", () => targetSocket.destroy()); + targetSocket.once("error", () => socket.destroy()); + targetSocket.once("connect", () => { + forwardWebSocketResponse(targetSocket, socket, target.targetPort); + targetSocket.write(websocketHeaders(req, target.targetPort, authorization.requestPath)); + if (head.length > 0) { + targetSocket.write(head); + } + socket.pipe(targetSocket); + }); +} diff --git a/src/gateway/portals/portal-service.test.ts b/src/gateway/portals/portal-service.test.ts new file mode 100644 index 000000000000..7abb0d107531 --- /dev/null +++ b/src/gateway/portals/portal-service.test.ts @@ -0,0 +1,112 @@ +import { request } from "node:http"; +import net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { createGatewayPortalService, type GatewayPortalService } from "./portal-service.js"; + +const services = new Set(); + +afterEach(async () => { + await Promise.all([...services].map((service) => service.closeAll())); + services.clear(); +}); + +function makeService(hosts: string[]) { + const httpServers: import("node:http").Server[] = []; + const service = createGatewayPortalService({ httpBindHosts: hosts, httpServers }); + services.add(service); + return { service, httpServers }; +} + +async function getStatus(host: string, port: number, path: string): Promise { + return await new Promise((resolve, reject) => { + const req = request({ host, port, path }, (res) => { + res.resume(); + res.once("end", () => resolve(res.statusCode ?? 0)); + }); + req.once("error", reject); + req.end(); + }); +} + +async function expectConnectionRefused(port: number): Promise { + await new Promise((resolve, reject) => { + const socket = net.connect({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.destroy(); + reject(new Error(`listener ${port} remained open`)); + }); + socket.once("error", () => resolve()); + }); +} + +describe("gateway portal service", () => { + it("allocates one port across every frozen bind host", async () => { + const { service, httpServers } = makeService(["127.0.0.1", "::1"]); + const portal = await service.open({ targetPort: 3000, title: "App" }); + + expect(portal).toMatchObject({ id: "p3000", port: 3000, title: "App" }); + expect(portal.listenPort).toBeGreaterThan(0); + expect(httpServers).toHaveLength(2); + expect(await getStatus("127.0.0.1", portal.listenPort, "/")).toBe(401); + expect(await getStatus("::1", portal.listenPort, "/")).toBe(401); + }); + + it("updates an existing target without replacing its listener or token", async () => { + const { service, httpServers } = makeService(["127.0.0.1"]); + const first = await service.open({ targetPort: 3000, title: "First" }); + const second = await service.open({ + targetPort: 3000, + title: "Second", + description: "Updated", + path: "/preview", + }); + + expect(second).toMatchObject({ + id: first.id, + listenPort: first.listenPort, + tokenQuery: first.tokenQuery, + title: "Second", + description: "Updated", + path: "/preview", + publicUrl: `http://127.0.0.1:${first.listenPort}/preview`, + }); + expect(second.url).toBe(`${second.publicUrl}?${second.tokenQuery}`); + expect(httpServers).toHaveLength(1); + expect(service.list()).toEqual([second]); + }); + + it("closes idempotently and closes every portal on shutdown", async () => { + const { service, httpServers } = makeService(["127.0.0.1"]); + const first = await service.open({ targetPort: 3000 }); + const second = await service.open({ targetPort: 4000 }); + + await service.close(first.id); + await service.close(first.id); + expect(service.list().map((entry) => entry.id)).toEqual([second.id]); + await expectConnectionRefused(first.listenPort); + + await service.closeAll(); + expect(service.list()).toEqual([]); + expect(httpServers).toEqual([]); + await expectConnectionRefused(second.listenPort); + }); + + it("removes every registered listener after a partial bind failure", async () => { + const { service, httpServers } = makeService(["127.0.0.1", "127.0.0.1"]); + + await expect(service.open({ targetPort: 3000 })).rejects.toThrow(/already listening/u); + expect(service.list()).toEqual([]); + expect(httpServers).toEqual([]); + }); + + it.each([ + ["0.0.0.0", "127.0.0.1"], + ["::", "[::1]"], + ])("maps wildcard bind host %s to openable host %s", async (bindHost, openableHost) => { + const { service } = makeService([bindHost]); + const portal = await service.open({ targetPort: 3000 }); + + expect(portal.publicUrl).toBe(`http://${openableHost}:${portal.listenPort}/`); + expect(portal.url).toBe(`${portal.publicUrl}?${portal.tokenQuery}`); + }); +}); diff --git a/src/gateway/portals/portal-service.ts b/src/gateway/portals/portal-service.ts new file mode 100644 index 000000000000..09bf8f5af711 --- /dev/null +++ b/src/gateway/portals/portal-service.ts @@ -0,0 +1,237 @@ +import { randomBytes } from "node:crypto"; +import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; +import type { TlsOptions } from "node:tls"; +import type { + PortalOpenResult, + PortalSummary, +} from "../../../packages/gateway-protocol/src/index.js"; +import { listenGatewayHttpServer } from "../server/http-listen.js"; +import { handlePortalProxyRequest, handlePortalProxyUpgrade } from "./portal-http-proxy.js"; + +type PortalEntry = { + id: string; + title: string; + description?: string; + path?: string; + targetPort: number; + token: string; + listenPort: number; + createdAtMs: number; +}; + +type PortalRuntimeEntry = { + portal: PortalEntry; + servers: HttpServer[]; + upgradedSockets: Set; +}; + +type GatewayPortalOpenParams = { + targetPort: number; + title?: string; + description?: string; + path?: string; +}; + +export type GatewayPortalService = { + open: (params: GatewayPortalOpenParams) => Promise; + list: () => PortalSummary[]; + close: (id: string) => Promise; + closeAll: () => Promise; +}; + +function removeServers(shared: HttpServer[], owned: readonly HttpServer[]): void { + for (const server of owned) { + const index = shared.indexOf(server); + if (index >= 0) { + shared.splice(index, 1); + } + } +} + +async function closeServers(servers: readonly HttpServer[]): Promise { + await Promise.all( + servers.map( + (server) => + new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.close(() => resolve()); + server.closeAllConnections(); + }), + ), + ); +} + +function formatPortalHost(host: string): string { + const openableHost = host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host; + return openableHost.includes(":") ? `[${openableHost}]` : openableHost; +} + +/** Creates the gateway-lifetime registry and per-portal transport listeners. */ +export function createGatewayPortalService(params: { + httpBindHosts: readonly string[]; + tlsOptions?: TlsOptions; + httpServers: HttpServer[]; +}): GatewayPortalService { + const entries = new Map(); + const operations = new Map>(); + let closed = false; + + const summarize = (portal: PortalEntry): PortalOpenResult => { + const host = params.httpBindHosts[0]; + if (!host) { + throw new Error("Gateway listener must start before opening a portal"); + } + const scheme = params.tlsOptions ? "https" : "http"; + const tokenQuery = `openclaw_portal=${portal.token}`; + const publicUrl = `${scheme}://${formatPortalHost(host)}:${portal.listenPort}${portal.path ?? "/"}`; + const openableUrl = new URL(publicUrl); + openableUrl.searchParams.set("openclaw_portal", portal.token); + return { + id: portal.id, + title: portal.title, + port: portal.targetPort, + listenPort: portal.listenPort, + tokenQuery, + url: openableUrl.toString(), + publicUrl, + ...(portal.path ? { path: portal.path } : {}), + ...(portal.description ? { description: portal.description } : {}), + createdAtMs: portal.createdAtMs, + }; + }; + + const serialize = async (id: string, operation: () => Promise): Promise => { + const previous = operations.get(id) ?? Promise.resolve(); + const result = previous.then(operation, operation); + const completion = result.then( + () => undefined, + () => undefined, + ); + operations.set(id, completion); + try { + return await result; + } finally { + if (operations.get(id) === completion) { + operations.delete(id); + } + } + }; + + const closeEntry = async (id: string): Promise => { + const runtime = entries.get(id); + if (!runtime) { + return; + } + // Remove authority before asynchronous teardown so no request can rediscover a closing portal. + entries.delete(id); + removeServers(params.httpServers, runtime.servers); + for (const socket of runtime.upgradedSockets) { + socket.destroy(); + } + runtime.upgradedSockets.clear(); + await closeServers(runtime.servers); + }; + + return { + open: async (input) => { + const id = `p${input.targetPort}`; + return await serialize(id, async () => { + if (closed) { + throw new Error("portals unavailable"); + } + const existing = entries.get(id); + if (existing) { + existing.portal.title = input.title?.trim() || existing.portal.title; + if (input.description !== undefined) { + existing.portal.description = input.description; + } + if (input.path !== undefined) { + existing.portal.path = input.path; + } + return summarize(existing.portal); + } + if (params.httpBindHosts.length === 0) { + throw new Error("Gateway listener must start before opening a portal"); + } + + const portal: PortalEntry = { + id, + title: input.title?.trim() || `Port ${input.targetPort}`, + ...(input.description ? { description: input.description } : {}), + ...(input.path ? { path: input.path } : {}), + targetPort: input.targetPort, + token: randomBytes(32).toString("hex"), + listenPort: 0, + createdAtMs: Date.now(), + }; + const upgradedSockets = new Set(); + const handler = ( + req: import("node:http").IncomingMessage, + res: import("node:http").ServerResponse, + ) => + handlePortalProxyRequest({ req, res, target: portal, tls: Boolean(params.tlsOptions) }); + const servers = params.httpBindHosts.map(() => + params.tlsOptions + ? createHttpsServer(params.tlsOptions, handler) + : createHttpServer(handler), + ); + for (const server of servers) { + server.on("upgrade", (req, socket, head) => + handlePortalProxyUpgrade({ req, socket, head, target: portal, upgradedSockets }), + ); + } + // Registration precedes every bind so whole-gateway cleanup owns partial startup. + params.httpServers.push(...servers); + try { + for (const [index, host] of params.httpBindHosts.entries()) { + const server = servers[index]; + if (!server) { + throw new Error(`Missing portal HTTP server for bind host ${host}`); + } + await listenGatewayHttpServer({ + httpServer: server, + bindHost: host, + port: index === 0 ? 0 : portal.listenPort, + retryEaddrinuse: false, + serviceName: "portal", + endpointScheme: params.tlsOptions ? "https" : "http", + }); + if (index === 0) { + const address = server.address() as AddressInfo | null; + if (!address || typeof address === "string") { + throw new Error("Portal listener failed to resolve its port"); + } + portal.listenPort = address.port; + } + } + } catch (error) { + removeServers(params.httpServers, servers); + await closeServers(servers); + throw error; + } + entries.set(id, { portal, servers, upgradedSockets }); + return summarize(portal); + }); + }, + list: () => + [...entries.values()] + .map(({ portal }) => summarize(portal)) + .toSorted( + (left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id), + ), + close: async (id) => { + await serialize(id, () => closeEntry(id)); + }, + closeAll: async () => { + closed = true; + const ids = new Set([...entries.keys(), ...operations.keys()]); + await Promise.all([...ids].map((id) => serialize(id, () => closeEntry(id)))); + }, + }; +} diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 9679606f64d3..fc18803a364f 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -85,6 +85,7 @@ const EVENT_SCOPE_GUARDS: Record = { // methods; also targeted to the owning connection at broadcast time. "terminal.data": [ADMIN_SCOPE], "terminal.exit": [ADMIN_SCOPE], + "portal.changed": [READ_SCOPE], }; // Opt-in scoped clients never receive session-bearing broadcasts without an diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index d9d2955ab61e..442a015a4dc9 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -3440,6 +3440,79 @@ describe("agent event handler", () => { expect(requireRecord(payload.session, "nested session")).not.toHaveProperty("goal"); }); + it("omits non-authoritative model, thinking, and usage from lifecycle snapshots", async () => { + vi.mocked(loadGatewaySessionRow).mockReturnValue({ + key: "session-lightweight", + kind: "direct", + updatedAt: 1_650, + sessionId: "session-lightweight", + status: "running", + modelProvider: "custom-provider", + model: "custom-legacy-model", + agentRuntime: { id: "openclaw", source: "default" }, + thinkingLevel: "high", + thinkingLevels: [{ id: "off", label: "off" }], + thinkingOptions: ["off"], + thinkingDefault: "off", + totalTokens: undefined, + totalTokensFresh: false, + contextTokens: 200_000, + estimatedCostUsd: undefined, + verboseLevel: "full", + }); + + const { broadcastToConnIds, sessionEventSubscribers, handler } = createHarness({ + resolveSessionKeyForRun: () => "session-lightweight", + }); + sessionEventSubscribers.subscribe("conn-session"); + + emitAgentEvent( + handler, + "run-lightweight", + "lifecycle", + { phase: "end", endedAt: 1_700 }, + { seq: 2, ts: 1_800 }, + ); + + await waitForFast(() => { + expect( + broadcastToConnIds.mock.calls.filter(([event]) => event === "sessions.changed"), + ).toHaveLength(1); + }); + const payload = requireRecord( + // oxlint-disable-next-line unicorn/prefer-structured-clone -- verify the gateway JSON wire shape + JSON.parse( + JSON.stringify(requireMockArg(broadcastToConnIds, 0, 1, "sessions changed payload")), + ), + "serialized sessions changed payload", + ); + const session = requireRecord(payload.session, "nested session"); + for (const field of [ + "modelProvider", + "model", + "agentRuntime", + "thinkingLevels", + "thinkingOptions", + "thinkingDefault", + "totalTokens", + "totalTokensFresh", + "contextTokens", + "estimatedCostUsd", + ]) { + expect(payload).not.toHaveProperty(field); + expect(session).not.toHaveProperty(field); + } + expectPayloadFields(payload, { + sessionKey: "session-lightweight", + status: "running", + }); + expectPayloadFields(session, { + thinkingLevel: "high", + verboseLevel: "full", + status: "running", + }); + }); + it.each([ { name: "keeps tool output for Control UI recipients when verbose is on", diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index f08c548d760e..547869339d46 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -56,6 +56,7 @@ import type { import { loadGatewaySessionLifecycleSnapshot } from "./server-chat.load-gateway-session-row.runtime.js"; import { persistGatewaySessionLifecycleEvent } from "./server-chat.persist-session-lifecycle.runtime.js"; import { hasSessionChangeReceivers } from "./session-change-receivers.js"; +import { buildGatewaySessionEventRow } from "./session-event-payload.js"; import { deriveGatewaySessionLifecycleProjectionPatch, isRestartRecoveryLifecycleEvent, @@ -564,6 +565,7 @@ export function createAgentEventHandler({ evt?: AgentEventPayload, agentId?: string, includeActiveRunState = false, + lifecycleProjection = false, ) => { const snapshotOptions = agentId ? { agentId } : undefined; const lifecycleSnapshot = loadGatewaySessionLifecycleSnapshotForEvent( @@ -612,9 +614,14 @@ export function createAgentEventHandler({ : {}; const clearsLastRunError = Object.hasOwn(lifecyclePatch, "lastRunError") && lifecyclePatch.lastRunError === undefined; - const session = row + const projectedRow = row + ? lifecycleProjection + ? buildGatewaySessionEventRow(row, { lifecycle: true }) + : row + : undefined; + const session = projectedRow ? { - ...row, + ...projectedRow, ...lifecyclePatch, ...activeRunFields, // JSON drops undefined values, so a start/success must send null to @@ -664,17 +671,17 @@ export function createAgentEventHandler({ lastTo: row?.lastTo, lastAccountId: row?.lastAccountId, lastThreadId: row?.lastThreadId, - totalTokens: row?.totalTokens, - totalTokensFresh: row?.totalTokensFresh, + totalTokens: projectedRow?.totalTokens, + totalTokensFresh: projectedRow?.totalTokensFresh, ...(omitUnscopedGlobalGoal ? {} : { goal: row?.goal ?? null }), - contextTokens: row?.contextTokens, - estimatedCostUsd: row?.estimatedCostUsd, + contextTokens: projectedRow?.contextTokens, + estimatedCostUsd: projectedRow?.estimatedCostUsd, responseUsage: row?.responseUsage, // Carry the row-built channel-aware effective mode so the chat snapshot // matches the session-event/list projections. effectiveResponseUsage: row?.effectiveResponseUsage, - modelProvider: row?.modelProvider, - model: row?.model, + modelProvider: projectedRow?.modelProvider, + model: projectedRow?.model, ...activeRunFields, status: snapshotSource.status, lastRunError: snapshotSource.lastRunError ?? null, @@ -901,7 +908,7 @@ export function createAgentEventHandler({ runId: evt.runId, ...(eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}), ts: evt.ts, - ...buildSessionEventSnapshot(sessionKey, snapshotEvent, sessionAgentId, true), + ...buildSessionEventSnapshot(sessionKey, snapshotEvent, sessionAgentId, true, true), }, sessionEventConnIds, { dropIfSlow: true }, @@ -1771,7 +1778,7 @@ export function createAgentEventHandler({ runId: evt.runId, ...(eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}), ts: evt.ts, - ...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId, true), + ...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId, true, true), }, sessionEventConnIds, { dropIfSlow: true }, diff --git a/src/gateway/server-kernel-request-runtime.ts b/src/gateway/server-kernel-request-runtime.ts index 587d2257b120..737cd937e455 100644 --- a/src/gateway/server-kernel-request-runtime.ts +++ b/src/gateway/server-kernel-request-runtime.ts @@ -31,6 +31,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { sessionObserver, getMcpAppSandboxPort, ensureSandboxHostPort, + getPortalService, terminalLaunchPolicy, execApprovalManager, cancelRunBoundApprovals, @@ -118,6 +119,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { sessionObserver, getMcpAppSandboxPort, ensureSandboxHostPort, + getPortalService, resolveTerminalLaunchPolicy: terminalLaunchPolicy.resolve, isTerminalEnabled: terminalLaunchPolicy.isEnabled, execApprovalManager, diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index 8ba899d3a8a0..67080fadebbb 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -536,6 +536,7 @@ export async function prepareGatewayLifecycle(params: { const { createGatewayCloseHandler, drainActiveSessionsForShutdown } = await loadGatewayCloseModule(); const transport = transportBridge.current(); + await transport?.portalService.closeAll(); await createGatewayCloseHandler({ bonjourStop: runtimeState.bonjourStop, tailscaleCleanup: runtimeState.tailscaleCleanup, diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 306df4ec9ec0..22687931f6d8 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -26,6 +26,10 @@ describe("GATEWAY_EVENTS", () => { expect(GATEWAY_EVENTS).toContain("skills.changed"); }); + it("advertises portal replace-set updates", () => { + expect(GATEWAY_EVENTS).toContain("portal.changed"); + }); + it("advertises session observer digests", () => { expect(GATEWAY_EVENTS).toContain("session.observer"); }); @@ -66,7 +70,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-50)).toEqual([ + expect(listGatewayMethods().slice(-53)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -117,6 +121,9 @@ describe("listGatewayMethods", () => { "desktop.launch", "device.scopes.requestUpgrade", "device.scopes.waitUpgrade", + "portal.list", + "portal.open", + "portal.close", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -222,7 +229,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-57)).toEqual([ + expect(coreMethods.slice(-60)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -280,6 +287,9 @@ describe("listGatewayMethods", () => { "desktop.launch", "device.scopes.requestUpgrade", "device.scopes.waitUpgrade", + "portal.list", + "portal.open", + "portal.close", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); @@ -313,6 +323,9 @@ describe("listGatewayMethods", () => { expect(methods.indexOf("device.scopes.waitUpgrade")).toBe( methods.indexOf("device.scopes.requestUpgrade") + 1, ); + expect(methods.indexOf("portal.list")).toBe(methods.indexOf("device.scopes.waitUpgrade") + 1); + expect(methods.indexOf("portal.open")).toBe(methods.indexOf("portal.list") + 1); + expect(methods.indexOf("portal.close")).toBe(methods.indexOf("portal.open") + 1); }); it("advertises the versioned Talk session RPCs", () => { diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 486d00817a9e..f55a09fb638a 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -83,4 +83,5 @@ export const GATEWAY_EVENTS = [ "terminal.data", "terminal.exit", GATEWAY_EVENT_UPDATE_AVAILABLE, + "portal.changed", ]; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 44486ab0a5c2..71251c066ab3 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -126,6 +126,7 @@ const CORE_GATEWAY_HANDLER_MODULES = { import("./server-methods/plugin-host-hooks.js").then((module) => module.pluginHostHookHandlers), plugins: () => import("./server-methods/plugins.js").then((module) => module.pluginsHandlers), projects: () => import("./server-methods/projects.js").then((module) => module.projectsHandlers), + portals: () => import("./server-methods/portals.js").then((module) => module.portalHandlers), migrations: () => import("./server-methods/migrations.js").then((module) => module.migrationsHandlers), push: () => import("./server-methods/push.js").then((module) => module.pushHandlers), diff --git a/src/gateway/server-methods/environments.test.ts b/src/gateway/server-methods/environments.test.ts index c3c2ec949339..ad9faea2ab6e 100644 --- a/src/gateway/server-methods/environments.test.ts +++ b/src/gateway/server-methods/environments.test.ts @@ -67,6 +67,11 @@ function mockContext( platform: "ios", caps: ["camera"], commands: ["system.run"], + workerRuns: { + bundleHash: "a".repeat(64), + openclawVersion: "2026.8.12", + protocolFeatures: ["worker-heartbeat-v1"], + }, connectedAtMs: 123, }, ], @@ -199,7 +204,7 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()); describe("environment gateway methods", () => { - it("keeps the existing gateway and node projection unchanged without a worker service", async () => { + it("projects live node session-host capability without a worker service", async () => { const [ok, payload] = await callEnvironmentMethod("environments.list", {}); expect(ok).toBe(true); @@ -221,7 +226,7 @@ describe("environment gateway methods", () => { label: "Live Node", status: "available", platform: "ios", - sessionHost: false, + sessionHost: true, trust: "persistent", capabilities: ["camera", "system.run"], }, @@ -394,7 +399,7 @@ describe("environment gateway methods", () => { label: "Live Node", status: "available", platform: "ios", - sessionHost: false, + sessionHost: true, trust: "persistent", capabilities: ["camera", "system.run"], }); diff --git a/src/gateway/server-methods/environments.ts b/src/gateway/server-methods/environments.ts index 35e5cbd1ca5c..51fd527c366e 100644 --- a/src/gateway/server-methods/environments.ts +++ b/src/gateway/server-methods/environments.ts @@ -86,7 +86,7 @@ function summarizeNodeEnvironment( label: node.displayName ?? node.nodeId, status: node.connected ? "available" : "unavailable", ...(platform ? { platform } : {}), - sessionHost: false, + sessionHost: node.connected === true && node.sessionHost === true, trust: "persistent", ...(desktop ? { desktop: true } : {}), ...(capabilities.length > 0 ? { capabilities } : {}), diff --git a/src/gateway/server-methods/portals.test.ts b/src/gateway/server-methods/portals.test.ts new file mode 100644 index 000000000000..f5c9028d98b9 --- /dev/null +++ b/src/gateway/server-methods/portals.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + PortalOpenResult, + PortalSummary, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveCoreOperatorGatewayMethodScope } from "../methods/core-descriptors.js"; +import type { GatewayPortalService } from "../portals/portal-service.js"; +import { createGatewayBroadcaster } from "../server-broadcast.js"; +import type { GatewayWsClient } from "../server/ws-types.js"; +import { portalHandlers } from "./portals.js"; + +const portal = { + id: "p3000", + title: "App", + port: 3000, + listenPort: 43123, + tokenQuery: `openclaw_portal=${"a".repeat(64)}`, + url: `http://127.0.0.1:43123/?openclaw_portal=${"a".repeat(64)}`, + publicUrl: "http://127.0.0.1:43123/", + createdAtMs: 1, +} satisfies PortalOpenResult; + +function harness(service?: GatewayPortalService, scopes = ["operator.write"]) { + const broadcast = vi.fn(); + const invoke = async (method: keyof typeof portalHandlers, params: Record) => { + const respond = vi.fn(); + await portalHandlers[method]!({ + params, + respond, + client: { connect: { scopes } } as never, + context: { portalService: service, broadcast } as never, + } as never); + return respond; + }; + return { broadcast, invoke }; +} + +describe("portal gateway methods", () => { + it("registers list and mutations with least-privilege scopes", () => { + expect(resolveCoreOperatorGatewayMethodScope("portal.list")).toBe("operator.read"); + expect(resolveCoreOperatorGatewayMethodScope("portal.open")).toBe("operator.write"); + expect(resolveCoreOperatorGatewayMethodScope("portal.close")).toBe("operator.write"); + }); + + it("round-trips list, open, and idempotent close with replace-set broadcasts", async () => { + let portals: PortalSummary[] = []; + const service: GatewayPortalService = { + list: () => portals, + open: vi.fn(async () => { + portals = [portal]; + return portal; + }), + close: vi.fn(async () => { + portals = []; + }), + closeAll: vi.fn(async () => {}), + }; + const { invoke, broadcast } = harness(service); + + expect((await invoke("portal.list", {})).mock.calls[0]).toEqual([ + true, + { portals: [] }, + undefined, + ]); + expect((await invoke("portal.open", { port: 3000, title: "App" })).mock.calls[0]).toEqual([ + true, + portal, + undefined, + ]); + expect(service.open).toHaveBeenCalledWith({ targetPort: 3000, title: "App" }); + expect(broadcast).toHaveBeenLastCalledWith( + "portal.changed", + { + portals: [ + { + id: portal.id, + title: portal.title, + port: portal.port, + listenPort: portal.listenPort, + publicUrl: portal.publicUrl, + createdAtMs: portal.createdAtMs, + }, + ], + }, + { dropIfSlow: true }, + ); + expect((await invoke("portal.close", { id: "missing" })).mock.calls[0]).toEqual([ + true, + { closed: true }, + undefined, + ]); + expect(broadcast).toHaveBeenLastCalledWith( + "portal.changed", + { portals: [] }, + { dropIfSlow: true }, + ); + }); + + it("returns portal credentials only to write-capable operators", async () => { + const service: GatewayPortalService = { + list: () => [portal], + open: vi.fn(), + close: vi.fn(), + closeAll: vi.fn(), + }; + + const readResponse = await harness(service, ["operator.read"]).invoke("portal.list", {}); + expect(readResponse.mock.calls[0]?.[1]).toEqual({ + portals: [ + { + id: portal.id, + title: portal.title, + port: portal.port, + listenPort: portal.listenPort, + publicUrl: portal.publicUrl, + createdAtMs: portal.createdAtMs, + }, + ], + }); + + const writeResponse = await harness(service, ["operator.write"]).invoke("portal.list", {}); + expect(writeResponse.mock.calls[0]?.[1]).toEqual({ portals: [portal] }); + + const adminResponse = await harness(service, ["operator.admin"]).invoke("portal.list", {}); + expect(adminResponse.mock.calls[0]?.[1]).toEqual({ portals: [portal] }); + }); + + it("rejects malformed requests before service access and reports absent transports", async () => { + const service: GatewayPortalService = { + list: vi.fn(() => []), + open: vi.fn(), + close: vi.fn(), + closeAll: vi.fn(), + }; + const invalid = await harness(service).invoke("portal.open", { port: 0 }); + expect(invalid).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + expect(service.open).not.toHaveBeenCalled(); + + const unavailable = await harness().invoke("portal.list", {}); + expect(unavailable).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST", message: "portals unavailable" }), + ); + }); + + it("returns Error messages without the Error prefix", async () => { + const service: GatewayPortalService = { + list: () => [], + open: vi.fn(async () => { + throw new Error("portal bind failed"); + }), + close: vi.fn(async () => {}), + closeAll: vi.fn(async () => {}), + }; + + const response = await harness(service).invoke("portal.open", { port: 3000 }); + expect(response).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "UNAVAILABLE", message: "portal bind failed" }), + ); + }); + + it("delivers portal changes only to read-capable operators", () => { + const events = new Map(); + const client = (id: string, role: "node" | "operator", scopes: string[]): GatewayWsClient => { + events.set(id, []); + return { + connId: id, + usesSharedGatewayAuth: false, + connect: { role, scopes } as GatewayWsClient["connect"], + socket: { + bufferedAmount: 0, + close: vi.fn(), + send: (value: string) => + events.get(id)?.push((JSON.parse(value) as { event: string }).event), + } as never, + }; + }; + const clients = new Set([ + client("pairing", "operator", ["operator.pairing"]), + client("node", "node", ["operator.read"]), + client("read", "operator", ["operator.read"]), + client("write", "operator", ["operator.write"]), + ]); + createGatewayBroadcaster({ clients }).broadcast("portal.changed", { portals: [portal] }); + + expect(events.get("pairing")).toEqual([]); + expect(events.get("node")).toEqual([]); + expect(events.get("read")).toEqual(["portal.changed"]); + expect(events.get("write")).toEqual(["portal.changed"]); + }); +}); diff --git a/src/gateway/server-methods/portals.ts b/src/gateway/server-methods/portals.ts new file mode 100644 index 000000000000..047de2ac8510 --- /dev/null +++ b/src/gateway/server-methods/portals.ts @@ -0,0 +1,121 @@ +import { + ErrorCodes, + errorShape, + formatValidationErrors, + type PortalCloseParams, + type PortalOpenParams, + type PortalSummary, + validatePortalCloseParams, + validatePortalListParams, + validatePortalOpenParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { ADMIN_SCOPE, WRITE_SCOPE } from "../operator-scopes.js"; +import type { GatewayRequestHandlers, RespondFn } from "./types.js"; + +function invalidParams(method: string, errors: unknown, respond: RespondFn): void { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid ${method} params: ${formatValidationErrors(errors as never)}`, + ), + ); +} + +function requirePortalService( + context: Parameters[0]["context"], + respond: RespondFn, +) { + const service = context.portalService; + if (!service) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "portals unavailable")); + } + return service; +} + +function redactPortalSummary(summary: PortalSummary): PortalSummary { + const { tokenQuery: _tokenQuery, url: _url, ...redacted } = summary; + return redacted; +} + +export const portalHandlers: GatewayRequestHandlers = { + "portal.list": ({ params, respond, context, client }) => { + if (!validatePortalListParams(params)) { + invalidParams("portal.list", validatePortalListParams.errors, respond); + return; + } + const service = requirePortalService(context, respond); + if (!service) { + return; + } + const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + const portals = service.list(); + respond( + true, + { + portals: + scopes.includes(WRITE_SCOPE) || scopes.includes(ADMIN_SCOPE) + ? portals + : portals.map(redactPortalSummary), + }, + undefined, + ); + }, + "portal.open": async ({ params, respond, context }) => { + if (!validatePortalOpenParams(params)) { + invalidParams("portal.open", validatePortalOpenParams.errors, respond); + return; + } + const service = requirePortalService(context, respond); + if (!service) { + return; + } + try { + const request = params as PortalOpenParams; + const portal = await service.open({ + targetPort: request.port, + ...(request.title !== undefined ? { title: request.title } : {}), + ...(request.description !== undefined ? { description: request.description } : {}), + ...(request.path !== undefined ? { path: request.path } : {}), + }); + context.broadcast( + "portal.changed", + { portals: service.list().map(redactPortalSummary) }, + { dropIfSlow: true }, + ); + respond(true, portal, undefined); + } catch (error) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, error instanceof Error ? error.message : String(error)), + ); + } + }, + "portal.close": async ({ params, respond, context }) => { + if (!validatePortalCloseParams(params)) { + invalidParams("portal.close", validatePortalCloseParams.errors, respond); + return; + } + const service = requirePortalService(context, respond); + if (!service) { + return; + } + try { + await service.close((params as PortalCloseParams).id); + context.broadcast( + "portal.changed", + { portals: service.list().map(redactPortalSummary) }, + { dropIfSlow: true }, + ); + respond(true, { closed: true }, undefined); + } catch (error) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, error instanceof Error ? error.message : String(error)), + ); + } + }, +}; diff --git a/src/gateway/server-methods/session-catalog-terminal-start.test.ts b/src/gateway/server-methods/session-catalog-terminal-start.test.ts index 359ea6d6c1a8..d9c3ae46585b 100644 --- a/src/gateway/server-methods/session-catalog-terminal-start.test.ts +++ b/src/gateway/server-methods/session-catalog-terminal-start.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import type { SessionCatalogProvider } from "../../plugins/session-catalog.js"; +import { withEnvAsync } from "../../test-utils/env.js"; import { catalogStartHandler } from "./session-catalog-terminal-start.js"; function provider(overrides: Partial = {}): SessionCatalogProvider { @@ -215,6 +216,54 @@ describe("sessions.catalog.startTerminal", () => { ); }); + it("rejects local terminal start for a named profile before provider fallback", async () => { + const cwd = process.cwd(); + const startTerminalSession = vi.fn(async (request: { allowProcessHomeFallback?: boolean }) => { + throw new Error( + request.allowProcessHomeFallback === false + ? "local Test sessions are unavailable in isolated state" + : "unguarded local terminal start", + ); + }); + activeProvider = provider({ startTerminalSession: startTerminalSession as never }); + const home = os.userInfo().homedir; + const stateDir = path.join(home, ".openclaw-dev"); + + const respond = await withEnvAsync( + { + HOME: home, + USERPROFILE: home, + OPENCLAW_HOME: undefined, + OPENCLAW_PROFILE: "dev", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + }, + async () => + await call( + { catalogId: "codex", agentId: "main", cwd }, + { gateway: { cliAgents: { enabled: true } } }, + { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + { + isTerminalEnabled: () => true, + terminalSessions: { open: vi.fn() }, + resolveTerminalLaunchPolicy: () => ({ + ok: true, + plan: { agentId: "main", cwd, shell: "/bin/zsh", args: [] }, + }), + isConnectionActive: () => true, + }, + ), + ); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("local Test sessions are unavailable in isolated state"), + }), + ); + }); + it("reuses terminal.open admission and manager ownership for terminal start", async () => { const cwd = process.cwd(); const startTerminalSession = vi.fn(async () => ({ @@ -260,6 +309,7 @@ describe("sessions.catalog.startTerminal", () => { expect(resolveCreateTarget).toHaveBeenCalledWith("codex", "research", config); expect(startTerminalSession).toHaveBeenCalledWith({ agentId: "research", + allowProcessHomeFallback: false, cwd, initialMessage: "Inspect the failing test", }); @@ -313,6 +363,7 @@ describe("sessions.catalog.startTerminal", () => { expect(startTerminalSession).toHaveBeenCalledWith({ agentId: "main", + allowProcessHomeFallback: false, cwd: "/remote/worktree", nodeId: "remote", }); diff --git a/src/gateway/server-methods/session-catalog-terminal-start.ts b/src/gateway/server-methods/session-catalog-terminal-start.ts index ada13472495b..afdb4773a5a7 100644 --- a/src/gateway/server-methods/session-catalog-terminal-start.ts +++ b/src/gateway/server-methods/session-catalog-terminal-start.ts @@ -6,6 +6,7 @@ import { type SessionsCatalogStartTerminalParams, validateSessionsCatalogStartTerminalParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { allowsProcessHomeSessionScan } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SessionCatalogProvider } from "../../plugins/session-catalog.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -140,6 +141,7 @@ export function catalogStartHandler( failureHint: "check the selected CLI, host, and terminal configuration, then retry", resolveCatalogPlan: async () => { const plan = await startTerminalSession.call(provider, { + allowProcessHomeFallback: allowsProcessHomeSessionScan(), agentId: request.agentId, cwd: request.cwd, ...(request.initialMessage !== undefined diff --git a/src/gateway/server-methods/session-catalog.home-isolation.test.ts b/src/gateway/server-methods/session-catalog.home-isolation.test.ts new file mode 100644 index 000000000000..e719bbf0cd2e --- /dev/null +++ b/src/gateway/server-methods/session-catalog.home-isolation.test.ts @@ -0,0 +1,153 @@ +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; +import type { PluginRegistry } from "../../plugins/registry-types.js"; +import type { SessionCatalogProvider } from "../../plugins/session-catalog.js"; +import { withEnvAsync } from "../../test-utils/env.js"; + +type TestPluginRegistry = Omit & { + sessionCatalogs: Array<{ provider: SessionCatalogProvider }>; +}; + +const hoisted = vi.hoisted(() => ({ + activeRegistry: {} as TestPluginRegistry, + listSessionEntriesReadOnly: vi.fn(() => []), +})); + +vi.mock("../../plugins/runtime.js", () => ({ + getActivePluginRegistry: () => hoisted.activeRegistry, + requireActivePluginRegistry: () => hoisted.activeRegistry, +})); +vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => ({ + ...(await importOriginal()), + listSessionEntriesReadOnly: hoisted.listSessionEntriesReadOnly, +})); + +const { sessionCatalogHandlers } = await import("./session-catalog.js"); + +function provider( + id: string, + overrides: Partial = {}, +): SessionCatalogProvider { + return { + id, + label: id.toUpperCase(), + list: vi.fn(async () => []), + read: vi.fn(async ({ hostId, threadId }) => ({ hostId, threadId, items: [] })), + ...overrides, + }; +} + +async function call( + method: keyof typeof sessionCatalogHandlers, + params: unknown, + logGateway?: { warn: (message: string, fields?: Record) => void }, +) { + const respond = vi.fn(); + await sessionCatalogHandlers[method]?.({ + params, + respond, + context: { getRuntimeConfig: () => ({}), ...(logGateway ? { logGateway } : {}) }, + } as never); + return respond; +} + +function withProfile(profile: string | undefined, run: () => Promise): Promise { + const home = os.userInfo().homedir; + const stateDir = path.join(home, profile ? `.openclaw-${profile}` : ".openclaw"); + return withEnvAsync( + { + HOME: home, + USERPROFILE: home, + OPENCLAW_HOME: undefined, + OPENCLAW_PROFILE: profile, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + }, + run, + ); +} + +describe("session catalog Gateway HOME isolation", () => { + beforeEach(() => { + hoisted.activeRegistry = createEmptyPluginRegistry() as TestPluginRegistry; + hoisted.listSessionEntriesReadOnly.mockReset().mockReturnValue([]); + }); + + it("suppresses only process-HOME local hosts for a named profile", async () => { + const localHost = { + hostId: "gateway:local", + label: "Local", + kind: "gateway" as const, + connected: true, + sessions: [], + }; + const nodeHost = { + hostId: "node:devbox", + label: "Devbox", + kind: "node" as const, + connected: true, + nodeId: "devbox", + sessions: [], + }; + const list = vi.fn(async (query: Parameters[0]) => [ + ...(query.allowProcessHomeFallback === false ? [] : [localHost]), + nodeHost, + ]); + hoisted.activeRegistry.sessionCatalogs = [{ provider: provider("claude", { list }) }]; + const logGateway = { warn: vi.fn() }; + + const defaultRespond = await withProfile(undefined, () => + call("sessions.catalog.list", {}, logGateway), + ); + expect(defaultRespond).toHaveBeenCalledWith(true, { + catalogs: [expect.objectContaining({ id: "claude", hosts: [localHost, nodeHost] })], + }); + + const respond = await withProfile("dev", () => call("sessions.catalog.list", {}, logGateway)); + await withProfile("dev", () => + call("sessions.catalog.list", { search: "second request" }, logGateway), + ); + + expect(list).toHaveBeenCalledTimes(3); + expect(respond).toHaveBeenCalledWith(true, { + catalogs: [expect.objectContaining({ id: "claude", hosts: [nodeHost] })], + }); + expect(logGateway.warn).toHaveBeenCalledOnce(); + expect(logGateway.warn).toHaveBeenCalledWith( + "external session catalog HOME fallback skipped: isolated state; configure an explicit root to enable", + { reason: "isolated_state" }, + ); + }); + + it.each([ + ["continue", "continueSession", {}], + ["archive", "archive", { confirmNoOtherRunner: true }], + ] as const)("rejects a known local %s for a named profile", async (method, hook, extra) => { + const rejectLocal = vi.fn(async (request: { allowProcessHomeFallback?: boolean }) => { + if (request.allowProcessHomeFallback === false) { + throw new Error("local Test sessions are unavailable in isolated state"); + } + return hook === "archive" ? { ok: true as const } : { sessionKey: "agent:main:known" }; + }); + hoisted.activeRegistry.sessionCatalogs = [ + { provider: provider("test", { [hook]: rejectLocal } as Partial) }, + ]; + + const respond = await withProfile("dev", () => + call(`sessions.catalog.${method}`, { + catalogId: "test", + hostId: "gateway:local", + threadId: "known-thread", + ...extra, + }), + ); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: "local Test sessions are unavailable in isolated state" }), + ); + }); +}); diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index 12af3516b164..d75fbe97078f 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -814,6 +814,7 @@ describe("session catalog Gateway methods", () => { { connect: { scopes: ["operator.write", "operator.admin"] } }, ); expect(continueSession).toHaveBeenCalledWith({ + allowProcessHomeFallback: false, hostId: "gateway:local", threadId: "thread-1", clientScopes: ["operator.write", "operator.admin"], @@ -830,6 +831,7 @@ describe("session catalog Gateway methods", () => { threadId: "thread-1", }); expect(continueSession).toHaveBeenCalledWith({ + allowProcessHomeFallback: false, hostId: "gateway:local", threadId: "thread-1", clientScopes: [], diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index ed761f7aefd4..ccef2b09aff6 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -13,6 +13,7 @@ import { validateSessionsCatalogListParams, validateSessionsCatalogReadParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { allowsProcessHomeSessionScan } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { getPluginRegistryRuntime } from "../../plugins/registry-runtime-binding.js"; @@ -41,6 +42,21 @@ const SESSION_CATALOG_SHARE_WINDOW_MS = 3_000; const SESSION_CATALOG_LIST_CACHE_MAX_ENTRIES = 128; const MAX_CONCURRENT_SESSION_CATALOG_LISTS = 4; const MAX_QUEUED_SESSION_CATALOG_LISTS = 32; +const PROCESS_HOME_CATALOG_SKIP_MESSAGE = + "external session catalog HOME fallback skipped: isolated state; configure an explicit root to enable"; + +let reportedProcessHomeCatalogSkip = false; + +function allowProcessHomeFallback(logGateway?: { + warn: (message: string, fields?: Record) => void; +}): boolean { + const allowed = allowsProcessHomeSessionScan(); + if (!allowed && !reportedProcessHomeCatalogSkip && logGateway) { + reportedProcessHomeCatalogSkip = true; + logGateway.warn(PROCESS_HOME_CATALOG_SKIP_MESSAGE, { reason: "isolated_state" }); + } + return allowed; +} // Catalog adapters may scan local databases or invoke external CLIs. Bound the // expensive provider operation itself so adding providers cannot multiply the cap. @@ -239,6 +255,7 @@ function sessionCatalogListKey(params: { agentId: string; request: SessionsCatalogListParams; search?: string; + allowProcessHomeFallback: boolean; }): string { const cursors = params.request.cursors ? Object.entries(params.request.cursors).toSorted(([left], [right]) => @@ -252,6 +269,7 @@ function sessionCatalogListKey(params: { params.request.limitPerHost ?? null, params.request.hostIds ?? null, cursors, + params.allowProcessHomeFallback, ]); } @@ -367,12 +385,14 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { return; } const search = normalizeSessionCatalogSearch(request.search); + const allowHomeFallback = allowProcessHomeFallback(context.logGateway); const progressId = request.progressId; const progressConnId = progressId && client?.connId ? client.connId : undefined; const listKey = sessionCatalogListKey({ agentId: resolvedAgent.agentId, request, search, + allowProcessHomeFallback: allowHomeFallback, }); const cache = catalogListCache(config, catalogRegistrations); const cached = cache.get(listKey); @@ -442,6 +462,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { try { const hosts = await sessionCatalogListAdmission.run(() => provider.list({ + allowProcessHomeFallback: allowHomeFallback, search, limitPerHost: request.limitPerHost, hostIds: request.hostIds, @@ -486,7 +507,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { } }, - "sessions.catalog.read": async ({ params, respond }) => { + "sessions.catalog.read": async ({ params, respond, context }) => { if ( !assertValidParams( params, @@ -504,7 +525,13 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { } try { const { catalogId: _catalogId, ...providerRequest } = request; - respond(true, await provider.read(providerRequest)); + respond( + true, + await provider.read({ + ...providerRequest, + allowProcessHomeFallback: allowProcessHomeFallback(context.logGateway), + }), + ); } catch (error) { const details = catalogError(error); respond( @@ -515,7 +542,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { } }, - "sessions.catalog.continue": async ({ params, respond, client }) => { + "sessions.catalog.continue": async ({ params, respond, client, context }) => { if ( !assertValidParams( params, @@ -541,7 +568,11 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { // Fail closed for unscoped callers: providers gate high-authority // continues (e.g. node-executing bindings) on these scopes. const clientScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; - const result = await provider.continueSession({ ...providerRequest, clientScopes }); + const result = await provider.continueSession({ + ...providerRequest, + allowProcessHomeFallback: allowProcessHomeFallback(context.logGateway), + clientScopes, + }); if (result.conversationBinding) { // operator.write on Continue is the approval boundary. Per-turn plugin and // node command authorization still applies after this binding is installed. @@ -599,7 +630,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { resolveRegisteredCatalogCreateTarget, ), - "sessions.catalog.archive": async ({ params, respond }) => { + "sessions.catalog.archive": async ({ params, respond, context }) => { if ( !assertValidParams( params, @@ -621,7 +652,13 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { } try { const { catalogId: _catalogId, ...providerRequest } = request; - respond(true, await provider.archive(providerRequest)); + respond( + true, + await provider.archive({ + ...providerRequest, + allowProcessHomeFallback: allowProcessHomeFallback(context.logGateway), + }), + ); } catch (error) { const details = catalogError(error); respond( diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 68271f0a3e7c..7640b19c3b76 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -34,6 +34,7 @@ import type { HealthSummary } from "../health/types.js"; import type { GatewayMethodRegistryView } from "../methods/descriptor.js"; import type { NodeRegistry } from "../node-registry.js"; import type { PluginNodeCapabilitySurface } from "../plugin-node-capability.js"; +import type { GatewayPortalService } from "../portals/portal-service.js"; import type { GatewayBroadcastFn, GatewayBroadcastToConnIdsFn } from "../server-broadcast-types.js"; import type { ChannelRuntimeSnapshot, @@ -280,6 +281,7 @@ type GatewayKernelContext = { /** Socket-bound services and connection state supplied by the Gateway transports. */ type GatewayTransportContext = { + portalService?: GatewayPortalService; getMcpAppSandboxPort?: () => number | undefined; ensureSandboxHostPort?: () => Promise; broadcast: GatewayBroadcastFn; diff --git a/src/gateway/server-methods/terminal.test.ts b/src/gateway/server-methods/terminal.test.ts index d4c1e8ba4964..8c0559c762e9 100644 --- a/src/gateway/server-methods/terminal.test.ts +++ b/src/gateway/server-methods/terminal.test.ts @@ -230,7 +230,11 @@ describe("terminal gateway policy", () => { ); await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); - expect(openTerminal).toHaveBeenCalledWith({ hostId: "gateway:local", threadId: "thread" }); + expect(openTerminal).toHaveBeenCalledWith({ + allowProcessHomeFallback: false, + hostId: "gateway:local", + threadId: "thread", + }); expect(sessions.open).toHaveBeenCalledWith( expect.objectContaining({ shell: expect.any(String), diff --git a/src/gateway/server-methods/terminal.ts b/src/gateway/server-methods/terminal.ts index 23f44dc05890..ab8ebdb10c20 100644 --- a/src/gateway/server-methods/terminal.ts +++ b/src/gateway/server-methods/terminal.ts @@ -19,6 +19,7 @@ import { validateTerminalResizeParams, validateTerminalUploadResult, } from "../../../packages/gateway-protocol/src/index.js"; +import { allowsProcessHomeSessionScan } from "../../config/paths.js"; import { NODE_TERMINAL_UPLOAD_COMMAND } from "../../infra/node-commands.js"; import { mergeProcessEnv } from "../../infra/process-env.js"; import type { TerminalUploadFile } from "../../infra/terminal-file-upload.js"; @@ -481,6 +482,7 @@ export const terminalHandlers: GatewayRequestHandlers = { const catalog = p.catalog; resolveCatalogPlan = async () => await openTerminal.call(provider, { + allowProcessHomeFallback: allowsProcessHomeSessionScan(), hostId: catalog.hostId, threadId: catalog.threadId, }); diff --git a/src/gateway/server-methods/update-hold.test.ts b/src/gateway/server-methods/update-hold.test.ts index 2876ee953cb3..f31889522d0e 100644 --- a/src/gateway/server-methods/update-hold.test.ts +++ b/src/gateway/server-methods/update-hold.test.ts @@ -1,6 +1,11 @@ // Update hold tests cover campaign deferral and its validated schedule response. import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + PortalCloseResultSchema, + PortalListResultSchema, + PortalSummarySchema, +} from "../../../packages/gateway-protocol/src/schema/portals.js"; type UpdateScheduleState = import("../../../packages/gateway-protocol/src/index.js").UpdateScheduleState; @@ -27,6 +32,9 @@ vi.mock("../../infra/update-startup.js", () => ({ })); vi.mock("../../../packages/gateway-protocol/src/index.js", () => ({ + PortalCloseResultSchema, + PortalListResultSchema, + PortalSummarySchema, validateUpdateHoldParams: () => true, validateUpdateHoldResult: validateUpdateHoldResultMock, validateUpdateRunParams: () => true, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 562cf35cfedd..9abdbb332053 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1,5 +1,7 @@ // Gateway plugin tests cover plugin loading, auto-enable, runtime registry setup, // request-scope injection, diagnostics, and handler dispatch integration. +import os from "node:os"; +import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; @@ -16,6 +18,7 @@ import type { PluginRegistry } from "../plugins/registry.js"; import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js"; import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.test-fixtures.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; +import { withEnv } from "../test-utils/env.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; const loadOpenClawPlugins = vi.hoisted(() => vi.fn()); @@ -541,6 +544,38 @@ describe("loadGatewayPlugins", () => { expect(getLastPluginLoadOption("preferBuiltPluginArtifacts")).toBe(true); }); + test("injects the process HOME-isolation fact into registry construction", () => { + loadOpenClawPlugins.mockReturnValue(createRegistry([])); + const home = os.userInfo().homedir; + const defaultStateDir = path.join(home, ".openclaw"); + withEnv( + { + HOME: home, + USERPROFILE: home, + OPENCLAW_HOME: undefined, + OPENCLAW_PROFILE: undefined, + OPENCLAW_STATE_DIR: defaultStateDir, + OPENCLAW_CONFIG_PATH: path.join(defaultStateDir, "openclaw.json"), + }, + () => loadGatewayPluginsForTest(), + ); + expect(getLastPluginLoadOption("allowProcessHomeSessionCatalogs")).toBe(true); + + withEnv( + { + HOME: home, + USERPROFILE: home, + OPENCLAW_HOME: undefined, + OPENCLAW_PROFILE: "dev", + OPENCLAW_STATE_DIR: path.join(home, ".openclaw-dev"), + OPENCLAW_CONFIG_PATH: path.join(home, ".openclaw-dev", "openclaw.json"), + }, + () => loadGatewayPluginsForTest(), + ); + + expect(getLastPluginLoadOption("allowProcessHomeSessionCatalogs")).toBe(false); + }); + test("routes plugin registration logs through the plugin logger", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); const log = loadGatewayPluginsForTest(); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 202bf9b706b6..de193e444497 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import { performance } from "node:perf_hooks"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js"; +import { allowsProcessHomeSessionScan } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; @@ -462,6 +463,7 @@ export function loadGatewayPlugins(params: { ambientEnvTriggers?: AmbientEnvTriggerPolicy; }) { const started = performance.now(); + const allowProcessHomeSessionCatalogs = allowsProcessHomeSessionScan(); const activationAutoEnabled = params.activationSourceConfig !== undefined && params.autoEnabledReasons === undefined ? applyPluginAutoEnable({ @@ -535,6 +537,7 @@ export function loadGatewayPlugins(params: { const gatewayRuntimeBindings = getGatewayPluginRuntimeBindings(); const pluginRegistry = loadAndActivateRootPluginRegistry({ config: resolvedConfig, + allowProcessHomeSessionCatalogs, activationSourceConfig: params.activationSourceConfig ?? params.cfg, autoEnabledReasons: autoEnabled.autoEnabledReasons, workspaceDir: params.workspaceDir, diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 39642374f3f2..95942dbd24ec 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -38,6 +38,7 @@ type GatewayRequestContextParams = { sessionObserver: SessionObserverService; getMcpAppSandboxPort?: GatewayRequestContext["getMcpAppSandboxPort"]; ensureSandboxHostPort?: GatewayRequestContext["ensureSandboxHostPort"]; + getPortalService?: () => GatewayRequestContext["portalService"]; resolveTerminalLaunchPolicy: GatewayRequestContext["resolveTerminalLaunchPolicy"]; isTerminalEnabled: GatewayRequestContext["isTerminalEnabled"]; execApprovalManager: GatewayRequestContext["execApprovalManager"]; @@ -187,6 +188,9 @@ export function createGatewayRequestContext( notifyPluginMetadataChanged: params.notifyPluginMetadataChanged, getMcpAppSandboxPort: params.getMcpAppSandboxPort, ensureSandboxHostPort: params.ensureSandboxHostPort, + get portalService() { + return params.getPortalService?.(); + }, resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy, isTerminalEnabled: params.isTerminalEnabled, execApprovalManager: params.execApprovalManager, diff --git a/src/gateway/server-runtime-state-prepare.ts b/src/gateway/server-runtime-state-prepare.ts index cabbd53a1301..1d06e2a56e4f 100644 --- a/src/gateway/server-runtime-state-prepare.ts +++ b/src/gateway/server-runtime-state-prepare.ts @@ -556,6 +556,7 @@ export async function prepareGatewayKernelState(params: { getWorkerIngressEndpoint: transportBridge.getWorkerIngressEndpoint, getMcpAppSandboxPort: transportBridge.getMcpAppSandboxPort, ensureSandboxHostPort: transportBridge.ensureSandboxHostPort, + getPortalService: transportBridge.getPortalService, workerGatewayEndpoint, }; } diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 4244f7ebc4d6..f98b247835ac 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -25,6 +25,7 @@ import type { HooksConfigResolved } from "./hooks.js"; import type { AuthorizedGatewayHttpRequest } from "./http-auth-utils.js"; import { createSandboxHostHttpServer } from "./mcp-app-sandbox-http.js"; import { isLoopbackHost, resolveGatewayListenHosts } from "./net.js"; +import { createGatewayPortalService, type GatewayPortalService } from "./portals/portal-service.js"; import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js"; import { attachGatewayUpgradeHandler, @@ -130,6 +131,7 @@ export async function createGatewayHttpTransport(params: { startListening: () => Promise; wss: WebSocketServer; preauthConnectionBudget: PreauthConnectionBudget; + portalService: GatewayPortalService; getWorkerIngressEndpoint: () => { host: "127.0.0.1"; port: number } | undefined; getMcpAppSandboxPort: () => number | undefined; ensureSandboxHostPort: () => Promise; @@ -265,6 +267,11 @@ export async function createGatewayHttpTransport(params: { const httpServers: HttpServer[] = []; const gatewayHttpServers: HttpServer[] = []; const httpBindHosts: string[] = []; + const portalService = createGatewayPortalService({ + httpBindHosts, + httpServers, + ...(params.gatewayTls?.enabled ? { tlsOptions: params.gatewayTls.tlsOptions } : {}), + }); for (const _ of bindHosts) { const httpServer = createGatewayHttpServer({ clients: params.clients, @@ -494,6 +501,7 @@ export async function createGatewayHttpTransport(params: { startListening, wss, preauthConnectionBudget, + portalService, getWorkerIngressEndpoint: () => workerIngressPort === undefined ? undefined diff --git a/src/gateway/server-transport-bridge.ts b/src/gateway/server-transport-bridge.ts index b9cd6ae98389..081f2fae47df 100644 --- a/src/gateway/server-transport-bridge.ts +++ b/src/gateway/server-transport-bridge.ts @@ -11,6 +11,7 @@ export function createGatewayTransportBridge() { current = transport; }, current: () => current, + getPortalService: () => current?.portalService, getWorkerIngressEndpoint: () => current?.getWorkerIngressEndpoint(), getMcpAppSandboxPort: () => current?.getMcpAppSandboxPort(), ensureSandboxHostPort: async () => { diff --git a/src/gateway/server-worker-environment-startup.ts b/src/gateway/server-worker-environment-startup.ts index 46753c2d63d8..40b8b7c379d3 100644 --- a/src/gateway/server-worker-environment-startup.ts +++ b/src/gateway/server-worker-environment-startup.ts @@ -180,6 +180,10 @@ export async function createGatewayWorkerEnvironmentRuntime(params: { ? deviceRuntime.provider : resolveWorkerProvider(params.getPluginRegistry(), providerId), prepareInstallation, + resolveNodeWorkerBuild: async (deviceId) => { + const build = await deviceRuntime.resolveWorkerBuild(deviceId); + return build ? structuredClone(build) : undefined; + }, tunnelManager: workerTunnelManager, resolveWorkerGateway: params.resolveWorkerGateway, applyTranscriptCommit: createWorkerTranscriptCommitter({ diff --git a/src/gateway/session-companion.test.ts b/src/gateway/session-companion.test.ts index 818468bf9882..6361ff38b5a7 100644 --- a/src/gateway/session-companion.test.ts +++ b/src/gateway/session-companion.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createAgentToAgentPolicy, - createSessionVisibilityGuard, -} from "../agents/tools/sessions-helpers.js"; + resolveSessionToolAccess, +} from "../agents/tools/sessions-access.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { SessionCompanionAskError } from "./session-companion-ask.js"; import type { SessionCompanionContextReader } from "./session-companion-context.js"; @@ -685,14 +685,28 @@ describe("session companion tool scope", () => { expect(cfg.tools?.toolSearch).toMatchObject({ enabled: false }); expect(cfg.tools?.codeMode).toMatchObject({ enabled: false }); - const guard = await createSessionVisibilityGuard({ + const targetAccess = await resolveSessionToolAccess({ action: "history", + requesterAgentId: "main", requesterSessionKey: "agent:main:target", + targetAgentId: "main", + targetSessionKey: "agent:main:target", + requesterOwned: false, visibility: "self", a2aPolicy: createAgentToAgentPolicy(cfg), }); - expect(guard.check("agent:main:target")).toMatchObject({ allowed: true }); - expect(guard.check("agent:main:different")).toMatchObject({ + expect(targetAccess).toMatchObject({ allowed: true }); + const differentAccess = await resolveSessionToolAccess({ + action: "history", + requesterAgentId: "main", + requesterSessionKey: "agent:main:target", + targetAgentId: "main", + targetSessionKey: "agent:main:different", + requesterOwned: false, + visibility: "self", + a2aPolicy: createAgentToAgentPolicy(cfg), + }); + expect(differentAccess).toMatchObject({ allowed: false, status: "forbidden", }); diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts index a0976d63221b..01447ee69c0d 100644 --- a/src/gateway/session-event-payload.ts +++ b/src/gateway/session-event-payload.ts @@ -6,11 +6,25 @@ import type { GatewaySessionRow } from "./session-utils.js"; * Picker metadata comes from catalog-backed list/patch responses; emitting a * locally reconstructed subset here would replace richer client state. */ -export function buildGatewaySessionEventRow(sessionRow: GatewaySessionRow): GatewaySessionRow { +export function buildGatewaySessionEventRow( + sessionRow: GatewaySessionRow, + options: { lifecycle?: boolean } = {}, +): GatewaySessionRow { const session = { ...sessionRow }; delete session.thinkingLevels; delete session.thinkingOptions; delete session.thinkingDefault; + if (options.lifecycle) { + delete session.modelProvider; + delete session.model; + delete session.agentRuntime; + if (session.totalTokensFresh !== true) { + delete session.totalTokens; + delete session.totalTokensFresh; + delete session.contextTokens; + delete session.estimatedCostUsd; + } + } return session; } diff --git a/src/gateway/session-utils-model.ts b/src/gateway/session-utils-model.ts index ec3bbaae00ee..f29c953d1cd0 100644 --- a/src/gateway/session-utils-model.ts +++ b/src/gateway/session-utils-model.ts @@ -22,6 +22,7 @@ import { resolveDefaultModelForAgent, resolveThinkingDefault, } from "../agents/model-selection.js"; +import { resolveThinkingDefaultCore } from "../agents/model-thinking-default-core.js"; import { publishedModelCatalogOwnerMatchesAgent } from "../agents/prepared-model-catalog-owner.js"; import { resolveSessionModelRef } from "../agents/session-model-ref.js"; import { resolveSessionRuntimeOverrideForProvider } from "../agents/session-runtime-compat.js"; @@ -30,9 +31,9 @@ import { resolveEffectiveAgentRuntime, } from "../agents/thinking-runtime.js"; import { - listThinkingLevelOptions, normalizeThinkLevel, resolveSupportedThinkingLevel, + resolveThinkingProfile, } from "../auto-reply/thinking.js"; import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { resolveAgentMainSessionKey, type SessionEntry } from "../config/sessions.js"; @@ -46,12 +47,33 @@ import { } from "./session-utils-contracts.js"; import type { GatewaySessionsDefaults, SessionsPatchResult } from "./session-utils.types.js"; +type ThinkingProviderPolicySource = NonNullable< + Parameters[0]["providerPolicySource"] +>; + +function listGatewayThinkingLevelOptions(params: { + provider: string; + model: string; + modelCatalog?: ModelCatalogEntry[]; + agentRuntime: string; + providerPolicySource?: ThinkingProviderPolicySource; +}) { + return resolveThinkingProfile({ + provider: params.provider, + model: params.model, + catalog: params.modelCatalog, + agentRuntime: params.agentRuntime, + providerPolicySource: params.providerPolicySource, + }).levels.map(({ id, label }) => ({ id, label })); +} + function resolveGatewaySessionThinkingLevel(params: { provider: string; model: string; level: NonNullable>; modelCatalog?: ModelCatalogEntry[]; agentRuntime: string; + providerPolicySource?: ThinkingProviderPolicySource; }) { const catalogEntry = params.modelCatalog ? findModelCatalogEntry(params.modelCatalog, { @@ -71,6 +93,7 @@ function resolveGatewaySessionThinkingLevel(params: { level: params.level, catalog: params.modelCatalog, agentRuntime: params.agentRuntime, + providerPolicySource: params.providerPolicySource, }); } @@ -81,13 +104,19 @@ function resolveGatewaySessionThinkingDefault(params: { agentId?: string; modelCatalog?: ModelCatalogEntry[]; agentRuntime: string; + providerPolicySource?: ThinkingProviderPolicySource; }) { const agentThinkingDefault = params.agentId ? resolveAgentConfig(params.cfg, params.agentId)?.thinkingDefault : undefined; + const resolveDefault = + params.providerPolicySource === "active" + ? (defaultParams: Parameters[0]) => + resolveThinkingDefaultCore({ ...defaultParams, providerPolicySource: "active" }) + : resolveThinkingDefault; const defaultLevel = agentThinkingDefault ?? - resolveThinkingDefault({ + resolveDefault({ cfg: params.cfg, provider: params.provider, model: params.model, @@ -100,6 +129,7 @@ function resolveGatewaySessionThinkingDefault(params: { level: defaultLevel, modelCatalog: params.modelCatalog, agentRuntime: params.agentRuntime, + providerPolicySource: params.providerPolicySource, }); } @@ -112,6 +142,7 @@ export function resolveGatewayModelThinkingProfile(params: { modelCatalog?: ModelCatalogEntry[]; rowContext?: SessionListRowContext; sessionKey?: string; + providerPolicySource?: ThinkingProviderPolicySource; }): GatewayModelThinkingProfile { const catalogEntry = params.modelCatalog ? findModelCatalogEntry(params.modelCatalog, { @@ -132,12 +163,13 @@ export function resolveGatewayModelThinkingProfile(params: { }); if (!params.rowContext) { return { - thinkingLevels: listThinkingLevelOptions( - params.provider, - params.model, - params.modelCatalog, + thinkingLevels: listGatewayThinkingLevelOptions({ + provider: params.provider, + model: params.model, + modelCatalog: params.modelCatalog, agentRuntime, - ), + providerPolicySource: params.providerPolicySource, + }), thinkingDefault: resolveGatewaySessionThinkingDefault({ cfg: params.cfg, provider: params.provider, @@ -145,10 +177,11 @@ export function resolveGatewayModelThinkingProfile(params: { agentId: params.agentId, modelCatalog: params.modelCatalog, agentRuntime, + providerPolicySource: params.providerPolicySource, }), }; } - const key = `${normalizeAgentId(params.agentId)}\0${agentRuntime}\0${createSessionRowModelCacheKey( + const key = `${normalizeAgentId(params.agentId)}\0${agentRuntime}\0${params.providerPolicySource ?? "active-or-bundled"}\0${createSessionRowModelCacheKey( params.provider, params.model, )}`; @@ -157,12 +190,13 @@ export function resolveGatewayModelThinkingProfile(params: { return cached; } const metadata = { - thinkingLevels: listThinkingLevelOptions( - params.provider, - params.model, - params.modelCatalog, + thinkingLevels: listGatewayThinkingLevelOptions({ + provider: params.provider, + model: params.model, + modelCatalog: params.modelCatalog, agentRuntime, - ), + providerPolicySource: params.providerPolicySource, + }), thinkingDefault: resolveGatewaySessionThinkingDefault({ cfg: params.cfg, provider: params.provider, @@ -170,6 +204,7 @@ export function resolveGatewayModelThinkingProfile(params: { agentId: params.agentId, modelCatalog: params.modelCatalog, agentRuntime, + providerPolicySource: params.providerPolicySource, }), }; params.rowContext.thinkingMetadataByModelRef.set(key, metadata); @@ -185,6 +220,7 @@ type GatewaySessionThinkingProjectionParams = { entry?: SessionEntry; modelCatalog?: ModelCatalogEntry[]; rowContext?: SessionListRowContext; + providerPolicySource?: ThinkingProviderPolicySource; }; export function resolveGatewaySessionThinkingProjectionInternal( @@ -245,6 +281,7 @@ export function resolveGatewaySessionThinkingProjectionInternal( agentRuntime: thinkingRuntime, modelCatalog: params.modelCatalog, rowContext: params.rowContext, + providerPolicySource: params.providerPolicySource, }); const storedThinkingLevel = normalizeThinkLevel(params.entry?.thinkingLevel); const thinkingLevel = storedThinkingLevel @@ -254,6 +291,7 @@ export function resolveGatewaySessionThinkingProjectionInternal( level: storedThinkingLevel, modelCatalog: params.modelCatalog, agentRuntime: thinkingRuntime, + providerPolicySource: params.providerPolicySource, }) : undefined; return { diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 5e4a1fd7b31e..23221615d77f 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -363,6 +363,10 @@ export function buildGatewaySessionRow(params: { const thinkingProvider = rowModelProvider ?? DEFAULT_PROVIDER; const thinkingModel = rowModel ?? DEFAULT_MODEL; + // Event/list rows must not rediscover plugin-backed configured catalog metadata. + // Lightweight projections may use an already-active provider policy, but must + // not fall through to public artifacts that reload the manifest registry. + const thinkingModelCatalog = params.modelCatalog ?? (lightweight ? [] : undefined); const thinkingProjection = resolveGatewaySessionThinkingProjectionInternal({ cfg, agentId: sessionAgentId, @@ -370,8 +374,9 @@ export function buildGatewaySessionRow(params: { model: thinkingModel, sessionKey: acpSessionKey, entry, - modelCatalog: params.modelCatalog, + modelCatalog: thinkingModelCatalog, rowContext, + providerPolicySource: lightweight ? "active" : undefined, }); const fastModeState = resolveFastModeState({ cfg, diff --git a/src/gateway/session-utils-search.ts b/src/gateway/session-utils-search.ts index edd7050ffd8a..eb89321c51cc 100644 --- a/src/gateway/session-utils-search.ts +++ b/src/gateway/session-utils-search.ts @@ -153,9 +153,10 @@ type LoadGatewaySessionRowOptions = { transcriptUsageMaxBytes?: number; }; -export function loadGatewaySessionLifecycleSnapshot( +function loadGatewaySessionSnapshot( sessionKey: string, options?: LoadGatewaySessionRowOptions, + lightweight = false, ): { lifecycleRunId?: string; row: GatewaySessionRow | null } { const now = options?.now ?? Date.now(); const { cfg, storePath, store, entry, canonicalKey } = loadGatewaySessionEntryReadOnly( @@ -189,16 +190,25 @@ export function loadGatewaySessionLifecycleSnapshot( includeLastMessage: options?.includeLastMessage, transcriptUsageMaxBytes: options?.transcriptUsageMaxBytes, storeChildSessionsByKey, + skipTranscriptUsageFallback: lightweight, + lightweightListRow: lightweight, ...(options?.agentId ? { agentId: options.agentId } : {}), }), }; } +export function loadGatewaySessionLifecycleSnapshot( + sessionKey: string, + options?: LoadGatewaySessionRowOptions, +): { lifecycleRunId?: string; row: GatewaySessionRow | null } { + return loadGatewaySessionSnapshot(sessionKey, options, true); +} + export function loadGatewaySessionRow( sessionKey: string, options?: LoadGatewaySessionRowOptions, ): GatewaySessionRow | null { - return loadGatewaySessionLifecycleSnapshot(sessionKey, options).row; + return loadGatewaySessionSnapshot(sessionKey, options).row; } export function buildGatewaySessionInfo(params: { diff --git a/src/gateway/session-utils.perf.test.ts b/src/gateway/session-utils.perf.test.ts index 087298bd3c62..7718bf4a9dc9 100644 --- a/src/gateway/session-utils.perf.test.ts +++ b/src/gateway/session-utils.perf.test.ts @@ -55,8 +55,8 @@ describe("listSessionsFromStore resolver cache", () => { const rowCount = 30; const rowContext = buildSessionListRowMetadataContext({ now }); const thinkingSpy = vi - .spyOn(thinking, "listThinkingLevelOptions") - .mockReturnValue([{ id: "off", label: "Off" }]); + .spyOn(thinking, "resolveThinkingProfile") + .mockReturnValue({ levels: [{ id: "off", label: "Off", rank: 0 }], defaultLevel: "off" }); const costSpy = vi.spyOn(usageFormat, "resolveModelCostConfig").mockReturnValue({ input: 1, output: 1, diff --git a/src/gateway/session-utils.plugin-runtime.test.ts b/src/gateway/session-utils.plugin-runtime.test.ts index 2c858a5fbab5..a875a867d1df 100644 --- a/src/gateway/session-utils.plugin-runtime.test.ts +++ b/src/gateway/session-utils.plugin-runtime.test.ts @@ -3,9 +3,14 @@ */ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { SessionEntry } from "../config/sessions.js"; +import { resolveSessionStorePathCore, type SessionEntry } from "../config/sessions.js"; +import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; +import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; const normalizeProviderModelIdWithPluginMock = vi.fn(); +const loadPluginManifestRegistryCoreMock = vi.hoisted(() => + vi.fn(() => ({ plugins: [], diagnostics: [] })), +); const emptyPluginMetadataSnapshot = vi.hoisted(() => ({ configFingerprint: "gateway-session-utils-plugin-runtime-test-empty-plugin-metadata", plugins: [], @@ -20,6 +25,11 @@ vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ getCurrentPluginMetadataSnapshot: () => emptyPluginMetadataSnapshot, })); +vi.mock("../plugins/manifest-registry.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadPluginManifestRegistryCore: loadPluginManifestRegistryCoreMock, +})); + let sessionUtils: typeof import("./session-utils.js"); describe("gateway session list plugin runtime normalization", () => { @@ -30,6 +40,7 @@ describe("gateway session list plugin runtime normalization", () => { beforeEach(() => { normalizeProviderModelIdWithPluginMock.mockReset(); + loadPluginManifestRegistryCoreMock.mockClear(); }); it("skips provider runtime normalization for lightweight list rows", async () => { @@ -86,4 +97,39 @@ describe("gateway session list plugin runtime normalization", () => { expect(row.model).toBe("custom-modern-model"); expect(normalizeProviderModelIdWithPluginMock).toHaveBeenCalled(); }); + + it("keeps lifecycle event rows lightweight without changing explicit detail rows", async () => { + await withStateDirEnv("openclaw-lifecycle-row-plugin-runtime-", async () => { + normalizeProviderModelIdWithPluginMock.mockImplementation( + ({ provider, context }: { provider?: string; context?: { modelId?: string } }) => + provider === "custom-provider" && context?.modelId === "custom-legacy-model" + ? "custom-modern-model" + : undefined, + ); + const cfg = { + agents: { + defaults: { model: { primary: "custom-provider/custom-legacy-model" } }, + }, + } as OpenClawConfig; + const configRuntime = await import("../config/config.js"); + configRuntime.resetConfigRuntimeState(); + configRuntime.setRuntimeConfigSnapshot(cfg, cfg); + const sessionKey = "agent:main:lifecycle-plugin-runtime"; + const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId: "main" }); + await replaceSessionEntry({ sessionKey, storePath }, { + sessionId: "lifecycle-plugin-runtime", + updatedAt: 1, + } satisfies SessionEntry); + + const lifecycle = sessionUtils.loadGatewaySessionLifecycleSnapshot(sessionKey); + + expect(lifecycle.row?.model).toBe("custom-legacy-model"); + expect(normalizeProviderModelIdWithPluginMock).not.toHaveBeenCalled(); + expect(loadPluginManifestRegistryCoreMock).not.toHaveBeenCalled(); + + expect(sessionUtils.loadGatewaySessionRow(sessionKey)?.model).toBe("custom-modern-model"); + expect(normalizeProviderModelIdWithPluginMock).toHaveBeenCalled(); + configRuntime.resetConfigRuntimeState(); + }); + }); }); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 2357850fa858..bfd49c16aeec 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -986,6 +986,7 @@ describe("gateway session utils", () => { storePath: "", store: {}, key: "main", + lightweightListRow: false, }); expect(defaults.thinkingLevels?.map((level) => level.id)).toContain("xhigh"); diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index 2d412b4f7026..b5727439798b 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -330,6 +330,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { "sessions", "screen", "terminal", + "portal", "conversations_list", "conversations_send", "conversations_turn", @@ -344,6 +345,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { "sessions", "screen", "terminal", + "portal", "conversations_list", "conversations_send", "conversations_turn", diff --git a/src/gateway/worker-environments/admission.ts b/src/gateway/worker-environments/admission.ts index 0aaa46840a8f..b9d13a09cd9d 100644 --- a/src/gateway/worker-environments/admission.ts +++ b/src/gateway/worker-environments/admission.ts @@ -19,6 +19,13 @@ export type ExpectedWorkerBuild = { protocolFeatures: readonly string[]; }; +/** Local-install receipts pin the node's paired-machine claim instead of Gateway bundle bytes. */ +export function resolveLocalWorkerBuild( + receipt: (WorkerAdmissionHandshake & { installKind?: "bundle" | "local" }) | null | undefined, +): ExpectedWorkerBuild | undefined { + return receipt?.installKind === "local" ? receipt : undefined; +} + /** True only for bundles that accept the exact admitted execution carrier. */ export function supportsWorkerExecutionContextLaunch( handshake: Pick | null | undefined, diff --git a/src/gateway/worker-environments/bundle.test.ts b/src/gateway/worker-environments/bundle.test.ts index edfad23d64b2..7d76ff4cf551 100644 --- a/src/gateway/worker-environments/bundle.test.ts +++ b/src/gateway/worker-environments/bundle.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import * as tar from "tar"; import { describe, expect, it, vi } from "vitest"; +import { resolveNodeWorkerBuild } from "../../node-host/node-worker-build.js"; import { runCommandWithTimeout } from "../../process/exec.js"; import { withTestDir } from "../../test-helpers/temp-dir.js"; import { @@ -82,9 +83,19 @@ describe("worker bundle producer", () => { cacheDir: path.join(root, "cache-b"), openclawVersion: "1.2.3", }).prepare(); + const nodeBuild = await resolveNodeWorkerBuild({ + packageRoot: packageA, + openclawVersion: "1.2.3", + protocolFeatures: [], + }); expect(first.bundleHash).toMatch(/^[a-f0-9]{64}$/u); expect(second.bundleHash).toBe(first.bundleHash); + expect(nodeBuild).toEqual({ + bundleHash: first.bundleHash, + openclawVersion: first.openclawVersion, + protocolFeatures: first.protocolFeatures, + }); await expect(listTarball(first.tarballPath)).resolves.toEqual([ "dist/entry.js", "dist/nested/worker.js", diff --git a/src/gateway/worker-environments/bundle.ts b/src/gateway/worker-environments/bundle.ts index 063ddab8fb1c..6af2acc394b0 100644 --- a/src/gateway/worker-environments/bundle.ts +++ b/src/gateway/worker-environments/bundle.ts @@ -8,6 +8,10 @@ import { resolveStateDir } from "../../config/paths.js"; import { isExactSemverVersion, resolveNpmJsonEntries } from "../../infra/npm-registry-spec.js"; import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; import { runCommandWithTimeout } from "../../process/exec.js"; +import { + hashWorkerBundleManifest, + WORKER_BUNDLE_MANIFEST_VERSION, +} from "../../shared/worker-bundle-hash.js"; import { VERSION } from "../../version.js"; import { collectWorkerBundleManifest, @@ -15,7 +19,7 @@ import { type WorkerBundleManifestEntry, } from "./bundle-staging.js"; -export const WORKER_BUNDLE_MANIFEST_VERSION = "openclaw-worker-bundle-v1"; +export { WORKER_BUNDLE_MANIFEST_VERSION }; const OPENCLAW_NPM_REGISTRY = "https://registry.npmjs.org/"; const NPM_RELEASE_PROOF_TIMEOUT_MS = 60_000; const NPM_SHA512_INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]{86}==$/u; @@ -271,15 +275,6 @@ async function verifyPublishedNpmRelease(params: { } } -function hashWorkerBundleManifest(entries: readonly WorkerBundleManifestEntry[]): string { - const hash = createHash("sha256"); - hash.update(`${WORKER_BUNDLE_MANIFEST_VERSION}\0`); - for (const entry of entries) { - hash.update(`${entry.path}\0${entry.mode.toString(8)}\0${entry.size}\0${entry.sha256}\0`); - } - return hash.digest("hex"); -} - function manifestsMatch( left: readonly WorkerBundleManifestEntry[], right: readonly WorkerBundleManifestEntry[], diff --git a/src/gateway/worker-environments/credential-broker.ts b/src/gateway/worker-environments/credential-broker.ts index 1270ca142f4b..9b331bf4c639 100644 --- a/src/gateway/worker-environments/credential-broker.ts +++ b/src/gateway/worker-environments/credential-broker.ts @@ -1,5 +1,12 @@ -import { WORKER_RPC_SET_VERSION } from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; -import { verifyWorkerAdmissionHandshake } from "./admission.js"; +import { + type WorkerAdmissionHandshake, + WORKER_RPC_SET_VERSION, +} from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; +import { + resolveLocalWorkerBuild, + verifyWorkerAdmissionHandshake, + type ExpectedWorkerBuild, +} from "./admission.js"; import type { WorkerInstallationArtifact } from "./bundle.js"; import { createWorkerCredentialMaterial, @@ -14,6 +21,7 @@ import type { WorkerEnvironmentState } from "./state.js"; import { type WorkerEnvironmentRecord, type WorkerEnvironmentStore, + type WorkerEnvironmentTransitionPatch, WorkerSessionAlreadyAttachedError, } from "./store.js"; import type { WorkerTunnelManager } from "./tunnel.js"; @@ -114,6 +122,33 @@ export function createWorkerCredentialBroker(options: WorkerCredentialBrokerOpti return grant; }; + const commitReady = ( + record: WorkerEnvironmentRecord, + receipt: WorkerAdmissionHandshake & { installKind: "bundle" | "local" }, + patch: WorkerEnvironmentTransitionPatch = {}, + ) => { + const material = credentialMaterial(); + // Receipt, owner epoch, and credential hash commit together. A failed write leaves the + // durable lease retryable without ever admitting a partial identity. + const ready = move(record, "ready", { + ...patch, + bootstrapReceipt: receipt, + credential: { + credentialHash: material.credentialHash, + sessionId: null, + rpcSetVersion: WORKER_RPC_SET_VERSION, + expiresAtMs: credentialExpiry(), + }, + }); + stageCredential( + grantFrom({ + credential: material.credential, + record: store.getCredential(record.environmentId), + }), + ); + return ready; + }; + const ensurePendingCredential = (record: WorkerEnvironmentRecord, sessionId: string | null) => { const credential = store.getCredential(record.environmentId); const pending = pendingCredentials.get(record.environmentId); @@ -183,9 +218,11 @@ export function createWorkerCredentialBroker(options: WorkerCredentialBrokerOpti if (current.state !== "ready" && current.state !== "idle") { throw serviceError("invalid_state", `Cannot attach worker in state: ${current.state}`); } - let currentBuild: WorkerInstallationArtifact; + let currentBuild: ExpectedWorkerBuild; try { - currentBuild = await options.prepareInstallation("bundle"); + currentBuild = + resolveLocalWorkerBuild(current.bootstrapReceipt) ?? + (await options.prepareInstallation("bundle")); } catch { throw serviceError("invalid_state", "Current worker build identity is unavailable"); } @@ -337,6 +374,7 @@ export function createWorkerCredentialBroker(options: WorkerCredentialBrokerOpti attachSession, clear: () => pendingCredentials.clear(), clearEnvironment: (environmentId: string) => pendingCredentials.delete(environmentId), + commitReady, credentialExpiry, credentialMaterial, ensurePendingCredential, diff --git a/src/gateway/worker-environments/device-provider.test.ts b/src/gateway/worker-environments/device-provider.test.ts index 5cb3258a3773..d46e750de56f 100644 --- a/src/gateway/worker-environments/device-provider.test.ts +++ b/src/gateway/worker-environments/device-provider.test.ts @@ -10,6 +10,11 @@ import type { NodeWorkerSupervisorNodeProof } from "../node-registry-private.js" import { createDeviceWorkerRuntime } from "./device-provider.js"; const DEVICE_ID = "device-session-host"; +const WORKER_BUILD = { + bundleHash: "a".repeat(64), + openclawVersion: "2026.8.12", + protocolFeatures: ["worker-heartbeat-v1"], +}; function pairedDevice(deviceId = DEVICE_ID): PairedDevice { return { @@ -32,7 +37,7 @@ function pairedDevice(deviceId = DEVICE_ID): PairedDevice { function connectedNode( deviceId = DEVICE_ID, - commands: readonly string[] = ["system.run"], + workerRuns: NodeWorkerSupervisorNodeProof["workerRuns"] | null = WORKER_BUILD, ): NodeWorkerSupervisorNodeProof { return { nodeId: deviceId, @@ -42,7 +47,8 @@ function connectedNode( clientId: GATEWAY_CLIENT_IDS.NODE_HOST, clientMode: GATEWAY_CLIENT_MODES.NODE, protocolFeature: NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE, - commands, + commands: ["system.run"], + ...(workerRuns ? { workerRuns } : {}), }; } @@ -92,9 +98,9 @@ describe("device worker provider", () => { listCurrentNodes: async () => [], }, { - name: "connected node without session execution", + name: "connected node without worker session hosting", getPairedDevice: async () => pairedDevice(), - listCurrentNodes: async () => [connectedNode(DEVICE_ID, [])], + listCurrentNodes: async () => [connectedNode(DEVICE_ID, null)], }, ])("rejects $name during provision", async ({ getPairedDevice, listCurrentNodes }) => { const provider = deviceRuntime({ getPairedDevice, listCurrentNodes }).provider; diff --git a/src/gateway/worker-environments/device-provider.ts b/src/gateway/worker-environments/device-provider.ts index 53fbeeeabbd2..1543d4f36975 100644 --- a/src/gateway/worker-environments/device-provider.ts +++ b/src/gateway/worker-environments/device-provider.ts @@ -46,7 +46,7 @@ function requireDeviceId(profile: WorkerProfile): string { } function isSessionCapableNode(node: NodeWorkerSupervisorNodeProof): boolean { - return node.commands.includes("system.run"); + return node.workerRuns !== undefined; } function hasPairedNodeRole(device: PairedDevice | null): device is PairedDevice { @@ -106,6 +106,10 @@ export function createDeviceWorkerRuntime(options: DeviceWorkerRuntimeOptions) { provider, isAvailable, launchNodeWorker: launchAdapter.launch, + // Provisioning reads the node-advertised local-install build through the + // runtime so node lookups keep one owner; absent means not connected or + // not session-capable, and the caller fails provisioning closed. + resolveWorkerBuild: async (deviceId: string) => (await findConnectedNode(deviceId))?.workerRuns, bindNodeTransport: (transport: NodeWorkerSupervisorTransport) => { nodeTransport = transport; }, diff --git a/src/gateway/worker-environments/environment-access.test.ts b/src/gateway/worker-environments/environment-access.test.ts index c19715f67bf3..55ee74348bc0 100644 --- a/src/gateway/worker-environments/environment-access.test.ts +++ b/src/gateway/worker-environments/environment-access.test.ts @@ -3,6 +3,7 @@ import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../../state/openclaw-state-db.js"; +import { VERSION } from "../../version.js"; import * as support from "./service.test-support.js"; import { createWorkerEnvironmentStore } from "./store.js"; import type { WorkerTunnelManager } from "./tunnel.js"; @@ -78,9 +79,19 @@ describe("worker environment service", () => { } as unknown as WorkerTunnelManager; const workerService = support.createService( support.createProvider({ - provision: async () => ({ leaseId: "device-lease", node: { deviceId: "device-1" } }), + provision: async () => ({ + leaseId: "device-lease", + node: { deviceId: "device-1" }, + }), }), - { tunnelManager }, + { + tunnelManager, + resolveNodeWorkerBuild: async () => ({ + bundleHash: "c".repeat(64), + openclawVersion: VERSION, + protocolFeatures: ["worker-heartbeat-v1"], + }), + }, ); const environment = await workerService.create("development", "device-tunnel-gate"); diff --git a/src/gateway/worker-environments/placement-dispatch-device.test.ts b/src/gateway/worker-environments/placement-dispatch-device.test.ts index 6fedee75a67e..aef9efd88ba8 100644 --- a/src/gateway/worker-environments/placement-dispatch-device.test.ts +++ b/src/gateway/worker-environments/placement-dispatch-device.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WORKER_EXECUTION_CONTEXT_PROTOCOL_FEATURE } from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { closeOpenClawStateDatabaseForTest, @@ -39,7 +40,12 @@ describe("device worker placement dispatch", () => { profileSnapshot: { install: "bundle", settings: { device: "device-1" } }, leaseId: "device-lease-1", sshEndpoint: null, - bootstrapReceipt: null, + bootstrapReceipt: { + bundleHash: "a".repeat(64), + openclawVersion: "2026.8.12", + protocolFeatures: [WORKER_EXECUTION_CONTEXT_PROTOCOL_FEATURE], + installKind: "local", + }, sharedHost: true, tunnelStatus: "stopped", }); @@ -64,9 +70,13 @@ describe("device worker placement dispatch", () => { ); expect(harness.environments.startTunnel).toHaveBeenCalledWith({ environmentId: harness.ready.environmentId, - ownerEpoch: harness.ready.ownerEpoch, + ownerEpoch: expect.any(Number), + }); + expect(harness.environments.attachSession).toHaveBeenCalledWith({ + environmentId: harness.ready.environmentId, + ownerEpoch: harness.ready.ownerEpoch, + sessionId: REQUEST.sessionId, }); - expect(harness.environments.attachSession).not.toHaveBeenCalled(); expect(harness.environments.destroy).toHaveBeenCalledWith(harness.ready.environmentId); expect(harness.placements.current()).toMatchObject({ state: "failed", diff --git a/src/gateway/worker-environments/provider-lifecycle.ts b/src/gateway/worker-environments/provider-lifecycle.ts index 5367e7bbf1fa..4827aef2bb97 100644 --- a/src/gateway/worker-environments/provider-lifecycle.ts +++ b/src/gateway/worker-environments/provider-lifecycle.ts @@ -1,10 +1,7 @@ import { isDeepStrictEqual } from "node:util"; import { expectDefined } from "@openclaw/normalization-core"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; -import { - type WorkerAdmissionHandshake, - WORKER_RPC_SET_VERSION, -} from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; +import type { WorkerAdmissionHandshake } from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; import type { OpenClawConfig } from "../../config/types.js"; import type { SecretRef } from "../../config/types.secrets.js"; import { validateCloudWorkerProfileSettings } from "../../config/zod-schema.cloud-workers.js"; @@ -17,7 +14,8 @@ import { type WorkerSshEndpoint, type WorkerSshIdentity, } from "../../plugins/types.js"; -import { verifyWorkerAdmissionHandshake } from "./admission.js"; +import { VERSION } from "../../version.js"; +import { resolveLocalWorkerBuild, verifyWorkerAdmissionHandshake } from "./admission.js"; import type { WorkerInstallationArtifact } from "./bundle.js"; import type { WorkerCredentialBroker } from "./credential-broker.js"; import { deriveEnvironmentIntent } from "./service-contract.js"; @@ -53,6 +51,7 @@ type WorkerProviderLifecycleOptions = { profile: WorkerProfile; keyRef: SecretRef; }) => Promise; + resolveNodeWorkerBuild?: (deviceId: string) => Promise; providerCallTimeoutMs?: number; tunnelManager?: WorkerTunnelManager; credentialBroker: WorkerCredentialBroker; @@ -103,13 +102,7 @@ export function createWorkerProviderLifecycle(options: WorkerProviderLifecycleOp const saveError = options.saveError; const serviceError = options.serviceError; const withLock = options.withLock; - const { - credentialExpiry, - credentialMaterial, - ensurePendingCredential, - grantFrom, - stageCredential, - } = options.credentialBroker; + const { commitReady, ensurePendingCredential } = options.credentialBroker; function requireWorkerProfile(value: unknown): WorkerProfile { const error = validateCloudWorkerProfileSettings(value); @@ -239,24 +232,7 @@ export function createWorkerProviderLifecycle(options: WorkerProviderLifecycleOp } catch (error) { return await failBootstrap(record, leaseId, provider, error); } - const material = credentialMaterial(); - // Receipt, owner epoch, and credential hash commit together. A failed write leaves the - // durable lease bootstrapping so reconcile can retry without admitting a partial identity. - const ready = move(record, "ready", { - bootstrapReceipt: receipt, - credential: { - credentialHash: material.credentialHash, - sessionId: null, - rpcSetVersion: WORKER_RPC_SET_VERSION, - expiresAtMs: credentialExpiry(), - }, - }); - const grant = grantFrom({ - credential: material.credential, - record: store.getCredential(record.environmentId), - }); - stageCredential(grant); - return ready; + return commitReady(record, { ...receipt, installKind: "bundle" }); }; const finishProvision = async ( @@ -297,7 +273,24 @@ export function createWorkerProviderLifecycle(options: WorkerProviderLifecycleOp desktop: lease.desktop ?? null, }; if (lease.node) { - return move(record, "ready", { ...patch, sshEndpoint: null }); + const nodeBuild = await options.resolveNodeWorkerBuild?.(lease.node.deviceId); + if (!nodeBuild) { + const detail = `Device worker no longer advertises session hosting: ${lease.node.deviceId}`; + move(record, "failed", { lastError: detail }); + throw serviceError("bootstrap_failure", detail); + } + if (nodeBuild.openclawVersion !== VERSION) { + const detail = `Device worker runs OpenClaw ${nodeBuild.openclawVersion}, but this gateway runs ${VERSION}; update the node to match the gateway, then retry`; + move(record, "failed", { lastError: detail }); + throw serviceError("bootstrap_failure", detail); + } + // Admin pairing already trusts this machine. Pinning its exact claimed hash plus an exact + // version match prevents skew; milestone 7 replaces the claim with Gateway-pushed bytes. + return commitReady( + record, + { ...nodeBuild, installKind: "local" }, + { ...patch, sshEndpoint: null }, + ); } const bootstrapping = move(record, "bootstrapping", { ...patch, @@ -395,16 +388,17 @@ export function createWorkerProviderLifecycle(options: WorkerProviderLifecycleOp } let currentBundle: WorkerInstallationArtifact | undefined; if (record.destroyRequestedAtMs === null && inState(record, "ready", "idle", "attached")) { + const localBuild = resolveLocalWorkerBuild(record.bootstrapReceipt); try { - currentBundle = await options.prepareInstallation("bundle"); - if ( - record.bootstrapReceipt && - verifyWorkerAdmissionHandshake(record.bootstrapReceipt, currentBundle) - ) { - const sessionId = record.state === "attached" ? record.attachedSessionIds[0] : null; - if (record.state !== "attached" || sessionId) { - ensurePendingCredential(record, sessionId ?? null); - record = store.get(record.environmentId) ?? record; + currentBundle = localBuild ? undefined : await options.prepareInstallation("bundle"); + const expectedBuild = localBuild ?? currentBundle; + if (record.bootstrapReceipt && expectedBuild) { + if (verifyWorkerAdmissionHandshake(record.bootstrapReceipt, expectedBuild)) { + const sessionId = record.state === "attached" ? record.attachedSessionIds[0] : null; + if (record.state !== "attached" || sessionId) { + ensurePendingCredential(record, sessionId ?? null); + record = store.get(record.environmentId) ?? record; + } } } } catch { diff --git a/src/gateway/worker-environments/provider-provisioning.test.ts b/src/gateway/worker-environments/provider-provisioning.test.ts index 02464d3e1c31..56552723652c 100644 --- a/src/gateway/worker-environments/provider-provisioning.test.ts +++ b/src/gateway/worker-environments/provider-provisioning.test.ts @@ -7,9 +7,11 @@ import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../../state/openclaw-state-db.js"; +import { VERSION } from "../../version.js"; import type { GatewaySessionRow } from "../session-utils.types.js"; import { writeSessionStore } from "../test-helpers.js"; import { directSessionReq } from "../test/server-sessions.test-helpers.js"; +import { admitWorkerConnection } from "./admission.js"; import { hashWorkerCredential } from "./credential.js"; import { createWorkerPlacementDispatchService } from "./placement-dispatch.js"; import { createWorkerSessionPlacementStore } from "./placement-store.js"; @@ -76,7 +78,12 @@ describe("worker environment service", () => { expect(workerService.takeMintedCredential(binding)).toBeUndefined(); }); - it("holds a node lease ready without entering SSH bootstrap", async () => { + it("commits a local-install receipt and credential for a node lease", async () => { + const workerBuild = { + bundleHash: "c".repeat(64), + openclawVersion: VERSION, + protocolFeatures: ["worker-heartbeat-v1"], + }; support.testState.prepareInstallation = vi.fn(async () => { throw new Error("node leases must not prepare an SSH installation"); }); @@ -89,6 +96,7 @@ describe("worker environment service", () => { sharedHost: true, }), }), + { resolveNodeWorkerBuild: async () => workerBuild }, ); const result = await workerService.create("development", "request-device"); @@ -97,13 +105,86 @@ describe("worker environment service", () => { state: "ready", leaseId: "device-lease-1", sshEndpoint: null, - bootstrapReceipt: null, + bootstrapReceipt: { ...workerBuild, installKind: "local" }, sharedHost: true, ownerEpoch: 1, }); expect(support.testState.prepareInstallation).not.toHaveBeenCalled(); expect(support.testState.bootstrapWorker).not.toHaveBeenCalled(); - expect(support.testState.store.getCredential(result.environmentId)).toBeUndefined(); + const credential = workerService.takeMintedCredential({ + environmentId: result.environmentId, + ownerEpoch: result.ownerEpoch, + sessionId: null, + }); + expect(credential).toMatchObject({ + credential: support.CREDENTIAL, + bundleHash: "c".repeat(64), + }); + const attachedCredential = await workerService.attachSession({ + environmentId: result.environmentId, + ownerEpoch: result.ownerEpoch, + sessionId: "session-device", + }); + const attached = support.testState.store.get(result.environmentId)!; + const admission = { + environmentId: result.environmentId, + credential: attachedCredential.credential, + ownerEpoch: attached.ownerEpoch, + rpcSetVersion: 1, + sessionId: "session-device", + runId: "run-device", + handshake: workerBuild, + } as const; + expect( + admitWorkerConnection({ + store: support.testState.store, + admission, + expectedBuild: workerBuild, + nowMs: support.testState.nowMs, + }), + ).toMatchObject({ ok: true }); + expect( + admitWorkerConnection({ + store: support.testState.store, + admission: { + ...admission, + handshake: { ...workerBuild, bundleHash: "d".repeat(64) }, + }, + expectedBuild: workerBuild, + nowMs: support.testState.nowMs, + }), + ).toEqual({ ok: false, reason: "bundle-mismatch" }); + }); + + it("fails node provisioning visibly when the node version differs", async () => { + const nodeVersion = "0.0.0-node"; + const workerService = support.createService( + support.createProvider({ + provisionBeforeInstallation: true, + provision: async () => ({ + leaseId: "device-lease-version-mismatch", + node: { deviceId: "device-1" }, + }), + }), + { + resolveNodeWorkerBuild: async () => ({ + bundleHash: "c".repeat(64), + openclawVersion: nodeVersion, + protocolFeatures: ["worker-heartbeat-v1"], + }), + }, + ); + + await expect( + workerService.create("development", "request-device-mismatch"), + ).rejects.toMatchObject({ + code: "bootstrap_failure", + message: expect.stringContaining(`OpenClaw ${nodeVersion}`), + } satisfies Partial); + expect(support.testState.store.list()[0]).toMatchObject({ + state: "failed", + lastError: expect.stringContaining(`gateway runs ${VERSION}`), + }); }); it("creates a nested environment from its parent's snapshot after config drift", async () => { diff --git a/src/gateway/worker-environments/service.test-support.ts b/src/gateway/worker-environments/service.test-support.ts index 91689ec630ef..0121e9d027ec 100644 --- a/src/gateway/worker-environments/service.test-support.ts +++ b/src/gateway/worker-environments/service.test-support.ts @@ -161,6 +161,7 @@ export function createService( | "executeInference" | "providerCallTimeoutMs" | "resolveSshIdentity" + | "resolveNodeWorkerBuild" | "resolveWorkerGateway" | "tunnelManager" | "generateWorkerCredential" diff --git a/src/gateway/worker-environments/service.ts b/src/gateway/worker-environments/service.ts index 15e0dbfc3e3f..0d8d30aac67e 100644 --- a/src/gateway/worker-environments/service.ts +++ b/src/gateway/worker-environments/service.ts @@ -87,6 +87,7 @@ type WorkerEnvironmentServiceOptions = { profile: WorkerProfile; keyRef: SecretRef; }) => Promise; + resolveNodeWorkerBuild?: (deviceId: string) => Promise; tunnelManager?: WorkerTunnelManager; reconcileIntervalMs?: number; providerCallTimeoutMs?: number; @@ -261,6 +262,7 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService prepareInstallation: options.prepareInstallation, bootstrapWorker: options.bootstrapWorker, resolveSshIdentity: options.resolveSshIdentity, + resolveNodeWorkerBuild: options.resolveNodeWorkerBuild, providerCallTimeoutMs: options.providerCallTimeoutMs, tunnelManager: options.tunnelManager, credentialBroker, diff --git a/src/gateway/worker-environments/store.test.ts b/src/gateway/worker-environments/store.test.ts index 05d5998fdb51..240f2a1e83c6 100644 --- a/src/gateway/worker-environments/store.test.ts +++ b/src/gateway/worker-environments/store.test.ts @@ -25,7 +25,9 @@ import { type WorkerEnvironmentStore, } from "./store.js"; -type WorkerEnvironmentBootstrapReceipt = WorkerAdmissionHandshake; +type WorkerEnvironmentBootstrapReceipt = WorkerAdmissionHandshake & { + installKind?: "bundle" | "local"; +}; type WorkerEnvironmentProfileSnapshot = WorkerProfile; type WorkerEnvironmentSshEndpoint = WorkerSshEndpoint; @@ -670,7 +672,7 @@ describe("worker environment store", () => { ).toThrow("lease id is immutable"); }); - it("persists a ready node lease without validating SSH metadata", () => { + it("persists a credential-bound local receipt without SSH metadata", () => { createIntent("worker-node", { settings: { device: "device-1" } }); store.transition({ environmentId: "worker-node", from: "requested", to: "provisioning" }); @@ -678,23 +680,38 @@ describe("worker environment store", () => { environmentId: "worker-node", from: "provisioning", to: "ready", - patch: { leaseId: "device-lease-1", sshEndpoint: null, sharedHost: true }, + patch: { + leaseId: "device-lease-1", + sshEndpoint: null, + sharedHost: true, + ...readyPatch({ ...BOOTSTRAP_RECEIPT, installKind: "local" }), + }, }); expect(ready).toMatchObject({ state: "ready", leaseId: "device-lease-1", sshEndpoint: null, - bootstrapReceipt: null, + bootstrapReceipt: { + ...BOOTSTRAP_RECEIPT, + protocolFeatures: ["model-proxy-v1", "workspace-sync-v1"], + installKind: "local", + }, sharedHost: true, ownerEpoch: 1, }); expect(store.get("worker-node")).toEqual(ready); expect( database.db - .prepare("SELECT ssh_host, ssh_host_key FROM worker_environments WHERE environment_id = ?") + .prepare( + "SELECT ssh_host, ssh_host_key, bootstrap_install_kind FROM worker_environments WHERE environment_id = ?", + ) .get("worker-node"), - ).toEqual({ ssh_host: null, ssh_host_key: null }); + ).toEqual({ + ssh_host: null, + ssh_host_key: null, + bootstrap_install_kind: "local", + }); }); it("enforces one credential-bound session and teardown fencing", () => { diff --git a/src/gateway/worker-environments/store.ts b/src/gateway/worker-environments/store.ts index 66b97428926b..5c0ee9fee549 100644 --- a/src/gateway/worker-environments/store.ts +++ b/src/gateway/worker-environments/store.ts @@ -44,7 +44,11 @@ import { type WorkerEnvironmentProfileSnapshot = WorkerProfile; type WorkerEnvironmentSshEndpoint = WorkerSshEndpoint; -type WorkerEnvironmentBootstrapReceipt = WorkerAdmissionHandshake; +type WorkerBootstrapInstallKind = "bundle" | "local"; +type WorkerEnvironmentBootstrapReceipt = WorkerAdmissionHandshake & { + /** Provenance only; admission authority remains the exact stored build identity. */ + installKind?: WorkerBootstrapInstallKind; +}; type WorkerEnvironmentTeardownTerminalState = "destroyed" | "failed"; type RecordIdentity = { environmentId: string; providerId: string; profileId: string }; type RecordBase = RecordIdentity & { @@ -178,6 +182,7 @@ function normalizeBootstrapReceipt(value: { bundleHash: unknown; openclawVersion: unknown; protocolFeatures: unknown; + installKind?: unknown; }): WorkerEnvironmentBootstrapReceipt { const bundleHash = required(value.bundleHash, "bootstrap bundle hash"); if (!WORKER_BUNDLE_HASH_PATTERN.test(bundleHash)) { @@ -195,10 +200,18 @@ function normalizeBootstrapReceipt(value: { ) { throw new Error("Worker environment bootstrap protocol features exceed admission limits"); } + if ( + value.installKind !== undefined && + value.installKind !== "bundle" && + value.installKind !== "local" + ) { + throw new Error("Worker environment bootstrap install kind is invalid"); + } return { bundleHash, openclawVersion: required(value.openclawVersion, "bootstrap OpenClaw version"), protocolFeatures: normalizeSortedUniqueTrimmedStringList(value.protocolFeatures), + ...(value.installKind ? { installKind: value.installKind } : {}), }; } function normalizeCredentialHash(value: unknown): string { @@ -385,6 +398,7 @@ function bootstrapReceiptFrom(row: Row): WorkerEnvironmentBootstrapReceipt | nul bootstrap_bundle_hash: bundleHash, bootstrap_openclaw_version: openclawVersion, bootstrap_protocol_features_json: encodedFeatures, + bootstrap_install_kind: installKind, } = row; if (bundleHash === null && openclawVersion === null && encodedFeatures === null) { return null; @@ -396,6 +410,7 @@ function bootstrapReceiptFrom(row: Row): WorkerEnvironmentBootstrapReceipt | nul bundleHash, openclawVersion, protocolFeatures: JSON.parse(encodedFeatures) as unknown, + ...(installKind === null ? {} : { installKind }), }); } function assertShape( @@ -822,6 +837,7 @@ export function createWorkerEnvironmentStore( bootstrap_bundle_hash: null, bootstrap_openclaw_version: null, bootstrap_protocol_features_json: null, + bootstrap_install_kind: null, owner_epoch: 0, teardown_terminal_state: null, state: "requested", @@ -957,10 +973,10 @@ export function createWorkerEnvironmentStore( : patch.desktop === null ? null : normalizeWorkerDesktopEndpoint(patch.desktop); - const acceptsBootstrapReceipt = from === "bootstrapping" && to === "ready"; - const acceptsDeferredNodeReady = - from === "provisioning" && to === "ready" && sshEndpoint === null; - if (to === "ready" && !acceptsBootstrapReceipt && !acceptsDeferredNodeReady) { + const acceptsBootstrapReceipt = + to === "ready" && + (from === "bootstrapping" || (from === "provisioning" && sshEndpoint === null)); + if (to === "ready" && !acceptsBootstrapReceipt) { throw new Error("Ready worker transition requires bootstrap proof or a node lease"); } if (patch.bootstrapReceipt !== undefined && !acceptsBootstrapReceipt) { @@ -1032,12 +1048,11 @@ export function createWorkerEnvironmentStore( to === "destroyed" || to === "failed" || to === "orphaned"); - const ownerEpoch = - acceptsBootstrapReceipt || acceptsDeferredNodeReady - ? Math.max(1, current.ownerEpoch) - : acceptsAttachedCredential || ownerEndingTransition - ? nextGlobalOwnerEpoch(db) - : current.ownerEpoch; + const ownerEpoch = acceptsBootstrapReceipt + ? Math.max(1, current.ownerEpoch) + : acceptsAttachedCredential || ownerEndingTransition + ? nextGlobalOwnerEpoch(db) + : current.ownerEpoch; updateRow(db, environmentId, from, { lease_id: leaseId, shared_host: sharedHost === null ? null : sharedHost ? 1 : 0, @@ -1052,6 +1067,7 @@ export function createWorkerEnvironmentStore( bootstrap_protocol_features_json: bootstrapReceipt ? json(bootstrapReceipt.protocolFeatures) : null, + bootstrap_install_kind: bootstrapReceipt?.installKind ?? null, owner_epoch: ownerEpoch, state: to, attached_session_ids_json: json(attachedSessionIds), diff --git a/src/gateway/worker-environments/worker-turn-rpc.ts b/src/gateway/worker-environments/worker-turn-rpc.ts index 33ce7d69ff15..b8a2f491b8e8 100644 --- a/src/gateway/worker-environments/worker-turn-rpc.ts +++ b/src/gateway/worker-environments/worker-turn-rpc.ts @@ -20,6 +20,7 @@ import { safeEqualSecret } from "../../security/secret-equal.js"; import type { WorkerSessionToolName } from "../../worker/tool-authority.js"; import { admitWorkerConnection, + resolveLocalWorkerBuild, validateWorkerConnectionIdentity, type ExpectedWorkerBuild, type WorkerConnectionIdentity, @@ -522,7 +523,9 @@ export function createWorkerTurnRpc(options: WorkerTurnRpcOptions) { } let expectedBuild: ExpectedWorkerBuild; try { - expectedBuild = await options.prepareInstallation("bundle"); + expectedBuild = + resolveLocalWorkerBuild(store.get(admission.environmentId)?.bootstrapReceipt) ?? + (await options.prepareInstallation("bundle")); } catch { return { ok: false, reason: "environment-unavailable" } as const; } diff --git a/src/infra/sqlite-private-directory.test.ts b/src/infra/sqlite-private-directory.test.ts new file mode 100644 index 000000000000..0a604279f972 --- /dev/null +++ b/src/infra/sqlite-private-directory.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const childProcess = vi.hoisted(() => ({ + execFile: vi.fn(), + execFileSync: vi.fn(), +})); + +vi.mock("node:child_process", () => childProcess); +vi.mock("./resolve-system-bin.js", () => ({ + resolveSystemBin: () => "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", +})); +vi.mock("./windows-encoding.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + decodeWindowsOutputBuffer: (params: { buffer: Buffer }) => + actual.decodeWindowsOutputBuffer({ ...params, platform: "win32", windowsEncoding: "gbk" }), + }; +}); + +import { + createPrivateSqliteDirectory, + createPrivateSqliteTempDirectorySync, +} from "./sqlite-private-directory.js"; + +function errorCause(error: unknown): Error { + expect(error).toBeInstanceOf(Error); + const cause = (error as Error & { cause?: unknown }).cause; + expect(cause).toBeInstanceOf(Error); + return cause as Error; +} + +describe("private Windows SQLite directory diagnostics", () => { + afterEach(() => { + vi.restoreAllMocks(); + childProcess.execFile.mockReset(); + childProcess.execFileSync.mockReset(); + }); + + it("reports bounded async stderr without retaining the child-process error", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const original = Object.assign( + new Error("Command failed: powershell -EncodedCommand secret-payload"), + { + cmd: "powershell -EncodedCommand secret-payload", + code: 7, + killed: true, + signal: "SIGTERM", + }, + ); + childProcess.execFile.mockImplementation((_file, _args, _options, callback) => { + callback( + original, + Buffer.from("stdout fallback"), + Buffer.concat([ + Buffer.from([0xb2, 0xe2, 0xca, 0xd4]), + Buffer.from( + ` useful stderr\n-EncodedCommand secret\nbenign after redaction ${"tail ".repeat(250)}`, + ), + ]), + ); + }); + + const error = await createPrivateSqliteDirectory("C:\\private").catch( + (cause: unknown) => cause, + ); + const cause = errorCause(error); + expect(cause.message).toContain("exit=7, killed=true, signal=SIGTERM"); + expect(cause.message).toContain("测试"); + expect(cause.message).toContain("stderr: 测试 useful stderr"); + expect(cause.message).toContain("benign after redaction"); + expect(cause.message).not.toContain("stdout fallback"); + expect(cause.message).not.toContain("EncodedCommand"); + expect(cause.message.length).toBeLessThanOrEqual(1100); + expect(cause).not.toBe(original); + expect((cause as Error & { cause?: unknown; cmd?: unknown }).cause).toBeUndefined(); + expect((cause as Error & { cmd?: unknown }).cmd).toBeUndefined(); + }); + + it("reports sync status and string codes from sanitized stderr", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + childProcess.execFileSync.mockImplementation(() => { + throw Object.assign(new Error("powershell -EncodedCommand secret"), { + code: "ETIMEDOUT", + status: 1, + stderr: Buffer.from("native directory creation failed"), + stdout: Buffer.from("stdout fallback"), + }); + }); + + let error: unknown; + try { + createPrivateSqliteTempDirectorySync("C:\\root", "stage-"); + } catch (cause) { + error = cause; + } + expect(errorCause(error).message).toBe( + "PowerShell failed (status=1, code=ETIMEDOUT); stderr: native directory creation failed", + ); + }); + + it("preserves the EEXIST contract from child output", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + childProcess.execFile.mockImplementation((_file, _args, _options, callback) => { + callback(new Error("failed"), "", "OPENCLAW_SQLITE_DIRECTORY_EXISTS"); + }); + + const error = await createPrivateSqliteDirectory("C:\\existing").catch( + (cause: unknown) => cause, + ); + expect(error).toMatchObject({ + code: "EEXIST", + message: "Private SQLite directory already exists: C:\\existing", + }); + expect((error as Error & { cause?: unknown }).cause).toBeUndefined(); + }); +}); diff --git a/src/infra/sqlite-private-directory.ts b/src/infra/sqlite-private-directory.ts index f8d796d657ec..f92604d2aaf9 100644 --- a/src/infra/sqlite-private-directory.ts +++ b/src/infra/sqlite-private-directory.ts @@ -4,7 +4,9 @@ import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveSystemBin } from "./resolve-system-bin.js"; +import { decodeWindowsOutputBuffer } from "./windows-encoding.js"; const SQLITE_DIRECTORY_MODE = 0o700; const WINDOWS_DIRECTORY_EXISTS_MARKER = "OPENCLAW_SQLITE_DIRECTORY_EXISTS"; @@ -71,19 +73,77 @@ public static class OpenClawPrivateDirectory } `; -function runPrivateDirectoryPowerShell(powershell: string, encodedCommand: string): Promise { +function failureText(value: unknown): string { + const text = Buffer.isBuffer(value) + ? decodeWindowsOutputBuffer({ buffer: value }) + : typeof value === "string" + ? value + : ""; + return truncateUtf16Safe( + text + .split(/\r?\n/u) + .filter((line) => !line.toLowerCase().includes("encodedcommand")) + .join("\n") + .trim(), + 1000, + ); +} + +function privateDirectoryError( + directoryPath: string, + error: unknown, + stdout?: unknown, + stderr?: unknown, +): Error { + const failure = error && typeof error === "object" ? (error as Record) : {}; + if ( + [error, stderr, stdout, failure.stderr, failure.stdout].some((value) => + String(value).includes(WINDOWS_DIRECTORY_EXISTS_MARKER), + ) + ) { + const existsError = new Error(`Private SQLite directory already exists: ${directoryPath}`); + (existsError as NodeJS.ErrnoException).code = "EEXIST"; + return existsError; + } + const status = [ + typeof failure.status === "number" ? `status=${failure.status}` : "", + typeof failure.code === "number" + ? `exit=${failure.code}` + : typeof failure.code === "string" + ? `code=${failure.code}` + : "", + typeof failure.killed === "boolean" ? `killed=${failure.killed}` : "", + typeof failure.signal === "string" ? `signal=${failure.signal}` : "", + ].filter(Boolean); + const stderrText = failureText(stderr) || failureText(failure.stderr); + const stdoutText = failureText(stdout) || failureText(failure.stdout); + const detail = stderrText ? `stderr: ${stderrText}` : stdoutText ? `stdout: ${stdoutText}` : ""; + const cause = new Error( + `PowerShell failed${status.length ? ` (${status.join(", ")})` : ""}${detail ? `; ${detail}` : ""}`, + ); + return new Error(`Unable to create private Windows SQLite directory: ${directoryPath}`, { + cause, + }); +} + +function runPrivateDirectoryPowerShell( + directoryPath: string, + powershell: string, + encodedCommand: string, +): Promise { return new Promise((resolve, reject) => { execFile( powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand], { + encoding: "buffer", maxBuffer: 64 * 1024, timeout: 10_000, windowsHide: true, }, - (error) => { + (error, stdout, stderr) => { if (error) { - reject(new Error(error.message, { cause: error })); + reject(privateDirectoryError(directoryPath, error, stdout, stderr)); return; } resolve(); @@ -129,17 +189,6 @@ function resolvePrivateDirectoryPowerShell(directoryPath: string): { }; } -function privateDirectoryError(directoryPath: string, error: unknown): Error { - if (String(error).includes(WINDOWS_DIRECTORY_EXISTS_MARKER)) { - const existsError = new Error(`Private SQLite directory already exists: ${directoryPath}`); - (existsError as NodeJS.ErrnoException).code = "EEXIST"; - return existsError; - } - return new Error(`Unable to create private Windows SQLite directory: ${directoryPath}`, { - cause: error, - }); -} - export async function createPrivateSqliteDirectory(directoryPath: string): Promise { if (process.platform !== "win32") { await fs.mkdir(directoryPath, { mode: SQLITE_DIRECTORY_MODE }); @@ -147,11 +196,7 @@ export async function createPrivateSqliteDirectory(directoryPath: string): Promi } // This raw Win32 call bypasses Node's automatic long-path normalization. const { encodedCommand, powershell } = resolvePrivateDirectoryPowerShell(directoryPath); - try { - await runPrivateDirectoryPowerShell(powershell, encodedCommand); - } catch (error) { - throw privateDirectoryError(directoryPath, error); - } + await runPrivateDirectoryPowerShell(directoryPath, powershell, encodedCommand); } function createPrivateSqliteDirectorySync(directoryPath: string): void { diff --git a/src/infra/sqlite-private-directory.windows.test.ts b/src/infra/sqlite-private-directory.windows.test.ts new file mode 100644 index 000000000000..1af44f48dc7e --- /dev/null +++ b/src/infra/sqlite-private-directory.windows.test.ts @@ -0,0 +1,28 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { createPrivateSqliteDirectory } from "./sqlite-private-directory.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("private SQLite directory creation on Windows", () => { + it.runIf(process.platform === "win32")( + "surfaces native stderr without exposing the encoded command", + async () => { + const root = tempDirs.make("openclaw-sqlite-private-directory-"); + const regularFile = path.join(root, "parent-file"); + await fs.writeFile(regularFile, "not a directory"); + + const error = await createPrivateSqliteDirectory(path.join(regularFile, "child")).catch( + (cause: unknown) => cause, + ); + expect(error).toBeInstanceOf(Error); + const child = (error as Error & { cause?: unknown }).cause; + expect(child).toBeInstanceOf(Error); + expect((child as Error).message).toMatch(/\bexit=1\b/u); + expect((child as Error).message).toContain("stderr:"); + expect((child as Error).message).not.toContain("EncodedCommand"); + }, + ); +}); diff --git a/src/node-host/node-worker-build.ts b/src/node-host/node-worker-build.ts new file mode 100644 index 000000000000..90660886521c --- /dev/null +++ b/src/node-host/node-worker-build.ts @@ -0,0 +1,42 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { WorkerAdmissionHandshake } from "../../packages/gateway-protocol/src/schema/worker-admission.js"; +import { WORKER_PROTOCOL_FEATURES } from "../../packages/gateway-protocol/src/schema/worker-admission.js"; +import { collectWorkerBundleManifest } from "../gateway/worker-environments/bundle-staging.js"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; +import { hashWorkerBundleManifest } from "../shared/worker-bundle-hash.js"; +import { VERSION } from "../version.js"; + +type NodeWorkerBuildOptions = { + packageRoot?: string; + openclawVersion?: string; + protocolFeatures?: readonly string[]; +}; + +/** Computes the build identity of the node host's own worker-capable installation. */ +export async function resolveNodeWorkerBuild( + options: NodeWorkerBuildOptions = {}, +): Promise { + const packageRoot = + options.packageRoot ?? + resolveOpenClawPackageRootSync({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + if (!packageRoot) { + throw new Error("Unable to locate the running OpenClaw package root for node worker hosting"); + } + const stagingRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-worker-build-")); + try { + const manifest = await collectWorkerBundleManifest(packageRoot, stagingRoot); + return { + bundleHash: hashWorkerBundleManifest(manifest), + openclawVersion: options.openclawVersion ?? VERSION, + protocolFeatures: [...(options.protocolFeatures ?? WORKER_PROTOCOL_FEATURES)].toSorted(), + }; + } finally { + await fs.rm(stagingRoot, { recursive: true, force: true }); + } +} diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index bd79ddfd24dc..7ed92ceb8e2c 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -34,6 +34,11 @@ const mocks = vi.hoisted(() => ({ availabilityChanged: undefined as (() => void) | undefined, normalizedPath: null as string | null, resolvedExecutables: new Map(), + nodeWorkerBuild: { + bundleHash: "a".repeat(64), + openclawVersion: "2026.8.12", + protocolFeatures: ["worker-heartbeat-v1"], + }, runtimeClient: undefined as | { request: (method: string, params?: unknown) => Promise } | undefined, @@ -159,6 +164,10 @@ vi.mock("./mcp.js", () => ({ })), })); +vi.mock("./node-worker-build.js", () => ({ + resolveNodeWorkerBuild: vi.fn(async () => structuredClone(mocks.nodeWorkerBuild)), +})); + vi.mock("./skills.js", () => ({ scanNodeHostedSkills: vi.fn(() => mocks.nodeSkillDescriptors), })); @@ -597,6 +606,20 @@ describe("runNodeHost", () => { expect(lastCapturedOptions()?.caps).toContain("mcp"); expect(lastCapturedOptions()?.commands).toContain("mcp.tools.call.v1"); expect(lastCapturedOptions()?.commands).not.toContain("agent.cli.claude.run.v1"); + expect(lastCapturedOptions()?.workerRuns).toBeUndefined(); + }); + + it("advertises the local worker build only after node-local opt-in", async () => { + mocks.getRuntimeConfig.mockReturnValue({ + gateway: { handshakeTimeoutMs: 1_000 }, + nodeHost: { workerRuns: { enabled: true } }, + } as never); + + await expect(runNodeHost({ gatewayHost: "127.0.0.1", gatewayPort: 18789 })).rejects.toThrow( + "event loop readiness timeout", + ); + + expect(lastCapturedOptions()?.workerRuns).toEqual(mocks.nodeWorkerBuild); }); it("advertises Claude agent runs only after node-local opt-in and binary resolution", async () => { diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 55f65620c240..6a1d9cdf0062 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -234,6 +234,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { config: cfg, env: process.env, enableAgentRuns: true, + enableWorkerRuns: true, installedAppsSharingEnabled: config.installedAppsSharing, }); const { token, password } = opts.preferGatewayBootstrapToken @@ -490,6 +491,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { // restart-scoped availability, not a capability upgrade requiring re-pairing. caps: preparedRuntime.manifest.caps, commands: preparedRuntime.manifest.commands, + workerRuns: preparedRuntime.manifest.workerRuns, pathEnv: preparedRuntime.manifest.pathEnv, permissions: undefined, deviceIdentity: loadOrCreateDeviceIdentity(), diff --git a/src/node-host/runtime.ts b/src/node-host/runtime.ts index 40c7dfb242a8..c1a835ee3cae 100644 --- a/src/node-host/runtime.ts +++ b/src/node-host/runtime.ts @@ -1,5 +1,6 @@ /** Transport-independent CLI node-host runtime shared by Gateway and app workers. */ import fs from "node:fs"; +import type { WorkerAdmissionHandshake } from "../../packages/gateway-protocol/src/schema/worker-admission.js"; import type { OpenClawConfig } from "../config/config.js"; import { getRuntimeConfig } from "../config/config.js"; import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; @@ -26,6 +27,7 @@ import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } f import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js"; import { buildNodeEventParams } from "./node-event-params.js"; import { createNodeInvokeProgressWriter } from "./node-invoke-progress.js"; +import { resolveNodeWorkerBuild } from "./node-worker-build.js"; import { createNodeWorkerSupervisor } from "./node-worker-supervisor.js"; import { ensureNodeHostPluginRegistry, @@ -41,6 +43,7 @@ type NodeHostManifest = { caps: string[]; commands: string[]; pathEnv: string; + workerRuns?: WorkerAdmissionHandshake; }; export type NodeHostInventory = { @@ -230,7 +233,8 @@ function sameManifest(left: NodeHostManifest, right: NodeHostManifest): boolean return ( left.pathEnv === right.pathEnv && sameStringList(left.caps, right.caps) && - sameStringList(left.commands, right.commands) + sameStringList(left.commands, right.commands) && + JSON.stringify(left.workerRuns) === JSON.stringify(right.workerRuns) ); } @@ -239,6 +243,8 @@ export async function prepareNodeHostRuntime(params?: { env?: NodeJS.ProcessEnv; /** The embedded app worker never advertises native agent runs. */ enableAgentRuns?: boolean; + /** The embedded app worker never advertises full worker session hosting. */ + enableWorkerRuns?: boolean; /** Embedded workers may still host long-lived plugin commands over the app-owned socket. */ enableDuplexPluginCommands?: boolean; installedAppsSharingEnabled?: boolean; @@ -270,6 +276,10 @@ export async function prepareNodeHostRuntime(params?: { params?.enableAgentRuns === true && config.nodeHost?.agentRuns?.claude?.enabled === true ? resolveExecutableTrustPathFromEnv("claude", pathEnv) : null; + const workerRuns = + params?.enableWorkerRuns === true && config.nodeHost?.workerRuns?.enabled === true + ? await resolveNodeWorkerBuild() + : undefined; const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills(); const buildManifest = (pluginManifest: typeof pluginNodeHost): NodeHostManifest => ({ caps: [ @@ -294,6 +304,7 @@ export async function prepareNodeHostRuntime(params?: { ]), ].toSorted(), pathEnv, + ...(workerRuns ? { workerRuns } : {}), }); const manifest = buildManifest(pluginNodeHost); const initialInventory = createInventory({ diff --git a/src/plugin-sdk/session-visibility-internal.ts b/src/plugin-sdk/session-visibility-internal.ts new file mode 100644 index 000000000000..3dc8d45c4289 --- /dev/null +++ b/src/plugin-sdk/session-visibility-internal.ts @@ -0,0 +1,127 @@ +/** Core-private spawned-session ownership lookup; not a published plugin SDK subpath. */ +import { err, ok, type Result } from "@openclaw/normalization-core/result"; +import { normalizeTrimmedStringList } from "../../packages/normalization-core/src/string-normalization.js"; +import { + GatewayCredentialsRequiredError, + GatewayExplicitAuthRequiredError, + isGatewayTransportError, + callGateway as defaultCallGateway, +} from "../gateway/call.js"; +import { GatewayClientRequestError } from "../gateway/client.js"; +import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { logWarn } from "../logger.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; + +type GatewayCaller = typeof defaultCallGateway; + +export type LookupFailureKind = "transient" | "credentials" | "unknown"; + +export function classifyLookupFailure(error: unknown): LookupFailureKind { + if (error instanceof GatewayClientRequestError && error.retryable) { + return "transient"; + } + if ( + isGatewayTransportError(error) && + (error.kind === "timeout" || error.code === 1006 || error.code === 1013) + ) { + return "transient"; + } + if ( + error instanceof GatewayCredentialsRequiredError || + error instanceof GatewayExplicitAuthRequiredError || + error instanceof GatewaySecretRefUnavailableError + ) { + return "credentials"; + } + return "unknown"; +} + +export function lookupFailedDenialSuffix(kind: LookupFailureKind): string { + if (kind === "transient") { + return "spawned-session ownership lookup failed (transient); retry once, then ask the operator to inspect OpenClaw logs."; + } + if (kind === "credentials") { + return "spawned-session ownership lookup failed; ask the operator to check gateway configuration and credentials."; + } + return "spawned-session ownership lookup failed; ask the operator to inspect OpenClaw logs."; +} + +export function lookupFailedDenialMessage( + action: "history" | "send" | "status" | "list" | "search", + kind: LookupFailureKind, +): string { + const label = action === "list" ? "Session list" : `Session ${action}`; + return `${label} denied because ${lookupFailedDenialSuffix(kind)}`; +} + +export function lookupFailedOperationMessage( + action: "history" | "send" | "status" | "list" | "search", + kind: LookupFailureKind, +): string { + const label = action === "list" ? "Session list" : `Session ${action}`; + const guidance = + kind === "transient" + ? "retry once, then ask the operator to inspect OpenClaw logs" + : kind === "credentials" + ? "ask the operator to check gateway configuration and credentials" + : "ask the operator to inspect OpenClaw logs"; + return `${label} failed because session lookup failed${kind === "transient" ? " (transient)" : ""}; ${guidance}.`; +} + +export type SessionOwnershipLookupFailure = { + kind: LookupFailureKind; + diagnostic: string; +}; + +export function sessionOwnershipLookupFailure(error: unknown): SessionOwnershipLookupFailure { + return { + kind: classifyLookupFailure(error), + diagnostic: formatErrorMessage(error), + }; +} + +export function logSessionOwnershipLookupFailure(params: { + requesterSessionKey: string; + failure: SessionOwnershipLookupFailure; +}): void { + logWarn( + `session-visibility: spawned-session ownership lookup failed for requester=${redactIdentifier(params.requesterSessionKey)}: ${params.failure.diagnostic}`, + ); +} + +/** List sessions spawned by the requester through the gateway session list method. */ +export async function listSpawnedSessionKeysWithResult(params: { + requesterSessionKey: string; + limit?: number; + callGateway?: GatewayCaller; +}): Promise, SessionOwnershipLookupFailure>> { + const limit = + typeof params.limit === "number" && Number.isFinite(params.limit) + ? Math.max(1, Math.floor(params.limit)) + : undefined; + try { + const list = await (params.callGateway ?? defaultCallGateway)<{ + sessions: Array<{ key?: unknown }>; + }>({ + method: "sessions.list", + params: { + includeGlobal: false, + includeUnknown: false, + ...(limit !== undefined ? { limit } : {}), + spawnedBy: params.requesterSessionKey, + }, + }); + if (!Array.isArray(list?.sessions)) { + return err({ + kind: "unknown", + diagnostic: "gateway sessions.list returned an invalid response", + }); + } + const sessions = list.sessions; + const keys = normalizeTrimmedStringList(sessions.map((entry) => entry?.key)); + return ok(new Set(keys)); + } catch (error) { + return err(sessionOwnershipLookupFailure(error)); + } +} diff --git a/src/plugin-sdk/session-visibility.test.ts b/src/plugin-sdk/session-visibility.test.ts index 04249c2dc189..399462d6448f 100644 --- a/src/plugin-sdk/session-visibility.test.ts +++ b/src/plugin-sdk/session-visibility.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { GatewayCredentialsRequiredError } from "../gateway/call.js"; +import { GatewayClientRequestError } from "../gateway/client.js"; +import { classifyLookupFailure, lookupFailedDenialSuffix } from "./session-visibility-internal.js"; import { createAgentToAgentPolicy, createSessionVisibilityChecker, @@ -42,6 +45,24 @@ describe("scoped session access providers", () => { }); }); + it("accepts a legacy Set spawnedKeys input on the exported checker", () => { + const checker = createSessionVisibilityChecker({ + action: "history", + requesterSessionKey: "agent:main:main", + visibility: "tree", + a2aPolicy: createAgentToAgentPolicy({}), + spawnedKeys: new Set(["agent:main:subagent:child-1"]), + }); + + expect(checker.check("agent:main:subagent:child-1")).toEqual({ allowed: true }); + expect(checker.check("agent:main:subagent:unrelated")).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history visibility is restricted to the current session tree and any watched same-agent group sessions (tools.sessions.visibility=tree).", + }); + }); + it("keeps exact and current self aliases available without a configured default", () => { const checker = createSessionVisibilityChecker({ action: "history", @@ -140,6 +161,41 @@ describe("scoped session access providers", () => { expect(history.check(target).allowed).toBe(false); }); + it("keeps incognito sessions hidden from scoped and ownership grants", () => { + const requesterSessionKey = "agent:main:main"; + const targetSessionKey = "agent:main:dashboard:incognito-private"; + const expected = { + allowed: false, + status: "forbidden", + error: `Session not visible from session tools: ${targetSessionKey}`, + } as const; + const unregister = createSessionVisibilityChecker.registerScopedAccessProvider(() => ({ + expectedSessionId: "incognito-incarnation", + })); + try { + const direct = createSessionVisibilityChecker({ + action: "history", + requesterSessionKey, + visibility: "all", + a2aPolicy: createAgentToAgentPolicy({}), + spawnedKeys: new Set([targetSessionKey]), + }); + const row = createSessionVisibilityRowChecker({ + action: "history", + requesterSessionKey, + visibility: "all", + a2aPolicy: createAgentToAgentPolicy({}), + }); + + expect(direct.check(targetSessionKey)).toEqual(expected); + expect(row.check({ key: targetSessionKey, spawnedBy: requesterSessionKey })).toEqual( + expected, + ); + } finally { + unregister(); + } + }); + it("fails closed when a provider throws", () => { const unregister = createSessionVisibilityChecker.registerScopedAccessProvider(() => { throw new Error("provider failure"); @@ -159,3 +215,61 @@ describe("scoped session access providers", () => { } }); }); + +describe("classifyLookupFailure", () => { + it("classifies a retryable gateway request error as transient", () => { + const error = new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "transport timeout", + retryable: true, + }); + expect(classifyLookupFailure(error)).toBe("transient"); + }); + + it.each([ + { kind: "timeout", code: undefined, expected: "transient" }, + { kind: "closed", code: 1006, expected: "transient" }, + { kind: "closed", code: 1013, expected: "transient" }, + { kind: "closed", code: 1008, expected: "unknown" }, + ] as const)( + "classifies gateway transport $kind/$code as $expected", + ({ kind, code, expected }) => { + const error = Object.assign(new Error("gateway transport failed"), { + name: "GatewayTransportError", + kind, + connectionDetails: {}, + ...(code === undefined ? {} : { code }), + }); + expect(classifyLookupFailure(error)).toBe(expected); + }, + ); + + it("classifies an explicit pre-connect auth failure as credentials", () => { + const error = new GatewayCredentialsRequiredError({ + method: "sessions.list", + configPath: "/tmp/openclaw.json", + }); + expect(classifyLookupFailure(error)).toBe("credentials"); + }); + + it("keeps unknown and non-retryable request failures generic", () => { + const requestError = new GatewayClientRequestError({ + code: "INTERNAL_ERROR", + message: "failed to decode session row", + retryable: false, + }); + expect(classifyLookupFailure(requestError)).toBe("unknown"); + expect(classifyLookupFailure(new Error("something else"))).toBe("unknown"); + expect(classifyLookupFailure(null)).toBe("unknown"); + expect(classifyLookupFailure(undefined)).toBe("unknown"); + }); + + it("renders cause-appropriate denial suffixes", () => { + expect(lookupFailedDenialSuffix("transient")).toMatch(/transient\); retry/i); + expect(lookupFailedDenialSuffix("credentials")).toMatch( + /check gateway configuration and credentials/i, + ); + expect(lookupFailedDenialSuffix("unknown")).toMatch(/inspect OpenClaw logs/i); + expect(lookupFailedDenialSuffix("unknown")).not.toMatch(/credentials|retry/i); + }); +}); diff --git a/src/plugin-sdk/session-visibility.ts b/src/plugin-sdk/session-visibility.ts index fea41ba2109e..a509c3556fab 100644 --- a/src/plugin-sdk/session-visibility.ts +++ b/src/plugin-sdk/session-visibility.ts @@ -1,13 +1,24 @@ +import type { Result } from "@openclaw/normalization-core/result"; // Session visibility helpers decide which plugin sessions appear in user-facing lists. import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../../packages/normalization-core/src/string-coerce.js"; -import { normalizeTrimmedStringList } from "../../packages/normalization-core/src/string-normalization.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway as defaultCallGateway } from "../gateway/call.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { + isAcpSessionKey, + isIncognitoSessionKey, + isSubagentSessionKey, + resolveAgentIdFromSessionKey, +} from "../routing/session-key.js"; import { listAmbientGroupWatchTargets } from "../sessions/session-state-events.js"; +import { + listSpawnedSessionKeysWithResult, + logSessionOwnershipLookupFailure, + lookupFailedDenialMessage, + type SessionOwnershipLookupFailure, +} from "./session-visibility-internal.js"; type GatewayCaller = typeof defaultCallGateway; @@ -51,6 +62,11 @@ function registerScopedSessionAccessProvider(provider: ScopedSessionAccessProvid function resolveScopedSessionAccess( request: ScopedSessionAccessRequest, ): ScopedSessionAccessGrant | undefined { + // Incognito transcripts must never be re-persisted through another session, + // including host-scoped access paths that bypass normal visibility policy. + if (resolveIncognitoSessionAccessDenial(request.targetSessionKey)) { + return undefined; + } for (const provider of scopedSessionAccessProviders) { try { const grant = provider(request); @@ -74,34 +90,21 @@ export type SessionVisibilityRow = { parentSessionKey?: string; }; -/** List sessions spawned by the requester through the gateway session list method. */ +/** Public compatibility wrapper; direct guards use the richer private result. */ export async function listSpawnedSessionKeys(params: { requesterSessionKey: string; limit?: number; callGateway?: GatewayCaller; }): Promise> { - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? Math.max(1, Math.floor(params.limit)) - : undefined; - try { - const list = await (params.callGateway ?? defaultCallGateway)<{ - sessions: Array<{ key?: unknown }>; - }>({ - method: "sessions.list", - params: { - includeGlobal: false, - includeUnknown: false, - ...(limit !== undefined ? { limit } : {}), - spawnedBy: params.requesterSessionKey, - }, + const result = await listSpawnedSessionKeysWithResult(params); + if (!result.ok) { + logSessionOwnershipLookupFailure({ + requesterSessionKey: params.requesterSessionKey, + failure: result.error, }); - const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - const keys = normalizeTrimmedStringList(sessions.map((entry) => entry?.key)); - return new Set(keys); - } catch { return new Set(); } + return result.value; } /** Resolve configured session-tool visibility, defaulting invalid or missing values to tree. */ @@ -304,17 +307,37 @@ function treeVisibilityMessage(action: SessionAccessAction): string { return `${actionPrefix(action)} visibility is restricted to the current session tree and any watched same-agent group sessions (tools.sessions.visibility=tree).`; } -/** Create a direct session-key visibility checker for one requester/action pair. */ -function createSessionVisibilityCheckerImpl(params: { +function resolveIncognitoSessionAccessDenial( + targetSessionKey: string, +): SessionAccessResult | undefined { + // Session-tool output is persisted into the caller transcript. Process-only + // incognito sessions must stay hidden even from owners and scoped grants. + if (!isIncognitoSessionKey(targetSessionKey)) { + return undefined; + } + return { + allowed: false, + status: "forbidden", + error: `Session not visible from session tools: ${targetSessionKey}`, + }; +} + +type SessionVisibilityCheckerParams = { action: SessionAccessAction; defaultAgentId?: string; requesterAgentId?: string; requesterSessionKey: string; visibility: SessionToolsVisibility; a2aPolicy: AgentToAgentPolicy; - spawnedKeys: Set | null; -}): { check: (targetSessionKey: string) => SessionAccessResult } { +}; + +function createSessionVisibilityCheckerWithResult( + params: SessionVisibilityCheckerParams & { + spawnedKeys: Result, SessionOwnershipLookupFailure> | null; + }, +): { check: (targetSessionKey: string) => SessionAccessResult } { const spawnedKeys = params.spawnedKeys; + let lookupFailureLogged = false; const rowChecker = createSessionVisibilityRowChecker({ action: params.action, defaultAgentId: params.defaultAgentId, @@ -325,6 +348,10 @@ function createSessionVisibilityCheckerImpl(params: { }); const check = (targetSessionKey: string): SessionAccessResult => { + const incognitoDenial = resolveIncognitoSessionAccessDenial(targetSessionKey); + if (incognitoDenial) { + return incognitoDenial; + } if (params.action !== "list") { const scoped = resolveScopedSessionAccess({ action: params.action, @@ -335,16 +362,56 @@ function createSessionVisibilityCheckerImpl(params: { return { allowed: true, expectedSessionId: scoped.expectedSessionId }; } } - const isSpawnedSession = spawnedKeys?.has(targetSessionKey) === true; - return rowChecker.check({ + const spawnedKeySet = spawnedKeys?.ok ? spawnedKeys.value : undefined; + const isSpawnedSession = spawnedKeySet?.has(targetSessionKey) === true; + const result = rowChecker.check({ key: targetSessionKey, spawnedBy: isSpawnedSession ? params.requesterSessionKey : undefined, }); + if (!result.allowed) { + const ownedResult = rowChecker.check({ + key: targetSessionKey, + spawnedBy: params.requesterSessionKey, + }); + // Preserve denials that ownership cannot change; only ownership-dependent + // denials should be replaced by lookup-failure guidance. + const lookupFailed = + spawnedKeys !== null && + !spawnedKeys.ok && + targetSessionKey !== params.requesterSessionKey && + targetSessionKey !== "current" && + ownedResult.allowed; + if (lookupFailed) { + if (!lookupFailureLogged) { + lookupFailureLogged = true; + logSessionOwnershipLookupFailure({ + requesterSessionKey: params.requesterSessionKey, + failure: spawnedKeys.error, + }); + } + return { + allowed: false, + status: "forbidden", + error: lookupFailedDenialMessage(params.action, spawnedKeys.error.kind), + }; + } + } + return result; }; return { check }; } +/** Create a direct session-key visibility checker for one requester/action pair. */ +function createSessionVisibilityCheckerImpl( + params: SessionVisibilityCheckerParams & { spawnedKeys: Set | null }, +): { check: (targetSessionKey: string) => SessionAccessResult } { + return createSessionVisibilityCheckerWithResult({ + ...params, + spawnedKeys: params.spawnedKeys ? { ok: true, value: params.spawnedKeys } : null, + }); +} + /** Direct-key visibility checker plus registration for narrow host-owned grants. */ export const createSessionVisibilityChecker = Object.assign(createSessionVisibilityCheckerImpl, { registerScopedAccessProvider: registerScopedSessionAccessProvider, @@ -375,6 +442,10 @@ export function createSessionVisibilityRowChecker(params: { const check = (row: SessionVisibilityRow): SessionAccessResult => { const targetSessionKey = row.key; + const incognitoDenial = resolveIncognitoSessionAccessDenial(targetSessionKey); + if (incognitoDenial) { + return incognitoDenial; + } const isRequesterSession = targetSessionKey === params.requesterSessionKey || targetSessionKey === "current"; let targetAgentId = normalizeLowercaseStringOrEmpty(row.agentId); @@ -407,16 +478,21 @@ export function createSessionVisibilityRowChecker(params: { targetSessionKey, ); const isRequesterOwned = rowOwnedByRequester(row, params.requesterSessionKey) || isWatchedRead; + const isCrossAgent = targetAgentId !== requesterAgentId; // Row ownership is stronger than agent ids: ACP children may use a backend - // agent id while still belonging to the requester that spawned them. + // agent id while still belonging to the requester that spawned them. Only + // native child namespaces can cross that agent boundary; ordinary sessions + // remain subject to A2A policy even if malformed lineage claims otherwise. if ( !isRequesterSession && isRequesterOwned && + (!isCrossAgent || + isAcpSessionKey(targetSessionKey) || + isSubagentSessionKey(targetSessionKey)) && (params.visibility === "tree" || params.visibility === "all") ) { return { allowed: true }; } - const isCrossAgent = targetAgentId !== requesterAgentId; if (isCrossAgent) { if (params.visibility !== "all") { return { @@ -480,12 +556,12 @@ export async function createSessionVisibilityGuard(params: { // this lookup until every caller can pass a normalized session row. const spawnedKeys = params.action !== "list" && (params.visibility === "tree" || params.visibility === "all") - ? await listSpawnedSessionKeys({ + ? await listSpawnedSessionKeysWithResult({ requesterSessionKey: params.requesterSessionKey, callGateway: params.callGateway, }) : null; - return createSessionVisibilityChecker({ + return createSessionVisibilityCheckerWithResult({ action: params.action, defaultAgentId: params.defaultAgentId, requesterAgentId: params.requesterAgentId, diff --git a/src/plugin-sdk/test-helpers/contracts-testkit.ts b/src/plugin-sdk/test-helpers/contracts-testkit.ts index 242c9216d958..089f7b94859a 100644 --- a/src/plugin-sdk/test-helpers/contracts-testkit.ts +++ b/src/plugin-sdk/test-helpers/contracts-testkit.ts @@ -18,7 +18,10 @@ export { registerProviders, requireProvider }; /** Creates a minimal plugin registry fixture with quiet logger defaults. */ export function createPluginRegistryFixture( config = {} as OpenClawConfig, - params: { hostServices?: PluginRegistryParams["hostServices"] } = {}, + params: { + allowProcessHomeSessionCatalogs?: boolean; + hostServices?: PluginRegistryParams["hostServices"]; + } = {}, ) { return { config, @@ -30,6 +33,7 @@ export function createPluginRegistryFixture( debug() {}, }, runtime: {} as PluginRuntime, + allowProcessHomeSessionCatalogs: params.allowProcessHomeSessionCatalogs ?? true, ...(params.hostServices ? { hostServices: params.hostServices } : {}), }), }; diff --git a/src/plugins/current-plugin-metadata-snapshot.test.ts b/src/plugins/current-plugin-metadata-snapshot.test.ts index e643758d378c..3fd86cd7d3c7 100644 --- a/src/plugins/current-plugin-metadata-snapshot.test.ts +++ b/src/plugins/current-plugin-metadata-snapshot.test.ts @@ -11,11 +11,17 @@ import { withPluginMetadataSnapshotScope, } from "./current-plugin-metadata-snapshot.js"; import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js"; +import { getGlobalHookRunnerRegistry } from "./hook-runner-global-state.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; +import { resolveProviderRuntimePlugin } from "./provider-hook-runtime.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js"; +import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; +import { withPluginRuntimeGenerationScope } from "./runtime/generation-scope.js"; function createSnapshot( params: { @@ -160,6 +166,109 @@ describe("current plugin metadata snapshot", () => { ).toBe(globalSnapshot); }); + it("carries prepared metadata and registry as one runtime generation", async () => { + const config = { plugins: { allow: ["scoped"] } }; + const workspaceDir = "/workspace/scoped"; + const metadataSnapshot = createSnapshot({ config, workspaceDir }); + const pluginRegistry = createEmptyPluginRegistry(); + setCurrentPluginMetadataSnapshot(undefined); + + await withPluginRuntimeGenerationScope( + { config, metadataSnapshot, pluginRegistry, workspaceDir }, + async () => { + await Promise.resolve(); + expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir })).toBe(metadataSnapshot); + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(pluginRegistry); + }, + ); + + expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir })).toBeUndefined(); + expect(getPluginRuntimeGatewayRequestScope()).toBeUndefined(); + }); + + it("isolates a registry-less nested generation and restores the outer generation on rejection", async () => { + const outerConfig = { plugins: { allow: ["outer"] } }; + const innerConfig = { plugins: { allow: ["inner"] } }; + const outerSnapshot = createSnapshot({ config: outerConfig, workspaceDir: "/workspace/outer" }); + const innerSnapshot = createSnapshot({ config: innerConfig, workspaceDir: "/workspace/inner" }); + const outerRegistry = createEmptyPluginRegistry(); + outerRegistry.providers.push({ + pluginId: "outer", + source: "test", + provider: { id: "outer", label: "Outer", auth: [] }, + }); + outerRegistry.trustedToolPolicies = [ + { + pluginId: "outer", + pluginName: "Outer", + source: "test", + policy: { + id: "outer-policy", + description: "outer", + evaluate: () => undefined, + }, + }, + ]; + setActivePluginRegistry(outerRegistry, "outer-generation", "default", "/workspace/outer"); + + try { + await withPluginRuntimeGenerationScope( + { + config: outerConfig, + metadataSnapshot: outerSnapshot, + pluginRegistry: outerRegistry, + workspaceDir: "/workspace/outer", + }, + async () => { + await expect( + withPluginRuntimeGenerationScope( + { + config: innerConfig, + metadataSnapshot: innerSnapshot, + workspaceDir: "/workspace/inner", + }, + async () => { + await Promise.resolve(); + expect( + getCurrentPluginMetadataSnapshot({ + config: innerConfig, + workspaceDir: "/workspace/inner", + }), + ).toBe(innerSnapshot); + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).not.toBe( + outerRegistry, + ); + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry?.providers).toEqual( + [], + ); + expect(resolveProviderRuntimePlugin({ provider: "outer" })).toBeUndefined(); + expect(getGlobalHookRunnerRegistry()?.trustedToolPolicies).toEqual([]); + throw new Error("inner generation failed"); + }, + ), + ).rejects.toThrow("inner generation failed"); + + expect( + getCurrentPluginMetadataSnapshot({ + config: outerConfig, + workspaceDir: "/workspace/outer", + }), + ).toBe(outerSnapshot); + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(outerRegistry); + expect(resolveProviderRuntimePlugin({ provider: "outer" })?.id).toBe("outer"); + expect( + getGlobalHookRunnerRegistry()?.trustedToolPolicies?.map((entry) => entry.policy.id), + ).toEqual(["outer-policy"]); + }, + ); + + expect(getCurrentPluginMetadataSnapshot()).toBeUndefined(); + expect(getPluginRuntimeGatewayRequestScope()).toBeUndefined(); + } finally { + resetPluginRuntimeStateForTest(); + } + }); + it("lets configless nested readers inherit explicit owner discovery context", () => { const config = { plugins: { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 144933d80d34..e46f4dd4b3fd 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -1380,6 +1380,65 @@ describe("discoverOpenClawPlugins", () => { ).toBe(true); }); + it("adds managed ownership to bundled candidates deduplicated in the shared scan", () => { + const stateDir = makeTempDir(); + const bundledDir = path.join(stateDir, "bundled"); + const plainDir = path.join(bundledDir, "plain"); + const packageDir = path.join(bundledDir, "package"); + mkdirSafe(plainDir); + mkdirSafe(packageDir); + writePluginManifest({ pluginDir: plainDir, id: "plain" }); + writePluginEntry(path.join(plainDir, "index.js")); + writePluginPackageManifest({ + packageDir, + packageName: "@openclaw/package", + extensions: ["./index.js"], + }); + writePluginManifest({ pluginDir: packageDir, id: "package" }); + writePluginEntry(path.join(packageDir, "index.js")); + const env = buildDiscoveryEnvWithOverrides(stateDir, { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + }); + const installRecords = { + "plain-owner": { source: "path", installPath: plainDir }, + "package-owner": { source: "path", installPath: packageDir }, + } satisfies Record; + + const result = discoverOpenClawPlugins({ env, installRecords }); + + expectCandidateSource(result.candidates, "plain", path.join(plainDir, "index.js")); + expectCandidateFields(requireCandidateById(result.candidates, "plain"), { + origin: "bundled", + packageName: undefined, + installOwner: "plain-owner", + }); + expectCandidateSource(result.candidates, "package", path.join(packageDir, "index.js")); + expectCandidateFields(requireCandidateById(result.candidates, "package"), { + origin: "bundled", + packageName: "@openclaw/package", + installOwner: "package-owner", + }); + + const ambiguous = discoverOpenClawPlugins({ + env, + installRecords: { + ...installRecords, + "other-owner": installRecords["plain-owner"], + }, + }); + + expectCandidateFields(requireCandidateById(ambiguous.candidates, "plain"), { + origin: "bundled", + installOwner: undefined, + installOwnerAmbiguous: true, + }); + expectDiagnostic({ + diagnostics: ambiguous.diagnostics, + level: "error", + messageIncludes: "multiple plugin install records claim the same package path", + }); + }); + it("reuses one filesystem realpath lookup per package root within a discovery run", () => { const stateDir = makeTempDir(); const packageDir = path.join(stateDir, "extensions", "pack"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index c8802eb06bc9..2aa208bece59 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -391,6 +391,24 @@ function createDiscoveryResult(): PluginDiscoveryResult { }; } +function mergeCandidateInstallOwner( + existing: PluginCandidate, + candidateOwner: string | undefined, + candidateOwnerAmbiguous: boolean, +): void { + const existingOwner = resolvePluginCandidateInstallOwner(existing); + const ownerConflict = existingOwner && candidateOwner && existingOwner !== candidateOwner; + if ( + isPluginCandidateInstallOwnerAmbiguous(existing) || + candidateOwnerAmbiguous || + ownerConflict + ) { + recordPluginCandidateInstallOwner(existing, undefined, true); + } else if (candidateOwner) { + recordPluginCandidateInstallOwner(existing, candidateOwner); + } +} + function mergeDiscoveryResult( target: PluginDiscoveryResult, source: PluginDiscoveryResult, @@ -404,18 +422,11 @@ function mergeDiscoveryResult( const key = safeRealpathSync(candidate.source, realpathCache) ?? path.resolve(candidate.source); const existing = candidatesBySource.get(key); if (existing) { - const existingOwner = resolvePluginCandidateInstallOwner(existing); - const candidateOwner = resolvePluginCandidateInstallOwner(candidate); - const ownerConflict = existingOwner && candidateOwner && existingOwner !== candidateOwner; - if ( - isPluginCandidateInstallOwnerAmbiguous(existing) || - isPluginCandidateInstallOwnerAmbiguous(candidate) || - ownerConflict - ) { - recordPluginCandidateInstallOwner(existing, undefined, true); - } else if (candidateOwner) { - recordPluginCandidateInstallOwner(existing, candidateOwner); - } + mergeCandidateInstallOwner( + existing, + resolvePluginCandidateInstallOwner(candidate), + isPluginCandidateInstallOwnerAmbiguous(candidate), + ); continue; } candidatesBySource.set(key, candidate); @@ -802,6 +813,14 @@ function addCandidate(params: { }) { const resolved = path.resolve(params.source); if (params.seen.has(resolved)) { + const existing = params.candidates.find((candidate) => candidate.source === resolved); + if (existing) { + mergeCandidateInstallOwner( + existing, + params.installOwner, + params.installOwnerAmbiguous === true, + ); + } return; } const resolvedRoot = diff --git a/src/plugins/hook-runner-global-state.ts b/src/plugins/hook-runner-global-state.ts index 6979d6258790..c9610f3d6b1f 100644 --- a/src/plugins/hook-runner-global-state.ts +++ b/src/plugins/hook-runner-global-state.ts @@ -9,6 +9,7 @@ import type { } from "./registry-types.js"; import { getActivePluginRegistry } from "./runtime.js"; import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; +import { getPluginRuntimeGenerationRegistry } from "./runtime/generation-scope.js"; type TrustedPolicyHookRunnerRegistry = GlobalHookRunnerRegistry & { trustedToolPolicies?: PluginTrustedToolPolicyRegistryRegistration[]; @@ -121,6 +122,10 @@ function overlayHookRegistries( } function resolveHookRegistry(state: HookRunnerGlobalState): TrustedPolicyHookRunnerRegistry | null { + const generationRegistry = getPluginRuntimeGenerationRegistry(); + if (generationRegistry) { + return generationRegistry; + } return overlayHookRegistries( resolveRootHookRegistry(state), getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? null, diff --git a/src/plugins/install-persistence.test.ts b/src/plugins/install-persistence.test.ts index 6d1376195766..2e94c2503175 100644 --- a/src/plugins/install-persistence.test.ts +++ b/src/plugins/install-persistence.test.ts @@ -44,6 +44,7 @@ function expectRuntimeLogIncludes(fragment: string) { function createManifestRecord( id: string, overrides: Partial = {}, + owner = id, ): PluginManifestRecord { const rootDir = path.join(os.tmpdir(), "openclaw-plugin-fixtures", id); return recordPluginManifestInstallOwner( @@ -60,7 +61,7 @@ function createManifestRecord( manifestPath: path.join(rootDir, "openclaw.plugin.json"), ...overrides, }, - id, + owner, ); } @@ -621,26 +622,20 @@ describe("persistPluginInstall", () => { expect(clearPluginRegistryLoadCacheMock).not.toHaveBeenCalled(); }); - it("removes stale denylist entries before enabling installed plugins", async () => { + it("restores runtime child policy when reinstalling its package owner", async () => { const { persistPluginInstall } = await import("./install-persistence.js"); const baseConfig = { plugins: { - deny: ["alpha", "other"], + allow: ["memory-core"], + deny: ["demo-plugin-npm", "other"], }, } as OpenClawConfig; - const enabledConfig = { - plugins: { - deny: ["other"], - entries: { - alpha: { enabled: true }, - }, - }, - } as OpenClawConfig; - enablePluginInConfigMock.mockImplementation((...args: unknown[]) => { - const [cfg, pluginId] = args as [OpenClawConfig, string]; - expect(pluginId).toBe("alpha"); - expect(cfg.plugins?.deny).toEqual(["other"]); - return { config: enabledConfig, enabled: true }; + setInstalledPluginIndexInstallRecords({ + "demo-package": { source: "npm", spec: "@openclaw/demo-package@0.0.1" }, + }); + loadPluginManifestRegistryMock.mockReturnValue({ + plugins: [createManifestRecord("demo-plugin-npm", {}, "demo-package")], + diagnostics: [], }); const next = await persistPluginInstall({ @@ -649,15 +644,17 @@ describe("persistPluginInstall", () => { baseHash: "config-1", writeOptions: installWriteOptions, }, - pluginId: "alpha", + pluginId: "demo-package", install: { source: "npm", - spec: "alpha@1.0.0", - installPath: "/tmp/alpha", + spec: "@openclaw/demo-package@0.0.1", + installPath: "/tmp/demo-package", }, }); - expect(next).toEqual(enabledConfig); + expect(next.plugins?.allow).toEqual(["memory-core", "demo-plugin-npm"]); + expect(next.plugins?.deny).toEqual(["other"]); + expect(enablePluginInConfigMock).toHaveBeenCalledTimes(1); }); it("scopes runtime kind lookup to the selected plugin when metadata omits kind", async () => { diff --git a/src/plugins/install-persistence.ts b/src/plugins/install-persistence.ts index a05c9d26b404..4ee3d5fddac9 100644 --- a/src/plugins/install-persistence.ts +++ b/src/plugins/install-persistence.ts @@ -579,28 +579,20 @@ export async function persistPluginInstall(params: { let next = reconciledConfig; const enabledPluginIds: string[] = []; - const preserveExistingPolicy = previousInstall !== undefined; for (const pluginId of ownedPluginIds) { const configEnablement = enablementByPluginId.get(pluginId) ?? { mode: "ready" as const }; const explicitlyDisabled = reconciledConfig.plugins?.entries?.[pluginId]?.enabled === false; - const existingAllow = reconciledConfig.plugins?.allow ?? []; - const blockedByExistingPolicy = - preserveExistingPolicy && - ((reconciledConfig.plugins?.deny ?? []).includes(pluginId) || - (existingAllow.length > 0 && !existingAllow.includes(pluginId))); if (configEnablement.mode === "missing") { next = prepareConfigForDisabledInstall(next, pluginId); } if (params.enable === false) { continue; } - if (!preserveExistingPolicy) { - next = removeInstalledPluginFromDenylist( - addInstalledPluginToAllowlist(next, pluginId), - pluginId, - ); - } - if (configEnablement.mode !== "ready" || explicitlyDisabled || blockedByExistingPolicy) { + next = removeInstalledPluginFromDenylist( + addInstalledPluginToAllowlist(next, pluginId), + pluginId, + ); + if (configEnablement.mode !== "ready" || explicitlyDisabled) { continue; } const enabled = enablePluginInConfig(next, pluginId, { updateChannelConfig: false }); diff --git a/src/plugins/install-persistence.warning-sink.test.ts b/src/plugins/install-persistence.warning-sink.test.ts index a8ef85e2f699..dc90623ec6bd 100644 --- a/src/plugins/install-persistence.warning-sink.test.ts +++ b/src/plugins/install-persistence.warning-sink.test.ts @@ -10,6 +10,7 @@ import { pluginsCliRuntimeLogs, setInstalledPluginIndexInstallRecords, } from "../cli/plugins-cli-test-helpers.js"; +import { recordPluginManifestInstallOwner } from "./manifest-install-owner.js"; const snapshot = { config: {}, @@ -33,15 +34,18 @@ describe("plugin install persistence warning audiences", () => { const warn = vi.fn(); loadPluginManifestRegistryMock.mockReturnValue({ plugins: [ - { - id: "workboard", - manifestPath: "/tmp/workboard/openclaw.plugin.json", - configSchema: { - type: "object", - required: ["token"], - properties: { token: { type: "string" } }, + recordPluginManifestInstallOwner( + { + id: "workboard", + manifestPath: `${install.installPath}/openclaw.plugin.json`, + configSchema: { + type: "object", + required: ["token"], + properties: { token: { type: "string" } }, + }, }, - }, + "workboard", + ), ], diagnostics: [], }); @@ -69,19 +73,22 @@ describe("plugin install persistence warning audiences", () => { const warning = 'Exclusive slot "memory" switched from "memory-core" to "workboard".'; loadPluginManifestRegistryMock.mockReturnValue({ plugins: [ - { - id: "workboard", - kind: "memory", - channels: [], - providers: [], - cliBackends: [], - skills: [], - hooks: [], - origin: "config", - rootDir: "/tmp/workboard", - source: "/tmp/workboard/index.js", - manifestPath: "/tmp/workboard/openclaw.plugin.json", - }, + recordPluginManifestInstallOwner( + { + id: "workboard", + kind: "memory", + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + origin: "config", + rootDir: install.installPath, + source: `${install.installPath}/index.js`, + manifestPath: `${install.installPath}/openclaw.plugin.json`, + }, + "workboard", + ), ], diagnostics: [], }); diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index 85637069ea7d..4c4f22e0e382 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -15,6 +15,7 @@ import { runOpenClawStateWriteTransaction, } from "../state/openclaw-state-db.js"; import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; +import { recordPluginCandidateInstallOwner } from "./candidate-install-owner.js"; import type { PluginCandidate } from "./discovery.js"; import { resolvePluginNpmGenerationProjectDir, @@ -50,12 +51,15 @@ function createPluginCandidate(stateDir: string, pluginId: string): PluginCandid }), "utf8", ); - return { - idHint: pluginId, - source, - rootDir, - origin: "global", - }; + return recordPluginCandidateInstallOwner( + { + idHint: pluginId, + source, + rootDir, + origin: "global", + }, + pluginId, + ); } function expectRecordFields(record: unknown, expected: Record) { diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 615f6bf64a75..6e8c5f14643b 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { recordPluginCandidateInstallOwner } from "./candidate-install-owner.js"; import type { PluginCandidate } from "./discovery.js"; +import { resolveInstalledPluginIndexInstallOwner } from "./installed-plugin-index-install-owner.js"; import { buildInstalledPluginIndexRecords } from "./installed-plugin-index-record-builder.js"; import { loadInstalledPluginIndexInstallRecordsSync, @@ -96,22 +98,28 @@ function createPluginCandidate(params: { packageManifest?: OpenClawPackageManifest; format?: PluginCandidate["format"]; bundleFormat?: PluginCandidate["bundleFormat"]; + installOwner?: string; }): PluginCandidate { - return { - idHint: params.idHint ?? "demo", - source: params.format === "bundle" ? params.rootDir : path.join(params.rootDir, "index.ts"), - rootDir: params.rootDir, - origin: params.origin ?? "global", - format: params.format, - bundleFormat: params.bundleFormat, - packageName: params.packageName, - packageVersion: params.packageVersion, - packageDir: params.packageDir ?? params.rootDir, - packageManifest: params.packageManifest, - }; + return recordPluginCandidateInstallOwner( + { + idHint: params.idHint ?? "demo", + source: params.format === "bundle" ? params.rootDir : path.join(params.rootDir, "index.ts"), + rootDir: params.rootDir, + origin: params.origin ?? "global", + format: params.format, + bundleFormat: params.bundleFormat, + packageName: params.packageName, + packageVersion: params.packageVersion, + packageDir: params.packageDir ?? params.rootDir, + packageManifest: params.packageManifest, + }, + params.installOwner, + ); } -function createRichPluginFixture(params: { id?: string; packageVersion?: string } = {}) { +function createRichPluginFixture( + params: { id?: string; packageVersion?: string; installOwner?: string } = {}, +) { const rootDir = makeTempDir(); const id = params.id ?? "demo"; writeRuntimeEntry(rootDir); @@ -180,6 +188,7 @@ function createRichPluginFixture(params: { id?: string; packageVersion?: string defaultChoice: "npm", }, }, + installOwner: params.installOwner, }), }; } @@ -247,6 +256,7 @@ describe("installed plugin index", () => { path: "package.json", }); expectSha256(packageJson.hash); + expect(resolveInstalledPluginIndexInstallOwner(plugin)).toBeUndefined(); expect(index.plugins[0]?.installRecord).toBeUndefined(); expect(index.plugins[0]?.installRecordHash).toBeUndefined(); }); @@ -608,7 +618,7 @@ describe("installed plugin index", () => { }); it("records explicit install records separately from package install intent", () => { - const fixture = createRichPluginFixture(); + const fixture = createRichPluginFixture({ installOwner: "demo" }); const index = loadInstalledPluginIndex({ candidates: [fixture.candidate], @@ -688,6 +698,7 @@ describe("installed plugin index", () => { rootDir: globalDir, idHint: "duplicate-demo", origin: "global", + installOwner: "duplicate-demo", }), ], installRecords: { @@ -716,7 +727,7 @@ describe("installed plugin index", () => { }); it("indexes npm plugin index records written before a process reload", () => { - const fixture = createRichPluginFixture(); + const fixture = createRichPluginFixture({ installOwner: "demo" }); const cfg = recordPluginInstall( {}, { @@ -765,7 +776,7 @@ describe("installed plugin index", () => { }); it("indexes persisted plugin index records from an explicit state directory", async () => { - const fixture = createRichPluginFixture(); + const fixture = createRichPluginFixture({ installOwner: "demo" }); const stateDir = makeTempDir(); await writePersistedInstalledPluginIndexInstallRecords( { @@ -848,7 +859,7 @@ describe("installed plugin index", () => { }); it("indexes local fallback plugin index records written before a process reload", () => { - const fixture = createRichPluginFixture(); + const fixture = createRichPluginFixture({ installOwner: "demo" }); const cfg = recordPluginInstall( {}, { @@ -883,7 +894,7 @@ describe("installed plugin index", () => { }); it("does not treat package install intent as source invalidation", () => { - const fixture = createRichPluginFixture(); + const fixture = createRichPluginFixture({ installOwner: "demo" }); const previous = loadInstalledPluginIndex({ candidates: [fixture.candidate], installRecords: { @@ -912,7 +923,7 @@ describe("installed plugin index", () => { }); it("treats plugin index changes as source invalidation", () => { - const fixture = createRichPluginFixture(); + const fixture = createRichPluginFixture({ installOwner: "demo" }); const previous = loadInstalledPluginIndex({ candidates: [fixture.candidate], installRecords: { @@ -1092,7 +1103,7 @@ describe("installed plugin index", () => { }); it("diffs invalidation reasons for manifest, package, source, host, compat, and migration changes", () => { - const fixture = createRichPluginFixture(); + const fixture = createRichPluginFixture({ installOwner: "demo" }); const previous = loadInstalledPluginIndex({ candidates: [fixture.candidate], config: { diff --git a/src/plugins/loader-load-context.ts b/src/plugins/loader-load-context.ts index fd54d7d922d3..e7b1fe2ad7fc 100644 --- a/src/plugins/loader-load-context.ts +++ b/src/plugins/loader-load-context.ts @@ -189,6 +189,7 @@ function buildCacheKey(params: { runtimeBindingIdentity?: string; pluginSdkResolution?: PluginSdkResolutionPreference; coreGatewayMethodNames?: string[]; + allowProcessHomeSessionCatalogs?: boolean; activate?: boolean; }): string { const discoveryContext = resolvePluginDiscoveryContext({ @@ -237,6 +238,7 @@ function buildCacheKey(params: { installs, loadPaths, activationMetadataKey: params.activationMetadataKey ?? "", + allowProcessHomeSessionCatalogs: params.allowProcessHomeSessionCatalogs !== false, }, )}::${serializePluginIdScope(params.onlyPluginIds)}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${params.channelPluginLoadIntent}::${bundledArtifactMode}::${rawConfigEnvMode}::${moduleLoadMode}::${discoveryMode}::${params.runtimeSubagentMode ?? "default"}::${params.runtimeBindingIdentity ?? "{}"}::${params.pluginSdkResolution ?? "auto"}::${JSON.stringify(params.coreGatewayMethodNames ?? [])}::${activationMode}`; return createHash("sha256").update(cacheIdentity).digest("hex"); @@ -387,6 +389,7 @@ export function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { runtimeBindingIdentity: resolveRuntimeBindingCacheIdentity(options.runtimeOptions), pluginSdkResolution: options.pluginSdkResolution, coreGatewayMethodNames, + allowProcessHomeSessionCatalogs: options.allowProcessHomeSessionCatalogs, activate: options.activate, }); return { diff --git a/src/plugins/loader-runtime-load.ts b/src/plugins/loader-runtime-load.ts index fbf85d6c6d05..98716d9dd752 100644 --- a/src/plugins/loader-runtime-load.ts +++ b/src/plugins/loader-runtime-load.ts @@ -152,6 +152,7 @@ function loadOpenClawPluginsInternal( registryBuilder = createPluginRegistry({ logger, runtime, + allowProcessHomeSessionCatalogs: options.allowProcessHomeSessionCatalogs ?? true, coreGatewayHandlers: options.coreGatewayHandlers as Record, ...(options.coreGatewayMethodNames !== undefined && { coreGatewayMethodNames: options.coreGatewayMethodNames, diff --git a/src/plugins/loader-types.ts b/src/plugins/loader-types.ts index bc85ed0adcc8..9babb6e6a5ab 100644 --- a/src/plugins/loader-types.ts +++ b/src/plugins/loader-types.ts @@ -25,6 +25,8 @@ export type PluginLoadOptions = { logger?: PluginLogger; coreGatewayHandlers?: Record; coreGatewayMethodNames?: readonly string[]; + /** Registry-construction fact supplied by the process composition root. */ + allowProcessHomeSessionCatalogs?: boolean; hostServices?: PluginRegistryParams["hostServices"]; runtimeOptions?: CreatePluginRuntimeOptions; startupTrace?: { diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 43a56926bd22..678d4280a685 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -84,6 +84,17 @@ function setLoaderMetadataSnapshot(params: { pluginIds?: readonly string[] } = { } describe("resolvePluginLoadCacheContext", () => { + it("partitions process-HOME catalog registration policy", () => { + const processHomeKey = resolvePluginLoadCacheContext({ + allowProcessHomeSessionCatalogs: true, + }).cacheKey; + const isolatedKey = resolvePluginLoadCacheContext({ + allowProcessHomeSessionCatalogs: false, + }).cacheKey; + + expect(isolatedKey).not.toBe(processHomeKey); + }); + it("partitions full and setup channel plugin load intent", () => { const fullKey = resolvePluginLoadCacheContext({ config: {} }).cacheKey; const setupKey = resolvePluginLoadCacheContext({ diff --git a/src/plugins/management-service-featured.test.ts b/src/plugins/management-service-featured.test.ts index 664067cb6b6a..b26d19fa69fa 100644 --- a/src/plugins/management-service-featured.test.ts +++ b/src/plugins/management-service-featured.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { recordInstalledPluginIndexInstallOwner } from "./installed-plugin-index-install-owner.js"; +import { recordPluginManifestInstallOwner } from "./manifest-install-owner.js"; const mocks = vi.hoisted(() => ({ metadata: vi.fn(), @@ -32,33 +34,45 @@ function metadataSnapshot(params: { const id = params.id ?? "workboard"; const packageName = params.packageName === null ? undefined : (params.packageName ?? `@openclaw/${id}`); - const manifest = { - id, - name: params.name ?? "Workboard", - description: params.description ?? "Coordinate agent work in a shared board.", - catalog: { featured: params.featured ?? true, order: 10 }, - ...(params.icon ? { icon: params.icon } : {}), - channels: [], - providers: [], - cliBackends: [], - skills: [], - hooks: [], - origin: params.origin ?? "bundled", - rootDir: `/tmp/${id}`, - source: `/tmp/${id}/index.ts`, - manifestPath: `/tmp/${id}/openclaw.plugin.json`, - }; + const rootDir = `/tmp/${id}`; + const installOwner = params.installRecord ? id : undefined; + const manifest = recordPluginManifestInstallOwner( + { + id, + name: params.name ?? "Workboard", + description: params.description ?? "Coordinate agent work in a shared board.", + catalog: { featured: params.featured ?? true, order: 10 }, + ...(params.icon ? { icon: params.icon } : {}), + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + origin: params.origin ?? "bundled", + rootDir, + source: `${rootDir}/index.ts`, + manifestPath: `${rootDir}/openclaw.plugin.json`, + }, + installOwner, + ); + const installRecord = params.installRecord + ? { ...params.installRecord, installPath: rootDir } + : undefined; return { index: { plugins: [ - { - pluginId: id, - ...(packageName ? { packageName } : {}), - origin: params.origin ?? "bundled", - enabled: true, - }, + recordInstalledPluginIndexInstallOwner( + { + pluginId: id, + ...(packageName ? { packageName } : {}), + origin: params.origin ?? "bundled", + rootDir, + enabled: true, + }, + installOwner, + ), ], - installRecords: params.installRecord ? { [id]: params.installRecord } : {}, + installRecords: installRecord ? { [id]: installRecord } : {}, }, byPluginId: new Map([[id, manifest]]), plugins: [manifest], diff --git a/src/plugins/management-service.registry-refresh.test.ts b/src/plugins/management-service.registry-refresh.test.ts index ab9a047dea8b..c9a2225e613a 100644 --- a/src/plugins/management-service.registry-refresh.test.ts +++ b/src/plugins/management-service.registry-refresh.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { recordInstalledPluginIndexInstallOwner } from "./installed-plugin-index-install-owner.js"; +import { recordPluginManifestInstallOwner } from "./manifest-install-owner.js"; const mocks = vi.hoisted(() => ({ clawhubInstall: vi.fn(), @@ -78,26 +80,47 @@ function mockClawHubWorkboardInstall() { }); } -function metadataSnapshot(enabled: boolean) { - const manifest = { - id: "workboard", - name: "Workboard", - channels: [], - providers: [], - cliBackends: [], - skills: [], - hooks: [], - origin: "bundled", - rootDir: "/tmp/workboard", - source: "/tmp/workboard/index.ts", - manifestPath: "/tmp/workboard/openclaw.plugin.json", - }; +function metadataSnapshot(enabled: boolean, installed = false) { + const installOwner = installed ? "workboard" : undefined; + const manifest = recordPluginManifestInstallOwner( + { + id: "workboard", + name: "Workboard", + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + origin: "bundled", + rootDir: "/tmp/workboard", + source: "/tmp/workboard/index.ts", + manifestPath: "/tmp/workboard/openclaw.plugin.json", + }, + installOwner, + ); return { index: { plugins: [ - { pluginId: "workboard", packageName: "@openclaw/workboard", origin: "bundled", enabled }, + recordInstalledPluginIndexInstallOwner( + { + pluginId: "workboard", + packageName: "@openclaw/workboard", + origin: "bundled", + rootDir: "/tmp/workboard", + enabled, + }, + installOwner, + ), ], - installRecords: {}, + installRecords: installed + ? { + workboard: { + source: "clawhub", + spec: "clawhub:community/workboard", + installPath: "/tmp/workboard", + }, + } + : {}, }, byPluginId: new Map([["workboard", manifest]]), plugins: [manifest], @@ -169,7 +192,7 @@ describe("plugin management registry refresh", () => { return { plugins: { entries: { workboard: { enabled: false } } } }; }, ); - mocks.metadata.mockReturnValue(metadataSnapshot(false)); + mocks.metadata.mockReturnValue(metadataSnapshot(false, true)); const result = await installManagedPlugin({ request: { source: "clawhub", packageName: "community/workboard" }, diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index fa70f204fa98..5e5175ba55df 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -6,6 +6,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { recordPluginCandidateInstallOwner } from "./candidate-install-owner.js"; import type { PluginCandidate } from "./discovery.js"; import { readPersistedInstalledPluginIndex, @@ -77,7 +78,11 @@ function hashFile(filePath: string): string { return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } -function createCandidate(rootDir: string, pluginId = "demo"): PluginCandidate { +function createCandidate( + rootDir: string, + pluginId = "demo", + installOwner?: string, +): PluginCandidate { fs.writeFileSync( path.join(rootDir, "index.ts"), "throw new Error('runtime entry should not load while reading plugin registry');\n", @@ -124,12 +129,15 @@ function createCandidate(rootDir: string, pluginId = "demo"): PluginCandidate { }), "utf8", ); - return { - idHint: pluginId, - source: path.join(rootDir, "index.ts"), - rootDir, - origin: "global", - }; + return recordPluginCandidateInstallOwner( + { + idHint: pluginId, + source: path.join(rootDir, "index.ts"), + rootDir, + origin: "global", + }, + installOwner, + ); } function createIndex( @@ -558,15 +566,19 @@ describe("plugin registry facade", () => { const tempDir = makeTempDir(); const rootDir = makeTempDir(); const filePath = path.join(tempDir, "custom-registry.sqlite"); - const env = hermeticEnv(); + const env = hermeticEnv({ + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", + OPENCLAW_STATE_DIR: tempDir, + }); + const installRecords = { + demo: { source: "npm" as const, spec: "demo@1.0.0", installPath: rootDir }, + }; const persisted = loadPluginRegistrySnapshot({ - candidates: [createCandidate(rootDir)], + candidates: [createCandidate(rootDir, "demo", "demo")], + installRecords, env, preferPersisted: false, }); - persisted.installRecords = { - demo: { source: "npm", spec: "demo@1.0.0", installPath: rootDir }, - }; await writePersistedInstalledPluginIndex(persisted, { filePath }); const result = loadPluginRegistrySnapshotWithMetadata({ filePath, env }); diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index c97dfac27629..54afd01131e7 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -23,6 +23,7 @@ import { getPluginRegistryState, } from "./runtime-state.js"; import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; +import { getPluginRuntimeGenerationRegistry } from "./runtime/generation-scope.js"; import type { ProviderPlugin, ProviderExtraParamsForTransportContext, @@ -152,6 +153,14 @@ function findProviderRuntimePluginInLoadedRegistries(params: { lookup: ProviderRuntimePluginLookupParams; ownerRefs: readonly string[]; }): ProviderPlugin | undefined { + const generationRegistry = getPluginRuntimeGenerationRegistry(); + if (generationRegistry) { + return findProviderRuntimePluginInRegistry({ + registry: generationRegistry, + provider: params.lookup.provider, + ownerRefs: params.ownerRefs, + }); + } const scopedRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry; const scopedPlugin = scopedRegistry ? findProviderRuntimePluginInRegistry({ @@ -185,17 +194,23 @@ function findProviderRuntimePluginInRegistry(params: { provider: string; ownerRefs: readonly string[]; }): ProviderPlugin | undefined { - return params.registry.providers - .map((entry) => Object.assign({}, entry.provider, { pluginId: entry.pluginId })) - .find((plugin) => { - if (params.ownerRefs.length > 0) { - return ( - matchesProviderLiteralId(plugin, params.provider) || - params.ownerRefs.some((ownerRef) => matchesProviderPluginRef(plugin, ownerRef)) - ); - } - return matchesProviderPluginRef(plugin, params.provider); - }); + return listProviderRuntimePluginsInRegistry(params.registry).find((plugin) => { + if (params.ownerRefs.length > 0) { + return ( + matchesProviderLiteralId(plugin, params.provider) || + params.ownerRefs.some((ownerRef) => matchesProviderPluginRef(plugin, ownerRef)) + ); + } + return matchesProviderPluginRef(plugin, params.provider); + }); +} + +function listProviderRuntimePluginsInRegistry( + registry: PluginRegistry, +): Array { + return registry.providers.map((entry) => + Object.assign({}, entry.provider, { pluginId: entry.pluginId }), + ); } function hasConfiguredModelProvider(params: { @@ -217,6 +232,17 @@ export function resolveProviderPluginsForHooks(params: { applyAutoEnable?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; }): ProviderPlugin[] { + const generationRegistry = getPluginRuntimeGenerationRegistry(); + if (generationRegistry) { + const plugins = listProviderRuntimePluginsInRegistry(generationRegistry); + const onlyPluginIds = params.onlyPluginIds ? new Set(params.onlyPluginIds) : undefined; + return plugins.filter( + (plugin) => + (!onlyPluginIds || onlyPluginIds.has(plugin.pluginId)) && + (!params.providerRefs?.length || + params.providerRefs.some((providerRef) => matchesProviderPluginRef(plugin, providerRef))), + ); + } const env = params.env ?? process.env; const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); return resolvePluginProvidersCore({ @@ -248,6 +274,9 @@ export function resolveProviderRuntimePlugin( if (loadedPlugin) { return loadedPlugin; } + if (getPluginRuntimeGenerationRegistry()) { + return undefined; + } if ( isPluginProvidersLoadInFlight({ ...params, diff --git a/src/plugins/registry-registrars-network.mcp-resolver.test.ts b/src/plugins/registry-registrars-network.mcp-resolver.test.ts index 874bf7a548ea..8e79c6f26bd5 100644 --- a/src/plugins/registry-registrars-network.mcp-resolver.test.ts +++ b/src/plugins/registry-registrars-network.mcp-resolver.test.ts @@ -5,7 +5,7 @@ import { createPluginRegistry } from "./registry.js"; import type { PluginRuntime } from "./runtime/types.js"; import { createPluginRecord } from "./status.test-fixtures.js"; -function createRegistryHarness() { +function createRegistryHarness(allowProcessHomeSessionCatalogs = true) { const pluginRegistry = createPluginRegistry({ logger: { info() {}, @@ -14,6 +14,7 @@ function createRegistryHarness() { debug() {}, }, runtime: {} as PluginRuntime, + allowProcessHomeSessionCatalogs, activateGlobalSideEffects: false, }); const config = {} as OpenClawConfig; @@ -74,3 +75,44 @@ describe("registerMcpServerConnectionResolver ownership", () => { ).toEqual([]); }); }); + +describe("registerSessionCatalog ownership", () => { + it("keeps isolation-aware providers when process-HOME catalogs are disabled", () => { + const { pluginRegistry, apiFor } = createRegistryHarness(false); + apiFor("catalog").registerSessionCatalog({ + id: "catalog", + label: "Catalog", + supportsProcessHomeIsolation: true, + list: async () => [], + read: async ({ hostId, threadId }) => ({ hostId, threadId, items: [] }), + }); + + expect(pluginRegistry.registry.sessionCatalogs).toHaveLength(1); + }); + + it("suppresses legacy providers only when process-HOME catalogs are disabled", () => { + const legacyProvider = { + id: "legacy", + label: "Legacy", + list: async () => [], + read: async ({ hostId, threadId }: { hostId: string; threadId: string }) => ({ + hostId, + threadId, + items: [], + }), + }; + const isolated = createRegistryHarness(false); + isolated.apiFor("legacy").registerSessionCatalog(legacyProvider); + expect(isolated.pluginRegistry.registry.sessionCatalogs).toEqual([]); + expect(isolated.pluginRegistry.registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "warn", + message: expect.stringContaining("supportsProcessHomeIsolation"), + }), + ); + + const defaultIdentity = createRegistryHarness(); + defaultIdentity.apiFor("legacy").registerSessionCatalog(legacyProvider); + expect(defaultIdentity.pluginRegistry.registry.sessionCatalogs).toHaveLength(1); + }); +}); diff --git a/src/plugins/registry-registrars-network.ts b/src/plugins/registry-registrars-network.ts index 2a97a4ec2844..e9140ed960b0 100644 --- a/src/plugins/registry-registrars-network.ts +++ b/src/plugins/registry-registrars-network.ts @@ -40,6 +40,7 @@ function adaptPluginGatewayMethodHandler(handler: GatewayRequestHandler): Gatewa export function createNetworkRegistrars(state: PluginRegistryState) { const { registry, coreGatewayMethods, pluginsWithChannelRegistrationConflict, pushDiagnostic } = state; + let reportedLegacyCatalogSkip = false; const registerGatewayMethod = ( record: PluginRecord, @@ -93,6 +94,19 @@ export function createNetworkRegistrars(state: PluginRegistryState) { }); return; } + if (!state.allowProcessHomeSessionCatalogs && provider.supportsProcessHomeIsolation !== true) { + if (!reportedLegacyCatalogSkip) { + reportedLegacyCatalogSkip = true; + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: + "external session catalog skipped in isolated state: provider must declare supportsProcessHomeIsolation", + }); + } + return; + } const existing = registry.sessionCatalogs.find((entry) => entry.provider.id === id); if (existing) { pushDiagnostic({ diff --git a/src/plugins/registry-state.ts b/src/plugins/registry-state.ts index d689666c44d8..059511b6f5af 100644 --- a/src/plugins/registry-state.ts +++ b/src/plugins/registry-state.ts @@ -78,6 +78,7 @@ export function createPluginRegistryState(registryParams: PluginRegistryParams) return { registry, registryParams, + allowProcessHomeSessionCatalogs: registryParams.allowProcessHomeSessionCatalogs ?? true, coreGatewayMethods: new Set(coreGatewayMethodNames), getHostCronService: () => registryParams.hostServices?.cron, pluginsWithChannelRegistrationConflict: new Set(), diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 8d4b0f5a58fa..4cc4fdbd9784 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -584,6 +584,8 @@ export type PluginRegistryParams = { coreGatewayHandlers?: GatewayRequestHandlers; coreGatewayMethodNames?: readonly string[]; runtime: PluginRuntime; + /** Process-owner policy for registering catalogs that may fall back to HOME. */ + allowProcessHomeSessionCatalogs?: boolean; hostServices?: { /** May be a live accessor; plugin APIs must read it at call time. */ cron?: import("../cron/service-contract.js").CronServiceContract; diff --git a/src/plugins/runtime/gateway-request-scope.test.ts b/src/plugins/runtime/gateway-request-scope.test.ts index 451a298bb46e..840aa698f555 100644 --- a/src/plugins/runtime/gateway-request-scope.test.ts +++ b/src/plugins/runtime/gateway-request-scope.test.ts @@ -14,7 +14,11 @@ const TEST_SCOPE: PluginRuntimeGatewayRequestScope = { }; describe("gateway request scope", () => { - afterEach(() => resetPluginRuntimeStateForTest()); + afterEach(() => { + vi.doUnmock("../current-plugin-metadata-snapshot.js"); + vi.resetModules(); + resetPluginRuntimeStateForTest(); + }); async function importGatewayRequestScopeModule() { return await import("./gateway-request-scope.js"); } @@ -57,6 +61,17 @@ describe("gateway request scope", () => { }); } + it("does not import the plugin metadata control plane", async () => { + vi.resetModules(); + vi.doMock("../current-plugin-metadata-snapshot.js", () => { + throw new Error("gateway request scope must remain lightweight"); + }); + + const runtimeScope = await importGatewayRequestScopeModule(); + + expect(runtimeScope.withPluginRuntimeGatewayRequestScope).toBeTypeOf("function"); + }); + it("reuses AsyncLocalStorage across reloaded module instances", async () => { const first = await importGatewayRequestScopeModule(); diff --git a/src/plugins/runtime/generation-scope.ts b/src/plugins/runtime/generation-scope.ts new file mode 100644 index 000000000000..e95eda4daf15 --- /dev/null +++ b/src/plugins/runtime/generation-scope.ts @@ -0,0 +1,45 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; +import { withPluginMetadataSnapshotScope } from "../current-plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugin-metadata-snapshot.types.js"; +import { createEmptyPluginRegistry } from "../registry-empty.js"; +import type { PluginRegistry } from "../registry-types.js"; +import { withPluginRuntimeRegistryScope } from "./gateway-request-scope.js"; + +const PLUGIN_RUNTIME_GENERATION_REGISTRY_SCOPE_KEY: unique symbol = Symbol.for( + "openclaw.pluginRuntimeGenerationRegistryScope", +); + +const pluginRuntimeGenerationRegistryScope = resolveGlobalSingleton< + AsyncLocalStorage +>(PLUGIN_RUNTIME_GENERATION_REGISTRY_SCOPE_KEY, () => new AsyncLocalStorage()); + +/** Carries one prepared plugin generation through all nested runtime lookups. */ +export function withPluginRuntimeGenerationScope( + generation: { + config: OpenClawConfig; + metadataSnapshot: PluginMetadataSnapshot; + pluginRegistry?: PluginRegistry; + workspaceDir?: string; + }, + run: () => T, +): T { + const pluginRegistry = generation.pluginRegistry ?? createEmptyPluginRegistry(); + return withPluginMetadataSnapshotScope( + generation.metadataSnapshot, + () => + pluginRuntimeGenerationRegistryScope.run(pluginRegistry, () => + withPluginRuntimeRegistryScope(pluginRegistry, run), + ), + { + config: generation.config, + ...(generation.workspaceDir ? { workspaceDir: generation.workspaceDir } : {}), + }, + ); +} + +/** Exact registry owned by the prepared generation, when one is active. */ +export function getPluginRuntimeGenerationRegistry(): PluginRegistry | undefined { + return pluginRuntimeGenerationRegistryScope.getStore(); +} diff --git a/src/plugins/session-catalog.ts b/src/plugins/session-catalog.ts index 2abe5d5b9f73..8c4911d74c04 100644 --- a/src/plugins/session-catalog.ts +++ b/src/plugins/session-catalog.ts @@ -12,6 +12,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginRuntime } from "./runtime/types.js"; export type SessionCatalogListProviderParams = { + /** False when Gateway-local scans must not inherit a root from process HOME. */ + allowProcessHomeFallback?: boolean; /** Trimmed, non-empty search capped at 500 UTF-16 code units by the gateway. */ search?: string; limitPerHost?: number; @@ -24,17 +26,30 @@ export type SessionCatalogListProviderParams = { /** Publishes completed hosts without waiting for slower machines in the same list. */ onHost?: (host: SessionCatalogHost) => void; }; -export type SessionCatalogReadProviderParams = Omit; +export type SessionCatalogReadProviderParams = Omit & { + /** False when Gateway-local reads must not inherit a root from process HOME. */ + allowProcessHomeFallback?: boolean; +}; export type SessionCatalogContinueProviderParams = Omit< SessionsCatalogContinueParams, "catalogId" > & { + /** False when Gateway-local continuation must not inherit a root from process HOME. */ + allowProcessHomeFallback?: boolean; /** Caller's gateway scopes so providers can gate high-authority continues up front. */ clientScopes?: readonly string[]; }; -export type SessionCatalogArchiveProviderParams = Omit; +export type SessionCatalogArchiveProviderParams = Omit< + SessionsCatalogArchiveParams, + "catalogId" +> & { + /** False when Gateway-local archive must not inherit a root from process HOME. */ + allowProcessHomeFallback?: boolean; +}; export type SessionCatalogStartTerminalProviderParams = { + /** False when Gateway-local terminal start must not inherit process HOME. */ + allowProcessHomeFallback?: boolean; agentId: string; cwd: string; initialMessage?: string; @@ -149,6 +164,8 @@ type SessionCatalogCreateParams = { export type SessionCatalogProvider = { id: string; label: string; + /** Declares that every HOME-sensitive action honors the host isolation policy. */ + supportsProcessHomeIsolation?: true; /** Config-derived target; the Gateway memoizes it for one runtime-config object identity. */ resolveCreateSession?: ( params: SessionCatalogCreateParams, @@ -158,9 +175,13 @@ export type SessionCatalogProvider = { continueSession?: ( params: SessionCatalogContinueProviderParams, ) => Promise; - checkUpstreamActivity?: (probes: SessionUpstreamProbe[]) => Promise; + checkUpstreamActivity?: ( + probes: SessionUpstreamProbe[], + policy?: { allowProcessHomeFallback?: boolean }, + ) => Promise; archive?: (params: SessionCatalogArchiveProviderParams) => Promise<{ ok: true }>; openTerminal?: (request: { + allowProcessHomeFallback?: boolean; hostId: string; threadId: string; }) => Promise; diff --git a/src/security/dangerous-tools.ts b/src/security/dangerous-tools.ts index 0f755fc0a3a8..d85920245c9b 100644 --- a/src/security/dangerous-tools.ts +++ b/src/security/dangerous-tools.ts @@ -24,6 +24,8 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [ "apply_patch", // Agent-owned host terminal — interactive RCE surface "terminal", + // Local HTTP exposure can publish arbitrary workspace applications. + "portal", // Session orchestration — spawning agents remotely is RCE "sessions_spawn", // Cross-session injection — message injection across sessions @@ -61,6 +63,7 @@ export const GATEWAY_OWNER_ONLY_CORE_TOOLS = [ "sessions", "screen", "terminal", + "portal", "conversations_list", "conversations_send", "conversations_turn", diff --git a/src/sessions/session-upstream-monitor.test.ts b/src/sessions/session-upstream-monitor.test.ts index a3ff4e534e57..2ff5989601d6 100644 --- a/src/sessions/session-upstream-monitor.test.ts +++ b/src/sessions/session-upstream-monitor.test.ts @@ -116,12 +116,16 @@ describe("session upstream monitor", () => { }); expect(checkUpstreamActivity).toHaveBeenCalledTimes(2); - expect(checkUpstreamActivity.mock.calls[0]?.[0]).toEqual([ - expect.objectContaining({ sessionKey: watched, marker: { offset: 0 } }), - ]); - expect(checkUpstreamActivity.mock.calls[1]?.[0]).toEqual([ - expect.objectContaining({ sessionKey: watched, marker: { offset: 8 } }), - ]); + expect(checkUpstreamActivity).toHaveBeenNthCalledWith( + 1, + [expect.objectContaining({ sessionKey: watched, marker: { offset: 0 } })], + { allowProcessHomeFallback: false }, + ); + expect(checkUpstreamActivity).toHaveBeenNthCalledWith( + 2, + [expect.objectContaining({ sessionKey: watched, marker: { offset: 8 } })], + { allowProcessHomeFallback: false }, + ); const events = listSessionStateEventsSince(watched, "main", 0, 20, database).events; expect(events).toHaveLength(1); expect(events[0]).toEqual( @@ -655,7 +659,9 @@ describe("session upstream monitor", () => { loadOwnRecentUserTexts: async () => [], }); - expect(check).toHaveBeenCalledWith([expect.objectContaining({ marker: { offset: 0 } })]); + expect(check).toHaveBeenCalledWith([expect.objectContaining({ marker: { offset: 0 } })], { + allowProcessHomeFallback: false, + }); }); it("defers activity when a run starts during the provider scan", async () => { @@ -820,9 +826,10 @@ describe("session upstream monitor", () => { isRunActive: () => false, }); - expect(check).toHaveBeenCalledWith([ - expect.objectContaining({ ownRecentUserTexts: ["exact decorated prompt"] }), - ]); + expect(check).toHaveBeenCalledWith( + [expect.objectContaining({ ownRecentUserTexts: ["exact decorated prompt"] })], + { allowProcessHomeFallback: false }, + ); expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]); }); @@ -865,7 +872,9 @@ describe("session upstream monitor", () => { isRunActive: () => false, }); - expect(check).toHaveBeenCalledWith([expect.objectContaining({ ownRecentUserTexts: [] })]); + expect(check).toHaveBeenCalledWith([expect.objectContaining({ ownRecentUserTexts: [] })], { + allowProcessHomeFallback: false, + }); expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([ expect.objectContaining({ kind: "human_direct_message", summary: "human message via pi" }), ]); diff --git a/src/sessions/session-upstream-monitor.ts b/src/sessions/session-upstream-monitor.ts index 3cb44ce984f6..d62086d49be4 100644 --- a/src/sessions/session-upstream-monitor.ts +++ b/src/sessions/session-upstream-monitor.ts @@ -1,6 +1,7 @@ /** Polls watched adopted sessions for direct upstream human activity. */ import { createHash } from "node:crypto"; import { isEmbeddedAgentRunActive } from "../agents/embedded-agent.js"; +import { allowsProcessHomeSessionScan } from "../config/paths.js"; import { loadSessionEntryReadOnly } from "../config/sessions/session-accessor.js"; import { resolveSessionStorePathForScope } from "../config/sessions/session-store-path.js"; import { readRecentUserAssistantTextForSession } from "../config/sessions/transcript.js"; @@ -245,7 +246,9 @@ async function runSessionUpstreamMonitorTick( links.map((link) => [link.sessionKey, link.updatedAt]), ); try { - const outcomes = await provider.checkUpstreamActivity(probes); + const outcomes = await provider.checkUpstreamActivity(probes, { + allowProcessHomeFallback: allowsProcessHomeSessionScan(options.env ?? process.env), + }); if (options.signal?.aborted) { return; } diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index 0768b6ca61b7..6fb2930275f7 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -18,6 +18,8 @@ export type NodeListNode = { pathEnv?: string; caps?: string[]; commands?: string[]; + /** Connected node currently advertises full worker session hosting. */ + sessionHost?: boolean; nodePluginTools?: NodePluginToolDescriptor[]; permissions?: Record; approvalState?: "approved" | "pending-approval" | "pending-reapproval" | "unapproved"; diff --git a/src/shared/worker-bundle-hash.ts b/src/shared/worker-bundle-hash.ts new file mode 100644 index 000000000000..d33f4af6f38d --- /dev/null +++ b/src/shared/worker-bundle-hash.ts @@ -0,0 +1,20 @@ +import { createHash } from "node:crypto"; + +export const WORKER_BUNDLE_MANIFEST_VERSION = "openclaw-worker-bundle-v1"; + +type WorkerBundleHashEntry = { + path: string; + mode: number; + size: number; + sha256: string; +}; + +/** Hashes the canonical worker manifest shared by Gateway bundles and node-local installs. */ +export function hashWorkerBundleManifest(entries: readonly WorkerBundleHashEntry[]): string { + const hash = createHash("sha256"); + hash.update(`${WORKER_BUNDLE_MANIFEST_VERSION}\0`); + for (const entry of entries) { + hash.update(`${entry.path}\0${entry.mode.toString(8)}\0${entry.size}\0${entry.sha256}\0`); + } + return hash.digest("hex"); +} diff --git a/src/state/openclaw-database-maintenance.test.ts b/src/state/openclaw-database-maintenance.test.ts index 869865edc2c4..994f6273c2ab 100644 --- a/src/state/openclaw-database-maintenance.test.ts +++ b/src/state/openclaw-database-maintenance.test.ts @@ -160,6 +160,7 @@ describe("OpenClaw database maintenance schema validation", () => { "claw_installs.bootstrap_content_digest TEXT", "claw_installs.bootstrap_source_path TEXT", "worker_environments.desktop_json TEXT", + "worker_environments.bootstrap_install_kind TEXT", "claw_package_refs.extension_adapter_identity TEXT", "claw_package_refs.extension_detected_format TEXT", "claw_package_refs.extension_format TEXT", diff --git a/src/state/openclaw-state-db-additive-columns.ts b/src/state/openclaw-state-db-additive-columns.ts index fa7560080174..3166b7c506d8 100644 --- a/src/state/openclaw-state-db-additive-columns.ts +++ b/src/state/openclaw-state-db-additive-columns.ts @@ -11,6 +11,7 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS = [ { columnName: "bootstrap_content_digest", dataType: "TEXT", tableName: "claw_installs" }, { columnName: "bootstrap_source_path", dataType: "TEXT", tableName: "claw_installs" }, { columnName: "desktop_json", dataType: "TEXT", tableName: "worker_environments" }, + { columnName: "bootstrap_install_kind", dataType: "TEXT", tableName: "worker_environments" }, { columnName: "extension_adapter_identity", dataType: "TEXT", tableName: "claw_package_refs" }, { columnName: "extension_detected_format", dataType: "TEXT", tableName: "claw_package_refs" }, { columnName: "extension_format", dataType: "TEXT", tableName: "claw_package_refs" }, diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index 6d27381b68a3..77ad6bdbf18e 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -424,6 +424,7 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void { ensureColumn(db, "worker_environments", "bootstrap_bundle_hash TEXT"); ensureColumn(db, "worker_environments", "bootstrap_openclaw_version TEXT"); ensureColumn(db, "worker_environments", "bootstrap_protocol_features_json TEXT"); + ensureColumn(db, "worker_environments", "bootstrap_install_kind TEXT"); ensureColumn( db, "worker_environments", diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 3e7fd2d9e236..802cb177775a 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -1484,6 +1484,7 @@ export interface WorkerEnvironmentSshFallbackPorts { export interface WorkerEnvironments { attached_session_ids_json: Generated; bootstrap_bundle_hash: string | null; + bootstrap_install_kind: string | null; bootstrap_openclaw_version: string | null; bootstrap_protocol_features_json: string | null; created_at_ms: number; diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index aba95b4790a5..2ce553600e17 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -1265,6 +1265,7 @@ function runConcurrentSchemaProbe(params: { ALTER TABLE worker_environments DROP COLUMN bootstrap_bundle_hash; ALTER TABLE worker_environments DROP COLUMN bootstrap_openclaw_version; ALTER TABLE worker_environments DROP COLUMN bootstrap_protocol_features_json; + ALTER TABLE worker_environments DROP COLUMN bootstrap_install_kind; ALTER TABLE worker_environments DROP COLUMN owner_epoch; ALTER TABLE worker_environments DROP COLUMN teardown_terminal_state; ALTER TABLE worker_environments DROP COLUMN ssh_host_key; @@ -4133,6 +4134,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're ALTER TABLE worker_environments DROP COLUMN bootstrap_bundle_hash; ALTER TABLE worker_environments DROP COLUMN bootstrap_openclaw_version; ALTER TABLE worker_environments DROP COLUMN bootstrap_protocol_features_json; + ALTER TABLE worker_environments DROP COLUMN bootstrap_install_kind; ALTER TABLE worker_environments DROP COLUMN owner_epoch; ALTER TABLE worker_environments DROP COLUMN teardown_terminal_state; ALTER TABLE worker_environments DROP COLUMN ssh_host_key; @@ -4152,6 +4154,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're "bootstrap_bundle_hash", "bootstrap_openclaw_version", "bootstrap_protocol_features_json", + "bootstrap_install_kind", "owner_epoch", "teardown_terminal_state", "ssh_host_key", diff --git a/src/state/openclaw-state-schema-compatibility.ts b/src/state/openclaw-state-schema-compatibility.ts index 02c383b406da..b042341b2108 100644 --- a/src/state/openclaw-state-schema-compatibility.ts +++ b/src/state/openclaw-state-schema-compatibility.ts @@ -17,6 +17,7 @@ const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [ "claw_installs.bootstrap_content_digest", "claw_installs.bootstrap_source_path", "worker_environments.desktop_json", + "worker_environments.bootstrap_install_kind", "claw_package_refs.extension_adapter_identity", "claw_package_refs.extension_detected_format", "claw_package_refs.extension_format", @@ -101,6 +102,7 @@ export const STATE_PERSISTENT_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = ], "operator_approvals.resolution_ref": ["resolution_ref TEXT"], "worker_environments.desktop_json": ["desktop_json TEXT"], + "worker_environments.bootstrap_install_kind": ["bootstrap_install_kind TEXT"], "worker_environments.shared_host": ["shared_host INTEGER CHECK (shared_host IN (0, 1))"], "worker_session_placements.terminal_reason": ["terminal_reason TEXT"], "worker_session_placements.terminal_at_ms": ["terminal_at_ms INTEGER"], diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 8e9dcd7c6438..342f61b97ccd 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1993,6 +1993,7 @@ CREATE TABLE IF NOT EXISTS worker_environments ( bootstrap_bundle_hash TEXT, bootstrap_openclaw_version TEXT, bootstrap_protocol_features_json TEXT, + bootstrap_install_kind TEXT, owner_epoch INTEGER NOT NULL DEFAULT 0 CHECK (owner_epoch >= 0), teardown_terminal_state TEXT CHECK (teardown_terminal_state IN ('destroyed', 'failed')), attached_session_ids_json TEXT NOT NULL DEFAULT '[]', diff --git a/src/wizard/setup.migration-import.test.ts b/src/wizard/setup.migration-import.test.ts index 3cfc06d6b017..a2109650cd91 100644 --- a/src/wizard/setup.migration-import.test.ts +++ b/src/wizard/setup.migration-import.test.ts @@ -6,6 +6,7 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { listSetupMigrationOptions } from "./setup.migration-import.js"; import { assertFreshSetupMigrationTarget, + buildSetupMigrationTargetSnapshot, inspectSetupMigrationFreshness, preserveSetupMigrationSecurityAcknowledgement, } from "./setup.migration-snapshot.js"; @@ -57,6 +58,27 @@ describe("setup migration import freshness", () => { expect(result).toEqual({ fresh: true, reasons: [] }); }); + it("ignores runtime state churn while still detecting workspace changes", async () => { + const root = tempRoots.make("openclaw-setup-migration-"); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + const initial = await buildSetupMigrationTargetSnapshot({ + config: {}, + stateDir, + workspaceDir, + }); + + await writeFile(path.join(stateDir, "state", "openclaw.sqlite"), "runtime database\n"); + expect(await buildSetupMigrationTargetSnapshot({ config: {}, stateDir, workspaceDir })).toBe( + initial, + ); + + await writeFile(path.join(workspaceDir, "external.txt"), "concurrent write\n"); + expect( + await buildSetupMigrationTargetSnapshot({ config: {}, stateDir, workspaceDir }), + ).not.toBe(initial); + }); + it("preserves the first-launch acknowledgement across the lock-time config reread", () => { expect( preserveSetupMigrationSecurityAcknowledgement( diff --git a/src/wizard/setup.migration-import.ts b/src/wizard/setup.migration-import.ts index b5b85c552099..d10c5fe30a7d 100644 --- a/src/wizard/setup.migration-import.ts +++ b/src/wizard/setup.migration-import.ts @@ -28,6 +28,7 @@ import { inspectSetupMigrationFreshness, preserveSetupMigrationSecurityAcknowledgement, prepareSetupMigrationAttemptBoundary, + SetupMigrationTargetChangedError, withSetupMigrationTargetLock, } from "./setup.migration-snapshot.js"; import { @@ -544,7 +545,9 @@ export async function runSetupMigrationImport(params: { buildSetupMigrationPlanSourceSnapshot(plan), ]); if (currentTargetSnapshotHash !== planningTargetSnapshotHash) { - throw new Error("Migration target changed before promotion. Review it and retry."); + throw new SetupMigrationTargetChangedError( + "Migration target changed before promotion. Review it and retry.", + ); } if (currentSourceSnapshotHash !== plannedSourceSnapshotHash) { throw new Error("Migration source changed before promotion. Review it and retry."); diff --git a/src/wizard/setup.migration-promotion.ts b/src/wizard/setup.migration-promotion.ts index 6badf6e83269..0339889a71a4 100644 --- a/src/wizard/setup.migration-promotion.ts +++ b/src/wizard/setup.migration-promotion.ts @@ -6,6 +6,7 @@ import { readDurableJsonFile, writeJsonAtomic } from "../infra/json-files.js"; import { isNotFoundPathError } from "../infra/path-guards.js"; import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js"; import { hashSetupMigrationConfig } from "./setup.migration-canonical.js"; +import { SetupMigrationTargetChangedError } from "./setup.migration-snapshot.js"; export const PROMOTION_JOURNAL_FILE = "onboarding-promotion.json"; export const PROMOTION_JOURNAL_VERSION = 1; @@ -358,7 +359,9 @@ export async function recordPromotionTargetState(component: PromotionComponent): } const stat = await fs.lstat(component.finalPath); if (!stat.isDirectory() || (await fs.readdir(component.finalPath)).length > 0) { - throw new Error(`Migration target changed before promotion: ${component.finalPath}`); + throw new SetupMigrationTargetChangedError( + `Migration target changed before promotion: ${component.finalPath}`, + ); } component.targetWasEmptyDirectory = true; component.emptyTargetBackupPath = await reserveEmptyTargetBackupPath(component.finalPath); @@ -370,7 +373,9 @@ export async function moveRecordedEmptyTarget(component: PromotionComponent): Pr } const entries = await fs.readdir(component.finalPath); if (entries.length > 0) { - throw new Error(`Migration target changed before promotion: ${component.finalPath}`); + throw new SetupMigrationTargetChangedError( + `Migration target changed before promotion: ${component.finalPath}`, + ); } if (component.emptyTargetBackupPath) { await fs.rename(component.finalPath, component.emptyTargetBackupPath); diff --git a/src/wizard/setup.migration-snapshot.ts b/src/wizard/setup.migration-snapshot.ts index 557176741ec9..f8c57b7dcf40 100644 --- a/src/wizard/setup.migration-snapshot.ts +++ b/src/wizard/setup.migration-snapshot.ts @@ -29,7 +29,6 @@ const MEANINGFUL_WORKSPACE_ENTRIES = [ "skills", ] as const; const IMPORT_BLOCKING_STATE_ENTRIES = ["credentials", "sessions", "agents"] as const; -const MIGRATION_TARGET_STATE_ENTRIES = [...IMPORT_BLOCKING_STATE_ENTRIES, "state"] as const; export class SetupTargetLockedError extends Error { readonly code = "setup_target_locked"; @@ -254,7 +253,7 @@ export async function buildSetupMigrationTargetSnapshot(params: { const targetConfig = buildSetupMigrationSnapshotConfig(params.config); hash.update(`config:${JSON.stringify(canonicalizeSetupMigrationValue(targetConfig))}\0`); await hashTargetPath(hash, params.workspaceDir, "workspace"); - for (const entry of MIGRATION_TARGET_STATE_ENTRIES) { + for (const entry of IMPORT_BLOCKING_STATE_ENTRIES) { await hashTargetPath(hash, path.join(params.stateDir, entry), `state/${entry}`); } return hash.digest("hex"); @@ -306,7 +305,9 @@ export async function prepareSetupMigrationAttemptBoundary(params: { workspaceDir: params.workspaceDir, }); if (currentTargetSnapshotHash !== params.expectedTargetSnapshotHash) { - throw new Error("Migration target changed while preparing the import. Review it and retry."); + throw new SetupMigrationTargetChangedError( + "Migration target changed while preparing the import. Review it and retry.", + ); } const sourceSnapshotHash = await buildSetupMigrationPlanSourceSnapshot(params.plan); if (sourceSnapshotHash !== params.expectedSourceSnapshotHash) { @@ -378,3 +379,4 @@ export function assertFreshSetupMigrationTarget(freshness: { } export class SetupMigrationFreshnessError extends Error {} +export class SetupMigrationTargetChangedError extends Error {} diff --git a/src/wizard/setup.migration-stage.ts b/src/wizard/setup.migration-stage.ts index 8b2e5aee3840..14fb384fd22f 100644 --- a/src/wizard/setup.migration-stage.ts +++ b/src/wizard/setup.migration-stage.ts @@ -14,7 +14,10 @@ import type { MigrationItem, MigrationPlan, } from "../plugins/types.js"; -import { registerOpenClawAgentDatabase } from "../state/openclaw-agent-db-registry.js"; +import { + registerOpenClawAgentDatabase, + unregisterOpenClawAgentDatabase, +} from "../state/openclaw-agent-db-registry.js"; import { disposeOpenClawAgentDatabaseByPath, openOpenClawAgentDatabase, @@ -37,6 +40,7 @@ import { type SetupMigrationPromotionContinuation, type SetupMigrationPromotionResume, } from "./setup.migration-promotion.js"; +import { SetupMigrationTargetChangedError } from "./setup.migration-snapshot.js"; export { recoverSetupMigrationPromotion } from "./setup.migration-promotion.js"; export type { @@ -319,6 +323,7 @@ export async function createSetupMigrationStage(params: { }); openOpenClawAgentDatabase({ agentId, env: stageEnv }); let databasesDisposed = false; + let finalAgentDatabaseRegistered = false; let retainForRecovery = false; const disposeDatabases = () => { @@ -328,13 +333,6 @@ export async function createSetupMigrationStage(params: { clearRuntimeAuthProfileStoreSnapshot(stagedAgentDir); const stagedAgentDatabasePath = path.join(stagedAgentDir, "openclaw-agent.sqlite"); disposeOpenClawAgentDatabaseByPath(stagedAgentDatabasePath, { env: stageEnv }); - // Verification may already close this handle. The staged registry still must - // publish the final path before its shared database is promoted. - registerOpenClawAgentDatabase({ - agentId, - path: path.join(finalAgentDir, "openclaw-agent.sqlite"), - env: stageEnv, - }); closeOpenClawStateDatabaseByPath(resolveOpenClawStateSqlitePath(stageEnv)); databasesDisposed = true; }; @@ -367,9 +365,13 @@ export async function createSetupMigrationStage(params: { } const configBefore = await readConfigFile(); if (hashSetupMigrationConfig(configBefore) !== hashSetupMigrationConfig(expectedConfig)) { - throw new Error("Migration config changed before promotion. Review it and retry."); + throw new SetupMigrationTargetChangedError( + "Migration config changed before promotion. Review it and retry.", + ); } const configTarget = configs.getFinalConfig(); + // Shared state is owned by the live runtime. Promote durable import artifacts, + // then merge the derived agent registry fact instead of replacing its database. const components: PromotionComponent[] = [ { name: "workspace", @@ -383,12 +385,6 @@ export async function createSetupMigrationStage(params: { finalPath: finalAgentDir, status: "staged", }, - { - name: "state", - stagedPath: path.join(stagedStateDir, "state"), - finalPath: path.join(params.stateDir, "state"), - status: "staged", - }, ]; const existingComponents: PromotionComponent[] = []; for (const component of components) { @@ -441,6 +437,14 @@ export async function createSetupMigrationStage(params: { } await fs.mkdir(path.dirname(component.finalPath), { recursive: true, mode: 0o700 }); await fs.rename(component.stagedPath, component.finalPath); + if (component.name === "agent") { + registerOpenClawAgentDatabase({ + agentId, + path: path.join(finalAgentDir, "openclaw-agent.sqlite"), + env: finalEnv, + }); + finalAgentDatabaseRegistered = true; + } component.status = "promoted"; await writePromotionJournal(journalPath, journal); } @@ -472,6 +476,14 @@ export async function createSetupMigrationStage(params: { if (retainForRecovery) { throw error; } + if (finalAgentDatabaseRegistered) { + unregisterOpenClawAgentDatabase({ + agentId, + path: path.join(finalAgentDir, "openclaw-agent.sqlite"), + env: finalEnv, + }); + finalAgentDatabaseRegistered = false; + } if (await rollbackComponents(journal.components)) { journal.status = "rolled-back"; await writePromotionJournal(journalPath, journal); diff --git a/src/wizard/setup.migration-transaction.test.ts b/src/wizard/setup.migration-transaction.test.ts index 5459d9cb88a4..20d86e056a65 100644 --- a/src/wizard/setup.migration-transaction.test.ts +++ b/src/wizard/setup.migration-transaction.test.ts @@ -12,6 +12,10 @@ import type { MigrationProviderContext, MigrationProviderPlugin, } from "../plugins/types.js"; +import { + listOpenClawRegisteredAgentDatabases, + registerOpenClawAgentDatabase, +} from "../state/openclaw-agent-db-registry.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; const mocks = vi.hoisted(() => ({ @@ -394,6 +398,61 @@ describe("transactional setup migration import", () => { await expect(fs.access(path.join(root, "workspace", "MEMORY.md"))).rejects.toThrow(); }); + it("promotes while the live runtime state database changes during staged apply", async () => { + const root = tempRoots.make("openclaw-migration-transaction-"); + const source = path.join(root, "source-memory.md"); + const stateDir = path.join(root, "openclaw-state"); + const liveEnv = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const runtimeDatabasePath = path.join(root, "runtime-agent.sqlite"); + await fs.writeFile(source, "remember this\n", "utf8"); + mocks.provider = provider({ + source, + mutateDuringApply: async () => { + registerOpenClawAgentDatabase({ + agentId: "runtime", + path: runtimeDatabasePath, + env: liveEnv, + }); + }, + }); + const currentConfig = { value: {} }; + + await expect(runImport({ root, source, currentConfig })).resolves.toEqual({ + kind: "no-imported-inference", + }); + + expect(await fs.readFile(path.join(root, "workspace", "MEMORY.md"), "utf8")).toBe( + "remember this\n", + ); + expect(listOpenClawRegisteredAgentDatabases({ env: liveEnv })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ agentId: "main" }), + expect.objectContaining({ agentId: "runtime", path: runtimeDatabasePath }), + ]), + ); + }); + + it("still aborts promotion when another writer changes the workspace", async () => { + const root = tempRoots.make("openclaw-migration-transaction-"); + const source = path.join(root, "source-memory.md"); + const externalFile = path.join(root, "workspace", "external.txt"); + await fs.writeFile(source, "remember this\n", "utf8"); + mocks.provider = provider({ + source, + mutateDuringApply: async () => { + await fs.mkdir(path.dirname(externalFile), { recursive: true }); + await fs.writeFile(externalFile, "concurrent write\n", "utf8"); + }, + }); + const currentConfig = { value: {} }; + + await expect(runImport({ root, source, currentConfig })).rejects.toThrow( + "Migration target changed before promotion", + ); + await expect(fs.access(path.join(root, "workspace", "MEMORY.md"))).rejects.toThrow(); + expect(await fs.readFile(externalFile, "utf8")).toBe("concurrent write\n"); + }); + it("runs deferred activation only after promotion and keeps failures as warnings", async () => { const root = tempRoots.make("openclaw-migration-transaction-"); const source = path.join(root, "source-memory.md"); diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index d2bfe04990bf..bf782e772fa0 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -19,7 +19,10 @@ import type { ProviderAuthResult } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { WizardCancelledError, type WizardPrompter, type WizardSelectParams } from "./prompts.js"; import { runSetupWizard } from "./setup.js"; -import { SetupMigrationFreshnessError } from "./setup.migration-snapshot.js"; +import { + SetupMigrationFreshnessError, + SetupMigrationTargetChangedError, +} from "./setup.migration-snapshot.js"; type ResolveProviderPluginChoice = typeof import("../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice; @@ -1465,14 +1468,25 @@ describe("runSetupWizard", () => { expect(runSetupMemoryImportStep).not.toHaveBeenCalled(); }); - it("returns to setup mode after an interactive import freshness rejection", async () => { - const workspaceDir = await makeCaseDir("import-freshness-retry-"); - listSetupMigrationOptions.mockResolvedValueOnce([{ providerId: "hermes", label: "Hermes" }]); - runSetupMigrationImport.mockRejectedValueOnce( - new SetupMigrationFreshnessError( + it.each([ + { + label: "freshness rejection", + error: new SetupMigrationFreshnessError( "Migration import during onboarding requires a fresh OpenClaw setup.\nExisting setup:\n- state agents/ exists", ), - ); + detail: "state agents/ exists", + }, + { + label: "target change", + error: new SetupMigrationTargetChangedError( + "Migration target changed before promotion. Review it and retry.", + ), + detail: "Migration target changed before promotion", + }, + ])("returns to setup mode after an interactive import $label", async ({ error, detail }) => { + const workspaceDir = await makeCaseDir("import-retry-"); + listSetupMigrationOptions.mockResolvedValueOnce([{ providerId: "hermes", label: "Hermes" }]); + runSetupMigrationImport.mockRejectedValueOnce(error); const setupChoices: Array<"import:hermes" | "quickstart"> = ["import:hermes", "quickstart"]; const select = vi.fn(async ({ message }: WizardSelectParams) => { if (message === "Setup mode") { @@ -1501,7 +1515,7 @@ describe("runSetupWizard", () => { expect(select.mock.calls.filter(([params]) => params.message === "Setup mode")).toHaveLength(2); expect(runSetupMigrationImport).toHaveBeenCalledOnce(); expect(prompter.note).toHaveBeenCalledWith( - expect.stringContaining("state agents/ exists"), + expect.stringContaining(detail), "Existing config detected", ); expect(finalizeSetupWizard).toHaveBeenCalledOnce(); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 894d61f6dde2..42912d6558d9 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -28,7 +28,10 @@ import { listSetupMigrationOptions, runSetupMigrationImport, } from "./setup.migration-import.js"; -import { SetupMigrationFreshnessError } from "./setup.migration-snapshot.js"; +import { + SetupMigrationFreshnessError, + SetupMigrationTargetChangedError, +} from "./setup.migration-snapshot.js"; import { runSetupModelAuthStep, type SetupModelAuthCandidate } from "./setup.model-auth.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import { @@ -272,7 +275,10 @@ async function runSetupWizardOnce( continueOnboarding: true, }); } catch (error) { - if (!(error instanceof SetupMigrationFreshnessError) || !flowFromPrompt) { + const canReturnToSetupMode = + error instanceof SetupMigrationFreshnessError || + error instanceof SetupMigrationTargetChangedError; + if (!canReturnToSetupMode || !flowFromPrompt) { throw error; } await prompter.note(formatErrorMessage(error), t("wizard.setup.existingConfigTitle")); diff --git a/test/scripts/bench-gateway-concurrency.test.ts b/test/scripts/bench-gateway-concurrency.test.ts index 2ae00f6dc633..192853a04299 100644 --- a/test/scripts/bench-gateway-concurrency.test.ts +++ b/test/scripts/bench-gateway-concurrency.test.ts @@ -20,6 +20,11 @@ describe("gateway concurrency benchmark script", () => { "50", "--timeout-ms", "90000", + "--cpu-prof-dir", + "/tmp/gateway-cpu-profiles", + "--plugin-count", + "50", + "--tool-events", "--output", "concurrency.json", "--json", @@ -27,10 +32,13 @@ describe("gateway concurrency benchmark script", () => { ).toMatchObject({ cadenceMs: 50, concurrency: 12, + cpuProfDir: "/tmp/gateway-cpu-profiles", json: true, output: "concurrency.json", + pluginCount: 50, runs: 2, timeoutMs: 90_000, + toolEvents: true, warmup: 0, }); expect(() => testing.parseOptions(["--concurrency", "65"])).toThrow( @@ -40,6 +48,45 @@ describe("gateway concurrency benchmark script", () => { "--runs was provided more than once", ); expect(() => testing.parseOptions(["--wat"])).toThrow("Unknown argument: --wat"); + expect(() => testing.parseOptions(["--plugin-count", "101"])).toThrow( + "--plugin-count must be at most 100", + ); + }); + + it("summarizes plugin metadata scans captured after startup warmup", () => { + expect( + testing.summarizePluginMetadataScans([ + { durationMs: 18, name: "plugins.metadata.scan" }, + { durationMs: 22, name: "plugins.metadata.scan" }, + { durationMs: 9, name: "plugins.metadata.freeze" }, + ]), + ).toEqual({ + count: 2, + durationMs: { count: 2, max: 22, p50: 18, p95: 22, p99: 22 }, + totalDurationMs: 40, + }); + }); + + it("aggregates plugin metadata scans across measured runs", () => { + const createRun = (count: number, durations: number[]) => ({ + controlUi: [], + durationMs: 10, + probeWarmup: { durationMs: 2, samples: [] }, + pluginMetadataScans: { + count, + durationMs: testing.summarizeNumbers(durations), + totalDurationMs: durations.reduce((sum, value) => sum + value, 0), + }, + readyz: [], + sessionsList: [], + turnCount: 1, + turnsDurationMs: 5, + }); + + expect(testing.summarizeRuns([createRun(2, [10, 20]), createRun(1, [30])])).toMatchObject({ + pluginMetadataScanCount: 3, + pluginMetadataScanTotalDurationMs: 60, + }); }); it("reports p50, p95, p99, and max with nearest-rank percentiles", () => { @@ -81,6 +128,7 @@ describe("gateway concurrency benchmark script", () => { const sample = { controlUi: [], durationMs: 10, + pluginMetadataScans: { count: 0, durationMs: null, totalDurationMs: 0 }, probeWarmup: { durationMs: 2, samples: [] }, readyz: [], sessionsList: [], @@ -163,17 +211,21 @@ describe("gateway concurrency benchmark script", () => { }; const healthyFast = { controlUi: { ...healthySlow.controlUi, latencyMs: 10 }, - readyz: { ...healthySlow.readyz, latencyMs: 10 }, + readyz: { ...healthySlow.readyz, degraded: true, latencyMs: 10 }, sessionsList: { ...healthySlow.sessionsList, latencyMs: 10 }, }; - const samples = [sample, healthySlow, healthyFast]; + const healthySettled = { + ...healthyFast, + readyz: { ...healthyFast.readyz, degraded: false }, + }; + const samples = [sample, healthySlow, healthyFast, healthySettled]; const warmed = await testing.warmGatewayProbes({ deadlineAt: performance.now() + 5_000, retryDelayMs: 0, sample: async () => samples.shift() ?? healthyFast, targetMs: 100, }); - expect(warmed.samples).toHaveLength(3); + expect(warmed.samples).toHaveLength(4); } finally { server.close(); } diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 9547414a6442..e391a9c5c510 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -1881,12 +1881,17 @@ describe("scripts/changed-lanes", () => { "src/channels/turn/run-channel-turn.ts", "scripts/check-wrapper-shadowing.mts", "scripts/check-export-name-collisions.mts", - "scripts/lib/wrapper-shadowing-baseline.json", "scripts/lib/ts-guard-utils.mts", "package.json", ]), ).toBe(true); - expect(shouldRunWrapperShadowingCheck(["docs/concepts/message-lifecycle.md"])).toBe(false); + expect( + shouldRunWrapperShadowingCheck([ + "docs/concepts/message-lifecycle.md", + "scripts/lib/wrapper-shadowing-baseline.json", + "scripts/lib/export-name-collision-baseline.json", + ]), + ).toBe(false); const plan = createChangedCheckPlan( detectChangedLanes(["scripts/check-wrapper-shadowing.mts"]), diff --git a/test/scripts/check-deadcode-exports.test.ts b/test/scripts/check-deadcode-exports.test.ts index ec4eafaf8edf..b51624e24b7f 100644 --- a/test/scripts/check-deadcode-exports.test.ts +++ b/test/scripts/check-deadcode-exports.test.ts @@ -121,7 +121,6 @@ describe("check-deadcode-exports", () => { expect.arrayContaining([ ".agents/skills/**/scripts/**/*.{js,mjs,cjs,ts,mts,cts}!", ".github/actions/setup-node-env/dependency-fingerprint.mjs!", - ".github/actions/register-bind-mount-cleanup/main.cjs!", "apps/android/scripts/build-release-artifacts.ts!", "security/opengrep/check-rule-metadata.mjs!", "skills/meme-maker/scripts/meme.mjs!", diff --git a/test/scripts/check-export-name-collisions.test.ts b/test/scripts/check-export-name-collisions.test.ts index 5a250beff175..e273ccbda95b 100644 --- a/test/scripts/check-export-name-collisions.test.ts +++ b/test/scripts/check-export-name-collisions.test.ts @@ -1,16 +1,21 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { collectModuleExportNames, collectRepositoryCollisions, - compareExportNameCollisionDebt, findAliasingReExports, findExportNameCollisions, isExcludedExportCollisionSource, } from "../../scripts/check-export-name-collisions.mts"; import { withTempDir } from "../../src/test-utils/temp-dir.js"; +const guardScriptPath = fileURLToPath( + new URL("../../scripts/check-export-name-collisions.mts", import.meta.url), +); + describe("export name collision guard", () => { it.each([ ["src/example.test.ts", true], @@ -244,34 +249,17 @@ describe("export name collision guard", () => { }, ]); }); -}); -describe("export name collision debt baseline", () => { - it("separates new debt from baseline improvements", () => { - expect( - compareExportNameCollisionDebt( - [ - { name: "added", files: ["src/a.ts", "src/b.ts"] }, - { name: "expanded", files: ["src/a.ts", "src/b.ts", "src/c.ts"], sdk: true }, - ], - [ - { name: "expanded", files: ["src/a.ts", "src/b.ts"] }, - { name: "removed", files: ["src/c.ts", "src/d.ts"] }, - ], - ), - ).toEqual({ - regressions: [ - { current: { name: "added", files: ["src/a.ts", "src/b.ts"] } }, - { - baseline: { name: "expanded", files: ["src/a.ts", "src/b.ts"] }, - current: { - name: "expanded", - files: ["src/a.ts", "src/b.ts", "src/c.ts"], - sdk: true, - }, - }, - ], - improvements: [{ baseline: { name: "removed", files: ["src/c.ts", "src/d.ts"] } }], - }); + it("rejects debt-baseline updates with the collision trailer", () => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", guardScriptPath, "--update-debt-baseline"], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(2); + expect(result.stderr.trimEnd().split("\n").at(-1)).toBe( + "[check-export-name-collisions] FAILED (exit 2)", + ); }); }); diff --git a/test/scripts/check-wrapper-shadowing.test.ts b/test/scripts/check-wrapper-shadowing.test.ts index af82146c25e3..956e4e2a36ea 100644 --- a/test/scripts/check-wrapper-shadowing.test.ts +++ b/test/scripts/check-wrapper-shadowing.test.ts @@ -3,38 +3,29 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { - evaluateWrapperShadowing, - type WrapperShadowingViolation, -} from "../../scripts/check-wrapper-shadowing.mts"; +import { collectRepositoryWrapperShadowing } from "../../scripts/check-wrapper-shadowing.mts"; import { withTempDir } from "../../src/test-utils/temp-dir.js"; const guardScriptPath = fileURLToPath( new URL("../../scripts/check-wrapper-shadowing.mts", import.meta.url), ); -type GuardFixture = { - baseline?: WrapperShadowingViolation[]; - files: Record; -}; +type GuardFixture = Record; -async function runFixture(fixture: GuardFixture) { +async function runFixture(files: GuardFixture) { return await withTempDir("openclaw-wrapper-shadowing-", async (repoRoot) => { await Promise.all( - Object.entries(fixture.files).map(async ([repoPath, content]) => { + Object.entries(files).map(async ([repoPath, content]) => { const filePath = path.join(repoRoot, repoPath); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, content); }), ); - const baselinePath = path.join(repoRoot, "scripts/lib/wrapper-shadowing-baseline.json"); - await fs.mkdir(path.dirname(baselinePath), { recursive: true }); - await fs.writeFile(baselinePath, `${JSON.stringify(fixture.baseline ?? [], null, 2)}\n`); - return await evaluateWrapperShadowing(repoRoot); + return await collectRepositoryWrapperShadowing(repoRoot); }); } -const directViolation: GuardFixture["files"] = { +const directViolation: GuardFixture = { "src/inner.ts": "export function runTask() { return 'inner'; }\n", "src/outer.ts": [ 'import { runTask as runTaskInner } from "./inner.js";', @@ -47,71 +38,26 @@ const directViolation: GuardFixture["files"] = { describe("wrapper shadowing guard", () => { it("fails for a same-name wrapper around an imported implementation", async () => { - const result = await runFixture({ files: directViolation }); + const result = await runFixture(directViolation); - expect(result.regressions).toEqual([ - { name: "runTask", wrapped: "src/inner.ts", wrapper: "src/outer.ts" }, - ]); + expect(result).toEqual([{ name: "runTask", wrapped: "src/inner.ts", wrapper: "src/outer.ts" }]); }); it("passes for a pure re-export", async () => { const result = await runFixture({ - files: { - "src/inner.ts": "export function runTask() { return 'inner'; }\n", - "src/outer.ts": 'export { runTask } from "./inner.js";\n', - }, + "src/inner.ts": "export function runTask() { return 'inner'; }\n", + "src/outer.ts": 'export { runTask } from "./inner.js";\n', }); - expect(result.current).toEqual([]); - expect(result.regressions).toEqual([]); + expect(result).toEqual([]); }); - it("passes for a baselined violation", async () => { - const violation = { name: "runTask", wrapped: "src/inner.ts", wrapper: "src/outer.ts" }; - const result = await runFixture({ - baseline: [ - violation, - { name: "removedTask", wrapped: "src/old-inner.ts", wrapper: "src/old-outer.ts" }, - ], - files: directViolation, - }); - - expect(result.current).toEqual([violation]); - expect(result.regressions).toEqual([]); - }); - - it("fails for a new violation on top of the baseline", async () => { - const baseline = { name: "runTask", wrapped: "src/inner.ts", wrapper: "src/outer.ts" }; - const result = await runFixture({ - baseline: [baseline], - files: { - ...directViolation, - "src/barrel.ts": 'export { sendTask } from "./sender.js";\n', - "src/sender.ts": "export const sendTask = () => 'sent';\n", - "src/send-wrapper.ts": [ - 'import { sendTask as sendTaskInner } from "./barrel.js";', - "export const sendTask = (...args: unknown[]) => {", - " recordSend();", - " return sendTaskInner(...args);", - "};", - ].join("\n"), - }, - }); - - expect(result.regressions).toEqual([ - { - name: "sendTask", - wrapped: "src/sender.ts", - wrapper: "src/send-wrapper.ts", - via: "src/barrel.ts", - }, - ]); - }); - - it("ends failures with the wrapper trailer", () => { - const result = spawnSync(process.execPath, ["--import", "tsx", guardScriptPath, "--invalid"], { - encoding: "utf8", - }); + it("rejects debt-baseline updates with the wrapper trailer", () => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", guardScriptPath, "--update-debt-baseline"], + { encoding: "utf8" }, + ); expect(result.status).toBe(2); expect(result.stderr.trimEnd().split("\n").at(-1)).toBe( diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index 75cfa7ef7526..f410c2d03500 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -266,8 +266,9 @@ describe("scripts/lib/ci-node-test-plan.mts", () => { compact: true, }); - // Rebalancing may change ownership but must not add CI workers. - expect(compact).toHaveLength(23); + // Rebalancing may change ownership, but the compact plan stays within the + // CI workflow's 28-worker cap. + expect(compact).toHaveLength(25); expect(compact.every((shard) => Array.isArray(shard.groups))).toBe(true); expect(compact.every((shard) => shard.groups.length <= 10)).toBe(true); expect(compact.some((shard) => shard.requiresDist)).toBe(true); @@ -287,9 +288,6 @@ describe("scripts/lib/ci-node-test-plan.mts", () => { // pairing them starves model visibility and repeatedly hits its timeout. expect(jobOf("agentic-agents-core-models")).not.toBe(jobOf("core-runtime-media-ui")); expect(jobOf("core-runtime-media-ui")).not.toBe(jobOf("core-unit-src-security")); - // Means expose recurrent 8-vCPU tails hidden by the median-only plan. Keep - // the observed pairing that dominated replayed job walls separated. - expect(jobOf("agentic-agents-core-tools")).not.toBe(jobOf("agentic-agents-embedded-base")); expect( compact[jobOf("core-unit-src-security")]?.groups.map((group) => group.shard_name), ).toEqual(["core-unit-src-security"]); @@ -380,12 +378,12 @@ describe("scripts/lib/ci-node-test-plan.mts", () => { ); const distJobs = compact.filter((shard) => shard.requiresDist); expect(largeJobs).toHaveLength(7); - expect(smallJobs).toHaveLength(14); + expect(smallJobs).toHaveLength(16); expect(distJobs).toHaveLength(2); const regularSmallJobs = smallJobs.filter((shard) => shard.groups.every((group) => !exclusiveGroupRe.test(group.shard_name)), ); - expect(regularSmallJobs).toHaveLength(10); + expect(regularSmallJobs).toHaveLength(11); const routed8VcpuCheckNames = [ "checks-node-compact-small-2", "checks-node-compact-small-5", @@ -410,7 +408,7 @@ describe("scripts/lib/ci-node-test-plan.mts", () => { "agentic-agents-support", "agentic-gateway-methods", "agentic-agents-core-runtime", - "agentic-agents-embedded-base", + "core-unit-fast-isolated", ]; const smallTailAnchors = [ "agentic-control-plane-auth-node", @@ -422,7 +420,8 @@ describe("scripts/lib/ci-node-test-plan.mts", () => { "agentic-agents-tools", "agentic-commands-agent-channel", "agentic-commands-doctor-config-state", - "auto-reply-reply-agent-runner", + "core-runtime-shared", + "auto-reply-reply-state-routing", ]; expect( largeJobs.map( diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 9060e4ba6755..3997b6a28a20 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -1,6 +1,5 @@ // Ci Workflow Guards tests cover ci workflow guards script behavior. -import { execFileSync, spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; import { chmodSync, existsSync, @@ -9,6 +8,7 @@ import { readdirSync, readFileSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs"; @@ -2877,27 +2877,126 @@ NODE }); }); - it("keeps sticky dependency snapshots on trusted Blacksmith Node shards", () => { - const workflow = readCiWorkflow(); - const blacksmithJobs = Object.entries(workflow.jobs).filter(([, job]) => { - const runsOn = (job as { "runs-on"?: unknown })["runs-on"]; - return typeof runsOn === "string" && runsOn.includes("blacksmith-"); + it("owns one exact immutable semantic dependency cache", () => { + const actionSource = readFileSync(".github/actions/setup-node-env/action.yml", "utf8"); + const ciSource = readFileSync(".github/workflows/ci.yml", "utf8"); + const action = parse(actionSource); + const workflow = parse(ciSource); + const actionSteps = action.runs.steps as WorkflowStep[]; + const step = (name: string) => + expectDefined( + actionSteps.find((candidate) => candidate.name === name), + name, + ); + const configureStore = step("Configure dependency cache store"); + const resolve = step("Resolve dependency cache key"); + const prepare = step("Prepare dependency cache restore"); + const restore = step("Restore exact dependency cache"); + const prepareFallback = step("Prepare dependency cache miss fallback"); + const setupPnpm = step("Setup pnpm"); + const install = step("Install dependencies"); + const installScript = expectDefined(install.run, "Install dependencies script"); + const save = step("Save exact dependency cache"); + const cachePaths = + "node_modules\nui/node_modules\npackages/*/node_modules\nexamples/*/node_modules\n.cache/openclaw-pnpm-store\n"; + + expect(action.inputs["dependency-cache"].default).toBe("false"); + expect(action.inputs["save-dependency-cache"].default).toBe("false"); + expect(action.inputs).not.toHaveProperty("sticky-disk"); + expect(action.inputs).not.toHaveProperty("save-sticky-disk"); + expect(actionSource).not.toContain("useblacksmith/stickydisk"); + + expect(configureStore.if).toBe("inputs.dependency-cache == 'true'"); + expect(configureStore.run).toContain( + 'echo "PNPM_CONFIG_STORE_DIR=$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store"', + ); + expect(resolve.if).toBe("inputs.dependency-cache == 'true'"); + expect(resolve.run).toContain('node "$GITHUB_ACTION_PATH/dependency-fingerprint.mjs"'); + expect(resolve.run).toContain("${GITHUB_REPOSITORY:?}-node-deps-v2"); + expect(resolve.run).toContain("${RUNNER_OS:?}-arch-${RUNNER_ARCH:?}"); + expect(resolve.run).toContain("node-$(node --version)-${deps_input_fingerprint:?}"); + expect(resolve.run).not.toMatch(/GITHUB_(?:REF|SHA|RUN_ID)|RUN_(?:ID|ATTEMPT)/u); + expect(actionSteps.indexOf(resolve)).toBeLessThan(actionSteps.indexOf(restore)); + for (const cleanup of [prepare, prepareFallback]) { + expect(cleanup.run).toContain('rm -rf "$GITHUB_WORKSPACE/node_modules"'); + expect(cleanup.run).toContain('"$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store"'); + expect(cleanup.run).toContain('"$GITHUB_WORKSPACE/packages"'); + expect(cleanup.run).toContain("-name node_modules"); + } + expect(actionSteps.indexOf(prepare)).toBeLessThan(actionSteps.indexOf(restore)); + expect(restore).toMatchObject({ + if: "inputs.dependency-cache == 'true'", + uses: CACHE_V5, + with: { key: "${{ steps.dependency-cache-key.outputs.key }}", path: cachePaths }, }); - const stickySteps = Object.entries(workflow.jobs).flatMap(([jobName, job]) => { - const steps = (job as { steps?: WorkflowStep[] }).steps ?? []; - return steps.flatMap((step) => { - const stepWith = step.with; - if (!stepWith || stepWith["sticky-disk"] === undefined) { - return []; - } - return [{ jobName, stepWith }]; - }); + expect((restore as WorkflowStep & { "continue-on-error"?: boolean })["continue-on-error"]).toBe( + true, + ); + expect(restore.with).not.toHaveProperty("restore-keys"); + expect(prepareFallback.if).toContain("steps.dependency-cache.outputs.cache-hit != 'true'"); + expect(prepareFallback.run).toContain( + "actions/cache treats service, download, and extraction failures as", + ); + expect(actionSteps.indexOf(restore)).toBeLessThan(actionSteps.indexOf(prepareFallback)); + expect(actionSteps.indexOf(prepareFallback)).toBeLessThan(actionSteps.indexOf(setupPnpm)); + expect(setupPnpm.with?.["use-actions-cache"]).toContain( + "steps.dependency-cache.outputs.cache-hit != 'true'", + ); + expect(setupPnpm.with?.["use-actions-cache"]).toContain( + "inputs.dependency-cache != 'true' && inputs.use-actions-cache == 'true'", + ); + expect(actionSteps.indexOf(restore)).toBeLessThan(actionSteps.indexOf(setupPnpm)); + + expect(installScript).toContain("install_args+=(--package-import-method=hardlink)"); + expect(installScript).toContain("run_pnpm_install --offline"); + expect(installScript).toContain("run_pnpm_install --prefer-offline"); + expect(installScript).toContain('[ "$DEPENDENCY_CACHE_HIT" = "true" ]'); + expect(installScript).toContain('rm -rf "$GITHUB_WORKSPACE/node_modules"'); + expect(installScript).toContain('"$GITHUB_WORKSPACE/packages"'); + expect(installScript).toContain("-name node_modules"); + expect(installScript).toContain('"${PNPM_CONFIG_STORE_DIR:?}"'); + expect(installScript.match(/run_pnpm_install/g)).toHaveLength(5); + expect(installScript).toContain('echo "OPENCLAW_BUILD_ALL_NO_PNPM=1" >> "$GITHUB_ENV"'); + expect(installScript).toContain( + 'echo "pnpm_config_verify_deps_before_run=false" >> "$GITHUB_ENV"', + ); + expect(save).toMatchObject({ + uses: "actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae", + with: { key: "${{ steps.dependency-cache-key.outputs.key }}", path: cachePaths }, }); - const preflightWriter = stickySteps.find((entry) => entry.jobName === "preflight"); - const stickyConsumers = stickySteps.filter((entry) => entry.jobName !== "preflight"); - // Every Linux Blacksmith lane that installs Node dependencies consumes - // the snapshot; missing entries silently pay the full install again. - expect(stickyConsumers.map((entry) => entry.jobName).toSorted()).toEqual([ + expect(save.if).toContain("inputs.save-dependency-cache == 'true'"); + expect(save.if).toContain("steps.dependency-cache.outputs.cache-hit != 'true'"); + expect((save as WorkflowStep & { "continue-on-error"?: boolean })["continue-on-error"]).toBe( + true, + ); + expect(actionSteps.indexOf(save)).toBe(actionSteps.indexOf(install) + 1); + + const dependencySetups = Object.entries(workflow.jobs).flatMap(([jobName, job]) => + ((job as { steps?: WorkflowStep[] }).steps ?? []).flatMap((candidate) => + candidate.uses === "./.github/actions/setup-node-env" && + candidate.with?.["dependency-cache"] !== undefined + ? [{ jobName, step: candidate }] + : [], + ), + ); + const writers = dependencySetups.filter( + ({ step: candidate }) => candidate.with?.["save-dependency-cache"] === "true", + ); + expect(writers).toHaveLength(1); + expect(writers[0]?.jobName).toBe("preflight"); + expect(writers[0]?.step).toMatchObject({ + if: expect.stringContaining("steps.manifest.outputs.run_node == 'true'"), + with: { + "dependency-cache": "true", + "save-actions-cache": "true", + "save-dependency-cache": "true", + "use-actions-cache": "true", + }, + }); + expect(writers[0]?.step.if).toContain("github.ref == 'refs/heads/main'"); + expect(writers[0]?.step.if).toContain("github.event_name == 'pull_request'"); + const consumers = dependencySetups.filter(({ jobName }) => jobName !== "preflight"); + expect(consumers.map(({ jobName }) => jobName).toSorted()).toEqual([ "build-artifacts", "check-additional-shard", "check-docs", @@ -2914,278 +3013,205 @@ NODE "qa-smoke-ci-profile", "sqlite-session-lifecycle", ]); - const hostedRetryJobs = new Set(["checks-ui-e2e", "checks-ui-e2e-real-gateway"]); - for (const { jobName, stepWith } of stickyConsumers) { - const stickyCondition = stepWith["sticky-disk"]; - const cacheCondition = stepWith["use-actions-cache"]; - if (hostedRetryJobs.has(jobName)) { - continue; - } - expect(stickyCondition, jobName).toContain("github.event_name != 'workflow_dispatch'"); - expect(cacheCondition, jobName).toContain("github.event_name != 'workflow_dispatch'"); - expect(cacheCondition, jobName).toContain("&& 'false' || 'true'"); - expect(stickyCondition, jobName).toContain( - "github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'", - ); - expect(cacheCondition, jobName).toContain( - "github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'", - ); + for (const { jobName, step: consumer } of consumers) { + const needs = workflow.jobs[jobName].needs; + expect(Array.isArray(needs) ? needs : [needs], jobName).toContain("preflight"); + expect(consumer.with, jobName).not.toHaveProperty("save-dependency-cache"); + expect(consumer.with?.["dependency-cache"], jobName).toContain("'true' || 'false'"); + expect(consumer.with?.["use-actions-cache"], jobName).toContain("'false' || 'true'"); } - // Required CI jobs only clone the snapshot. The disposable warmer below - // owns commits so writer coalescing cannot cancel a required build job. - for (const { jobName, stepWith } of stickyConsumers) { - expect(stepWith["save-sticky-disk"], jobName).toBeUndefined(); + for (const { jobName, step: setup } of Object.entries(workflow.jobs).flatMap(([jobName, job]) => + ((job as { steps?: WorkflowStep[] }).steps ?? []) + .filter((candidate) => candidate.uses === "./.github/actions/setup-node-env") + .map((candidate) => ({ jobName, step: candidate })), + )) { + expect(setup.with, jobName).not.toHaveProperty("sticky-disk"); + expect(setup.with, jobName).not.toHaveProperty("save-sticky-disk"); } - expect(preflightWriter?.stepWith).toMatchObject({ - "save-sticky-disk": "true", - "sticky-disk": "true", - "use-actions-cache": "false", - }); - const preflightSteps = workflow.jobs.preflight.steps as WorkflowStep[]; - const refreshStep = preflightSteps.find( - (step: WorkflowStep) => step.name === "Refresh sticky dependency snapshot", - )!; - const maintainStep = preflightSteps.find( - (step: WorkflowStep) => step.name === "Maintain sticky dependency store budget", - )!; - expect(refreshStep.if).toContain("github.event_name == 'push'"); - expect(refreshStep.if).toContain("github.repository == 'openclaw/openclaw'"); - expect(refreshStep.if).toContain("github.ref == 'refs/heads/main'"); - expect(refreshStep.if).toContain("steps.manifest.outputs.run_node == 'true'"); - expect(maintainStep.if).toBe(refreshStep.if); - expect(preflightSteps.indexOf(refreshStep)).toBeLessThan(preflightSteps.indexOf(maintainStep)); - expect(maintainStep.env?.OPENCLAW_PNPM_STORE_MAX_KIB).toBe("8388608"); - expect(maintainStep.run).toContain('store_dir="${PNPM_CONFIG_STORE_DIR:?}"'); - expect(maintainStep.run).toContain('PNPM_CONFIG_STORE_DIR="$store_dir" pnpm store prune'); - expect(maintainStep.run).toContain('>> "$GITHUB_STEP_SUMMARY"'); - expect(maintainStep.run).toContain('if [ -f "${OPENCLAW_STICKY_REBUILD_SIGNAL:?}" ]'); - expect(maintainStep.run).toContain("ensure-change /var/tmp/openclaw-node-deps"); - expect(maintainStep.run).toContain('"${OPENCLAW_STICKY_INITIAL_USAGE_BYTES:?}"'); - expect(workflow.jobs["pnpm-store-warmup"].if).toContain("github.ref == 'refs/heads/main'"); - expect(workflow.jobs["pnpm-store-warmup"].if).toContain( - "github.repository == 'openclaw/openclaw'", - ); - // Current sticky consumers all use the single supported Node line. A - // planner-provided version would silently create a writerless disk. - for (const { jobName, stepWith } of stickyConsumers) { - const nodeVersion = stepWith["node-version"]; - expect( - nodeVersion === undefined || - nodeVersion === "24.x" || - nodeVersion === "${{ matrix.node_version || '24.x' }}", - `${jobName} must resolve to the writer's 24.x snapshot key (got ${String(nodeVersion)})`, - ).toBe(true); - if (nodeVersion === "${{ matrix.node_version || '24.x' }}") { - expect(stepWith["sticky-disk"], jobName).toContain( - "matrix.node_version == null || matrix.node_version == '24.x'", - ); - } - } - const warmWorkflow = parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8")); - const warmSetupStep = warmWorkflow.jobs.warm.steps.find( - (step: WorkflowStep) => step.name === "Setup Node environment", - ); - expect(warmSetupStep.with["save-sticky-disk"]).toBeUndefined(); - expect(warmSetupStep.with["sticky-disk"]).toBe("false"); - expect(warmWorkflow.on).not.toHaveProperty("pull_request"); - expect(warmWorkflow.on).not.toHaveProperty("workflow_dispatch"); - expect(warmWorkflow.on).not.toHaveProperty("workflow_run"); - const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8")); - const validateLayoutStep = action.runs.steps.find( - (step: WorkflowStep) => step.name === "Validate sticky pnpm layout", - ); - const setupPnpmStep = action.runs.steps.find( - (step: WorkflowStep) => step.name === "Setup pnpm", - ); - const mountStep = action.runs.steps.find( - (step: WorkflowStep) => step.name === "Mount dependency sticky disk", - ); - const baselineStep = action.runs.steps.find( - (step: WorkflowStep) => step.name === "Record sticky disk allocation baseline", - ); - const cleanupStep = action.runs.steps.find( - (step: WorkflowStep) => step.name === "Register sticky bind cleanup", - ); - const bindStep = action.runs.steps.find( - (step: WorkflowStep) => step.name === "Bind sticky node_modules into workspace", - ); - const installStep = action.runs.steps.find( - (step: WorkflowStep) => step.name === "Install dependencies", - ); - - expect(blacksmithJobs.length).toBeGreaterThan(0); - for (const [jobName, job] of blacksmithJobs) { - expect( - (job as { "runs-on": string })["runs-on"], - `${jobName} must route fork pull requests to GitHub-hosted runners`, - ).toContain( - "github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'", - ); - } - expect(action.inputs["sticky-disk"].default).toBe("false"); - // Writers omit node-version, so the default is the writers' key segment. - expect(action.inputs["node-version"].default).toBe("24.x"); - expect(action.inputs["save-sticky-disk"].default).toBe("false"); - expect(validateLayoutStep.if).toBe("inputs.sticky-disk == 'true'"); - expect(validateLayoutStep.run).toContain("for config_name in modules-dir virtual-store-dir"); - expect(validateLayoutStep.run).toContain('config_value="$(pnpm config get "$config_name")"'); - expect(validateLayoutStep.run).toContain( - "sticky mode requires pnpm's stock node_modules layout", - ); - expect(action.runs.steps.indexOf(setupPnpmStep)).toBeLessThan( - action.runs.steps.indexOf(validateLayoutStep), - ); - expect(action.runs.steps.indexOf(validateLayoutStep)).toBeLessThan( - action.runs.steps.indexOf(mountStep), - ); - expect(mountStep).toMatchObject({ - if: "inputs.sticky-disk == 'true'", - uses: "useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494", - with: { - path: "/var/tmp/openclaw-node-deps", - }, - }); - // Bounded disks: Blacksmith caps sticky disks per installation, and the old - // per-PR/per-manifest-hash keys saturated that cap. Install inputs and exact - // runtime patches belong in the marker, not the backing-disk key. - expect(mountStep.with.key).toBe( - "${{ github.repository }}-node-deps-bind-v7-${{ inputs.node-version }}", - ); - expect(mountStep.with.commit).toBe( - "${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'on-change' || 'false' }}", - ); - expect(baselineStep).toMatchObject({ - if: "inputs.sticky-disk == 'true' && inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request'", - }); - expect(baselineStep.run).toContain('df -B1 --output=used "$sticky_root"'); - expect(baselineStep.run).toContain( - 'echo "OPENCLAW_STICKY_INITIAL_USAGE_BYTES=$initial_usage_bytes"', - ); - expect(baselineStep.run).toContain('echo "OPENCLAW_STICKY_REBUILD_SIGNAL=$rebuild_signal"'); - expect(action.runs.steps.indexOf(mountStep)).toBeLessThan( - action.runs.steps.indexOf(baselineStep), - ); - expect(cleanupStep).toMatchObject({ - if: "inputs.sticky-disk == 'true'", - uses: "./.github/actions/register-bind-mount-cleanup", - with: { path: "${{ github.workspace }}/node_modules" }, - }); - expect(action.runs.steps.indexOf(mountStep)).toBeLessThan( - action.runs.steps.indexOf(cleanupStep), - ); - expect(action.runs.steps.indexOf(cleanupStep)).toBeLessThan( - action.runs.steps.indexOf(bindStep), - ); - expect(bindStep.run).toContain('sudo mount --bind "$sticky_modules" "$workspace_modules"'); - expect(bindStep.run).toContain('echo "PNPM_CONFIG_STORE_DIR=$sticky_store"'); - expect(bindStep.run).toContain('echo "OPENCLAW_BUILD_ALL_NO_PNPM=1"'); - expect(bindStep.run).toContain( - 'deps_fingerprint="os-${RUNNER_OS:?}-arch-${RUNNER_ARCH:?}-node-$(node --version)-${deps_input_fingerprint:?}"', - ); - expect(bindStep.run).toContain('echo "OPENCLAW_STICKY_DEPS_FINGERPRINT=$deps_fingerprint"'); - expect(bindStep.run).not.toContain("PNPM_CONFIG_MODULES_DIR"); - expect(bindStep.run).not.toContain("PNPM_CONFIG_VIRTUAL_STORE_DIR"); - // Compute from the checkout before the bind mount adds snapshot-internal - // manifests. Ordinary package scripts must not rotate dependency trees. - expect(bindStep.env.FROZEN_LOCKFILE).toBe("${{ inputs.frozen-lockfile }}"); - expect(bindStep.env).not.toHaveProperty("DEPS_INPUT_FINGERPRINT"); - expect(bindStep.run).toContain('node "$GITHUB_ACTION_PATH/dependency-fingerprint.mjs"'); - expect(bindStep.run.indexOf("dependency-fingerprint.mjs")).toBeLessThan( - bindStep.run.indexOf('sudo mount --bind "$sticky_modules" "$workspace_modules"'), - ); - expect(installStep.env).toMatchObject({ - STICKY_DISK: "${{ inputs.sticky-disk }}", - STICKY_ROOT: "/var/tmp/openclaw-node-deps", - STICKY_WRITER: - "${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}", - }); - expect(installStep.run).toContain('sticky_marker="$STICKY_ROOT/.openclaw-deps-fingerprint"'); - expect(installStep.run).toContain( - '[ "$sticky_fingerprint" = "${OPENCLAW_STICKY_DEPS_FINGERPRINT:?}" ]', - ); - expect(installStep.run).toContain('sticky_fingerprint_matches="true"'); - expect(installStep.run).toContain( - "Sticky dependency fingerprint matches, but restored importer contents are incomplete; reinstalling", - ); - expect(installStep.run).toContain('[ "$STICKY_WRITER" != "true" ]'); - expect(installStep.run).toContain('sudo umount "$GITHUB_WORKSPACE/node_modules"'); - expect(installStep.run).toContain('ephemeral_store="${RUNNER_TEMP:?}/openclaw-pnpm-store"'); - expect(installStep.run).toContain( - "Sticky dependency snapshot is unusable; using runner-local storage for this read-only run", - ); - expect(installStep.run).toContain( - 'bash "$GITHUB_ACTION_PATH/sticky-importers.sh" restore "$STICKY_ROOT" "$GITHUB_WORKSPACE"', - ); - expect(installStep.run).toContain( - "Sticky dependency snapshot matches the install fingerprint and importer contents; skipping pnpm install", - ); - expect(installStep.run).toContain("timeout --signal=TERM --kill-after=15s 4m"); - expect(installStep.run).toContain("timeout --signal=TERM --kill-after=15s 15m"); - expect(installStep.run).toContain('pnpm "${install_args[@]}" --config.fetch-retries=0'); - const forceStickyWriterInstall = - 'if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" = "true" ] &&\n' + - ' [ "$sticky_snapshot_matches" != "true" ]; then'; - expect(installStep.run).toContain(forceStickyWriterInstall); - const clearStickyModules = - 'find "$GITHUB_WORKSPACE/node_modules" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +'; - expect(installStep.run).toContain(clearStickyModules); - expect(installStep.run).toContain("install_args+=(--force)"); - expect(installStep.run).toContain('sticky_writer_rebuild="true"'); - expect(installStep.run).toContain('if [ "$sticky_writer_rebuild" = "true" ]; then'); - expect(installStep.run).toContain("install_attempts=1"); - expect(installStep.run.indexOf(forceStickyWriterInstall)).toBeLessThan( - installStep.run.indexOf(clearStickyModules), - ); - expect(installStep.run.indexOf(clearStickyModules)).toBeLessThan( - installStep.run.indexOf("run_pnpm_install()"), - ); - expect(installStep.run).toContain("install_attempts=2"); - expect(installStep.run).toContain("install_attempts=3"); - expect(installStep.run).toContain( - "for (( attempt = 1; attempt <= install_attempts; attempt += 1 )); do", - ); - expect(installStep.run).toContain('if [ "$install_status" -ne 0 ]; then'); - expect(installStep.run).not.toContain("accepting the populated sticky tree"); - // Read-only consumers never capture; only the designated writer refreshes - // the archive and publishes the fingerprint after a successful install. - expect(installStep.run).toContain('[ "$STICKY_WRITER" = "true" ]'); - expect(installStep.run.indexOf('pnpm "${install_args[@]}"')).toBeLessThan( - installStep.run.indexOf( - 'bash "$GITHUB_ACTION_PATH/sticky-importers.sh" capture "$STICKY_ROOT" "$GITHUB_WORKSPACE" "$OPENCLAW_STICKY_DEPS_FINGERPRINT"', - ), - ); - expect(installStep.run).toContain('"${OPENCLAW_STICKY_REBUILD_SIGNAL:?}"'); - // The content-validated snapshot or successful install already owns - // dependency validation. pnpm's redundant check sees intentionally pruned - // plugin importers as stale, so it must not mutate during shard fanout. - const disableImplicitInstall = - 'echo "pnpm_config_verify_deps_before_run=false" >> "$GITHUB_ENV"'; - expect(installStep.run).toContain('if [ "$STICKY_DISK" = "true" ]; then'); - expect(installStep.run).not.toContain("pnpm_config_verify_deps_before_run=install pnpm exec"); - expect(installStep.run).toContain(disableImplicitInstall); - expect(installStep.run.indexOf('sticky-importers.sh" restore')).toBeLessThan( - installStep.run.indexOf(disableImplicitInstall), - ); - const cleanupAction = parse( - readFileSync(".github/actions/register-bind-mount-cleanup/action.yml", "utf8"), - ); - expect(cleanupAction.runs).toMatchObject({ - using: "node24", - main: "main.cjs", - post: "post.cjs", - "post-if": "always()", - }); - const cleanupPost = readFileSync( - ".github/actions/register-bind-mount-cleanup/post.cjs", - "utf8", - ); - expect(cleanupPost).toContain("mountpoint.status === 32"); - expect(cleanupPost).toContain('spawnSync("sudo", ["umount", mountPath]'); - expect(readFileSync(".github/actions/setup-pnpm-store-cache/action.yml", "utf8")).toContain( - "actions/cache/restore@", - ); }); + it.skipIf(process.platform === "win32")( + "preserves pnpm hard links and validates cached importers offline", + () => { + const root = tempDirs.make("openclaw-dependency-cache-"); + const source = path.join(root, "source"); + const registry = path.join(root, "registry"); + const workspace = path.join(root, "workspace"); + const consumer = path.join(workspace, "packages", "consumer"); + const store = path.join(workspace, ".cache", "openclaw-pnpm-store"); + const readyFile = path.join(root, "registry-ready"); + mkdirSync(source, { recursive: true }); + mkdirSync(registry, { recursive: true }); + mkdirSync(consumer, { recursive: true }); + writeFileSync( + path.join(source, "package.json"), + JSON.stringify({ files: ["index.js"], name: "cache-proof-dep", version: "1.0.0" }), + ); + writeFileSync(path.join(source, "index.js"), 'module.exports = "cache-proof-v1";\n'); + execFileSync("pnpm", ["pack", "--pack-destination", registry], { + cwd: source, + env: { ...process.env, CI: "true" }, + stdio: "pipe", + }); + const tarball = path.join(registry, "cache-proof-dep-1.0.0.tgz"); + const registryScript = String.raw` +const { createHash } = require("node:crypto"); +const { readFileSync, writeFileSync } = require("node:fs"); +const { createServer } = require("node:http"); +const tarballPath = process.argv[1]; +const readyPath = process.argv[2]; +const tarball = readFileSync(tarballPath); +const server = createServer((request, response) => { + if (request.url === "/cache-proof-dep") { + const port = server.address().port; + const metadata = { + name: "cache-proof-dep", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "cache-proof-dep", + version: "1.0.0", + dist: { + tarball: "http://127.0.0.1:" + port + "/cache-proof-dep-1.0.0.tgz", + shasum: createHash("sha1").update(tarball).digest("hex"), + integrity: "sha512-" + createHash("sha512").update(tarball).digest("base64"), + }, + }, + }, + }; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(metadata)); + return; + } + if (request.url === "/cache-proof-dep-1.0.0.tgz") { + response.setHeader("content-type", "application/octet-stream"); + response.end(tarball); + return; + } + response.statusCode = 404; + response.end(); +}); +server.listen(0, "127.0.0.1", () => writeFileSync(readyPath, String(server.address().port))); +`; + const registryServer = spawn(process.execPath, ["-e", registryScript, tarball, readyFile], { + stdio: "ignore", + }); + try { + for (let attempt = 0; attempt < 200 && !existsSync(readyFile); attempt += 1) { + if (registryServer.exitCode !== null) { + throw new Error(`fixture registry exited with ${registryServer.exitCode}`); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } + expect(existsSync(readyFile)).toBe(true); + const registryUrl = `http://127.0.0.1:${readFileSync(readyFile, "utf8")}`; + writeFileSync( + path.join(workspace, "package.json"), + JSON.stringify({ + dependencies: { "cache-proof-dep": "1.0.0" }, + name: "cache-proof-root", + private: true, + }), + ); + writeFileSync(path.join(workspace, "pnpm-workspace.yaml"), "packages:\n - packages/*\n"); + writeFileSync( + path.join(consumer, "package.json"), + JSON.stringify({ + dependencies: { "cache-proof-dep": "1.0.0" }, + name: "cache-proof-consumer", + private: true, + }), + ); + execFileSync( + "pnpm", + [ + "install", + `--registry=${registryUrl}`, + `--store-dir=${store}`, + "--package-import-method=hardlink", + "--ignore-scripts", + "--config.engine-strict=false", + ], + { cwd: workspace, env: { ...process.env, CI: "true" }, stdio: "pipe" }, + ); + + const findSameFile = (directory: string, referencePath: string): string | undefined => { + const reference = statSync(referencePath); + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + const nested = findSameFile(entryPath, referencePath); + if (nested) { + return nested; + } + } else if (entry.isFile()) { + const candidate = statSync(entryPath); + if (candidate.dev === reference.dev && candidate.ino === reference.ino) { + return entryPath; + } + } + } + return undefined; + }; + const rootPackageFile = path.join(workspace, "node_modules", "cache-proof-dep", "index.js"); + expect(findSameFile(store, rootPackageFile)).toBeDefined(); + + const archive = path.join(root, "dependency-cache.tar"); + execFileSync( + "tar", + [ + "-cf", + archive, + "-C", + workspace, + "node_modules", + "packages/consumer/node_modules", + ".cache/openclaw-pnpm-store", + ], + { stdio: "pipe" }, + ); + + rmSync(path.join(workspace, "node_modules"), { force: true, recursive: true }); + rmSync(path.join(consumer, "node_modules"), { force: true, recursive: true }); + rmSync(store, { force: true, recursive: true }); + execFileSync("tar", ["-xf", archive, "-C", workspace], { stdio: "pipe" }); + + const restoredPackageFile = path.join( + workspace, + "node_modules", + "cache-proof-dep", + "index.js", + ); + expect(findSameFile(store, restoredPackageFile)).toBeDefined(); + expect( + readFileSync(path.join(consumer, "node_modules", "cache-proof-dep", "index.js"), "utf8"), + ).toBe('module.exports = "cache-proof-v1";\n'); + + registryServer.kill("SIGTERM"); + rmSync(registry, { force: true, recursive: true }); + const reconciliation = execFileSync( + "pnpm", + [ + "install", + "--offline", + "--frozen-lockfile", + `--store-dir=${store}`, + "--package-import-method=hardlink", + "--ignore-scripts", + "--config.engine-strict=false", + ], + { cwd: workspace, encoding: "utf8", env: { ...process.env, CI: "true" } }, + ); + expect(reconciliation).toContain("Already up to date"); + expect( + readFileSync(path.join(consumer, "node_modules", "cache-proof-dep", "index.js"), "utf8"), + ).toBe('module.exports = "cache-proof-v1";\n'); + } finally { + registryServer.kill("SIGTERM"); + } + }, + ); + it("persists content-validated public full-build declarations", () => { const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8")); const installStep = action.runs.steps.find( @@ -3292,213 +3318,6 @@ NODE }); }); - it("restores importer-local node_modules from sticky snapshots", () => { - const root = mkdtempSync(path.join(tmpdir(), "openclaw-sticky-importers-")); - try { - const workspace = path.join(root, "workspace"); - const stickyRoot = path.join(root, "sticky"); - const importerRoot = path.join(workspace, "packages", "example"); - const rootModules = path.join(workspace, "node_modules"); - const importerModules = path.join(importerRoot, "node_modules"); - const rootDependency = path.join(rootModules, "ipaddr.js"); - const rootOptionalDependency = path.join(rootModules, "optional-ipaddr"); - const importerDependency = path.join(importerModules, "ipaddr.js"); - const helper = path.resolve(".github/actions/setup-node-env/sticky-importers.sh"); - const rebuildSignal = path.join(root, "rebuilt"); - const lockfile = [ - "lockfileVersion: '9.0'", - "importers:", - " packages/example:", - " dependencies:", - " ipaddr.js:", - " specifier: 2.4.0", - " version: 2.4.0", - " aliased-ipaddr:", - " specifier: npm:ipaddr.js@2.4.0", - " version: ipaddr.js@2.4.0", - " local-helper:", - " specifier: file:../local-helper", - " version: file:../local-helper", - " optionalDependencies:", - " optional-ipaddr:", - " specifier: npm:ipaddr.js@2.4.0", - " version: ipaddr.js@2.4.0", - " unsupported-optional:", - " specifier: 3.0.0", - " version: 3.0.0", - "", - ].join("\n"); - mkdirSync(workspace, { recursive: true }); - writeFileSync(path.join(workspace, "pnpm-lock.yaml"), lockfile, "utf8"); - mkdirSync(rootDependency, { recursive: true }); - mkdirSync(rootOptionalDependency, { recursive: true }); - mkdirSync(importerDependency, { recursive: true }); - writeFileSync( - path.join(rootDependency, "package.json"), - JSON.stringify({ name: "ipaddr.js", version: "1.9.1" }), - "utf8", - ); - writeFileSync( - path.join(rootOptionalDependency, "package.json"), - JSON.stringify({ name: "ipaddr.js", version: "1.9.1" }), - "utf8", - ); - writeFileSync( - path.join(importerDependency, "package.json"), - JSON.stringify({ name: "ipaddr.js", version: "2.4.0" }), - "utf8", - ); - for (const dependencyName of ["aliased-ipaddr", "optional-ipaddr"]) { - const dependencyRoot = path.join(importerModules, dependencyName); - mkdirSync(dependencyRoot, { recursive: true }); - writeFileSync( - path.join(dependencyRoot, "package.json"), - JSON.stringify({ name: "ipaddr.js", version: "2.4.0" }), - "utf8", - ); - } - writeFileSync( - path.join(rootModules, ".modules.yaml"), - JSON.stringify({ - hoistedLocations: { - "ipaddr.js@1.9.1": ["node_modules/ipaddr.js", "node_modules/optional-ipaddr"], - "ipaddr.js@2.4.0": [ - "packages/example/node_modules/ipaddr.js", - "packages/example/node_modules/aliased-ipaddr", - "packages/example/node_modules/optional-ipaddr", - ], - }, - }), - "utf8", - ); - writeFileSync(path.join(rootModules, "root-sentinel"), "before", "utf8"); - - execFileSync("bash", [ - helper, - "capture", - stickyRoot, - workspace, - "fingerprint-a", - rebuildSignal, - ]); - expect(existsSync(rebuildSignal)).toBe(true); - rmSync(importerModules, { recursive: true }); - writeFileSync(path.join(rootModules, "root-sentinel"), "after", "utf8"); - execFileSync("bash", [helper, "restore", stickyRoot, workspace]); - - expect( - JSON.parse(readFileSync(path.join(importerDependency, "package.json"), "utf8")), - ).toMatchObject({ version: "2.4.0" }); - expect(readFileSync(path.join(rootModules, "root-sentinel"), "utf8")).toBe("after"); - expect(readFileSync(path.join(stickyRoot, ".openclaw-deps-fingerprint"), "utf8")).toBe( - "fingerprint-a\n", - ); - rmSync(rebuildSignal); - execFileSync("bash", [ - helper, - "capture", - stickyRoot, - workspace, - "fingerprint-b", - rebuildSignal, - ]); - expect(existsSync(rebuildSignal)).toBe(true); - expect(readFileSync(path.join(stickyRoot, ".openclaw-deps-fingerprint"), "utf8")).toBe( - "fingerprint-b\n", - ); - - // Recreate the reported failure shape: a marker-matching archive can be - // structurally valid yet omit the importer-local override, causing Node - // to fall through to the stale root-hoisted version. - rmSync(importerModules, { recursive: true }); - const archive = path.join(stickyRoot, "importer-node-modules.tar"); - execFileSync("tar", ["--create", "--file", archive, "--files-from", "/dev/null"]); - const manifest = path.join(stickyRoot, "importer-node-modules.manifest"); - const archiveChecksum = createHash("sha256").update(readFileSync(archive)).digest("hex"); - const manifestChecksum = createHash("sha256").update(readFileSync(manifest)).digest("hex"); - writeFileSync( - path.join(stickyRoot, ".openclaw-importer-archive.sha256"), - `${archiveChecksum}\n${manifestChecksum}\n`, - "utf8", - ); - const failedRestore = spawnSync("bash", [helper, "restore", stickyRoot, workspace], { - encoding: "utf8", - }); - expect(failedRestore.status).toBe(1); - expect(failedRestore.stderr).toContain( - "ipaddr.js expected ipaddr.js@2.4.0, resolved ipaddr.js@1.9.1", - ); - expect(existsSync(importerModules)).toBe(false); - - rmSync(rebuildSignal); - const failedCapture = spawnSync( - "bash", - [helper, "capture", stickyRoot, workspace, "fingerprint-c", rebuildSignal], - { encoding: "utf8" }, - ); - expect(failedCapture.status).toBe(1); - expect(existsSync(rebuildSignal)).toBe(false); - expect(failedCapture.stderr).toContain( - "ipaddr.js expected ipaddr.js@2.4.0, resolved ipaddr.js@1.9.1", - ); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - it("forces StickyDisk's allocation delta after a successful rebuild", () => { - const root = mkdtempSync(path.join(tmpdir(), "openclaw-sticky-allocation-")); - try { - const fakeBin = path.join(root, "bin"); - const stickyRoot = path.join(root, "sticky"); - const usageFile = path.join(root, "usage"); - const helper = path.resolve(".github/actions/setup-node-env/sticky-importers.sh"); - mkdirSync(fakeBin, { recursive: true }); - mkdirSync(stickyRoot, { recursive: true }); - // Start one allocation block below the action's baseline. A fixed append - // can be cancelled by this shrink; the helper must measure the net delta. - writeFileSync(usageFile, "995904\n", "utf8"); - writeFileSync( - path.join(fakeBin, "df"), - '#!/usr/bin/env bash\necho Used\ncat "$OPENCLAW_TEST_USAGE_FILE"\n', - "utf8", - ); - writeFileSync( - path.join(fakeBin, "dd"), - `#!/usr/bin/env bash -set -euo pipefail -count=0 -for arg in "$@"; do - case "$arg" in count=*) count="\${arg#count=}" ;; esac -done -usage="$(<"$OPENCLAW_TEST_USAGE_FILE")" -printf '%s\n' "$((usage + count * 4096))" > "$OPENCLAW_TEST_USAGE_FILE" -`, - "utf8", - ); - writeFileSync(path.join(fakeBin, "sync"), "#!/usr/bin/env bash\nexit 0\n", "utf8"); - for (const command of ["df", "dd", "sync"]) { - chmodSync(path.join(fakeBin, command), 0o755); - } - - const result = spawnSync("bash", [helper, "ensure-change", stickyRoot, "1000000"], { - encoding: "utf8", - env: { - ...process.env, - OPENCLAW_TEST_USAGE_FILE: usageFile, - PATH: `${fakeBin}:${process.env.PATH ?? ""}`, - }, - }); - - expect(result.status, result.stderr).toBe(0); - const finalUsage = Number(readFileSync(usageFile, "utf8").trim()); - expect(Math.abs(finalUsage - 1_000_000)).toBeGreaterThan(65_536); - expect(result.stdout).toContain("Sticky dependency rebuild changed allocation"); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - it("fingerprints dependency install inputs without ordinary script churn", () => { const root = mkdtempSync(path.join(tmpdir(), "openclaw-dependency-fingerprint-")); try { @@ -3763,7 +3582,6 @@ printf '%s\n' "$((usage + count * 4096))" > "$OPENCLAW_TEST_USAGE_FILE" it("warms protected caches without main-run cancellation", () => { const warmerSource = readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8"); const warmer = parse(warmerSource); - const workflow = readCiWorkflow(); const warmerSetup = warmer.jobs.warm.steps.find( (step: WorkflowStep) => step.name === "Setup Node environment", ); @@ -3779,9 +3597,6 @@ printf '%s\n' "$((usage + count * 4096))" > "$OPENCLAW_TEST_USAGE_FILE" const maintainStoreStep = warmer.jobs.warm.steps.find( (step: WorkflowStep) => step.name === "Maintain dependency store budget", ); - const maintainStickyStoreStep = workflow.jobs.preflight.steps.find( - (step: WorkflowStep) => step.name === "Maintain sticky dependency store budget", - )!; expect(warmer.concurrency["cancel-in-progress"]).toBe(false); expect(warmer.concurrency.group).toBe("vitest-cache-warm"); @@ -3804,38 +3619,15 @@ printf '%s\n' "$((usage + count * 4096))" > "$OPENCLAW_TEST_USAGE_FILE" "save-actions-cache": "true", "save-node-compile-cache": "true", "save-vitest-fs-cache": "true", - "sticky-disk": "false", "use-actions-cache": "true", }); + expect(warmerSetup.with).not.toHaveProperty("dependency-cache"); // CI is restore-only, so no per-PR runtime cache family or close-time // cleanup workflow exists. Actions cache LRU/TTL expires old warmers. expect(existsSync(".github/workflows/pr-cache-cleanup.yml")).toBe(false); expect(seedStep.if).toBeUndefined(); expect(warmStep.if).toBeUndefined(); expect(maintainStoreStep).toBeUndefined(); - expect(maintainStickyStoreStep.env.OPENCLAW_PNPM_STORE_MAX_KIB).toBe("8388608"); - - const maintenanceRoot = mkdtempSync(path.join(tmpdir(), "openclaw-pnpm-maintenance-")); - try { - const storeDir = path.join(maintenanceRoot, "store"); - const summaryPath = path.join(maintenanceRoot, "summary.md"); - mkdirSync(storeDir); - const result = spawnSync("bash", ["-c", maintainStickyStoreStep.run], { - encoding: "utf8", - env: { - ...process.env, - GITHUB_STEP_SUMMARY: summaryPath, - OPENCLAW_PNPM_STORE_MAX_KIB: "-1", - OPENCLAW_STICKY_REBUILD_SIGNAL: path.join(maintenanceRoot, "not-rebuilt"), - PNPM_CONFIG_STORE_DIR: storeDir, - }, - }); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("pruning above -1 KiB ceiling"); - expect(readFileSync(summaryPath, "utf8")).toContain("- Pruned: true"); - } finally { - rmSync(maintenanceRoot, { force: true, recursive: true }); - } }); it("uses bundled Node shards and telemetry-backed runner sizes", () => { @@ -5716,7 +5508,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" const expectedUiE2eSetup = { "node-version": "24.x", "install-bun": "false", - "sticky-disk": + "dependency-cache": "${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'false' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false') }}", "use-actions-cache": "${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'true' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true') }}", @@ -5751,7 +5543,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" repository: "openclaw/openclaw", runAttempt: 1, }, - expected: { blacksmith: true, stickyDisk: "true", useActionsCache: "false" }, + expected: { blacksmith: true, dependencyCache: "true", useActionsCache: "false" }, }, { name: "same-repo pull request retry", @@ -5761,7 +5553,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" repository: "openclaw/openclaw", runAttempt: 2, }, - expected: { blacksmith: false, stickyDisk: "false", useActionsCache: "true" }, + expected: { blacksmith: false, dependencyCache: "false", useActionsCache: "true" }, }, { name: "fork pull request", @@ -5771,7 +5563,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" repository: "openclaw/openclaw", runAttempt: 1, }, - expected: { blacksmith: false, stickyDisk: "false", useActionsCache: "true" }, + expected: { blacksmith: false, dependencyCache: "false", useActionsCache: "true" }, }, { name: "workflow dispatch", @@ -5780,7 +5572,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" repository: "openclaw/openclaw", runAttempt: 1, }, - expected: { blacksmith: false, stickyDisk: "false", useActionsCache: "true" }, + expected: { blacksmith: false, dependencyCache: "false", useActionsCache: "true" }, }, { name: "canonical push retry", @@ -5789,7 +5581,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" repository: "openclaw/openclaw", runAttempt: 2, }, - expected: { blacksmith: true, stickyDisk: "true", useActionsCache: "false" }, + expected: { blacksmith: true, dependencyCache: "true", useActionsCache: "false" }, }, ] as const; for (const { blacksmithRunner, job, name: jobName, setup } of routedUiE2eJobs) { @@ -5805,9 +5597,9 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expectedRunner, ); expect( - evaluateWorkflowExpression(setup.with?.["sticky-disk"], context), + evaluateWorkflowExpression(setup.with?.["dependency-cache"], context), assertionName, - ).toBe(expected.stickyDisk); + ).toBe(expected.dependencyCache); expect( evaluateWorkflowExpression(setup.with?.["use-actions-cache"], context), assertionName, diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index eeb5574c1a7a..7c4e9dea8f80 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1514,7 +1514,6 @@ describe("package acceptance workflow", () => { const setupNodeAction = readFileSync(".github/actions/setup-node-env/action.yml", "utf8"); expect(setupNodeAction).toContain("Normalize container toolcache"); expect(setupNodeAction).toContain("ln -s /__t /opt/hostedtoolcache"); - expect(setupNodeAction).toContain("use-actions-cache: ${{ inputs.use-actions-cache }}"); for (const workflowPath of workflowPaths()) { const workflowText = readFileSync(workflowPath, "utf8"); @@ -3580,12 +3579,9 @@ describe("package artifact reuse", () => { expect(workflow).not.toContain('PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"'); expect(workflow).not.toContain("PNPM_CONFIG_MODULES_DIR"); expect(workflow).not.toContain("PNPM_CONFIG_VIRTUAL_STORE_DIR"); - expect(setupNodeWith["sticky-disk"]).toBe( - "${{ github.event_name == 'workflow_dispatch' && 'true' || 'false' }}", - ); - expect(setupNodeWith["use-actions-cache"]).toBe( - "${{ github.event_name == 'workflow_dispatch' && 'false' || 'true' }}", - ); + expect(setupNodeWith).not.toHaveProperty("dependency-cache"); + expect(setupNodeWith).not.toHaveProperty("sticky-disk"); + expect(setupNodeWith["use-actions-cache"]).toBe("true"); expect(checkTestboxJob["timeout-minutes"]).toBe( "${{ fromJSON(inputs.timeout_minutes || '120') }}", ); diff --git a/test/scripts/plugin-npm-extended-stable-workflow.test.ts b/test/scripts/plugin-npm-extended-stable-workflow.test.ts index b14dbb609c0a..1efb5c798b2b 100644 --- a/test/scripts/plugin-npm-extended-stable-workflow.test.ts +++ b/test/scripts/plugin-npm-extended-stable-workflow.test.ts @@ -114,7 +114,7 @@ describe("plugin npm extended-stable workflow", () => { expect(preflightCheckout.with).toMatchObject({ ref: "${{ github.workflow_sha }}", path: ".release-tooling", - "sparse-checkout": "scripts", + "sparse-checkout": "packages/normalization-core\nscripts\n", }); const previewCommand = step(parsed.jobs?.preview_plugin_pack, "Preview publish command").run; expect(previewCommand).toContain(".release-tooling/scripts/plugin-npm-publish.sh"); diff --git a/test/scripts/release-preflight.test.ts b/test/scripts/release-preflight.test.ts index bb96e0dd5fa6..c40f5b140ff7 100644 --- a/test/scripts/release-preflight.test.ts +++ b/test/scripts/release-preflight.test.ts @@ -1,7 +1,7 @@ // Release preflight tests keep generated-artifact checks fail-closed for operators. import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { delimiter, join, resolve } from "node:path"; +import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { delimiter, dirname, join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; @@ -123,11 +123,79 @@ function makeReleaseFixture( return root; } +function makeIsolatedPreflightFixture(params: Parameters[0] = {}): { + root: string; + script: string; +} { + const root = makeReleaseFixture(params); + const files = [ + "scripts/release-preflight.mjs", + "scripts/release-preflight.mts", + "scripts/windows-cmd-helpers.mjs", + "scripts/lib/error-format.mts", + "scripts/lib/failed-trailer.mts", + "scripts/lib/managed-child-process.mts", + "scripts/lib/release-version.mjs", + "scripts/lib/tsx-cli-shim.mjs", + "scripts/lib/windows-taskkill.mjs", + ]; + for (const file of files) { + const destination = join(root, file); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(file, destination); + } + return { root, script: join(root, "scripts", "release-preflight.mjs") }; +} + +function runIsolatedPreflight( + args: string[], + params: Parameters[0] = {}, +) { + const fixture = makeIsolatedPreflightFixture(params); + const env = { ...process.env }; + delete env.NODE_OPTIONS; + delete env.NODE_PATH; + delete env.PNPM_CONFIG_MODULES_DIR; + delete env.npm_config_modules_dir; + return spawnSync(process.execPath, [fixture.script, ...args], { + cwd: fixture.root, + encoding: "utf8", + env, + }); +} + function readPnpmLog(logPath: string): string[] { return readFileSync(logPath, "utf8").trimEnd().split("\n").filter(Boolean); } describe("scripts/release-preflight.mjs", () => { + it("checks valid macOS metadata without node_modules", () => { + const result = runIsolatedPreflight(["--macos-versions-only"]); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("[release-preflight] macOS app version metadata OK"); + }); + + it("reports stale macOS metadata without node_modules", () => { + const result = runIsolatedPreflight(["--macos-versions-only"], { + shortVersion: "2026.6.10", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + 'CFBundleShortVersionString is "2026.6.10"; expected "2026.7.1" from package.json base version', + ); + expect(result.stderr.trimEnd().split("\n").at(-1)).toBe("[release-preflight] FAILED (exit 1)"); + }); + + it("keeps multi-argument invocations on the tsx shim", () => { + const result = runIsolatedPreflight(["--macos-versions-only", "--check"]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Cannot find module 'tsx'"); + expect(result.stderr).toContain("[release-preflight] FAILED (exit 1)"); + }); + it("rejects unknown arguments before running release checks", () => { const result = runPreflight(["--fiix"]); diff --git a/test/scripts/upgrade-survivor-assertions.test.ts b/test/scripts/upgrade-survivor-assertions.test.ts index 24d33e2df9c4..b4d2b2a0e4c9 100644 --- a/test/scripts/upgrade-survivor-assertions.test.ts +++ b/test/scripts/upgrade-survivor-assertions.test.ts @@ -91,14 +91,9 @@ function writeMigratedSessionState(stateDir: string): void { } function createMigratedSessionFileStore( - stateDir: string, options: { includePrompt?: boolean } = {}, ): Record> { - const agentSessionsDir = join(stateDir, "agents", "main", "sessions"); - const main: Record = { - sessionId: "upgrade-main-session", - sessionFile: join(agentSessionsDir, "upgrade-main-session.jsonl"), - }; + const main: Record = { sessionId: "upgrade-main-session" }; if (options.includePrompt !== false) { main.skillsSnapshot = { prompt: "legacy prompt survives as metadata", @@ -106,14 +101,8 @@ function createMigratedSessionFileStore( } return { "agent:main:main": main, - "agent:main:+15551234567": { - sessionId: "upgrade-direct-session", - sessionFile: join(agentSessionsDir, "upgrade-direct-session.jsonl"), - }, - "agent:main:slack:channel:cupgrade": { - sessionId: "upgrade-group-session", - sessionFile: join(agentSessionsDir, "upgrade-group-session.jsonl"), - }, + "agent:main:+15551234567": { sessionId: "upgrade-direct-session" }, + "agent:main:slack:channel:cupgrade": { sessionId: "upgrade-group-session" }, }; } @@ -123,10 +112,7 @@ function writeMigratedSessionFiles( ): void { const agentSessionsDir = join(stateDir, "agents", "main", "sessions"); mkdirSync(agentSessionsDir, { recursive: true }); - writeJson( - join(agentSessionsDir, "sessions.json"), - createMigratedSessionFileStore(stateDir, options), - ); + writeJson(join(agentSessionsDir, "sessions.json"), createMigratedSessionFileStore(options)); for (const sessionId of [ "upgrade-main-session", "upgrade-direct-session", @@ -162,7 +148,7 @@ function writeLegacyCacheSessionState( const insert = db.prepare( "INSERT INTO cache_entries (scope, key, value_json) VALUES (?, ?, ?)", ); - for (const [key, entry] of Object.entries(createMigratedSessionFileStore(stateDir, options))) { + for (const [key, entry] of Object.entries(createMigratedSessionFileStore(options))) { insert.run("session_entries", key, JSON.stringify(entry)); } } finally { @@ -187,7 +173,7 @@ function writeLegacySessionEntriesState(stateDir: string): void { INSERT INTO session_entries (session_key, session_id, entry_json, updated_at) VALUES (?, ?, ?, ?) `); - for (const [key, entry] of Object.entries(createMigratedSessionFileStore(stateDir))) { + for (const [key, entry] of Object.entries(createMigratedSessionFileStore())) { const sessionId = entry.sessionId; if (typeof sessionId !== "string") { throw new TypeError(`missing fixture session id for ${key}`); @@ -594,6 +580,27 @@ describe("upgrade survivor assertions", () => { ).not.toThrow(); }); + it("rejects retired sessionFile metadata in SQLite-backed session rows", () => { + expect(() => + runSessionStateAssertion((stateDir) => { + writeMigratedSessionState(stateDir); + const db = new DatabaseSync( + join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"), + ); + try { + db.prepare("UPDATE session_nodes SET entry_json = ? WHERE session_key = ?").run( + JSON.stringify({ + sessionFile: join(stateDir, "sessions", "upgrade-main-session.jsonl"), + }), + "agent:main:main", + ); + } finally { + db.close(); + } + }), + ).toThrow(/retained retired sessionFile metadata/); + }); + it("rejects ClawHub npm-pack installs outside the managed extensions root", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-outside-")); try { diff --git a/test/vitest/vitest.agents-paths.mjs b/test/vitest/vitest.agents-paths.mjs index f7e1c4f9781d..5c4acd18e7f1 100644 --- a/test/vitest/vitest.agents-paths.mjs +++ b/test/vitest/vitest.agents-paths.mjs @@ -18,16 +18,12 @@ const coreIsolatedFiles = [ "src/agents/subagents/registry/subagent-registry-restart-recovery.test.ts", ]; const incompleteTurnFiles = [ - `${embeddedRoot}/run.incomplete-turn.attempt-lifecycle.test.ts`, `${embeddedRoot}/run.incomplete-turn.classification.test.ts`, `${embeddedRoot}/run.incomplete-turn.delivery-resolution.test.ts`, - `${embeddedRoot}/run.incomplete-turn.empty-response-recovery.test.ts`, `${embeddedRoot}/run.incomplete-turn.error-recovery.test.ts`, `${embeddedRoot}/run.incomplete-turn.payload-resolution.test.ts`, - `${embeddedRoot}/run.incomplete-turn.reasoning-recovery.test.ts`, `${embeddedRoot}/run.incomplete-turn.settled-tool-continuation.test.ts`, `${embeddedRoot}/run.incomplete-turn.settled-tool-recovery.test.ts`, - `${embeddedRoot}/run.incomplete-turn.silent-reply.test.ts`, `${embeddedRoot}/run.incomplete-turn.terminal-evidence.test.ts`, ]; const overflowCompactionFiles = [ diff --git a/ui/package.json b/ui/package.json index 257907bdec44..7bcd05526002 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,8 +32,8 @@ "@openclaw/session-url-contract": "workspace:*", "@openclaw/uirouter": "0.1.1", "@openclaw/workboard-contract": "workspace:*", - "@tanstack/lit-virtual": "3.13.35", - "@tanstack/virtual-core": "3.17.6", + "@tanstack/lit-virtual": "3.13.36", + "@tanstack/virtual-core": "3.17.7", "dompurify": "3.4.12", "ghostty-web": "0.4.0", "highlight.js": "11.11.1", diff --git a/ui/src/app-navigation-groups.test.ts b/ui/src/app-navigation-groups.test.ts index 93d2694aec0a..4c782a7d2099 100644 --- a/ui/src/app-navigation-groups.test.ts +++ b/ui/src/app-navigation-groups.test.ts @@ -97,6 +97,14 @@ describe("sidebar entries", () => { expect(isSettingsNavigationRoute("apps")).toBe(false); }); + it("keeps Portals as a first-class customizable workspace route", () => { + expect(SIDEBAR_NAV_ROUTES).toContain("portals"); + expect(DEFAULT_SIDEBAR_ENTRIES).not.toContain("route:portals"); + expect(sidebarMoreRoutes(DEFAULT_SIDEBAR_ENTRIES)).toContain("portals"); + expect(settingsRoutes).not.toContain("portals"); + expect(isSettingsNavigationRoute("portals")).toBe(false); + }); + it("keeps the plugin manager in customizable workspace routes", () => { expect(normalizeSidebarEntries(["route:plugins", "route:usage", "route:plugins"])).toEqual([ "route:plugins", diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index a01cd55dcaa8..be407b513805 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -94,6 +94,7 @@ describe("navigationIconForRoute", () => { custodian: "lobster", activity: "activity", apps: "layoutGrid", + portals: "monitor", approvals: "badgeCheck", workboard: "kanban", dashboards: "layoutDashboard", @@ -216,6 +217,7 @@ describe("titleForRoute", () => { custodian: "OpenClaw", activity: "Activity", apps: "Apps", + portals: "Portals", approvals: "Approvals", workboard: "Workboard", dashboards: "Dashboards", @@ -266,6 +268,7 @@ describe("subtitleForRoute", () => { custodian: "System setup and care.", activity: "Browser-local tool activity summaries.", apps: "Companion apps for phone, watch, desktop, and browser.", + portals: "Live previews from agent-run applications.", approvals: "Recent exec, plugin, and system-agent approvals.", workboard: "Agent work queue and session handoff.", dashboards: "Sessions that open on their dashboard face.", @@ -311,6 +314,7 @@ describe("pathForRoute", () => { it("returns correct path without base", () => { expect(pathForRoute("chat")).toBe("/chat"); expect(pathForRoute("apps")).toBe("/apps"); + expect(pathForRoute("portals")).toBe("/portals"); expect(pathForRoute("dashboards")).toBe("/dashboards"); expect(pathForRoute("custodian")).toBe("/custodian"); expect(pathForRoute("connection")).toBe("/settings/connection"); @@ -349,6 +353,7 @@ describe("routeIdFromPath", () => { expect(routeIdFromPath("/connection")).toBeNull(); expect(routeIdFromPath("/activity")).toBe("activity"); expect(routeIdFromPath("/apps")).toBe("apps"); + expect(routeIdFromPath("/portals")).toBe("portals"); expect(routeIdFromPath("/dashboards")).toBe("dashboards"); expect(routeIdFromPath("/sessions")).toBe("sessions"); expect(routeIdFromPath("/debug")).toBe("debug"); @@ -515,6 +520,18 @@ describe("plugin tabs route", () => { describe("SIDEBAR_NAV_ROUTES", () => { it("all routes are unique", () => { + expect(SIDEBAR_NAV_ROUTES).toEqual([ + "workboard", + "dashboards", + "usage", + "cron", + "tasks", + "sessions", + "activity", + "plugins", + "apps", + "portals", + ]); expect(new Set(SIDEBAR_NAV_ROUTES).size).toBe(SIDEBAR_NAV_ROUTES.length); }); diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index 421c4dcc499b..3d5f055295d5 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -26,6 +26,7 @@ export const SIDEBAR_NAV_ROUTES = [ "activity", "plugins", "apps", + "portals", ] as const satisfies readonly NavigationRouteId[]; // Routes presented as tabs of the Plugins hub. The sidebar highlights the @@ -222,6 +223,7 @@ const NAVIGATION_ICONS: NavigationItem = { agents: "bot", activity: "activity", apps: "layoutGrid", + portals: "monitor", approvals: "badgeCheck", workboard: "kanban", worktrees: "folder", @@ -330,6 +332,7 @@ const NAVIGATION_COPY: Record { expect(routeIdFromPath("/settings/secrets")).toBe("secrets"); }); + it("registers the Portals workspace path", () => { + expect(pathForRoute("portals")).toBe("/portals"); + expect(routeIdFromPath("/portals")).toBe("portals"); + }); + it.each(DYNAMIC_STARTUP_CASES)( "loads the $label once while publishing its real location", async ({ routeId, location: initialLocation }) => { diff --git a/ui/src/app-route-paths.ts b/ui/src/app-route-paths.ts index f8517d25736c..8b5f9acb2956 100644 --- a/ui/src/app-route-paths.ts +++ b/ui/src/app-route-paths.ts @@ -27,6 +27,7 @@ const APP_ROUTE_DEFINITIONS = { "new-session": { path: "/new" }, activity: { path: "/activity" }, apps: { path: "/apps" }, + portals: { path: "/portals" }, agents: { path: "/settings/agents", aliases: ["/agents"] }, channels: { path: "/settings/channels", aliases: ["/channels"] }, connection: { path: "/settings/connection" }, diff --git a/ui/src/app-routes.test.ts b/ui/src/app-routes.test.ts index 70f7b1a7fa7a..d26bac2770a6 100644 --- a/ui/src/app-routes.test.ts +++ b/ui/src/app-routes.test.ts @@ -12,6 +12,7 @@ describe("application router registration", () => { it("registers every route id exactly once", () => { const routeIds = router.routes.map((route) => route.id); + expect(routeIds).toContain("portals"); expect([...routeIds].toSorted()).toEqual([...APP_ROUTE_IDS].toSorted()); }); diff --git a/ui/src/app-routes.ts b/ui/src/app-routes.ts index f442e09d16c9..c36848326f35 100644 --- a/ui/src/app-routes.ts +++ b/ui/src/app-routes.ts @@ -47,6 +47,7 @@ import { page as modelSetupPage } from "./pages/model-setup/route.ts"; import { page as newSessionPage } from "./pages/new-session/route.ts"; import { page as pluginPage } from "./pages/plugin/route.ts"; import { page as pluginsPage } from "./pages/plugins/route.ts"; +import { page as portalsPage } from "./pages/portals/route.ts"; import { page as profilePage } from "./pages/profile/route.ts"; import { page as secretsPage } from "./pages/secrets/route.ts"; import { page as sessionsPage } from "./pages/sessions/route.ts"; @@ -80,6 +81,7 @@ const APP_ROUTE_TREE = [ activityPage, dashboardsPage, appsPage, + portalsPage, agentsPage, approvalsPage, channelsPage, diff --git a/ui/src/components/select-picker.ts b/ui/src/components/select-picker.ts index 61853d6769de..7eab8c93ed5e 100644 --- a/ui/src/components/select-picker.ts +++ b/ui/src/components/select-picker.ts @@ -23,6 +23,8 @@ export type PickerParams