diff --git a/.agents/skills/openclaw-parallels-smoke/SKILL.md b/.agents/skills/openclaw-parallels-smoke/SKILL.md index b8cd8143e438e..e7d98050c6ee4 100644 --- a/.agents/skills/openclaw-parallels-smoke/SKILL.md +++ b/.agents/skills/openclaw-parallels-smoke/SKILL.md @@ -16,13 +16,30 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo - Pass `--json` for machine-readable summaries. - Per-phase logs land under `/tmp/openclaw-parallels-*`. - Do not run local and gateway agent turns in parallel on the same fresh workspace or session. +- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around. + +## npm install then update + +- Preferred entrypoint: `pnpm test:parallels:npm-update` +- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again. +- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check. +- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. On Peter's current host, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`. +- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service. +- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs. +- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials. + +## CLI invocation footgun + +- The Parallels smoke shell scripts should tolerate a literal bare `--` arg so `pnpm test:parallels:* -- --json` and similar forwarded invocations work without needing to call `bash scripts/e2e/...` directly. ## macOS flow - Preferred entrypoint: `pnpm test:parallels:macos` - Default to the snapshot closest to `macOS 26.3.1 latest`. - On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness. +- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port. - `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters. +- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`. - On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed. - Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`. - Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task. @@ -34,7 +51,11 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo - Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`. - Prefer explicit `npm.cmd` and `openclaw.cmd`. - Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it. +- Multi-word `openclaw agent --message ...` checks should call `& $openclaw ...` inside PowerShell, not `Start-Process ... -ArgumentList` against `openclaw.cmd`, or Commander can see split argv and throw `too many arguments for 'agent'`. +- Windows installer/tgz phases now retry once after guest-ready recheck; keep new Windows smoke steps idempotent so a transport-flake retry is safe. +- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips. - Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths. +- If you hit an older run with `rc=255` plus an empty `fresh.install-main.log` or `upgrade.install-main.log`, treat it as a likely `prlctl exec` transport drop after guest start-up, not immediate proof of an npm/package failure. ## Linux flow diff --git a/.agents/skills/openclaw-release-maintainer/SKILL.md b/.agents/skills/openclaw-release-maintainer/SKILL.md index fc7674a774d98..bd9227c285361 100644 --- a/.agents/skills/openclaw-release-maintainer/SKILL.md +++ b/.agents/skills/openclaw-release-maintainer/SKILL.md @@ -11,7 +11,9 @@ Use this skill for release and publish-time workflow. Keep ordinary development - Do not change version numbers without explicit operator approval. - Ask permission before any npm publish or release step. -- Use the private maintainer release docs for the actual runbook and `docs/reference/RELEASING.md` for public policy. +- This skill should be sufficient to drive the normal release flow end-to-end. +- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy. +- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself. ## Keep release channel naming aligned @@ -31,8 +33,19 @@ Use this skill for release and publish-time workflow. Keep ordinary development - `apps/macos/Sources/OpenClaw/Resources/Info.plist` - `docs/install/updating.md` - Peekaboo Xcode project and plist version fields +- Before creating a release tag, make every version location above match the version encoded by that tag. +- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`. - “Bump version everywhere” means all version locations above except `appcast.xml`. - Release signing and notary credentials live outside the repo in the private maintainer docs. +- Every OpenClaw release ships the npm package and macOS app together. +- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo. +- That shared production Sparkle feed is stable-only. Beta mac releases may + upload assets to the GitHub prerelease, but they must not replace the shared + `appcast.xml` unless a separate beta feed exists. +- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays + at `YYYY.M.D`, but the mac release must use a strictly higher numeric + `APP_BUILD` / Sparkle build than the original release so existing installs + see it as newer. ## Build changelog-backed release notes @@ -59,16 +72,166 @@ pnpm test:install:smoke For a non-root smoke path: ```bash -OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke + OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke ``` +After npm publish, run: + +```bash +node --import tsx scripts/openclaw-npm-postpublish-verify.ts +``` + +- This verifies the published registry install path in a fresh temp prefix. +- For stable correction releases like `YYYY.M.D-N`, it also verifies the + upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot + silently leave existing global installs on the old base stable payload. + +## Check all relevant release builds + +- Always validate the OpenClaw npm release path before creating the tag. +- Default release checks: + - `pnpm check` + - `pnpm build` + - `node --import tsx scripts/release-check.ts` + - `pnpm release:check` + - `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` +- Check all release-related build surfaces touched by the release, not only the npm package. +- Include mac release readiness in preflight by running the public validation + workflow in `openclaw/openclaw` and the real mac preflight in + `openclaw/releases-private` for every release. +- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up. +- The workflows remain tag-based. The agent is responsible for making sure + preflight runs complete successfully before any publish run starts. +- Any fix after preflight means a new commit. Delete and recreate the tag and + matching GitHub release from the fixed commit, then rerun preflight from + scratch before publishing. +- For stable mac releases, generate the signed `appcast.xml` before uploading + public release assets so the updater feed cannot lag the published binaries. +- Serialize stable appcast-producing runs across tags so two releases do not + generate replacement `appcast.xml` files from the same stale seed. +- For stable releases, confirm the latest beta already passed the broader release workflows before cutting stable. +- If any required build, packaging step, or release workflow is red, do not say the release is ready. + ## Use the right auth flow -- Core `openclaw` publish uses GitHub trusted publishing. -- Do not use `NPM_TOKEN` or the plugin OTP flow for core releases. +- OpenClaw publish uses GitHub trusted publishing. +- The publish run must be started manually with `workflow_dispatch`. +- The npm workflow and the private mac publish workflow accept + `preflight_only=true` to run validation/build/package steps without uploading + public release assets. +- The private mac workflow also accepts `smoke_test_only=true` for branch-safe + workflow smoke tests that use ad-hoc signing, skip notarization, skip shared + appcast generation, and do not prove release readiness. +- `preflight_only=true` on the npm workflow is also the right way to validate an + existing tag after publish; it should keep running the build checks even when + the npm version is already published. +- Validation-only runs may be dispatched from a branch when you are testing a + workflow change before merge. +- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a + public validation-only handoff. It validates the tag/release state and points + operators to the private repo; it does not build or publish macOS artifacts. +- Real mac preflight and real mac publish both use + `openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`. +- The private mac workflow runs on GitHub's xlarge macOS runner and uses a + SwiftPM cache because the Swift build/test/package path is CPU-heavy. +- Private mac preflight uploads notarized build artifacts as workflow artifacts + instead of uploading public GitHub release assets. +- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as + workflow artifacts and intentionally skip stable `appcast.xml` generation. +- npm preflight, public mac validation, and private mac preflight must all pass + before any real publish run starts. +- Real publish runs must be dispatched from `main`; branch-dispatched publish + attempts should fail before the protected environment is reached. +- The release workflows stay tag-based; rely on the documented release sequence + rather than workflow-level SHA pinning. +- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues. +- Mac publish uses + `openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for + build, signing, notarization, packaged mac artifact generation, and + stable-feed `appcast.xml` artifact generation. +- Real private mac publish uploads the packaged `.zip`, `.dmg`, and + `.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw` + automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the + private repo `mac-release` environment. +- For stable releases, the agent must also download the signed + `macos-appcast-` artifact from the successful private mac workflow and + then update `appcast.xml` on `main`. +- For beta mac releases, do not update the shared production `appcast.xml` + unless a separate beta Sparkle feed exists. +- The private repo targets a dedicated `mac-release` environment. If the GitHub + plan does not yet support required reviewers there, do not assume the + environment alone is the approval boundary; rely on private repo access and + CODEOWNERS until those settings can be enabled. +- Do not use `NPM_TOKEN` or the plugin OTP flow for OpenClaw releases. - `@openclaw/*` plugin publishes use a separate maintainer-only flow. - Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished. +## Fallback local mac publish + +- Keep the original local macOS publish workflow available as a fallback in case + CI/CD mac publishing is unavailable or broken. +- Preserve the existing maintainer workflow Peter uses: run it on a real Mac + with local signing, notary, and Sparkle credentials already configured. +- Follow the private maintainer macOS runbook for the local steps: + `scripts/package-mac-dist.sh` to build, sign, notarize, and package the app; + manual GitHub release asset upload; then `scripts/make_appcast.sh` plus the + `appcast.xml` commit to `main`. +- `scripts/package-mac-dist.sh` now fails closed for release builds if the + bundled app comes out with a debug bundle id, an empty Sparkle feed URL, or a + `CFBundleVersion` below the canonical Sparkle build floor for that short + version. For correction tags, set a higher explicit `APP_BUILD`. +- `scripts/make_appcast.sh` first uses `generate_appcast` from `PATH`, then + falls back to the SwiftPM Sparkle tool output under `apps/macos/.build`. +- For stable tags, the local fallback may update the shared production + `appcast.xml`. +- For beta tags, the local fallback still publishes the mac assets but must not + update the shared production `appcast.xml` unless a separate beta feed exists. +- Treat the local workflow as fallback only. Prefer the CI/CD publish workflow + when it is working. +- After any stable mac publish, verify all of the following before you call the + release finished: + - the GitHub release has `.zip`, `.dmg`, and `.dSYM.zip` assets + - `appcast.xml` on `main` points at the new stable zip + - the packaged app reports the expected short version and a numeric + `CFBundleVersion` at or above the canonical Sparkle build floor + +## Run the release sequence + +1. Confirm the operator explicitly wants to cut a release. +2. Choose the exact target version and git tag. +3. Make every repo version location match that tag before creating it. +4. Update `CHANGELOG.md` and assemble the matching GitHub release notes. +5. Run the full preflight for all relevant release builds, including mac readiness. +6. Confirm the target npm version is not already published. +7. Create and push the git tag. +8. Create or refresh the matching GitHub release. +9. Start `.github/workflows/openclaw-npm-release.yml` with `preflight_only=true` + and wait for it to pass. +10. Start `.github/workflows/macos-release.yml` in `openclaw/openclaw` and wait + for the public validation-only run to pass. +11. Start + `openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` + with `preflight_only=true` and wait for it to pass. +12. If any preflight or validation run fails, fix the issue on a new commit, + delete the tag and matching GitHub release, recreate them from the fixed + commit, and rerun all relevant preflights from scratch before continuing. + Never reuse old preflight results after the commit changes. +13. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for + the real publish. +14. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`. +15. Start + `openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` + for the real publish and wait for success. +16. Verify the successful real private mac run uploaded the `.zip`, `.dmg`, + and `.dSYM.zip` artifacts to the existing GitHub release in + `openclaw/openclaw`. +17. For stable releases, download `macos-appcast-` from the successful + private mac run, update `appcast.xml` on `main`, and verify the feed. +18. For beta releases, publish the mac assets but expect no shared production + `appcast.xml` artifact and do not update the shared production feed unless a + separate beta feed exists. +19. After publish, verify npm and the attached release artifacts. + ## GHSA advisory work - Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks. diff --git a/.github/actions/ensure-base-commit/action.yml b/.github/actions/ensure-base-commit/action.yml index 0f87bce112d16..5a66566e871c2 100644 --- a/.github/actions/ensure-base-commit/action.yml +++ b/.github/actions/ensure-base-commit/action.yml @@ -23,6 +23,16 @@ runs: exit 0 fi + if ! [[ "$BASE_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + echo "::error title=ensure-base-commit invalid base sha::Refusing invalid base SHA: $BASE_SHA" + exit 2 + fi + + if ! git check-ref-format --branch "$FETCH_REF" >/dev/null 2>&1; then + echo "::error title=ensure-base-commit invalid fetch ref::Refusing invalid fetch ref: $FETCH_REF" + exit 2 + fi + if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then echo "Base commit already present: $BASE_SHA" exit 0 @@ -30,7 +40,7 @@ runs: for deepen_by in 25 100 300; do echo "Base commit missing; deepening $FETCH_REF by $deepen_by." - if ! git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF"; then + if ! git fetch --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then echo "::warning title=ensure-base-commit fetch failed::Failed to deepen $FETCH_REF by $deepen_by while looking for $BASE_SHA" fi if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then @@ -40,7 +50,7 @@ runs: done echo "Base commit still missing; fetching full history for $FETCH_REF." - if ! git fetch --no-tags origin "$FETCH_REF"; then + if ! git fetch --no-tags origin -- "$FETCH_REF"; then echo "::warning title=ensure-base-commit fetch failed::Failed to fetch full history for $FETCH_REF while looking for $BASE_SHA" fi if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then diff --git a/.github/labeler.yml b/.github/labeler.yml index cc5b7ea8fd991..13b16bf127ba7 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -246,6 +246,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/byteplus/**" +"extensions: deepseek": + - changed-files: + - any-glob-to-any-file: + - "extensions/deepseek/**" "extensions: anthropic": - changed-files: - any-glob-to-any-file: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1d4a0bbb53a52..6fb3d7d11fcbf 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,6 +31,32 @@ Describe the problem and fix in 2–5 bullets: - Closes # - Related # +- [ ] This PR fixes a bug or regression + +## Root Cause / Regression History (if applicable) + +For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`. + +- Root cause: +- Missing detection / guardrail: +- Prior context (`git blame`, prior PR, issue, or refactor if known): +- Why this regressed now: +- If unknown, what was ruled out: + +## Regression Test Plan (if applicable) + +For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write `N/A`. + +- Coverage level that should have caught this: + - [ ] Unit test + - [ ] Seam / integration test + - [ ] End-to-end test + - [ ] Existing coverage already sufficient +- Target test or file: +- Scenario the test should lock in: +- Why this is the smallest reliable guardrail: +- Existing test that already covers this (if any): +- If no new test is added, why not: ## User-visible / Behavior Changes diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index f6c527d1e379d..15e31000b24bf 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -11,6 +11,10 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} + permissions: {} jobs: diff --git a/.github/workflows/ci-bun.yml b/.github/workflows/ci-bun.yml new file mode 100644 index 0000000000000..f8eb3b012e9d7 --- /dev/null +++ b/.github/workflows/ci-bun.yml @@ -0,0 +1,72 @@ +name: CI Bun + +on: + push: + branches: [main] + +concurrency: + group: ci-bun-push-${{ github.run_id }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + build-bun-artifacts: + runs-on: blacksmith-16vcpu-ubuntu-2404 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + use-sticky-disk: "false" + + - name: Build A2UI bundle + run: pnpm canvas:a2ui:bundle + + - name: Upload A2UI bundle artifact + uses: actions/upload-artifact@v4 + with: + name: canvas-a2ui-bundle + path: src/canvas-host/a2ui/ + + bun-checks: + needs: [build-bun-artifacts] + runs-on: blacksmith-16vcpu-ubuntu-2404 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - shard_index: 1 + shard_count: 2 + command: OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 1/2 + - shard_index: 2 + shard_count: 2 + command: OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 2/2 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "true" + use-sticky-disk: "false" + + - name: Download A2UI bundle artifact + uses: actions/download-artifact@v8 + with: + name: canvas-a2ui-bundle + path: src/canvas-host/a2ui/ + + - name: Run Bun test shard + run: ${{ matrix.command }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 689725fd6077c..572a787b421fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,22 +6,24 @@ on: pull_request: types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] +permissions: + contents: read + concurrency: - group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-push-{0}', github.run_id) }} + group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: - # Preflight: establish the fast global truth for this revision before the - # expensive platform and test lanes fan out. + # Scope: establish the fast global truth for this revision before the + # expensive platform and platform-specific lanes fan out. # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android). - # Run scope detection, changed-extension detection, and fast security checks in - # one visible job so operators have a single preflight box to inspect and rerun. + # Keep this job focused on routing decisions so the rest of CI can fan out sooner. # Fail-safe: if detection steps are skipped, downstream outputs fall back to # conservative defaults that keep heavy lanes enabled. - preflight: + scope: if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 @@ -41,6 +43,7 @@ jobs: with: fetch-depth: 1 fetch-tags: false + persist-credentials: false submodules: false - name: Ensure preflight base commit @@ -101,6 +104,46 @@ jobs: appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8"); EOF + # Run the fast security/SCM checks in parallel with scope detection so the + # main Node jobs do not have to wait for Python/pre-commit setup. + security-fast: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: blacksmith-16vcpu-ubuntu-2404 + timeout-minutes: 20 + env: + PRE_COMMIT_CACHE_KEY_SUFFIX: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + fetch-tags: false + persist-credentials: false + submodules: false + + - name: Ensure security base commit + uses: ./.github/actions/ensure-base-commit + with: + base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }} + + - name: Prepare trusted pre-commit config + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + trusted_config="$RUNNER_TEMP/pre-commit-base.yaml" + git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config" + echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV" + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + install-deps: "false" + use-sticky-disk: "false" + - name: Setup Python id: setup-python uses: actions/setup-python@v6 @@ -116,15 +159,17 @@ jobs: uses: actions/cache@v5 with: path: ~/.cache/pre-commit - key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} + key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}-${{ env.PRE_COMMIT_CACHE_KEY_SUFFIX }} + restore-keys: | + pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}- - name: Install pre-commit run: | python -m pip install --upgrade pip - python -m pip install pre-commit + python -m pip install pre-commit==4.2.0 - name: Detect committed private keys - run: pre-commit run --all-files detect-private-key + run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files detect-private-key - name: Audit changed GitHub workflows with zizmor env: @@ -151,23 +196,24 @@ jobs: fi printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}" - pre-commit run zizmor --files "${workflow_files[@]}" + pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}" - name: Audit production dependencies - run: pre-commit run --all-files pnpm-audit-prod + run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files pnpm-audit-prod # Fanout: downstream lanes branch from preflight outputs instead of waiting # on unrelated Linux checks. # Build dist once for Node-relevant changes and share it with downstream jobs. build-artifacts: - needs: [preflight] - if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' + needs: [scope] + if: needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Ensure secrets base commit (PR fast path) @@ -186,6 +232,9 @@ jobs: - name: Build dist run: pnpm build + - name: Build Control UI + run: pnpm ui:build + - name: Upload dist artifact uses: actions/upload-artifact@v7 with: @@ -193,16 +242,25 @@ jobs: path: dist/ retention-days: 1 + - name: Upload A2UI bundle artifact + uses: actions/upload-artifact@v7 + with: + name: canvas-a2ui-bundle + path: src/canvas-host/a2ui/ + include-hidden-files: true + retention-days: 1 + # Validate npm pack contents after build (only on push to main, not PRs). release-check: - needs: [preflight, build-artifacts] - if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true' + needs: [scope, build-artifacts] + if: github.event_name == 'push' && needs.scope.outputs.docs_only != 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Node environment @@ -220,9 +278,42 @@ jobs: - name: Check release contents run: pnpm release:check + checks-fast: + needs: [scope] + if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - runtime: node + task: extensions + command: pnpm test:extensions + - runtime: node + task: contracts-protocol + command: | + pnpm test:contracts + pnpm protocol:check + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + use-sticky-disk: "false" + + - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) + run: ${{ matrix.command }} + checks: - needs: [preflight, build-artifacts] - if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') + needs: [scope, build-artifacts] + if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' && needs.build-artifacts.result == 'success' runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 strategy: @@ -232,34 +323,45 @@ jobs: - runtime: node task: test shard_index: 1 - shard_count: 2 - command: pnpm canvas:a2ui:bundle && pnpm test + shard_count: 4 + command: pnpm test - runtime: node task: test shard_index: 2 - shard_count: 2 - command: pnpm canvas:a2ui:bundle && pnpm test + shard_count: 4 + command: pnpm test - runtime: node - task: extensions - command: pnpm test:extensions + task: test + shard_index: 3 + shard_count: 4 + command: pnpm test + - runtime: node + task: test + shard_index: 4 + shard_count: 4 + command: pnpm test - runtime: node task: channels + shard_index: 1 + shard_count: 3 command: pnpm test:channels - runtime: node - task: contracts - command: pnpm test:contracts + task: channels + shard_index: 2 + shard_count: 3 + command: pnpm test:channels - runtime: node - task: protocol - command: pnpm protocol:check - - runtime: bun - task: test - command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts + task: channels + shard_index: 3 + shard_count: 3 + command: pnpm test:channels - runtime: node task: compat-node22 node_version: "22.x" cache_key_suffix: "node22" command: | pnpm build + pnpm ui:build node openclaw.mjs --help node openclaw.mjs status --json --timeout 1 pnpm test:build:singleton @@ -267,26 +369,27 @@ jobs: node --import tsx scripts/release-check.ts steps: - name: Skip compatibility lanes on pull requests - if: github.event_name == 'pull_request' && (matrix.runtime == 'bun' || matrix.task == 'compat-node22') + if: github.event_name == 'pull_request' && matrix.task == 'compat-node22' run: echo "Skipping push-only lane on pull requests." - name: Checkout - if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22') + if: github.event_name != 'pull_request' || matrix.task != 'compat-node22' uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Node environment - if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22') + if: github.event_name != 'pull_request' || matrix.task != 'compat-node22' uses: ./.github/actions/setup-node-env with: node-version: "${{ matrix.node_version || '24.x' }}" cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}" - install-bun: "${{ matrix.runtime == 'bun' }}" + install-bun: "false" use-sticky-disk: "false" - name: Configure Node test resources - if: (github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')) && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'compat-node22') + if: (github.event_name != 'pull_request' || matrix.task != 'compat-node22') && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'channels' || matrix.task == 'compat-node22') env: SHARD_COUNT: ${{ matrix.shard_count || '' }} SHARD_INDEX: ${{ matrix.shard_index || '' }} @@ -295,39 +398,47 @@ jobs: # Default heap limits have been too low on Linux CI (V8 OOM near 4GB). echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV" echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV" + if [ "${{ matrix.task }}" = "channels" ]; then + echo "OPENCLAW_TEST_WORKERS=1" >> "$GITHUB_ENV" + echo "OPENCLAW_TEST_ISOLATE=1" >> "$GITHUB_ENV" + fi if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV" echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV" fi - name: Download dist artifact - if: github.event_name == 'push' && matrix.task == 'test' + if: matrix.task == 'test' uses: actions/download-artifact@v8 with: name: dist-build path: dist/ - - name: Build dist - if: github.event_name != 'push' && matrix.task == 'test' && matrix.runtime == 'node' - run: pnpm build + - name: Download A2UI bundle artifact + if: matrix.task == 'test' || matrix.task == 'channels' + uses: actions/download-artifact@v8 + with: + name: canvas-a2ui-bundle + path: src/canvas-host/a2ui/ - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) - if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22') + if: github.event_name != 'pull_request' || matrix.task != 'compat-node22' run: ${{ matrix.command }} extension-fast: name: "extension-fast" - needs: [preflight] - if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.preflight.outputs.has_changed_extensions == 'true' + needs: [scope] + if: needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' && needs.scope.outputs.has_changed_extensions == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 strategy: fail-fast: false - matrix: ${{ fromJson(needs.preflight.outputs.changed_extensions_matrix) }} + matrix: ${{ fromJson(needs.scope.outputs.changed_extensions_matrix) }} steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Node environment @@ -344,14 +455,15 @@ jobs: # Types, lint, and format check. check: name: "check" - needs: [preflight] - if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' + needs: [scope] + if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && needs.scope.outputs.docs_only != 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Node environment @@ -368,14 +480,15 @@ jobs: check-additional: name: "check-additional" - needs: [preflight] - if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' + needs: [scope] + if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && needs.scope.outputs.docs_only != 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Node environment @@ -466,14 +579,15 @@ jobs: build-smoke: name: "build-smoke" - needs: [preflight, build-artifacts] - if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') + needs: [scope, build-artifacts] + if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Node environment @@ -507,14 +621,15 @@ jobs: # Validate docs (format, lint, broken links) only when docs files changed. check-docs: - needs: [preflight] - if: needs.preflight.outputs.docs_changed == 'true' + needs: [scope] + if: needs.scope.outputs.docs_changed == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Node environment @@ -527,14 +642,15 @@ jobs: run: pnpm check:docs skills-python: - needs: [preflight] - if: needs.preflight.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.preflight.outputs.run_skills_python == 'true') + needs: [scope] + if: needs.scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.scope.outputs.run_skills_python == 'true') runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Python @@ -554,8 +670,8 @@ jobs: run: python -m pytest -q skills checks-windows: - needs: [preflight, build-artifacts] - if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') + needs: [scope, build-artifacts] + if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_windows == 'true' && needs.build-artifacts.result == 'success' runs-on: blacksmith-32vcpu-windows-2025 timeout-minutes: 20 env: @@ -573,37 +689,53 @@ jobs: - runtime: node task: test shard_index: 1 - shard_count: 6 + shard_count: 9 command: pnpm test - runtime: node task: test shard_index: 2 - shard_count: 6 + shard_count: 9 command: pnpm test - runtime: node task: test shard_index: 3 - shard_count: 6 + shard_count: 9 command: pnpm test - runtime: node task: test shard_index: 4 - shard_count: 6 + shard_count: 9 command: pnpm test - runtime: node task: test shard_index: 5 - shard_count: 6 + shard_count: 9 command: pnpm test - runtime: node task: test shard_index: 6 - shard_count: 6 + shard_count: 9 + command: pnpm test + - runtime: node + task: test + shard_index: 7 + shard_count: 9 + command: pnpm test + - runtime: node + task: test + shard_index: 8 + shard_count: 9 + command: pnpm test + - runtime: node + task: test + shard_index: 9 + shard_count: 9 command: pnpm test steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Try to exclude workspace from Windows Defender (best-effort) @@ -671,20 +803,19 @@ jobs: echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV" echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV" - - name: Build A2UI bundle (Windows) - if: matrix.task == 'test' - run: pnpm canvas:a2ui:bundle - - name: Download dist artifact - if: github.event_name == 'push' && matrix.task == 'test' + if: matrix.task == 'test' uses: actions/download-artifact@v8 with: name: dist-build path: dist/ - - name: Build dist (Windows) - if: github.event_name != 'push' && matrix.task == 'test' - run: pnpm build + - name: Download A2UI bundle artifact + if: matrix.task == 'test' + uses: actions/download-artifact@v8 + with: + name: canvas-a2ui-bundle + path: src/canvas-host/a2ui/ - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) run: ${{ matrix.command }} @@ -694,14 +825,15 @@ jobs: # running 4 separate jobs per PR (as before) starved the queue. One job # per PR allows 5 PRs to run macOS checks simultaneously. macos: - needs: [preflight] - if: github.event_name == 'pull_request' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_macos == 'true' + needs: [scope] + if: github.event_name == 'pull_request' && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_macos == 'true' runs-on: macos-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Node environment @@ -771,8 +903,8 @@ jobs: exit 1 android: - needs: [preflight] - if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_android == 'true' + needs: [scope] + if: needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_android == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 20 strategy: @@ -791,6 +923,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: + persist-credentials: false submodules: false - name: Setup Java @@ -820,7 +953,7 @@ jobs: echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH" - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5 with: gradle-version: 8.11.1 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 218b1b9eaf198..b89974955006c 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -20,7 +20,7 @@ on: type: string concurrency: - group: docker-release-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || format('docker-release-push-{0}', github.run_id) }} cancel-in-progress: false env: diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index e8ebc323d3669..b765dbbb9fed9 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: concurrency: - group: ${{ github.event_name == 'pull_request' && format('install-smoke-pr-{0}', github.event.pull_request.number) || format('install-smoke-push-{0}', github.run_id) }} + group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 3a38e5213c3bc..143ebe4025ee1 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -19,6 +19,10 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} + permissions: {} jobs: diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml new file mode 100644 index 0000000000000..1197dd27da597 --- /dev/null +++ b/.github/workflows/macos-release.yml @@ -0,0 +1,86 @@ +name: macOS Release + +on: + workflow_dispatch: + inputs: + tag: + description: Existing release tag to validate for macOS release handoff (for example v2026.3.22 or v2026.3.22-beta.1) + required: true + type: string + preflight_only: + description: Retained for operator compatibility; this public workflow is validation-only + required: true + default: true + type: boolean + +concurrency: + group: macos-release-${{ inputs.tag }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.x" + PNPM_VERSION: "10.23.0" + +jobs: + validate_macos_release_request: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Validate tag input format + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then + echo "Invalid release tag format: ${RELEASE_TAG}" + exit 1 + fi + + - name: Checkout selected tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + + - name: Ensure matching GitHub release exists + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + + - name: Validate release tag and package metadata + env: + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_MAIN_REF: origin/main + run: | + set -euo pipefail + RELEASE_SHA=$(git rev-parse HEAD) + export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + pnpm release:openclaw:npm:check + + - name: Summarize next step + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + { + echo "## Public macOS validation only" + echo + echo "This workflow no longer builds, signs, notarizes, or uploads macOS assets." + echo + echo "Next step:" + echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\`." + echo "- Use \`preflight_only=true\` there for the full private mac preflight." + echo "- For the real publish path, the private run uploads the packaged \`.zip\`, \`.dmg\`, and \`.dSYM.zip\` files to the existing GitHub release in \`openclaw/openclaw\` automatically." + echo "- For stable releases, also download \`macos-appcast-${RELEASE_TAG}\` from the successful private run and commit \`appcast.xml\` back to \`main\` in \`openclaw/openclaw\`." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index 29be659056b79..236712d09ee0f 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -1,15 +1,17 @@ name: OpenClaw NPM Release on: - push: - tags: - - "v*" workflow_dispatch: inputs: tag: description: Release tag to publish (for example v2026.3.22, v2026.3.22-beta.1, or fallback v2026.3.22-1) required: true type: string + preflight_only: + description: Run validation/build only and skip the gated publish job + required: true + default: false + type: boolean concurrency: group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} @@ -21,15 +23,25 @@ env: PNPM_VERSION: "10.23.0" jobs: - preview_openclaw_npm: - if: github.event_name == 'push' + preflight_openclaw_npm: runs-on: ubuntu-latest permissions: contents: read steps: + - name: Validate tag input format + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then + echo "Invalid release tag format: ${RELEASE_TAG}" + exit 1 + fi + - name: Checkout uses: actions/checkout@v6 with: + ref: refs/tags/${{ inputs.tag }} fetch-depth: 0 - name: Setup Node environment @@ -40,38 +52,12 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Print release plan - env: - RELEASE_TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - RELEASE_SHA=$(git rev-parse HEAD) - PACKAGE_VERSION=$(node -p "require('./package.json').version") - if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then - TAG_KIND="fallback correction" - else - TAG_KIND="standard" - fi - echo "Release plan for ${RELEASE_TAG}:" - echo "Resolved release SHA: ${RELEASE_SHA}" - echo "Resolved package version: ${PACKAGE_VERSION}" - echo "Resolved tag kind: ${TAG_KIND}" - if [[ "${TAG_KIND}" == "fallback correction" ]]; then - echo "Correction tag note: npm version remains ${PACKAGE_VERSION}" - fi - echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main" - echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check" - echo "Would run: npm view openclaw@${PACKAGE_VERSION} version" - echo "Would run: pnpm check" - echo "Would run: pnpm build" - echo "Would run: pnpm release:check" - - name: Validate release tag and package metadata env: - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ inputs.tag }} RELEASE_MAIN_REF: origin/main run: | - set -euxo pipefail + set -euo pipefail RELEASE_SHA=$(git rev-parse HEAD) export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF # Fetch the full main ref so merge-base ancestry checks keep working @@ -81,52 +67,51 @@ jobs: - name: Ensure version is not already published env: - RELEASE_TAG: ${{ github.ref_name }} + PREFLIGHT_ONLY: ${{ inputs.preflight_only }} run: | - set -euxo pipefail + set -euo pipefail PACKAGE_VERSION=$(node -p "require('./package.json').version") - IS_CORRECTION_TAG=0 - if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then - IS_CORRECTION_TAG=1 - fi if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then - if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then - echo "openclaw@${PACKAGE_VERSION} is already published on npm." - echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error." + if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then + echo "openclaw@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true." exit 0 fi echo "openclaw@${PACKAGE_VERSION} is already published on npm." exit 1 fi - if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then - echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}" - else - echo "Previewing openclaw@${PACKAGE_VERSION}" - fi + echo "Publishing openclaw@${PACKAGE_VERSION}" - name: Check - run: | - set -euxo pipefail - pnpm check + run: pnpm check - name: Build - run: | - set -euxo pipefail - pnpm build + run: pnpm build - name: Verify release contents - run: | - set -euxo pipefail - pnpm release:check + run: pnpm release:check - - name: Preview publish command - run: bash scripts/openclaw-npm-publish.sh --dry-run + validate_publish_dispatch_ref: + if: ${{ !inputs.preflight_only }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Require main workflow ref for publish + env: + WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then + echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation." + exit 1 + fi publish_openclaw_npm: - if: github.event_name == 'workflow_dispatch' # npm trusted publishing + provenance requires a GitHub-hosted runner. + needs: [preflight_openclaw_npm, validate_publish_dispatch_ref] + if: ${{ !inputs.preflight_only }} runs-on: ubuntu-latest environment: npm-release permissions: @@ -182,14 +167,5 @@ jobs: echo "Publishing openclaw@${PACKAGE_VERSION}" - - name: Check - run: pnpm check - - - name: Build - run: pnpm build - - - name: Verify release contents - run: pnpm release:check - - name: Publish run: bash scripts/openclaw-npm-publish.sh --publish diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index 7eac64746f954..d5f560af466ea 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -15,7 +15,7 @@ on: - scripts/sandbox-common-setup.sh concurrency: - group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 5c6bee04a14f8..e1a16fe977e75 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: concurrency: - group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: diff --git a/AGENTS.md b/AGENTS.md index d453e2eff6749..d3b19ae89b218 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ - Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). - Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. +- When tests need example Anthropic/OpenAI model constants, prefer `sonnet-4.6` and `gpt-5.4`; update older Anthropic/GPT examples when you touch those tests. - Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic. - Write tests to clean up timers, env, globals, mocks, sockets, temp dirs, and module state so `--isolate=false` stays green. - Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat. diff --git a/CHANGELOG.md b/CHANGELOG.md index 55986b4bc8fa8..091a5dd2cadc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,127 +4,311 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Breaking + +### Changes + +- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live “Available Right Now” section in the Control UI so it is easier to see what will work before you ask. +- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev. +- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev. +- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev. +- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev. +- Control UI/agents: convert agent workspace file rows to expandable `
` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev. +- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev. +- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev. +- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev. +- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom. +- Discord/auto threads: add optional `autoThreadName: "generated"` naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman. +- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc. +- Gateway/OpenAI compatibility: add `/v1/models` and `/v1/embeddings`, and forward explicit model overrides through `/v1/chat/completions` and `/v1/responses` for broader client and RAG compatibility. Thanks @vincentkoc. + +### Fixes + +- Memory/builtin sqlite: cut redundant sync and status query churn by snapshotting file state once per source, reusing sync statements, and consolidating status aggregation reads, which reduces builtin memory overhead on sync/status/doctor-style paths. Thanks @vincentkoc. +- ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss. +- Doctor/image generation: seed migrated legacy Nano Banana Google provider config with the `/v1beta` API root and an empty model list so `openclaw doctor --fix` completes and the migrated native Google image path keeps hitting the correct endpoint. (#53757) Thanks @mahopan. +- Security/skills: validate skill installer metadata against strict regex allowlists per package manager, sanitize skill metadata for terminal output, add URL protocol allowlisting in markdown preview and skill homepage links, warn on non-bundled skill install sources, and remove unsafe `file://` workspace links. (#53471) Thanks @BunsDev. +- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ. +- Agents/cron: suppress the default heartbeat system prompt for cron-triggered embedded runs even when they target non-cron session keys, so cron tasks stop reading `HEARTBEAT.md` and polluting unrelated threads. (#53152) Thanks @Protocol-zero-0. +- TUI/chat: preserve pending user messages when a slow local run emits an empty final event, but still defer and flush the needed history reload after the newer active run finishes so silent/tool-only runs do not stay incomplete. (#53130) Thanks @joelnishanth. +- Docs/IRC: fix five `json55` code-fence typos in the IRC channel examples so Mintlify applies JSON5 syntax highlighting correctly. (#50842) Thanks @Hollychou924. +- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo +- Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman. +- Feishu/startup: treat unresolved `SecretRef` app credentials as not configured during account resolution so CLI startup and read-only Feishu config surfaces stop crashing before runtime-backed secret resolution is available. (#53675) Thanks @hpt. +- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn. +- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss. +- Runtime/build: stabilize long-lived lazy `dist` runtime entry paths and harden bundled plugin npm staging so local rebuilds stop breaking on missing hashed chunks or broken shell `npm` shims. (#53855) Thanks @vincentkoc. +- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc. +- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870. +- Models/google: normalize bare Google Generative AI API roots for custom provider names, and keep built-in Google model-id rewrites working when `api` is declared only on individual models, so custom Google lanes and older configs stop missing `/v1beta` or preview-id normalization. (#44969) Thanks @Kathie-yu. +- Gateway/ports: parse Docker Compose-style `OPENCLAW_GATEWAY_PORT` host publish values correctly without reviving the legacy `CLAWDBOT_GATEWAY_PORT` override. (#44083) Thanks @bebule. +- Feishu/MSTeams message tool: keep provider-native `card` payloads optional in merged tool schemas so media-only sends stop failing validation before channel runtime dispatch. (#53715) Thanks @lndyzwdxhs. +- Feishu/startup: keep `requireMention` enforcement strict when bot identity startup probes fail, raise the startup bot-info timeout to 30s, and add cancellable background identity recovery so mention-gated groups recover without noisy fallback. (#43788) Thanks @lefarcen. + +## 2026.3.23 + +### Breaking + +### Changes + +- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878) +- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev. +- CSP/Control UI: compute SHA-256 hashes for inline ``); + expect(hashes).toEqual([`sha256-${expected}`]); + }); + + it("skips scripts with src attribute", () => { + const hashes = computeInlineScriptHashes(''); + expect(hashes).toEqual([]); + }); + + it("does not treat data-src as an external script attribute", () => { + const content = "console.log('inline')"; + const expected = createHash("sha256").update(content, "utf8").digest("base64"); + const hashes = computeInlineScriptHashes( + ``, + ); + expect(hashes).toEqual([`sha256-${expected}`]); + }); + + it("hashes only inline scripts when mixed with external", () => { + const inlineContent = "console.log('init')"; + const expected = createHash("sha256").update(inlineContent, "utf8").digest("base64"); + const html = [ + "", + ``, + '', + "", + ].join(""); + const hashes = computeInlineScriptHashes(html); + expect(hashes).toEqual([`sha256-${expected}`]); + }); + + it("handles multiline inline scripts", () => { + const content = "\n var x = 1;\n console.log(x);\n"; + const expected = createHash("sha256").update(content, "utf8").digest("base64"); + const hashes = computeInlineScriptHashes(``); + expect(hashes).toEqual([`sha256-${expected}`]); + }); + + it("skips empty inline scripts", () => { + expect(computeInlineScriptHashes("")).toEqual([]); + }); }); diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index 8a7b56f1eb08c..25e54e99a92e7 100644 --- a/src/gateway/control-ui-csp.ts +++ b/src/gateway/control-ui-csp.ts @@ -1,14 +1,47 @@ -export function buildControlUiCspHeader(): string { - // Control UI: block framing, block inline scripts, keep styles permissive - // (UI uses a lot of inline style attributes in templates). - // Keep Google Fonts origins explicit in CSP for deployments that load - // external Google Fonts stylesheets/font files. +import { createHash } from "node:crypto"; + +const SCRIPT_ATTRIBUTE_NAME_RE = /\s([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g; + +/** + * Compute SHA-256 CSP hashes for inline `\n`; + const expectedHash = createHash("sha256").update(scriptContent, "utf8").digest("base64"); + await withControlUiRoot({ + indexHtml: html, + fn: async (tmp) => { + const { res, setHeader } = makeMockHttpResponse(); + handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { + root: { kind: "resolved", path: tmp }, + }); + const cspCalls = setHeader.mock.calls.filter( + (call) => call[0] === "Content-Security-Policy", + ); + const lastCsp = String(cspCalls[cspCalls.length - 1]?.[1] ?? ""); + expect(lastCsp).toContain(`'sha256-${expectedHash}'`); + expect(lastCsp).not.toMatch(/script-src[^;]*'unsafe-inline'/); + }, + }); + }); + it("does not inject inline scripts into index.html", async () => { const html = "Hello\n"; await withControlUiRoot({ diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index ece3c7f133739..b68b453cc185a 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -16,7 +16,7 @@ import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH, type ControlUiBootstrapConfig, } from "./control-ui-contract.js"; -import { buildControlUiCspHeader } from "./control-ui-csp.js"; +import { buildControlUiCspHeader, computeInlineScriptHashes } from "./control-ui-csp.js"; import { isReadHttpMethod, respondNotFound as respondControlUiNotFound, @@ -234,6 +234,13 @@ function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) } function serveResolvedIndexHtml(res: ServerResponse, body: string) { + const hashes = computeInlineScriptHashes(body); + if (hashes.length > 0) { + res.setHeader( + "Content-Security-Policy", + buildControlUiCspHeader({ inlineScriptHashes: hashes }), + ); + } res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); res.end(body); diff --git a/src/gateway/embeddings-http.test.ts b/src/gateway/embeddings-http.test.ts new file mode 100644 index 0000000000000..8bd70cd9d5554 --- /dev/null +++ b/src/gateway/embeddings-http.test.ts @@ -0,0 +1,195 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { resolveAgentDir } from "../agents/agent-scope.js"; +import { getFreePort, installGatewayTestHooks } from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +let startGatewayServer: typeof import("./server.js").startGatewayServer; +let createEmbeddingProviderMock: ReturnType; +let enabledServer: Awaited>; +let enabledPort: number; + +beforeAll(async () => { + vi.resetModules(); + createEmbeddingProviderMock = vi.fn(async (options: { provider: string; model: string }) => ({ + provider: { + id: options.provider, + model: options.model, + embedQuery: async () => [0.1, 0.2], + embedBatch: async (texts: string[]) => + texts.map((_text, index) => [index + 0.1, index + 0.2]), + }, + })); + vi.doMock("../memory/embeddings.js", async () => { + const actual = + await vi.importActual("../memory/embeddings.js"); + return { + ...actual, + createEmbeddingProvider: createEmbeddingProviderMock, + }; + }); + ({ startGatewayServer } = await import("./server.js")); + enabledPort = await getFreePort(); + enabledServer = await startServer(enabledPort, { openAiChatCompletionsEnabled: true }); +}); + +afterAll(async () => { + await enabledServer.close({ reason: "embeddings http enabled suite done" }); + vi.resetModules(); +}); + +async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) { + return await startGatewayServer(port, { + host: "127.0.0.1", + auth: { mode: "token", token: "secret" }, + controlUiEnabled: false, + openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? false, + }); +} + +async function postEmbeddings(body: unknown, headers?: Record) { + return await fetch(`http://127.0.0.1:${enabledPort}/v1/embeddings`, { + method: "POST", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { + it("embeds string and array inputs", async () => { + const single = await postEmbeddings({ + model: "openclaw/default", + input: "hello", + }); + expect(single.status).toBe(200); + const singleJson = (await single.json()) as { + object?: string; + data?: Array<{ object?: string; embedding?: number[]; index?: number }>; + }; + expect(singleJson.object).toBe("list"); + expect(singleJson.data?.[0]?.object).toBe("embedding"); + expect(singleJson.data?.[0]?.embedding).toEqual([0.1, 0.2]); + + const batch = await postEmbeddings({ + model: "openclaw/default", + input: ["a", "b"], + }); + expect(batch.status).toBe(200); + const batchJson = (await batch.json()) as { + data?: Array<{ embedding?: number[]; index?: number }>; + }; + expect(batchJson.data).toEqual([ + { object: "embedding", index: 0, embedding: [0.1, 0.2] }, + { object: "embedding", index: 1, embedding: [1.1, 1.2] }, + ]); + + const qualified = await postEmbeddings( + { + model: "openclaw/default", + input: "hello again", + }, + { "x-openclaw-model": "openai/text-embedding-3-small" }, + ); + expect(qualified.status).toBe(200); + const qualifiedJson = (await qualified.json()) as { model?: string }; + expect(qualifiedJson.model).toBe("openclaw/default"); + const lastCall = createEmbeddingProviderMock.mock.calls.at(-1)?.[0] as + | { provider?: string; model?: string } + | undefined; + expect(lastCall).toMatchObject({ + provider: "openai", + model: "text-embedding-3-small", + }); + }); + + it("supports base64 encoding and agent-scoped auth/config resolution", async () => { + const res = await postEmbeddings( + { + model: "openclaw/beta", + input: "hello", + encoding_format: "base64", + }, + { "x-openclaw-agent-id": "beta" }, + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { data?: Array<{ embedding?: string }> }; + expect(typeof json.data?.[0]?.embedding).toBe("string"); + expect(createEmbeddingProviderMock).toHaveBeenCalled(); + const lastCall = createEmbeddingProviderMock.mock.calls.at(-1)?.[0] as + | { provider?: string; model?: string; fallback?: string; agentDir?: string } + | undefined; + expect(typeof lastCall?.model).toBe("string"); + expect(lastCall?.fallback).toBe("none"); + expect(lastCall?.agentDir).toBe(resolveAgentDir({}, "beta")); + }); + + it("rejects invalid input shapes", async () => { + const res = await postEmbeddings({ + model: "openclaw/default", + input: [{ nope: true }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + }); + + it("rejects invalid agent targets", async () => { + const res = await postEmbeddings({ + model: "ollama/nomic-embed-text", + input: "hello", + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error).toEqual({ + type: "invalid_request_error", + message: "Invalid `model`. Use `openclaw` or `openclaw/`.", + }); + }); + + it("rejects disallowed x-openclaw-model provider overrides", async () => { + const res = await postEmbeddings( + { + model: "openclaw/default", + input: "hello", + }, + { "x-openclaw-model": "ollama/nomic-embed-text" }, + ); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error).toEqual({ + type: "invalid_request_error", + message: "This agent does not allow that embedding provider on `/v1/embeddings`.", + }); + }); + + it("rejects oversized batches", async () => { + const res = await postEmbeddings({ + model: "openclaw/default", + input: Array.from({ length: 129 }, () => "x"), + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error).toEqual({ + type: "invalid_request_error", + message: "Too many inputs (max 128).", + }); + }); + + it("sanitizes provider failures", async () => { + createEmbeddingProviderMock.mockRejectedValueOnce(new Error("secret upstream failure")); + const res = await postEmbeddings({ + model: "openclaw/default", + input: "hello", + }); + expect(res.status).toBe(500); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error).toEqual({ + type: "api_error", + message: "internal error", + }); + }); +}); diff --git a/src/gateway/embeddings-http.ts b/src/gateway/embeddings-http.ts new file mode 100644 index 0000000000000..bb11fdb0006c9 --- /dev/null +++ b/src/gateway/embeddings-http.ts @@ -0,0 +1,262 @@ +import { Buffer } from "node:buffer"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { resolveAgentDir } from "../agents/agent-scope.js"; +import { resolveMemorySearchConfig } from "../agents/memory-search.js"; +import { loadConfig } from "../config/config.js"; +import { logWarn } from "../logger.js"; +import { + createEmbeddingProvider, + type EmbeddingProviderOptions, + type EmbeddingProviderId, + type EmbeddingProviderRequest, +} from "../memory/embeddings.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { sendJson } from "./http-common.js"; +import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; +import { + OPENCLAW_MODEL_ID, + getHeader, + resolveAgentIdForRequest, + resolveAgentIdFromModel, +} from "./http-utils.js"; + +type OpenAiEmbeddingsHttpOptions = { + auth: ResolvedGatewayAuth; + maxBodyBytes?: number; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; +}; + +type EmbeddingsRequest = { + model?: unknown; + input?: unknown; + encoding_format?: unknown; + dimensions?: unknown; + user?: unknown; +}; + +const DEFAULT_EMBEDDINGS_BODY_BYTES = 5 * 1024 * 1024; +const MAX_EMBEDDING_INPUTS = 128; +const MAX_EMBEDDING_INPUT_CHARS = 8_192; +const MAX_EMBEDDING_TOTAL_CHARS = 65_536; +const SAFE_AUTO_EXPLICIT_PROVIDERS = new Set([ + "openai", + "gemini", + "voyage", + "mistral", +]); + +function coerceRequest(value: unknown): EmbeddingsRequest { + return value && typeof value === "object" ? (value as EmbeddingsRequest) : {}; +} + +function resolveInputTexts(input: unknown): string[] | null { + if (typeof input === "string") { + return [input]; + } + if (!Array.isArray(input)) { + return null; + } + if (input.every((entry) => typeof entry === "string")) { + return input; + } + return null; +} + +function encodeEmbeddingBase64(embedding: number[]): string { + const float32 = Float32Array.from(embedding); + return Buffer.from(float32.buffer).toString("base64"); +} + +function validateInputTexts(texts: string[]): string | undefined { + if (texts.length > MAX_EMBEDDING_INPUTS) { + return `Too many inputs (max ${MAX_EMBEDDING_INPUTS}).`; + } + let totalChars = 0; + for (const text of texts) { + if (text.length > MAX_EMBEDDING_INPUT_CHARS) { + return `Input too long (max ${MAX_EMBEDDING_INPUT_CHARS} chars).`; + } + totalChars += text.length; + if (totalChars > MAX_EMBEDDING_TOTAL_CHARS) { + return `Total input too large (max ${MAX_EMBEDDING_TOTAL_CHARS} chars).`; + } + } + return undefined; +} + +function resolveEmbeddingsTarget(params: { + requestModel: string; + configuredProvider: EmbeddingProviderRequest; +}): { provider: EmbeddingProviderRequest; model: string } | { errorMessage: string } { + const raw = params.requestModel.trim(); + const slash = raw.indexOf("/"); + if (slash === -1) { + return { provider: params.configuredProvider, model: raw }; + } + + const provider = raw.slice(0, slash).trim().toLowerCase() as EmbeddingProviderRequest; + const model = raw.slice(slash + 1).trim(); + if (!model) { + return { errorMessage: "Unsupported embedding model reference." }; + } + + if (params.configuredProvider === "auto") { + if (provider === "auto") { + return { provider: "auto", model }; + } + if (SAFE_AUTO_EXPLICIT_PROVIDERS.has(provider)) { + return { provider, model }; + } + return { + errorMessage: "This agent does not allow that embedding provider on `/v1/embeddings`.", + }; + } + + if (provider !== params.configuredProvider) { + return { + errorMessage: "This agent does not allow that embedding provider on `/v1/embeddings`.", + }; + } + + return { provider: params.configuredProvider, model }; +} + +export async function handleOpenAiEmbeddingsHttpRequest( + req: IncomingMessage, + res: ServerResponse, + opts: OpenAiEmbeddingsHttpOptions, +): Promise { + const handled = await handleGatewayPostJsonEndpoint(req, res, { + pathname: "/v1/embeddings", + auth: opts.auth, + trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, + rateLimiter: opts.rateLimiter, + maxBodyBytes: opts.maxBodyBytes ?? DEFAULT_EMBEDDINGS_BODY_BYTES, + }); + if (handled === false) { + return false; + } + if (!handled) { + return true; + } + + const payload = coerceRequest(handled.body); + const requestModel = typeof payload.model === "string" ? payload.model.trim() : ""; + if (!requestModel) { + sendJson(res, 400, { + error: { message: "Missing `model`.", type: "invalid_request_error" }, + }); + return true; + } + + const cfg = loadConfig(); + if (requestModel !== OPENCLAW_MODEL_ID && !resolveAgentIdFromModel(requestModel, cfg)) { + sendJson(res, 400, { + error: { + message: "Invalid `model`. Use `openclaw` or `openclaw/`.", + type: "invalid_request_error", + }, + }); + return true; + } + + const texts = resolveInputTexts(payload.input); + if (!texts) { + sendJson(res, 400, { + error: { + message: "`input` must be a string or an array of strings.", + type: "invalid_request_error", + }, + }); + return true; + } + const inputError = validateInputTexts(texts); + if (inputError) { + sendJson(res, 400, { + error: { message: inputError, type: "invalid_request_error" }, + }); + return true; + } + + const agentId = resolveAgentIdForRequest({ req, model: requestModel }); + const agentDir = resolveAgentDir(cfg, agentId); + const memorySearch = resolveMemorySearchConfig(cfg, agentId); + const configuredProvider = (memorySearch?.provider ?? "openai") as EmbeddingProviderRequest; + const overrideModel = getHeader(req, "x-openclaw-model")?.trim() || memorySearch?.model || ""; + const target = resolveEmbeddingsTarget({ requestModel: overrideModel, configuredProvider }); + if ("errorMessage" in target) { + sendJson(res, 400, { + error: { + message: target.errorMessage, + type: "invalid_request_error", + }, + }); + return true; + } + + const options: EmbeddingProviderOptions = { + config: cfg, + agentDir, + provider: target.provider, + model: target.model, + // Public HTTP embeddings should fail closed rather than silently mixing + // vector spaces across fallback providers/models. + fallback: "none", + local: memorySearch?.local, + remote: memorySearch?.remote + ? { + baseUrl: memorySearch.remote.baseUrl, + apiKey: memorySearch.remote.apiKey, + headers: memorySearch.remote.headers, + } + : undefined, + outputDimensionality: + typeof payload.dimensions === "number" && payload.dimensions > 0 + ? Math.floor(payload.dimensions) + : memorySearch?.outputDimensionality, + }; + + try { + const result = await createEmbeddingProvider(options); + if (!result.provider) { + sendJson(res, 503, { + error: { + message: result.providerUnavailableReason ?? "Embeddings provider unavailable.", + type: "api_error", + }, + }); + return true; + } + + const embeddings = await result.provider.embedBatch(texts); + const encodingFormat = payload.encoding_format === "base64" ? "base64" : "float"; + + sendJson(res, 200, { + object: "list", + data: embeddings.map((embedding, index) => ({ + object: "embedding", + index, + embedding: encodingFormat === "base64" ? encodeEmbeddingBase64(embedding) : embedding, + })), + model: requestModel, + usage: { + prompt_tokens: 0, + total_tokens: 0, + }, + }); + } catch (err) { + logWarn(`openai-compat: embeddings request failed: ${String(err)}`); + sendJson(res, 500, { + error: { + message: "internal error", + type: "api_error", + }, + }); + } + + return true; +} diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index d0d313cc455a9..8192861a32fea 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import { parseModelRef } from "../agents/model-selection.js"; import { clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; @@ -13,7 +14,7 @@ import { renderCatNoncePngBase64 } from "./live-image-probe.js"; import { startGatewayServer } from "./server.js"; import { extractPayloadText } from "./test-helpers.agent-results.js"; -const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); +const LIVE = isLiveTestEnabled(); const CLI_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND); const CLI_IMAGE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE); const CLI_RESUME = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE); diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index f25dbd5b4b6ef..23b590870df9f 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -2,9 +2,8 @@ import * as fs from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; import * as os from "node:os"; import * as path from "node:path"; -import { describe, expect, it, test, vi } from "vitest"; +import { beforeEach, describe, expect, it, test, vi } from "vitest"; import { defaultVoiceWakeTriggers } from "../infra/voicewake.js"; -import { GatewayClient } from "./client.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS, @@ -45,6 +44,29 @@ vi.mock("ws", () => ({ }, })); +let GatewayClient: typeof import("./client.js").GatewayClient; + +async function loadFreshGatewayClientModuleForTest() { + vi.resetModules(); + vi.doMock("ws", () => ({ + WebSocket: class MockWebSocket { + on = vi.fn(); + close = vi.fn(); + send = vi.fn(); + + constructor(url: unknown, opts: unknown) { + wsMockState.last = { url, opts }; + } + }, + })); + ({ GatewayClient } = await import("./client.js")); +} + +beforeEach(async () => { + wsMockState.last = null; + await loadFreshGatewayClientModuleForTest(); +}); + describe("GatewayClient", () => { async function withControlUiRoot( params: { faviconSvg?: string; indexHtml?: string }, @@ -63,7 +85,6 @@ describe("GatewayClient", () => { } test("uses a large maxPayload for node snapshots", () => { - wsMockState.last = null; const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); client.start(); const last = wsMockState.last as { url: unknown; opts: unknown } | null; diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index e742b32e6f00e..780ff4d83c5db 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -4,13 +4,12 @@ import { createServer } from "node:net"; import os from "node:os"; import path from "node:path"; import type { Api, Model } from "@mariozechner/pi-ai"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { type AuthProfileStore, ensureAuthProfileStore, - resolveAuthProfileOrder, saveAuthProfileStore, } from "../agents/auth-profiles.js"; import { @@ -18,8 +17,11 @@ import { isAnthropicBillingError, isAnthropicRateLimitError, } from "../agents/live-auth-keys.js"; -import { isModernModelRef } from "../agents/live-model-filter.js"; +import { isModelNotFoundErrorMessage } from "../agents/live-model-errors.js"; +import { isHighSignalLiveModelRef } from "../agents/live-model-filter.js"; +import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "../agents/live-test-helpers.js"; import { getApiKeyForModel } from "../agents/model-auth.js"; +import { normalizeGoogleModelId } from "../agents/model-id-normalization.js"; import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js"; import { ensureOpenClawModelsJson } from "../agents/models-config.js"; import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js"; @@ -28,6 +30,7 @@ import { clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; import type { ModelsConfig, OpenClawConfig, ModelProviderConfig } from "../config/types.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; +import { stripAssistantInternalScaffolding } from "../shared/text/assistant-visible-text.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; import { renderCatNoncePngBase64 } from "./live-image-probe.js"; @@ -40,9 +43,8 @@ import { import { startGatewayServer } from "./server.js"; import { loadSessionEntry, readSessionMessages } from "./session-utils.js"; -const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); -const GATEWAY_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY); const ZAI_FALLBACK = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY_ZAI_FALLBACK); +const REQUIRE_PROFILE_KEYS = isLiveProfileKeyModeEnabled(); const PROVIDERS = parseFilter(process.env.OPENCLAW_LIVE_GATEWAY_PROVIDERS); const THINKING_LEVEL = "high"; const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\s*>/i; @@ -59,10 +61,17 @@ const GATEWAY_LIVE_HEARTBEAT_MS = Math.max( 1_000, toInt(process.env.OPENCLAW_LIVE_GATEWAY_HEARTBEAT_MS, 30_000), ); +const GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS = new Set([ + "google/gemini-3-flash-preview", + "google/gemini-3-pro-preview", + "google/gemini-3.1-flash-lite-preview", + "google/gemini-3.1-pro-preview", + "google/gemini-3.1-pro-preview-customtools", +]); const GATEWAY_LIVE_MAX_MODELS = resolveGatewayLiveMaxModels(); const GATEWAY_LIVE_SUITE_TIMEOUT_MS = resolveGatewayLiveSuiteTimeoutMs(GATEWAY_LIVE_MAX_MODELS); -const describeLive = LIVE || GATEWAY_LIVE ? describe : describe.skip; +const describeLive = isLiveTestEnabled(["OPENCLAW_LIVE_GATEWAY"]) ? describe : describe.skip; function parseFilter(raw?: string): Set | null { const trimmed = raw?.trim(); @@ -268,6 +277,63 @@ function isMeaningful(text: string): boolean { return true; } +function shouldStripAssistantScaffoldingForLiveModel(modelKey?: string): boolean { + if (!modelKey) { + return false; + } + if (GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS.has(modelKey)) { + return true; + } + const [provider, ...rest] = modelKey.split("/"); + if (provider !== "google" || rest.length === 0) { + return false; + } + const normalizedKey = `${provider}/${normalizeGoogleModelId(rest.join("/"))}`; + return GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS.has(normalizedKey); +} + +function maybeStripAssistantScaffoldingForLiveModel(text: string, modelKey?: string): string { + if (!shouldStripAssistantScaffoldingForLiveModel(modelKey)) { + return text; + } + return stripAssistantInternalScaffolding(text).trim(); +} + +describe("maybeStripAssistantScaffoldingForLiveModel", () => { + it("strips scaffolding for Gemini preview models with known transcript wrappers", () => { + expect( + maybeStripAssistantScaffoldingForLiveModel( + "hiddenVisible", + "google/gemini-3.1-flash-preview", + ), + ).toBe("Visible"); + expect( + maybeStripAssistantScaffoldingForLiveModel( + "hiddenVisible", + "google/gemini-3.1-flash-lite-preview", + ), + ).toBe("Visible"); + expect( + maybeStripAssistantScaffoldingForLiveModel( + "hiddenVisible", + "google/gemini-3.1-pro-preview", + ), + ).toBe("Visible"); + expect( + maybeStripAssistantScaffoldingForLiveModel( + "hiddenVisible", + "google/gemini-3.1-pro-preview-customtools", + ), + ).toBe("Visible"); + expect( + maybeStripAssistantScaffoldingForLiveModel( + "hiddenVisible", + "google/gemini-2.5-flash", + ), + ).toBe("hiddenVisible"); + }); +}); + function isGoogleModelNotFoundText(text: string): boolean { const trimmed = text.trim(); if (!trimmed) { @@ -371,6 +437,7 @@ async function runAnthropicRefusalProbe(params: { message: `Reply with the single word ok. Test token: ${magic}`, thinkingLevel: params.thinkingLevel, context: `${params.label}: refusal-probe`, + modelKey: params.modelKey, }); assertNoReasoningTags({ text: probeText, @@ -389,6 +456,7 @@ async function runAnthropicRefusalProbe(params: { message: "Now reply with exactly: still ok.", thinkingLevel: params.thinkingLevel, context: `${params.label}: refusal-followup`, + modelKey: params.modelKey, }); assertNoReasoningTags({ text: followupText, @@ -561,7 +629,7 @@ function extractTranscriptMessageText(message: unknown): string { .trim(); } -function readSessionAssistantTexts(sessionKey: string): string[] { +function readSessionAssistantTexts(sessionKey: string, modelKey?: string): string[] { const { storePath, entry } = loadSessionEntry(sessionKey); if (!entry?.sessionId) { return []; @@ -576,7 +644,9 @@ function readSessionAssistantTexts(sessionKey: string): string[] { if (role !== "assistant") { continue; } - assistantTexts.push(extractTranscriptMessageText(message)); + assistantTexts.push( + maybeStripAssistantScaffoldingForLiveModel(extractTranscriptMessageText(message), modelKey), + ); } return assistantTexts; } @@ -585,12 +655,13 @@ async function waitForSessionAssistantText(params: { sessionKey: string; baselineAssistantCount: number; context: string; + modelKey?: string; }) { const startedAt = Date.now(); let lastHeartbeatAt = startedAt; let delayMs = 50; while (Date.now() - startedAt < GATEWAY_LIVE_PROBE_TIMEOUT_MS) { - const assistantTexts = readSessionAssistantTexts(params.sessionKey); + const assistantTexts = readSessionAssistantTexts(params.sessionKey, params.modelKey); if (assistantTexts.length > params.baselineAssistantCount) { const freshText = assistantTexts .slice(params.baselineAssistantCount) @@ -619,13 +690,17 @@ async function requestGatewayAgentText(params: { thinkingLevel: string; context: string; idempotencyKey: string; + modelKey?: string; attachments?: Array<{ mimeType: string; fileName: string; content: string; }>; }) { - const baselineAssistantCount = readSessionAssistantTexts(params.sessionKey).length; + const baselineAssistantCount = readSessionAssistantTexts( + params.sessionKey, + params.modelKey, + ).length; const accepted = await withGatewayLiveProbeTimeout( params.client.request<{ runId?: unknown; status?: unknown }>("agent", { sessionKey: params.sessionKey, @@ -644,6 +719,7 @@ async function requestGatewayAgentText(params: { sessionKey: params.sessionKey, baselineAssistantCount, context: `${params.context}: transcript-final`, + modelKey: params.modelKey, }); } @@ -651,6 +727,7 @@ type GatewayModelSuiteParams = { label: string; cfg: OpenClawConfig; candidates: Array>; + allowNotFoundSkip: boolean; extraToolProbes: boolean; extraImageProbes: boolean; thinkingLevel: string; @@ -936,6 +1013,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { client, sessionKey, idempotencyKey: `idem-${randomUUID()}`, + modelKey, message: "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", thinkingLevel: params.thinkingLevel, @@ -947,6 +1025,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { client, sessionKey, idempotencyKey: `idem-${randomUUID()}-retry`, + modelKey, message: "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", thinkingLevel: params.thinkingLevel, @@ -970,6 +1049,10 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`${progressLabel}: skip (google model not found)`); break; } + if (params.allowNotFoundSkip && isModelNotFoundErrorMessage(text)) { + logProgress(`${progressLabel}: skip (model not found)`); + break; + } assertNoReasoningTags({ text, model: modelKey, @@ -1002,6 +1085,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { client, sessionKey, idempotencyKey: `idem-${runIdTool}-tool-${toolReadAttempt + 1}`, + modelKey, message: strictReply ? "OpenClaw live tool probe (local, safe): " + `use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` + @@ -1065,6 +1149,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { client, sessionKey, idempotencyKey: `idem-${runIdTool}-exec-read-${execReadAttempt + 1}`, + modelKey, message: strictReply ? "OpenClaw live tool probe (local, safe): " + "use the tool named `exec` (or `Exec`) to run this command: " + @@ -1129,6 +1214,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { client, sessionKey, idempotencyKey: `idem-${runIdImage}-image`, + modelKey, message: "Look at the attached image. Reply with exactly two tokens separated by a single space: " + "(1) the animal shown or written in the image, lowercase; " + @@ -1186,6 +1272,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { client, sessionKey, idempotencyKey: `idem-${runId2}-1`, + modelKey, message: `Call the tool named \`read\` (or \`Read\`) on "${toolProbePath}". Do not write any other text.`, thinkingLevel: params.thinkingLevel, context: `${progressLabel}: tool-only-regression-first`, @@ -1201,6 +1288,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { client, sessionKey, idempotencyKey: `idem-${runId2}-2`, + modelKey, message: `Now answer: what are the values of nonceA and nonceB in "${toolProbePath}"? Reply with exactly: ${nonceA} ${nonceB}.`, thinkingLevel: params.thinkingLevel, context: `${progressLabel}: tool-only-regression-second`, @@ -1269,11 +1357,27 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`${progressLabel}: skip (google rate limit)`); break; } + if ( + (model.provider === "minimax" || + model.provider === "opencode" || + model.provider === "opencode-go" || + model.provider === "zai") && + isRateLimitErrorMessage(message) + ) { + skippedCount += 1; + logProgress(`${progressLabel}: skip (rate limit)`); + break; + } if (isProviderUnavailableErrorMessage(message)) { skippedCount += 1; logProgress(`${progressLabel}: skip (provider unavailable)`); break; } + if (params.allowNotFoundSkip && isModelNotFoundErrorMessage(message)) { + skippedCount += 1; + logProgress(`${progressLabel}: skip (model not found)`); + break; + } if ( model.provider === "anthropic" && isGatewayLiveProbeTimeout(message) && @@ -1317,9 +1421,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`${progressLabel}: skip (tool probe refusal)`); break; } - if (model.provider === "anthropic" && isToolNonceProbeMiss(message)) { + if ( + (model.provider === "anthropic" || + model.provider === "minimax" || + model.provider === "opencode-go") && + isToolNonceProbeMiss(message) + ) { skippedCount += 1; - logProgress(`${progressLabel}: skip (anthropic tool probe nonce miss)`); + logProgress(`${progressLabel}: skip (${model.provider} tool probe nonce miss)`); break; } if (isMissingProfileError(message)) { @@ -1383,9 +1492,6 @@ describeLive("gateway live (dev agent, profile keys)", () => { await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); - const authStore = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, - }); const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir); const all = modelRegistry.getAll(); @@ -1397,10 +1503,10 @@ describeLive("gateway live (dev agent, profile keys)", () => { const maxModels = GATEWAY_LIVE_MAX_MODELS; const wanted = filter ? all.filter((m) => filter.has(`${m.provider}/${m.id}`)) - : all.filter((m) => isModernModelRef({ provider: m.provider, id: m.id })); + : all.filter((m) => isHighSignalLiveModelRef({ provider: m.provider, id: m.id })); - const providerProfileCache = new Map(); const candidates: Array> = []; + const skipped: Array<{ model: string; error: string }> = []; for (const model of wanted) { if (shouldSuppressBuiltInModel({ provider: model.provider, id: model.id })) { continue; @@ -1408,23 +1514,28 @@ describeLive("gateway live (dev agent, profile keys)", () => { if (PROVIDERS && !PROVIDERS.has(model.provider)) { continue; } - let hasProfile = providerProfileCache.get(model.provider); - if (hasProfile === undefined) { - const order = resolveAuthProfileOrder({ - cfg, - store: authStore, - provider: model.provider, - }); - hasProfile = order.some((profileId) => Boolean(authStore.profiles[profileId])); - providerProfileCache.set(model.provider, hasProfile); - } - if (!hasProfile) { - continue; + const modelRef = `${model.provider}/${model.id}`; + try { + const apiKeyInfo = await getApiKeyForModel({ model, cfg }); + if (REQUIRE_PROFILE_KEYS && !apiKeyInfo.source.startsWith("profile:")) { + skipped.push({ + model: modelRef, + error: `non-profile credential source: ${apiKeyInfo.source}`, + }); + continue; + } + candidates.push(model); + } catch (error) { + skipped.push({ model: modelRef, error: String(error) }); } - candidates.push(model); } if (candidates.length === 0) { + if (skipped.length > 0) { + logProgress( + `[all-models] auth lookup skipped candidates:\n${formatFailurePreview(skipped, 8)}`, + ); + } logProgress("[all-models] no API keys found; skipping"); return; } @@ -1433,7 +1544,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { maxModels > 0 ? maxModels : candidates.length, (model) => model.provider, ); - logProgress(`[all-models] selection=${useExplicit ? "explicit" : "modern"}`); + logProgress(`[all-models] selection=${useExplicit ? "explicit" : "high-signal"}`); if (selectedCandidates.length < candidates.length) { logProgress( `[all-models] capped to ${selectedCandidates.length}/${candidates.length} via OPENCLAW_LIVE_GATEWAY_MAX_MODELS=${maxModels}`, @@ -1447,6 +1558,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { label: "all-models", cfg, candidates: selectedCandidates, + allowNotFoundSkip: useModern, extraToolProbes: true, extraImageProbes: true, thinkingLevel: THINKING_LEVEL, @@ -1468,6 +1580,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { label: "minimax-anthropic", cfg, candidates: minimaxCandidates, + allowNotFoundSkip: useModern, extraToolProbes: true, extraImageProbes: true, thinkingLevel: THINKING_LEVEL, @@ -1588,6 +1701,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { client, sessionKey, idempotencyKey: `idem-${randomUUID()}-tool`, + modelKey: "anthropic/claude-opus-4-5", message: `Call the tool named \`read\` (or \`Read\` if \`read\` is unavailable) with JSON arguments {"path":"${toolProbePath}"}. ` + `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`, @@ -1616,6 +1730,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { client, sessionKey, idempotencyKey: `idem-${randomUUID()}-followup`, + modelKey: "zai/glm-4.7", message: `What are the values of nonceA and nonceB in "${toolProbePath}"? ` + `Reply with exactly: ${nonceA} ${nonceB}.`, diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index c11595f8f1852..0733de195ca40 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -6,6 +6,7 @@ import type { ChannelId } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import type { HookExternalContentSource } from "../security/external-content.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js"; import { resolveAllowedAgentIds } from "./hooks-policy.js"; @@ -216,6 +217,7 @@ export type HookAgentPayload = { export type HookAgentDispatchPayload = Omit & { sessionKey: string; allowUnsafeExternalContent?: boolean; + externalContentSource?: HookExternalContentSource; }; const listHookChannelValues = () => ["last", ...listChannelPlugins().map((plugin) => plugin.id)]; diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index f3ffa8af7da33..ae4568b57361e 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -1,7 +1,20 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage } from "node:http"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + buildAllowedModelSet, + isCliProvider, + modelKey, + parseModelRef, + resolveDefaultModelForAgent, +} from "../agents/model-selection.js"; +import { loadConfig } from "../config/config.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; +import { loadGatewayModelCatalog } from "./server-model-catalog.js"; + +export const OPENCLAW_MODEL_ID = "openclaw"; +export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default"; export function getHeader(req: IncomingMessage, name: string): string | undefined { const raw = req.headers[name.toLowerCase()]; @@ -34,11 +47,18 @@ export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefin return normalizeAgentId(raw); } -export function resolveAgentIdFromModel(model: string | undefined): string | undefined { +export function resolveAgentIdFromModel( + model: string | undefined, + cfg = loadConfig(), +): string | undefined { const raw = model?.trim(); if (!raw) { return undefined; } + const lowered = raw.toLowerCase(); + if (lowered === OPENCLAW_MODEL_ID || lowered === OPENCLAW_DEFAULT_MODEL_ID) { + return resolveDefaultAgentId(cfg); + } const m = raw.match(/^openclaw[:/](?[a-z0-9][a-z0-9_-]{0,63})$/i) ?? @@ -50,17 +70,64 @@ export function resolveAgentIdFromModel(model: string | undefined): string | und return normalizeAgentId(agentId); } +export async function resolveOpenAiCompatModelOverride(params: { + req: IncomingMessage; + agentId: string; + model: string | undefined; +}): Promise<{ modelOverride?: string; errorMessage?: string }> { + const requestModel = params.model?.trim(); + if (requestModel && !resolveAgentIdFromModel(requestModel)) { + return { + errorMessage: "Invalid `model`. Use `openclaw` or `openclaw/`.", + }; + } + + const raw = getHeader(params.req, "x-openclaw-model")?.trim(); + if (!raw) { + return {}; + } + + const cfg = loadConfig(); + const defaultModelRef = resolveDefaultModelForAgent({ cfg, agentId: params.agentId }); + const defaultProvider = defaultModelRef.provider; + const parsed = parseModelRef(raw, defaultProvider); + if (!parsed) { + return { errorMessage: "Invalid `x-openclaw-model`." }; + } + + const catalog = await loadGatewayModelCatalog(); + const allowed = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider, + agentId: params.agentId, + }); + const normalized = modelKey(parsed.provider, parsed.model); + if ( + !isCliProvider(parsed.provider, cfg) && + !allowed.allowAny && + !allowed.allowedKeys.has(normalized) + ) { + return { + errorMessage: `Model '${normalized}' is not allowed for agent '${params.agentId}'.`, + }; + } + + return { modelOverride: raw }; +} + export function resolveAgentIdForRequest(params: { req: IncomingMessage; model: string | undefined; }): string { + const cfg = loadConfig(); const fromHeader = resolveAgentIdFromHeader(params.req); if (fromHeader) { return fromHeader; } - const fromModel = resolveAgentIdFromModel(params.model); - return fromModel ?? "main"; + const fromModel = resolveAgentIdFromModel(params.model, cfg); + return fromModel ?? resolveDefaultAgentId(cfg); } export function resolveSessionKey(params: { diff --git a/src/gateway/live-tool-probe-utils.test.ts b/src/gateway/live-tool-probe-utils.test.ts index 75f27c08036b5..def908d52ea3f 100644 --- a/src/gateway/live-tool-probe-utils.test.ts +++ b/src/gateway/live-tool-probe-utils.test.ts @@ -136,6 +136,30 @@ describe("live tool probe utils", () => { }, expected: true, }, + { + name: "retries conversational try-again output", + params: { + text: "Let me try reading the file again:", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "zai", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not retry generic conversational text without tool-retry context", + params: { + text: "Let me try a different approach.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "zai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, { name: "retries mistral nonce marker echoes without parsed values", params: { @@ -234,6 +258,28 @@ describe("live tool probe utils", () => { }, expected: true, }, + { + name: "retries conversational try-again exec output", + params: { + text: "Let me try reading the file again:", + nonce: "nonce-c", + provider: "zai", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not retry generic exec conversational text without tool-retry context", + params: { + text: "Let me try a different approach.", + nonce: "nonce-c", + provider: "zai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, { name: "does not special-case anthropic refusals for other providers", params: { diff --git a/src/gateway/live-tool-probe-utils.ts b/src/gateway/live-tool-probe-utils.ts index 62b618fe24d83..a9eed34017db1 100644 --- a/src/gateway/live-tool-probe-utils.ts +++ b/src/gateway/live-tool-probe-utils.ts @@ -53,6 +53,13 @@ function hasMalformedToolOutput(text: string): boolean { if (trimmed.includes("[object Object]")) { return true; } + if ( + lower.includes("try reading the file again") || + lower.includes("trying to read the file again") || + lower.includes("try the read tool again") + ) { + return true; + } if (/\bread\s*\[/.test(lower) || /\btool\b/.test(lower) || /\bfunction\b/.test(lower)) { return true; } diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 47f088646bb44..e22023eeaf103 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -61,6 +61,7 @@ const METHOD_SCOPE_GROUPS: Record = { "tts.providers", "models.list", "tools.catalog", + "tools.effective", "agents.list", "agent.identity.get", "skills.status", diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index b9bb05683e53a..7f79beebbe0be 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -201,4 +201,45 @@ describe("model-pricing-cache", () => { cacheWrite: 0, }); }); + + it("does not recurse forever for native openrouter auto refs", async () => { + const config = { + agents: { + defaults: { + model: { primary: "openrouter/auto" }, + }, + }, + } as unknown as OpenClawConfig; + + const fetchImpl = withFetchPreconnect( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "openrouter/auto", + pricing: { + prompt: "0.000001", + completion: "0.000002", + }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + await expect(refreshGatewayModelPricingCache({ config, fetchImpl })).resolves.toBeUndefined(); + expect( + getCachedGatewayModelPricing({ provider: "openrouter", model: "openrouter/auto" }), + ).toEqual({ + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }); + }); }); diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index ef05628d23414..8307cef2f6e3d 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -161,7 +161,14 @@ function canonicalizeOpenRouterLookupId(id: string): string { return `${provider}/${model}`; } -function buildOpenRouterExactCandidates(ref: ModelRef): string[] { +function buildOpenRouterExactCandidates(ref: ModelRef, seen = new Set()): string[] { + const refKey = modelKey(ref.provider, ref.model); + if (seen.has(refKey)) { + return []; + } + const nextSeen = new Set(seen); + nextSeen.add(refKey); + const candidates = new Set(); const canonicalProvider = canonicalizeOpenRouterProvider(ref.provider); const canonicalFullId = canonicalizeOpenRouterLookupId(modelKey(canonicalProvider, ref.model)); @@ -181,7 +188,7 @@ function buildOpenRouterExactCandidates(ref: ModelRef): string[] { if (WRAPPER_PROVIDERS.has(ref.provider) && ref.model.includes("/")) { const nestedRef = parseModelRef(ref.model, DEFAULT_PROVIDER); if (nestedRef) { - for (const candidate of buildOpenRouterExactCandidates(nestedRef)) { + for (const candidate of buildOpenRouterExactCandidates(nestedRef, nextSeen)) { candidates.add(candidate); } } diff --git a/src/gateway/models-http.test.ts b/src/gateway/models-http.test.ts new file mode 100644 index 0000000000000..9d9077ded48fd --- /dev/null +++ b/src/gateway/models-http.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getFreePort, installGatewayTestHooks } from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +let startGatewayServer: typeof import("./server.js").startGatewayServer; +let enabledServer: Awaited>; +let enabledPort: number; + +beforeAll(async () => { + ({ startGatewayServer } = await import("./server.js")); + enabledPort = await getFreePort(); + enabledServer = await startServer(enabledPort, { openAiChatCompletionsEnabled: true }); +}); + +afterAll(async () => { + await enabledServer.close({ reason: "models http enabled suite done" }); +}); + +async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) { + return await startGatewayServer(port, { + host: "127.0.0.1", + auth: { mode: "token", token: "secret" }, + controlUiEnabled: false, + openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? false, + }); +} + +async function getModels(pathname: string, headers?: Record) { + return await fetch(`http://127.0.0.1:${enabledPort}${pathname}`, { + headers: { + authorization: "Bearer secret", + ...headers, + }, + }); +} + +describe("OpenAI-compatible models HTTP API (e2e)", () => { + it("serves /v1/models when compatibility endpoints are enabled", async () => { + const res = await getModels("/v1/models"); + expect(res.status).toBe(200); + const json = (await res.json()) as { object?: string; data?: Array<{ id?: string }> }; + expect(json.object).toBe("list"); + expect(Array.isArray(json.data)).toBe(true); + expect((json.data?.length ?? 0) > 0).toBe(true); + expect(json.data?.map((entry) => entry.id)).toContain("openclaw"); + expect(json.data?.map((entry) => entry.id)).toContain("openclaw/default"); + expect( + json.data?.every((entry) => typeof entry.id === "string" && entry.id?.startsWith("openclaw")), + ).toBe(true); + }); + + it("serves /v1/models/{id}", async () => { + const list = (await (await getModels("/v1/models")).json()) as { + data?: Array<{ id?: string }>; + }; + const firstId = list.data?.[0]?.id; + expect(typeof firstId).toBe("string"); + const res = await getModels(`/v1/models/${encodeURIComponent(firstId!)}`); + expect(res.status).toBe(200); + const json = (await res.json()) as { id?: string; object?: string }; + expect(json.object).toBe("model"); + expect(json.id).toBe(firstId); + }); + + it("rejects when disabled", async () => { + const port = await getFreePort(); + const server = await startServer(port, { openAiChatCompletionsEnabled: false }); + try { + const res = await fetch(`http://127.0.0.1:${port}/v1/models`, { + headers: { authorization: "Bearer secret" }, + }); + expect(res.status).toBe(404); + } finally { + await server.close({ reason: "models disabled test done" }); + } + }); +}); diff --git a/src/gateway/models-http.ts b/src/gateway/models-http.ts new file mode 100644 index 0000000000000..9c255b54e3fbb --- /dev/null +++ b/src/gateway/models-http.ts @@ -0,0 +1,128 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { loadConfig } from "../config/config.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; +import { sendInvalidRequest, sendJson, sendMethodNotAllowed } from "./http-common.js"; +import { + OPENCLAW_DEFAULT_MODEL_ID, + OPENCLAW_MODEL_ID, + resolveAgentIdFromModel, +} from "./http-utils.js"; + +type OpenAiModelsHttpOptions = { + auth: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; +}; + +type OpenAiModelObject = { + id: string; + object: "model"; + created: number; + owned_by: string; + permission: []; +}; + +function toOpenAiModel(id: string): OpenAiModelObject { + return { + id, + object: "model", + created: 0, + owned_by: "openclaw", + permission: [], + }; +} + +async function authorizeRequest( + req: IncomingMessage, + res: ServerResponse, + opts: OpenAiModelsHttpOptions, +): Promise { + return await authorizeGatewayBearerRequestOrReply({ + req, + res, + auth: opts.auth, + trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, + rateLimiter: opts.rateLimiter, + }); +} + +function loadAgentModelIds(): string[] { + const cfg = loadConfig(); + const defaultAgentId = resolveDefaultAgentId(cfg); + const ids = new Set([OPENCLAW_MODEL_ID, OPENCLAW_DEFAULT_MODEL_ID]); + ids.add(`openclaw/${defaultAgentId}`); + for (const agentId of listAgentIds(cfg)) { + ids.add(`openclaw/${agentId}`); + } + return Array.from(ids); +} + +function resolveRequestPath(req: IncomingMessage): string { + return new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`).pathname; +} + +export async function handleOpenAiModelsHttpRequest( + req: IncomingMessage, + res: ServerResponse, + opts: OpenAiModelsHttpOptions, +): Promise { + const requestPath = resolveRequestPath(req); + if (requestPath !== "/v1/models" && !requestPath.startsWith("/v1/models/")) { + return false; + } + + if (req.method !== "GET") { + sendMethodNotAllowed(res, "GET"); + return true; + } + + if (!(await authorizeRequest(req, res, opts))) { + return true; + } + + const ids = loadAgentModelIds(); + if (requestPath === "/v1/models") { + sendJson(res, 200, { + object: "list", + data: ids.map(toOpenAiModel), + }); + return true; + } + + const encodedId = requestPath.slice("/v1/models/".length); + if (!encodedId) { + sendInvalidRequest(res, "Missing model id."); + return true; + } + + let decodedId: string; + try { + decodedId = decodeURIComponent(encodedId); + } catch { + sendInvalidRequest(res, "Invalid model id encoding."); + return true; + } + + if (decodedId !== OPENCLAW_MODEL_ID && !resolveAgentIdFromModel(decodedId)) { + sendInvalidRequest(res, "Invalid model id."); + return true; + } + + if (!ids.includes(decodedId)) { + sendJson(res, 404, { + error: { + message: `Model '${decodedId}' not found.`, + type: "invalid_request_error", + }, + }); + return true; + } + + sendJson(res, 200, toOpenAiModel(decodedId)); + return true; +} diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 82130807a1b36..575527b6cb84e 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; @@ -45,6 +47,15 @@ async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: }); } +async function writeGatewayConfig(config: Record) { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is required for gateway config tests"); + } + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); +} + async function postChatCompletions(port: number, body: unknown, headers?: Record) { const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: "POST", @@ -191,7 +202,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { { await expectAgentSessionKeyMatch({ body: { - model: "openclaw:beta", + model: "openclaw/beta", messages: [{ role: "user", content: "hi" }], }, matcher: /^agent:beta:/, @@ -201,11 +212,10 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { { await expectAgentSessionKeyMatch({ body: { - model: "openclaw:beta", + model: "openclaw/default", messages: [{ role: "user", content: "hi" }], }, - headers: { "x-openclaw-agent-id": "alpha" }, - matcher: /^agent:alpha:/, + matcher: /^agent:main:/, }); } @@ -244,6 +254,85 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { await res.text(); } + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions( + port, + { + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }, + { + "x-openclaw-model": "openai/gpt-5.4", + }, + ); + expect(res.status).toBe(200); + const opts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0]; + expect((opts as { model?: string } | undefined)?.model).toBe("openai/gpt-5.4"); + await res.text(); + } + + { + await writeGatewayConfig({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: { + "openai/gpt-5.4": {}, + }, + }, + }, + }); + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions( + port, + { + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }, + { + "x-openclaw-model": "gpt-5.4", + }, + ); + expect(res.status).toBe(200); + const opts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0]; + expect((opts as { model?: string } | undefined)?.model).toBe("gpt-5.4"); + await res.text(); + await writeGatewayConfig({}); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openai/", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message).toBe( + "Invalid `model`. Use `openclaw` or `openclaw/`.", + ); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions( + port, + { + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }, + { "x-openclaw-model": "openai/" }, + ); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message).toBe("Invalid `x-openclaw-model`."); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + { mockAgentOnce([{ text: "hello" }]); const res = await postChatCompletions(port, { diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 5809da5bceef0..dd8fecb9ebf2c 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -27,7 +27,7 @@ import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson, setSseHeaders, writeDone } from "./http-common.js"; import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; -import { resolveGatewayRequestContext } from "./http-utils.js"; +import { resolveGatewayRequestContext, resolveOpenAiCompatModelOverride } from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; type OpenAiHttpOptions = { @@ -102,6 +102,7 @@ function writeSse(res: ServerResponse, data: unknown) { function buildAgentCommandInput(params: { prompt: { message: string; extraSystemPrompt?: string; images?: ImageContent[] }; + modelOverride?: string; sessionKey: string; runId: string; messageChannel: string; @@ -110,6 +111,7 @@ function buildAgentCommandInput(params: { message: params.prompt.message, extraSystemPrompt: params.prompt.extraSystemPrompt, images: params.prompt.images, + model: params.modelOverride, sessionKey: params.sessionKey, runId: params.runId, deliver: false as const, @@ -432,7 +434,7 @@ export async function handleOpenAiHttpRequest( const model = typeof payload.model === "string" ? payload.model : "openclaw"; const user = typeof payload.user === "string" ? payload.user : undefined; - const { sessionKey, messageChannel } = resolveGatewayRequestContext({ + const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({ req, model, user, @@ -440,6 +442,17 @@ export async function handleOpenAiHttpRequest( defaultMessageChannel: "webchat", useMessageChannelHeader: true, }); + const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({ + req, + agentId, + model, + }); + if (modelError) { + sendJson(res, 400, { + error: { message: modelError, type: "invalid_request_error" }, + }); + return true; + } const activeTurnContext = resolveActiveTurnContext(payload.messages); const prompt = buildAgentPrompt(payload.messages, activeTurnContext.activeUserMessageIndex); let images: ImageContent[] = []; @@ -474,6 +487,7 @@ export async function handleOpenAiHttpRequest( extraSystemPrompt: prompt.extraSystemPrompt, images: images.length > 0 ? images : undefined, }, + modelOverride, sessionKey, runId, messageChannel, diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index b7549339dc776..9b6f5405311ba 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -17,12 +17,12 @@ let openResponsesTesting: { responseId: string, sessionKey: string, now: number, - scope?: { agentId: string; user?: string; requestedSessionKey?: string }, + scope?: { authSubject: string; agentId: string; requestedSessionKey?: string }, ): void; lookupResponseSessionAt( responseId: string | undefined, now: number, - scope?: { agentId: string; user?: string; requestedSessionKey?: string }, + scope?: { authSubject: string; agentId: string; requestedSessionKey?: string }, ): string | undefined; getResponseSessionIds(): string[]; }; @@ -233,6 +233,19 @@ describe("OpenResponses HTTP API (e2e)", () => { ); await ensureResponseConsumed(resMissingModel); + agentCommand.mockClear(); + const resInvalidModel = await postResponses(port, { model: "openai/", input: "hi" }); + expect(resInvalidModel.status).toBe(400); + const invalidModelJson = (await resInvalidModel.json()) as { + error?: { type?: string; message?: string }; + }; + expect(invalidModelJson.error?.type).toBe("invalid_request_error"); + expect(invalidModelJson.error?.message).toBe( + "Invalid `model`. Use `openclaw` or `openclaw/`.", + ); + expect(agentCommand).toHaveBeenCalledTimes(0); + await ensureResponseConsumed(resInvalidModel); + mockAgentOnce([{ text: "hello" }]); const resHeader = await postResponses( port, @@ -250,7 +263,7 @@ describe("OpenResponses HTTP API (e2e)", () => { await ensureResponseConsumed(resHeader); mockAgentOnce([{ text: "hello" }]); - const resModel = await postResponses(port, { model: "openclaw:beta", input: "hi" }); + const resModel = await postResponses(port, { model: "openclaw/beta", input: "hi" }); expect(resModel.status).toBe(200); const optsModel = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0]; expect((optsModel as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( @@ -258,6 +271,15 @@ describe("OpenResponses HTTP API (e2e)", () => { ); await ensureResponseConsumed(resModel); + mockAgentOnce([{ text: "hello" }]); + const resDefaultAlias = await postResponses(port, { model: "openclaw/default", input: "hi" }); + expect(resDefaultAlias.status).toBe(200); + const optsDefaultAlias = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0]; + expect((optsDefaultAlias as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( + /^agent:main:/, + ); + await ensureResponseConsumed(resDefaultAlias); + mockAgentOnce([{ text: "hello" }]); const resChannelHeader = await postResponses( port, @@ -267,10 +289,39 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(resChannelHeader.status).toBe(200); const optsChannelHeader = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0]; expect((optsChannelHeader as { messageChannel?: string } | undefined)?.messageChannel).toBe( - "webchat", + "custom-client-channel", ); await ensureResponseConsumed(resChannelHeader); + mockAgentOnce([{ text: "hello" }]); + const resModelOverride = await postResponses( + port, + { + model: "openclaw", + input: "hi", + }, + { "x-openclaw-model": "openai/gpt-5.4" }, + ); + expect(resModelOverride.status).toBe(200); + const optsModelOverride = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0]; + expect((optsModelOverride as { model?: string } | undefined)?.model).toBe("openai/gpt-5.4"); + await ensureResponseConsumed(resModelOverride); + + agentCommand.mockClear(); + const resInvalidOverride = await postResponses( + port, + { model: "openclaw", input: "hi" }, + { "x-openclaw-model": "openai/" }, + ); + expect(resInvalidOverride.status).toBe(400); + const invalidOverrideJson = (await resInvalidOverride.json()) as { + error?: { type?: string; message?: string }; + }; + expect(invalidOverrideJson.error?.type).toBe("invalid_request_error"); + expect(invalidOverrideJson.error?.message).toBe("Invalid `x-openclaw-model`."); + expect(agentCommand).toHaveBeenCalledTimes(0); + await ensureResponseConsumed(resInvalidOverride); + mockAgentOnce([{ text: "hello" }]); const resUser = await postResponses(port, { user: "alice", @@ -777,7 +828,7 @@ describe("OpenResponses HTTP API (e2e)", () => { await ensureResponseConsumed(secondResponse); }); - it("does not reuse prior sessions across different user scopes", async () => { + it("reuses prior sessions across different user values when auth scope matches", async () => { const port = enabledPort; agentCommand.mockClear(); agentCommand.mockResolvedValueOnce({ @@ -812,8 +863,7 @@ describe("OpenResponses HTTP API (e2e)", () => { const secondOpts = (agentCommand.mock.calls[1] as unknown[] | undefined)?.[0] as | { sessionKey?: string } | undefined; - expect(secondOpts?.sessionKey).not.toBe(firstOpts?.sessionKey); - expect(secondOpts?.sessionKey ?? "").toContain("openresponses-user:bob"); + expect(secondOpts?.sessionKey).toBe(firstOpts?.sessionKey); await ensureResponseConsumed(secondResponse); }); @@ -864,22 +914,22 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(openResponsesTesting.lookupResponseSessionAt("resp_504", 505)).toBe("session_504"); }); - it("does not reuse cached sessions when the user scope changes", () => { + it("does not reuse cached sessions when the auth subject changes", () => { openResponsesTesting.storeResponseSessionAt("resp_1", "session_1", 100, { + authSubject: "subject:a", agentId: "main", - user: "alice", }); expect( openResponsesTesting.lookupResponseSessionAt("resp_1", 101, { + authSubject: "subject:a", agentId: "main", - user: "alice", }), ).toBe("session_1"); expect( openResponsesTesting.lookupResponseSessionAt("resp_1", 101, { + authSubject: "subject:b", agentId: "main", - user: "bob", }), ).toBeUndefined(); }); diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index c23388c3d689d..7d5412d372262 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -6,7 +6,7 @@ * @see https://www.open-responses.com/ */ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { ImageContent } from "../agents/command/types.js"; import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js"; @@ -35,7 +35,13 @@ import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson, setSseHeaders, writeDone } from "./http-common.js"; import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; -import { getHeader, resolveGatewayRequestContext } from "./http-utils.js"; +import { + getBearerToken, + getHeader, + resolveAgentIdForRequest, + resolveGatewayRequestContext, + resolveOpenAiCompatModelOverride, +} from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; import { CreateResponseBodySchema, @@ -64,8 +70,8 @@ const DEFAULT_MAX_URL_PARTS = 8; const RESPONSE_SESSION_TTL_MS = 30 * 60 * 1000; const MAX_RESPONSE_SESSION_ENTRIES = 500; type ResponseSessionScope = { + authSubject: string; agentId: string; - user?: string; requestedSessionKey?: string; }; @@ -77,23 +83,40 @@ type ResponseSessionEntry = ResponseSessionScope & { const responseSessionMap = new Map(); function normalizeResponseSessionScope(scope: ResponseSessionScope): ResponseSessionScope { - const user = scope.user?.trim(); + const authSubject = scope.authSubject.trim(); const requestedSessionKey = scope.requestedSessionKey?.trim(); return { + authSubject, agentId: scope.agentId, - user: user || undefined, requestedSessionKey: requestedSessionKey || undefined, }; } +function resolveResponseSessionAuthSubject(params: { + req: IncomingMessage; + auth: ResolvedGatewayAuth; +}): string { + const bearer = getBearerToken(params.req); + if (bearer) { + return `bearer:${createHash("sha256").update(bearer).digest("hex")}`; + } + if (params.auth.mode === "trusted-proxy" && params.auth.trustedProxy?.userHeader) { + const user = getHeader(params.req, params.auth.trustedProxy.userHeader)?.trim(); + if (user) { + return `trusted-proxy:${user}`; + } + } + return `gateway-auth:${params.auth.mode}`; +} + function createResponseSessionScope(params: { req: IncomingMessage; + auth: ResolvedGatewayAuth; agentId: string; - user?: string; }): ResponseSessionScope { return normalizeResponseSessionScope({ + authSubject: resolveResponseSessionAuthSubject({ req: params.req, auth: params.auth }), agentId: params.agentId, - user: params.user, requestedSessionKey: getHeader(params.req, "x-openclaw-session-key"), }); } @@ -103,8 +126,8 @@ function matchesResponseSessionScope( scope: ResponseSessionScope, ): boolean { return ( + entry.authSubject === scope.authSubject && entry.agentId === scope.agentId && - entry.user === scope.user && entry.requestedSessionKey === scope.requestedSessionKey ); } @@ -176,14 +199,14 @@ export const __testing = { responseId: string, sessionKey: string, now: number, - scope: ResponseSessionScope = { agentId: "main" }, + scope: ResponseSessionScope = { authSubject: "test", agentId: "main" }, ) { storeResponseSession(responseId, sessionKey, normalizeResponseSessionScope(scope), now); }, lookupResponseSessionAt( responseId: string | undefined, now: number, - scope: ResponseSessionScope = { agentId: "main" }, + scope: ResponseSessionScope = { authSubject: "test", agentId: "main" }, ) { return lookupResponseSession(responseId, normalizeResponseSessionScope(scope), now); }, @@ -387,6 +410,7 @@ async function runResponsesAgentCommand(params: { images: ImageContent[]; clientTools: ClientToolDefinition[]; extraSystemPrompt: string; + modelOverride?: string; streamParams: { maxTokens: number } | undefined; sessionKey: string; runId: string; @@ -399,6 +423,7 @@ async function runResponsesAgentCommand(params: { images: params.images.length > 0 ? params.images : undefined, clientTools: params.clientTools.length > 0 ? params.clientTools : undefined, extraSystemPrompt: params.extraSystemPrompt || undefined, + model: params.modelOverride, streamParams: params.streamParams ?? undefined, sessionKey: params.sessionKey, runId: params.runId, @@ -455,6 +480,18 @@ export async function handleOpenResponsesHttpRequest( const stream = Boolean(payload.stream); const model = payload.model; const user = payload.user; + const agentId = resolveAgentIdForRequest({ req, model }); + const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({ + req, + agentId, + model, + }); + if (modelError) { + sendJson(res, 400, { + error: { message: modelError, type: "invalid_request_error" }, + }); + return true; + } // Extract images + files from input (Phase 2) let images: ImageContent[] = []; @@ -593,15 +630,15 @@ export async function handleOpenResponsesHttpRequest( user, sessionPrefix: "openresponses", defaultMessageChannel: "webchat", - useMessageChannelHeader: false, + useMessageChannelHeader: true, }); const responseSessionScope = createResponseSessionScope({ req, + auth: opts.auth, agentId: resolved.agentId, - user, }); // Resolve session key: reuse previous_response_id only when it matches the - // same agent/user/requested-session scope as the current request. + // same auth-subject/agent/requested-session scope as the current request. const previousSessionKey = lookupResponseSession( payload.previous_response_id, responseSessionScope, @@ -652,6 +689,7 @@ export async function handleOpenResponsesHttpRequest( images, clientTools: resolvedClientTools, extraSystemPrompt, + modelOverride, streamParams, sessionKey, runId: responseId, @@ -903,6 +941,7 @@ export async function handleOpenResponsesHttpRequest( images, clientTools: resolvedClientTools, extraSystemPrompt, + modelOverride, streamParams, sessionKey, runId: responseId, diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index b285c395c3dfd..f09ad3c997faf 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -64,12 +64,23 @@ export async function probeGateway(opts: { return await new Promise((resolve) => { let settled = false; + let timer: ReturnType | null = null; + const clearProbeTimer = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + }; + const armProbeTimer = (onTimeout: () => void) => { + clearProbeTimer(); + timer = setTimeout(onTimeout, clampProbeTimeoutMs(opts.timeoutMs)); + }; const settle = (result: Omit) => { if (settled) { return; } settled = true; - clearTimeout(timer); + clearProbeTimer(); client.stop(); resolve({ url: opts.url, ...result }); }; @@ -105,6 +116,20 @@ export async function probeGateway(opts: { }); return; } + // Once the gateway has accepted the session, a slow follow-up RPC should no longer + // downgrade the probe to "unreachable". Give detail fetching its own budget. + armProbeTimer(() => { + settle({ + ok: false, + connectLatencyMs, + error: "timeout", + close, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + }); try { if (detailLevel === "presence") { const presence = await client.request("system-presence"); @@ -151,7 +176,7 @@ export async function probeGateway(opts: { }, }); - const timer = setTimeout(() => { + armProbeTimer(() => { settle({ ok: false, connectLatencyMs, @@ -162,7 +187,7 @@ export async function probeGateway(opts: { presence: null, configSnapshot: null, }); - }, clampProbeTimeoutMs(opts.timeoutMs)); + }); client.start(); }); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 408074d44e4c4..c7d230dee0c4f 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -230,6 +230,9 @@ import { type ToolsCatalogParams, ToolsCatalogParamsSchema, type ToolsCatalogResult, + type ToolsEffectiveParams, + ToolsEffectiveParamsSchema, + type ToolsEffectiveResult, type Snapshot, SnapshotSchema, type StateVersion, @@ -390,6 +393,9 @@ export const validateChannelsLogoutParams = ajv.compile( export const validateModelsListParams = ajv.compile(ModelsListParamsSchema); export const validateSkillsStatusParams = ajv.compile(SkillsStatusParamsSchema); export const validateToolsCatalogParams = ajv.compile(ToolsCatalogParamsSchema); +export const validateToolsEffectiveParams = ajv.compile( + ToolsEffectiveParamsSchema, +); export const validateSkillsBinsParams = ajv.compile(SkillsBinsParamsSchema); export const validateSkillsInstallParams = ajv.compile(SkillsInstallParamsSchema); @@ -572,6 +578,7 @@ export { ModelsListParamsSchema, SkillsStatusParamsSchema, ToolsCatalogParamsSchema, + ToolsEffectiveParamsSchema, SkillsInstallParamsSchema, SkillsUpdateParamsSchema, CronJobSchema, @@ -664,6 +671,8 @@ export type { SkillsStatusParams, ToolsCatalogParams, ToolsCatalogResult, + ToolsEffectiveParams, + ToolsEffectiveResult, SkillsBinsParams, SkillsBinsResult, SkillsInstallParams, diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index f12ecb409b80e..41b332fd48d24 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -238,6 +238,14 @@ export const ToolsCatalogParamsSchema = Type.Object( { additionalProperties: false }, ); +export const ToolsEffectiveParamsSchema = Type.Object( + { + agentId: Type.Optional(NonEmptyString), + sessionKey: NonEmptyString, + }, + { additionalProperties: false }, +); + export const ToolCatalogProfileSchema = Type.Object( { id: Type.Union([ @@ -290,3 +298,35 @@ export const ToolsCatalogResultSchema = Type.Object( }, { additionalProperties: false }, ); + +export const ToolsEffectiveEntrySchema = Type.Object( + { + id: NonEmptyString, + label: NonEmptyString, + description: Type.String(), + rawDescription: Type.String(), + source: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]), + pluginId: Type.Optional(NonEmptyString), + channelId: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); + +export const ToolsEffectiveGroupSchema = Type.Object( + { + id: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]), + label: NonEmptyString, + source: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]), + tools: Type.Array(ToolsEffectiveEntrySchema), + }, + { additionalProperties: false }, +); + +export const ToolsEffectiveResultSchema = Type.Object( + { + agentId: NonEmptyString, + profile: NonEmptyString, + groups: Type.Array(ToolsEffectiveGroupSchema), + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index cf14fc446103a..906f13a71c996 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -39,6 +39,10 @@ import { ToolCatalogProfileSchema, ToolsCatalogParamsSchema, ToolsCatalogResultSchema, + ToolsEffectiveEntrySchema, + ToolsEffectiveGroupSchema, + ToolsEffectiveParamsSchema, + ToolsEffectiveResultSchema, } from "./agents-models-skills.js"; import { ChannelsLogoutParamsSchema, @@ -272,6 +276,10 @@ export const ProtocolSchemas = { ToolCatalogEntry: ToolCatalogEntrySchema, ToolCatalogGroup: ToolCatalogGroupSchema, ToolsCatalogResult: ToolsCatalogResultSchema, + ToolsEffectiveParams: ToolsEffectiveParamsSchema, + ToolsEffectiveEntry: ToolsEffectiveEntrySchema, + ToolsEffectiveGroup: ToolsEffectiveGroupSchema, + ToolsEffectiveResult: ToolsEffectiveResultSchema, SkillsBinsParams: SkillsBinsParamsSchema, SkillsBinsResult: SkillsBinsResultSchema, SkillsInstallParams: SkillsInstallParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index d74c08ad10bf8..c96d70b2e62e0 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -102,6 +102,10 @@ export type ToolCatalogProfile = SchemaType<"ToolCatalogProfile">; export type ToolCatalogEntry = SchemaType<"ToolCatalogEntry">; export type ToolCatalogGroup = SchemaType<"ToolCatalogGroup">; export type ToolsCatalogResult = SchemaType<"ToolsCatalogResult">; +export type ToolsEffectiveParams = SchemaType<"ToolsEffectiveParams">; +export type ToolsEffectiveEntry = SchemaType<"ToolsEffectiveEntry">; +export type ToolsEffectiveGroup = SchemaType<"ToolsEffectiveGroup">; +export type ToolsEffectiveResult = SchemaType<"ToolsEffectiveResult">; export type SkillsBinsParams = SchemaType<"SkillsBinsParams">; export type SkillsBinsResult = SchemaType<"SkillsBinsResult">; export type SkillsInstallParams = SchemaType<"SkillsInstallParams">; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index dd5a659dbc906..d426e1223c4da 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -14,6 +14,7 @@ import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveHookExternalContentSource as resolveHookExternalContentSourceFromSession } from "../security/external-content.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, @@ -33,6 +34,7 @@ import { handleControlUiHttpRequest, type ControlUiRootState, } from "./control-ui.js"; +import { handleOpenAiEmbeddingsHttpRequest } from "./embeddings-http.js"; import { applyHookMappings } from "./hooks-mapping.js"; import { extractHookToken, @@ -54,6 +56,7 @@ import { } from "./hooks.js"; import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; import { getBearerToken } from "./http-utils.js"; +import { handleOpenAiModelsHttpRequest } from "./models-http.js"; import { resolveRequestClientIp } from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; @@ -85,6 +88,19 @@ type HookDispatchers = { dispatchAgentHook: (value: HookAgentDispatchPayload) => string; }; +function resolveMappedHookExternalContentSource(params: { + subPath: string; + payload: Record; + sessionKey: string; +}) { + const payloadSource = + typeof params.payload.source === "string" ? params.payload.source.trim().toLowerCase() : ""; + if (params.subPath === "gmail" || payloadSource === "gmail") { + return "gmail" as const; + } + return resolveHookExternalContentSourceFromSession(params.sessionKey) ?? "webhook"; +} + export type HookClientIpConfig = Readonly<{ trustedProxies?: string[]; allowRealIpFallback?: boolean; @@ -602,6 +618,7 @@ export function createHooksRequestHandler( idempotencyKey, sessionKey: normalizedDispatchSessionKey, agentId: targetAgentId, + externalContentSource: "webhook", }); rememberHookRunId(replayKey, runId, now); sendJson(res, 200, { ok: true, runId }); @@ -695,6 +712,11 @@ export function createHooksRequestHandler( thinking: mapped.action.thinking, timeoutSeconds: mapped.action.timeoutSeconds, allowUnsafeExternalContent: mapped.action.allowUnsafeExternalContent, + externalContentSource: resolveMappedHookExternalContentSource({ + subPath, + payload: payload as Record, + sessionKey: sessionKey.value, + }), }); rememberHookRunId(replayKey, runId, now); sendJson(res, 200, { ok: true, runId }); @@ -752,6 +774,7 @@ export function createGatewayHttpServer(opts: { rateLimiter, getReadiness, } = opts; + const openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled; const httpServer: HttpServer = opts.tlsOptions ? createHttpsServer(opts.tlsOptions, (req, res) => { void handleRequest(req, res); @@ -792,6 +815,30 @@ export function createGatewayHttpServer(opts: { name: "hooks", run: () => handleHooksRequest(req, res), }, + { + name: "models", + run: () => + openAiCompatEnabled + ? handleOpenAiModelsHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }) + : false, + }, + { + name: "embeddings", + run: () => + openAiCompatEnabled + ? handleOpenAiEmbeddingsHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }) + : false, + }, { name: "tools-invoke", run: () => diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index e930f8b0517ea..d9aa68635d5d4 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -38,6 +38,7 @@ const BASE_METHODS = [ "talk.mode", "models.list", "tools.catalog", + "tools.effective", "agents.list", "agents.create", "agents.update", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index f6f052f8cc288..6276a5d4b3160 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -27,6 +27,7 @@ import { skillsHandlers } from "./server-methods/skills.js"; import { systemHandlers } from "./server-methods/system.js"; import { talkHandlers } from "./server-methods/talk.js"; import { toolsCatalogHandlers } from "./server-methods/tools-catalog.js"; +import { toolsEffectiveHandlers } from "./server-methods/tools-effective.js"; import { ttsHandlers } from "./server-methods/tts.js"; import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js"; import { updateHandlers } from "./server-methods/update.js"; @@ -82,6 +83,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...wizardHandlers, ...talkHandlers, ...toolsCatalogHandlers, + ...toolsEffectiveHandlers, ...ttsHandlers, ...skillsHandlers, ...sessionsHandlers, diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index f29a9a4c85d59..bb3d2be0a855d 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -803,7 +803,10 @@ describe("gateway agent handler", () => { sessionKey: "agent:main:main", idempotencyKey: "test-idem-new", }, - { reqId: "4" }, + { + reqId: "4", + client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"], + }, ); await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled()); @@ -831,7 +834,10 @@ describe("gateway agent handler", () => { sessionKey: "agent:main:main", idempotencyKey: "test-idem-reset-suffix", }, - { reqId: "4b" }, + { + reqId: "4b", + client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"], + }, ); const call = await expectResetCall("[Wed 2026-01-28 20:30 EST] check status"); @@ -861,6 +867,34 @@ describe("gateway agent handler", () => { ); }); + it("rejects /reset for write-scoped gateway callers", async () => { + mockMainSessionEntry({ sessionId: "existing-session-id" }); + mocks.performGatewaySessionReset.mockClear(); + mocks.agentCommand.mockClear(); + + const respond = await invokeAgent( + { + message: "/reset", + sessionKey: "agent:main:main", + idempotencyKey: "test-reset-write-scope", + }, + { + reqId: "4c", + client: { connect: { scopes: ["operator.write"] } } as AgentHandlerArgs["client"], + }, + ); + + expect(mocks.performGatewaySessionReset).not.toHaveBeenCalled(); + expect(mocks.agentCommand).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "missing scope: operator.admin", + }), + ); + }); + it("rejects malformed session keys in agent.identity.get", async () => { const respond = await invokeAgentIdentityGet( { diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index bd5637fa78fed..df928e626fa5e 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -79,6 +79,10 @@ function resolveAllowModelOverrideFromClient( return resolveSenderIsOwnerFromClient(client) || client?.internal?.allowModelOverride === true; } +function resolveCanResetSessionFromClient(client: GatewayRequestHandlerOptions["client"]): boolean { + return resolveSenderIsOwnerFromClient(client); +} + async function runSessionResetFromAgent(params: { key: string; reason: "new" | "reset"; @@ -240,6 +244,7 @@ export const agentHandlers: GatewayRequestHandlers = { }; const senderIsOwner = resolveSenderIsOwnerFromClient(client); const allowModelOverride = resolveAllowModelOverrideFromClient(client); + const canResetSession = resolveCanResetSessionFromClient(client); const requestedModelOverride = Boolean(request.provider || request.model); if (requestedModelOverride && !allowModelOverride) { respond( @@ -378,6 +383,14 @@ export const agentHandlers: GatewayRequestHandlers = { const resetCommandMatch = message.match(RESET_COMMAND_RE); if (resetCommandMatch && requestedSessionKey) { + if (!canResetSession) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${ADMIN_SCOPE}`), + ); + return; + } const resetReason = resetCommandMatch[1]?.toLowerCase() === "new" ? "new" : "reset"; const resetResult = await runSessionResetFromAgent({ key: requestedSessionKey, diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 02f4da99f6f0e..d62d1d4a9cf4c 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { describe, expect, it, vi, beforeEach } from "vitest"; +import { SafeOpenError } from "../../infra/fs-safe.js"; /* ------------------------------------------------------------------ */ /* Mocks */ @@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => ({ pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })), writeConfigFile: vi.fn(async () => {}), ensureAgentWorkspace: vi.fn(async () => {}), + isWorkspaceSetupCompleted: vi.fn(async () => false), resolveAgentDir: vi.fn(() => "/agents/test-agent"), resolveAgentWorkspaceDir: vi.fn(() => "/workspace/test-agent"), resolveSessionTranscriptsDirForAgent: vi.fn(() => "/transcripts/test-agent"), @@ -30,7 +32,9 @@ const mocks = vi.hoisted(() => ({ fsStat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null), fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null), fsRealpath: vi.fn(async (p: string) => p), + fsReadlink: vi.fn(async () => ""), fsOpen: vi.fn(async () => ({}) as unknown), + appendFileWithinRoot: vi.fn(async () => {}), writeFileWithinRoot: vi.fn(async () => {}), })); @@ -59,6 +63,7 @@ vi.mock("../../agents/workspace.js", async () => { return { ...actual, ensureAgentWorkspace: mocks.ensureAgentWorkspace, + isWorkspaceSetupCompleted: mocks.isWorkspaceSetupCompleted, }; }); @@ -83,6 +88,7 @@ vi.mock("../../infra/fs-safe.js", async () => { await vi.importActual("../../infra/fs-safe.js"); return { ...actual, + appendFileWithinRoot: mocks.appendFileWithinRoot, writeFileWithinRoot: mocks.writeFileWithinRoot, }; }); @@ -101,6 +107,7 @@ vi.mock("node:fs/promises", async () => { stat: mocks.fsStat, lstat: mocks.fsLstat, realpath: mocks.fsRealpath, + readlink: mocks.fsReadlink, open: mocks.fsOpen, }; return { ...patched, default: patched }; @@ -110,12 +117,16 @@ vi.mock("node:fs/promises", async () => { /* Import after mocks are set up */ /* ------------------------------------------------------------------ */ -const { agentsHandlers } = await import("./agents.js"); +const { __testing: agentsTesting, agentsHandlers } = await import("./agents.js"); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ +beforeEach(() => { + agentsTesting.resetDepsForTests(); +}); + function makeCall(method: keyof typeof agentsHandlers, params: Record) { const respond = vi.fn(); const handler = agentsHandlers[method]; @@ -160,36 +171,32 @@ function makeFileStat(params?: { } as unknown as import("node:fs").Stats; } -function makeSymlinkStat(params?: { dev?: number; ino?: number }): import("node:fs").Stats { - return { - isFile: () => false, - isSymbolicLink: () => true, - size: 0, - mtimeMs: 0, - dev: params?.dev ?? 1, - ino: params?.ino ?? 2, - } as unknown as import("node:fs").Stats; -} - function mockWorkspaceStateRead(params: { setupCompletedAt?: string; errorCode?: string; rawContent?: string; }) { - mocks.fsReadFile.mockImplementation(async (...args: unknown[]) => { - const filePath = args[0]; - if (String(filePath).endsWith("workspace-state.json")) { + agentsTesting.setDepsForTests({ + isWorkspaceSetupCompleted: async () => { if (params.errorCode) { throw createErrnoError(params.errorCode); } if (typeof params.rawContent === "string") { - return params.rawContent; + throw new SyntaxError("Expected property name or '}' in JSON"); } - return JSON.stringify({ - setupCompletedAt: params.setupCompletedAt ?? "2026-02-15T14:00:00.000Z", - }); + return ( + typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0 + ); + }, + }); + mocks.isWorkspaceSetupCompleted.mockImplementation(async () => { + if (params.errorCode) { + throw createErrnoError(params.errorCode); } - throw createEnoentError(); + if (typeof params.rawContent === "string") { + throw new SyntaxError("Expected property name or '}' in JSON"); + } + return typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0; }); } @@ -258,6 +265,7 @@ describe("agents.create", () => { mocks.loadConfigReturn = {}; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.applyAgentConfig.mockImplementation((_cfg, _opts) => ({})); + mocks.appendFileWithinRoot.mockResolvedValue(undefined); }); it("creates a new agent successfully", async () => { @@ -351,10 +359,13 @@ describe("agents.create", () => { }); await promise; - expect(mocks.fsAppendFile).toHaveBeenCalledWith( - expect.stringContaining("IDENTITY.md"), - expect.stringContaining("- Name: Plain Agent"), - "utf-8", + expect(mocks.appendFileWithinRoot).toHaveBeenCalledWith( + expect.objectContaining({ + rootDir: "/resolved/tmp/ws", + relativePath: "IDENTITY.md", + data: expect.stringContaining("- Name: Plain Agent"), + encoding: "utf8", + }), ); }); @@ -367,11 +378,59 @@ describe("agents.create", () => { }); await promise; - expect(mocks.fsAppendFile).toHaveBeenCalledWith( - expect.stringContaining("IDENTITY.md"), - expect.stringMatching(/- Name: Fancy Agent[\s\S]*- Emoji: 🤖[\s\S]*- Avatar:/), - "utf-8", + expect(mocks.appendFileWithinRoot).toHaveBeenCalledWith( + expect.objectContaining({ + rootDir: "/resolved/tmp/ws", + relativePath: "IDENTITY.md", + data: expect.stringMatching(/- Name: Fancy Agent[\s\S]*- Emoji: 🤖[\s\S]*- Avatar:/), + encoding: "utf8", + }), + ); + }); + + it("rejects creating an agent when IDENTITY.md resolves outside the workspace", async () => { + const workspace = "/resolved/tmp/ws"; + agentsTesting.setDepsForTests({ + resolveAgentWorkspaceFilePath: async ({ name }) => ({ + kind: "invalid", + requestPath: path.join(workspace, name), + reason: "path escapes workspace root", + }), + }); + + const { respond, promise } = makeCall("agents.create", { + name: "Unsafe Agent", + workspace: "/tmp/ws", + }); + await promise; + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), + ); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.appendFileWithinRoot).not.toHaveBeenCalled(); + }); + + it("does not persist config when IDENTITY.md append is rejected after preflight", async () => { + mocks.appendFileWithinRoot.mockRejectedValueOnce( + new SafeOpenError("path-mismatch", "path escapes workspace root"), + ); + + const { respond, promise } = makeCall("agents.create", { + name: "Append Reject Agent", + workspace: "/tmp/ws", + }); + await promise; + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), ); + expect(mocks.appendFileWithinRoot).toHaveBeenCalledTimes(1); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); }); @@ -381,6 +440,7 @@ describe("agents.update", () => { mocks.loadConfigReturn = {}; mocks.findAgentEntryIndex.mockReturnValue(0); mocks.applyAgentConfig.mockImplementation((_cfg, _opts) => ({})); + mocks.appendFileWithinRoot.mockResolvedValue(undefined); }); it("updates an existing agent successfully", async () => { @@ -424,6 +484,68 @@ describe("agents.update", () => { expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); }); + + it("appends avatar updates through appendFileWithinRoot", async () => { + const { promise } = makeCall("agents.update", { + agentId: "test-agent", + avatar: "https://example.com/avatar.png", + }); + await promise; + + expect(mocks.appendFileWithinRoot).toHaveBeenCalledWith( + expect.objectContaining({ + rootDir: "/workspace/test-agent", + relativePath: "IDENTITY.md", + data: "\n- Avatar: https://example.com/avatar.png\n", + encoding: "utf8", + }), + ); + }); + + it("rejects updating an agent when IDENTITY.md resolves outside the workspace", async () => { + const workspace = "/workspace/test-agent"; + agentsTesting.setDepsForTests({ + resolveAgentWorkspaceFilePath: async ({ name }) => ({ + kind: "invalid", + requestPath: path.join(workspace, name), + reason: "path escapes workspace root", + }), + }); + + const { respond, promise } = makeCall("agents.update", { + agentId: "test-agent", + avatar: "evil.png", + }); + await promise; + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), + ); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.appendFileWithinRoot).not.toHaveBeenCalled(); + }); + + it("does not persist config when avatar append is rejected after preflight", async () => { + mocks.appendFileWithinRoot.mockRejectedValueOnce( + new SafeOpenError("path-mismatch", "path escapes workspace root"), + ); + + const { respond, promise } = makeCall("agents.update", { + agentId: "test-agent", + avatar: "https://example.com/avatar.png", + }); + await promise; + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), + ); + expect(mocks.appendFileWithinRoot).toHaveBeenCalledTimes(1); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); }); describe("agents.delete", () => { @@ -505,6 +627,8 @@ describe("agents.files.list", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; + mocks.isWorkspaceSetupCompleted.mockReset().mockResolvedValue(false); + mocks.fsReadlink.mockReset().mockResolvedValue(""); }); it("includes BOOTSTRAP.md when setup has not completed", async () => { @@ -543,22 +667,12 @@ describe("agents.files.get/set symlink safety", () => { function mockWorkspaceEscapeSymlink() { const workspace = "/workspace/test-agent"; - const candidate = path.resolve(workspace, "AGENTS.md"); - mocks.fsRealpath.mockImplementation(async (p: string) => { - if (p === workspace) { - return workspace; - } - if (p === candidate) { - return "/outside/secret.txt"; - } - return p; - }); - mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { - const p = typeof args[0] === "string" ? args[0] : ""; - if (p === candidate) { - return makeSymlinkStat(); - } - throw createEnoentError(); + agentsTesting.setDepsForTests({ + resolveAgentWorkspaceFilePath: async ({ name }) => ({ + kind: "invalid", + requestPath: path.join(workspace, name), + reason: "path escapes workspace root", + }), }); } @@ -578,24 +692,24 @@ describe("agents.files.get/set symlink safety", () => { it("allows in-workspace symlink reads and writes through symlink aliases", async () => { const workspace = "/workspace/test-agent"; - const candidate = path.resolve(workspace, "AGENTS.md"); const target = path.resolve(workspace, "policies", "AGENTS.md"); const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 }); - mocks.fsRealpath.mockImplementation(async (p: string) => { - if (p === workspace) { - return workspace; - } - if (p === candidate) { - return target; - } - return p; + agentsTesting.setDepsForTests({ + readLocalFileSafely: async () => ({ + buffer: Buffer.from("inside\n"), + realPath: target, + stat: targetStat, + }), + resolveAgentWorkspaceFilePath: async ({ name }) => ({ + kind: "ready", + requestPath: path.join(workspace, name), + ioPath: target, + workspaceReal: workspace, + }), }); mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { const p = typeof args[0] === "string" ? args[0] : ""; - if (p === candidate) { - return makeSymlinkStat({ dev: 9, ino: 41 }); - } if (p === target) { return targetStat; } @@ -608,16 +722,6 @@ describe("agents.files.get/set symlink safety", () => { } throw createEnoentError(); }); - mocks.fsOpen.mockImplementation( - async () => - ({ - stat: async () => targetStat, - readFile: async () => Buffer.from("inside\n"), - truncate: async () => {}, - writeFile: async () => {}, - close: async () => {}, - }) as unknown, - ); const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" }); await getCall.promise; diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 73d640b2756f3..88f84c216b8ac 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -28,7 +28,12 @@ import { import { loadConfig, writeConfigFile } from "../../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; import { sameFileIdentity } from "../../infra/file-identity.js"; -import { SafeOpenError, readLocalFileSafely, writeFileWithinRoot } from "../../infra/fs-safe.js"; +import { + appendFileWithinRoot, + SafeOpenError, + readLocalFileSafely, + writeFileWithinRoot, +} from "../../infra/fs-safe.js"; import { assertNoPathAliasEscape } from "../../infra/path-alias-guards.js"; import { isNotFoundPathError } from "../../infra/path-guards.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; @@ -61,6 +66,35 @@ const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter( (name) => name !== DEFAULT_BOOTSTRAP_FILENAME, ); +const agentsHandlerDeps = { + isWorkspaceSetupCompleted, + readLocalFileSafely, + resolveAgentWorkspaceFilePath, + appendFileWithinRoot, + writeFileWithinRoot, +}; + +export const __testing = { + setDepsForTests( + overrides: Partial<{ + isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted; + readLocalFileSafely: typeof readLocalFileSafely; + resolveAgentWorkspaceFilePath: typeof resolveAgentWorkspaceFilePath; + appendFileWithinRoot: typeof appendFileWithinRoot; + writeFileWithinRoot: typeof writeFileWithinRoot; + }>, + ) { + Object.assign(agentsHandlerDeps, overrides); + }, + resetDepsForTests() { + agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted; + agentsHandlerDeps.readLocalFileSafely = readLocalFileSafely; + agentsHandlerDeps.resolveAgentWorkspaceFilePath = resolveAgentWorkspaceFilePath; + agentsHandlerDeps.appendFileWithinRoot = appendFileWithinRoot; + agentsHandlerDeps.writeFileWithinRoot = writeFileWithinRoot; + }, +}; + const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const; const ALLOWED_FILE_NAMES = new Set([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]); @@ -417,7 +451,7 @@ async function resolveWorkspaceFilePathOrRespond(params: { workspaceDir: string; name: string; }): Promise { - const resolvedPath = await resolveAgentWorkspaceFilePath({ + const resolvedPath = await agentsHandlerDeps.resolveAgentWorkspaceFilePath({ workspaceDir: params.workspaceDir, name: params.name, allowMissing: true, @@ -455,6 +489,39 @@ function respondWorkspaceFileMissing(params: { ); } +async function ensureWorkspaceFileReadyOrRespond(params: { + respond: RespondFn; + workspaceDir: string; + name: string; +}): Promise { + await fs.mkdir(params.workspaceDir, { recursive: true }); + const resolvedPath = await resolveWorkspaceFilePathOrRespond(params); + return resolvedPath !== undefined; +} + +async function appendWorkspaceFileOrRespond(params: { + respond: RespondFn; + workspaceDir: string; + name: string; + content: string; +}): Promise { + try { + await agentsHandlerDeps.appendFileWithinRoot({ + rootDir: params.workspaceDir, + relativePath: params.name, + data: params.content, + encoding: "utf8", + }); + } catch (err) { + if (err instanceof SafeOpenError) { + respondWorkspaceFileUnsafe(params.respond, params.name); + return false; + } + throw err; + } + return true; +} + export const agentsHandlers: GatewayRequestHandlers = { "agents.list": ({ params, respond }) => { if (!validateAgentsListParams(params)) { @@ -527,13 +594,10 @@ export const agentsHandlers: GatewayRequestHandlers = { await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap }); await fs.mkdir(resolveSessionTranscriptsDirForAgent(agentId), { recursive: true }); - await writeConfigFile(nextConfig); - // Always write Name to IDENTITY.md; optionally include emoji/avatar. const safeName = sanitizeIdentityLine(rawName); const emoji = resolveOptionalStringParam(params.emoji); const avatar = resolveOptionalStringParam(params.avatar); - const identityPath = path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME); const lines = [ "", `- Name: ${safeName}`, @@ -541,7 +605,28 @@ export const agentsHandlers: GatewayRequestHandlers = { ...(avatar ? [`- Avatar: ${sanitizeIdentityLine(avatar)}`] : []), "", ]; - await fs.appendFile(identityPath, lines.join("\n"), "utf-8"); + if ( + !(await ensureWorkspaceFileReadyOrRespond({ + respond, + workspaceDir, + name: DEFAULT_IDENTITY_FILENAME, + })) + ) { + return; + } + + if ( + !(await appendWorkspaceFileOrRespond({ + respond, + workspaceDir, + name: DEFAULT_IDENTITY_FILENAME, + content: lines.join("\n"), + })) + ) { + return; + } + + await writeConfigFile(nextConfig); respond(true, { ok: true, agentId, name: rawName, workspace: workspaceDir }, undefined); }, @@ -575,20 +660,42 @@ export const agentsHandlers: GatewayRequestHandlers = { ...(model ? { model } : {}), }); - await writeConfigFile(nextConfig); - if (workspaceDir) { const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap); await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap }); } + const identityWorkspaceDir = avatar ? resolveAgentWorkspaceDir(nextConfig, agentId) : undefined; + if ( + identityWorkspaceDir && + !(await ensureWorkspaceFileReadyOrRespond({ + respond, + workspaceDir: identityWorkspaceDir, + name: DEFAULT_IDENTITY_FILENAME, + })) + ) { + return; + } + if (avatar) { - const workspace = workspaceDir ?? resolveAgentWorkspaceDir(nextConfig, agentId); - await fs.mkdir(workspace, { recursive: true }); - const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME); - await fs.appendFile(identityPath, `\n- Avatar: ${sanitizeIdentityLine(avatar)}\n`, "utf-8"); + if (!identityWorkspaceDir) { + respondWorkspaceFileUnsafe(respond, DEFAULT_IDENTITY_FILENAME); + return; + } + if ( + !(await appendWorkspaceFileOrRespond({ + respond, + workspaceDir: identityWorkspaceDir, + name: DEFAULT_IDENTITY_FILENAME, + content: `\n- Avatar: ${sanitizeIdentityLine(avatar)}\n`, + })) + ) { + return; + } } + await writeConfigFile(nextConfig); + respond(true, { ok: true, agentId }, undefined); }, "agents.delete": async ({ params, respond }) => { @@ -653,7 +760,7 @@ export const agentsHandlers: GatewayRequestHandlers = { const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); let hideBootstrap = false; try { - hideBootstrap = await isWorkspaceSetupCompleted(workspaceDir); + hideBootstrap = await agentsHandlerDeps.isWorkspaceSetupCompleted(workspaceDir); } catch { // Fall back to showing BOOTSTRAP if workspace state cannot be read. } @@ -685,7 +792,7 @@ export const agentsHandlers: GatewayRequestHandlers = { } let safeRead: Awaited>; try { - safeRead = await readLocalFileSafely({ filePath: resolvedPath.ioPath }); + safeRead = await agentsHandlerDeps.readLocalFileSafely({ filePath: resolvedPath.ioPath }); } catch (err) { if (err instanceof SafeOpenError && err.code === "not-found") { respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath }); @@ -742,7 +849,7 @@ export const agentsHandlers: GatewayRequestHandlers = { return; } try { - await writeFileWithinRoot({ + await agentsHandlerDeps.writeFileWithinRoot({ rootDir: resolvedPath.workspaceReal, relativePath: relativeWritePath, data: content, diff --git a/src/gateway/server-methods/browser.ts b/src/gateway/server-methods/browser.ts index 0bb2db3dafdb3..72d88a4f4cfa5 100644 --- a/src/gateway/server-methods/browser.ts +++ b/src/gateway/server-methods/browser.ts @@ -4,6 +4,10 @@ import { startBrowserControlServiceFromConfig, } from "../../browser/control-service.js"; import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "../../browser/proxy-files.js"; +import { + isPersistentBrowserProfileMutation, + resolveRequestedBrowserProfile, +} from "../../browser/request-policy.js"; import { createBrowserRouteDispatcher } from "../../browser/routes/dispatcher.js"; import { loadConfig } from "../../config/config.js"; import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js"; @@ -20,45 +24,6 @@ type BrowserRequestParams = { timeoutMs?: number; }; -function normalizeBrowserRequestPath(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - return trimmed; - } - const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - if (withLeadingSlash.length <= 1) { - return withLeadingSlash; - } - return withLeadingSlash.replace(/\/+$/, ""); -} - -function isPersistentBrowserProfileMutation(method: string, path: string): boolean { - const normalizedPath = normalizeBrowserRequestPath(path); - if (method === "POST" && normalizedPath === "/profiles/create") { - return true; - } - return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath); -} - -function resolveRequestedProfile(params: { - query?: Record; - body?: unknown; -}): string | undefined { - const queryProfile = - typeof params.query?.profile === "string" ? params.query.profile.trim() : undefined; - if (queryProfile) { - return queryProfile; - } - if (!params.body || typeof params.body !== "object") { - return undefined; - } - const bodyProfile = - "profile" in params.body && typeof params.body.profile === "string" - ? params.body.profile.trim() - : undefined; - return bodyProfile || undefined; -} - type BrowserProxyFile = { path: string; base64: string; @@ -237,7 +202,7 @@ export const browserHandlers: GatewayRequestHandlers = { query, body, timeoutMs, - profile: resolveRequestedProfile({ query, body }), + profile: resolveRequestedBrowserProfile({ query, body }), }; const res = await context.nodeRegistry.invoke({ nodeId: nodeTarget.nodeId, diff --git a/src/gateway/server-methods/chat.abort.test-helpers.ts b/src/gateway/server-methods/chat.abort.test-helpers.ts index fb6efebd8f584..900c69c214e24 100644 --- a/src/gateway/server-methods/chat.abort.test-helpers.ts +++ b/src/gateway/server-methods/chat.abort.test-helpers.ts @@ -25,6 +25,7 @@ export type ChatAbortTestContext = Record & { chatAbortControllers: Map>; chatRunBuffers: Map; chatDeltaSentAt: Map; + chatDeltaLastBroadcastLen: Map; chatAbortedRuns: Map; removeChatRun: (...args: unknown[]) => { sessionKey: string; clientRunId: string } | undefined; agentRunSeq: Map; @@ -42,6 +43,7 @@ export function createChatAbortContext( chatAbortControllers: new Map(), chatRunBuffers: new Map(), chatDeltaSentAt: new Map(), + chatDeltaLastBroadcastLen: new Map(), chatAbortedRuns: new Map(), removeChatRun: vi .fn() diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 73a2c0871b46a..2aeca24ce0288 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -1185,6 +1185,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ], }, expectBroadcast: false, + waitForCompletion: false, }); await waitForAssertion(() => { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 22cf527a46bfe..408f789cb9d1a 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -1,15 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { sendHandlers } from "./send.js"; import type { GatewayRequestContext } from "./types.js"; +type ResolveOutboundTarget = typeof import("../../infra/outbound/targets.js").resolveOutboundTarget; + const mocks = vi.hoisted(() => ({ deliverOutboundPayloads: vi.fn(), appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })), - resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })), + resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })), resolveMessageChannelSelection: vi.fn(), sendPoll: vi.fn(async () => ({ messageId: "poll-1" })), getChannelPlugin: vi.fn(), @@ -31,6 +31,7 @@ vi.mock("../../channels/plugins/index.js", () => ({ })); const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace"; +let sendHandlers: typeof import("./send.js").sendHandlers; function resolveAgentIdFromSessionKeyForTests(params: { sessionKey?: string }): string { if (typeof params.sessionKey === "string") { @@ -89,6 +90,11 @@ vi.mock("../../config/sessions.js", async () => { }; }); +async function loadFreshSendHandlersForTest() { + vi.resetModules(); + ({ sendHandlers } = await import("./send.js")); +} + const makeContext = (): GatewayRequestContext => ({ dedupe: new Map(), @@ -142,7 +148,7 @@ function mockDeliverySuccess(messageId: string) { describe("gateway send mirroring", () => { let registrySeq = 0; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); registrySeq += 1; setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`); @@ -153,6 +159,7 @@ describe("gateway send mirroring", () => { }); mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" }); mocks.getChannelPlugin.mockReturnValue({ outbound: { sendPoll: mocks.sendPoll } }); + await loadFreshSendHandlersForTest(); }); it("accepts media-only sends without message", async () => { @@ -508,7 +515,7 @@ describe("gateway send mirroring", () => { }); it("returns invalid request when outbound target resolution fails", async () => { - vi.mocked(resolveOutboundTarget).mockReturnValue({ + mocks.resolveOutboundTarget.mockReturnValue({ ok: false, error: new Error("target not found"), }); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 2570f664e6d5b..b8c5bb6977976 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -55,6 +55,7 @@ import { loadSessionEntry, migrateAndPruneGatewaySessionStoreKey, readSessionPreviewItemsFromTranscript, + resolveFreshestSessionEntryFromStoreKeys, resolveGatewaySessionStoreTarget, resolveSessionModelRef, resolveSessionTranscriptCandidates, @@ -583,7 +584,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { key, store, }); - const entry = target.storeKeys.map((candidate) => store[candidate]).find(Boolean); + const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys); if (!entry?.sessionId) { previews.push({ key, status: "missing", items: [] }); continue; @@ -1026,7 +1027,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const { target, storePath } = resolveGatewaySessionTargetFromKey(key); const store = loadSessionStore(storePath); - const entry = target.storeKeys.map((k) => store[k]).find(Boolean); + const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys); if (!entry?.sessionId) { respond(true, { messages: [] }, undefined); return; diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts index 2eec921c4c09a..11462bdeaa2ec 100644 --- a/src/gateway/server-methods/tools-catalog.ts +++ b/src/gateway/server-methods/tools-catalog.ts @@ -15,6 +15,7 @@ import { ErrorCodes, errorShape, formatValidationErrors, + type ToolsCatalogResult, validateToolsCatalogParams, } from "../protocol/index.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; @@ -123,6 +124,33 @@ function buildPluginGroups(params: { .toSorted((a, b) => a.label.localeCompare(b.label)); } +export function buildToolsCatalogResult(params: { + cfg: ReturnType; + agentId?: string; + includePlugins?: boolean; +}): ToolsCatalogResult { + const agentId = params.agentId?.trim() || resolveDefaultAgentId(params.cfg); + const includePlugins = params.includePlugins !== false; + const groups = buildCoreGroups(); + if (includePlugins) { + const existingToolNames = new Set( + groups.flatMap((group) => group.tools.map((tool) => tool.id)), + ); + groups.push( + ...buildPluginGroups({ + cfg: params.cfg, + agentId, + existingToolNames, + }), + ); + } + return { + agentId, + profiles: PROFILE_OPTIONS.map((profile) => ({ id: profile.id, label: profile.label })), + groups, + }; +} + export const toolsCatalogHandlers: GatewayRequestHandlers = { "tools.catalog": ({ params, respond }) => { if (!validateToolsCatalogParams(params)) { @@ -140,27 +168,13 @@ export const toolsCatalogHandlers: GatewayRequestHandlers = { if (!resolved) { return; } - const includePlugins = params.includePlugins !== false; - const groups = buildCoreGroups(); - if (includePlugins) { - const existingToolNames = new Set( - groups.flatMap((group) => group.tools.map((tool) => tool.id)), - ); - groups.push( - ...buildPluginGroups({ - cfg: resolved.cfg, - agentId: resolved.agentId, - existingToolNames, - }), - ); - } respond( true, - { + buildToolsCatalogResult({ + cfg: resolved.cfg, agentId: resolved.agentId, - profiles: PROFILE_OPTIONS.map((profile) => ({ id: profile.id, label: profile.label })), - groups, - }, + includePlugins: params.includePlugins, + }), undefined, ); }, diff --git a/src/gateway/server-methods/tools-effective.test.ts b/src/gateway/server-methods/tools-effective.test.ts new file mode 100644 index 0000000000000..8e614e0dd9d4c --- /dev/null +++ b/src/gateway/server-methods/tools-effective.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js"; +import { ErrorCodes } from "../protocol/index.js"; +import { loadSessionEntry } from "../session-utils.js"; +import { toolsEffectiveHandlers } from "./tools-effective.js"; + +vi.mock("../../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), +})); + +vi.mock("../../agents/agent-scope.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listAgentIds: vi.fn(() => ["main"]), + resolveDefaultAgentId: vi.fn(() => "main"), + resolveSessionAgentId: vi.fn(() => "main"), + }; +}); + +vi.mock("../../agents/tools-effective-inventory.js", () => ({ + resolveEffectiveToolInventory: vi.fn(() => ({ + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "exec", + label: "Exec", + description: "Run shell commands", + rawDescription: "Run shell commands", + source: "core", + }, + ], + }, + ], + })), +})); + +vi.mock("../session-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionEntry: vi.fn(() => ({ + cfg: {}, + canonicalKey: "main:abc", + entry: { + sessionId: "session-1", + updatedAt: 1, + lastChannel: "telegram", + lastAccountId: "acct-1", + lastThreadId: "thread-2", + lastTo: "channel-1", + groupId: "group-4", + groupChannel: "#ops", + space: "workspace-5", + chatType: "group", + modelProvider: "openai", + model: "gpt-4.1", + }, + })), + resolveSessionModelRef: vi.fn(() => ({ provider: "openai", model: "gpt-4.1" })), + }; +}); + +vi.mock("../../utils/delivery-context.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deliveryContextFromSession: vi.fn(() => ({ + channel: "telegram", + to: "channel-1", + accountId: "acct-1", + threadId: "thread-2", + })), + }; +}); + +vi.mock("../../auto-reply/reply/reply-threading.js", () => ({ + resolveReplyToMode: vi.fn(() => "first"), +})); + +type RespondCall = [boolean, unknown?, { code: number; message: string }?]; + +function createInvokeParams(params: Record) { + const respond = vi.fn(); + return { + respond, + invoke: async () => + await toolsEffectiveHandlers["tools.effective"]({ + params, + respond: respond as never, + context: {} as never, + client: null, + req: { type: "req", id: "req-1", method: "tools.effective" }, + isWebchatConnect: () => false, + }), + }; +} + +describe("tools.effective handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects invalid params", async () => { + const { respond, invoke } = createInvokeParams({ includePlugins: false }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain("invalid tools.effective params"); + }); + + it("rejects missing sessionKey", async () => { + const { respond, invoke } = createInvokeParams({}); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain("invalid tools.effective params"); + }); + + it("rejects caller-supplied auth context params", async () => { + const { respond, invoke } = createInvokeParams({ senderIsOwner: true }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain("invalid tools.effective params"); + }); + + it("rejects unknown agent ids", async () => { + const { respond, invoke } = createInvokeParams({ + sessionKey: "main:abc", + agentId: "unknown-agent", + }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain("unknown agent id"); + }); + + it("rejects unknown session keys", async () => { + vi.mocked(loadSessionEntry).mockReturnValueOnce({ + cfg: {}, + canonicalKey: "missing-session", + entry: undefined, + legacyKey: undefined, + storePath: "/tmp/sessions.json", + } as never); + const { respond, invoke } = createInvokeParams({ sessionKey: "missing-session" }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain('unknown session key "missing-session"'); + }); + + it("returns the effective runtime inventory", async () => { + const { respond, invoke } = createInvokeParams({ sessionKey: "main:abc" }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(true); + expect(call?.[1]).toMatchObject({ + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + source: "core", + tools: [{ id: "exec", source: "core" }], + }, + ], + }); + expect(vi.mocked(resolveEffectiveToolInventory)).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + currentChannelId: "channel-1", + currentThreadTs: "thread-2", + accountId: "acct-1", + groupId: "group-4", + groupChannel: "#ops", + groupSpace: "workspace-5", + replyToMode: "first", + messageProvider: "telegram", + modelProvider: "openai", + modelId: "gpt-4.1", + }), + ); + }); + + it("passes senderIsOwner=true for admin-scoped callers", async () => { + const respond = vi.fn(); + await toolsEffectiveHandlers["tools.effective"]({ + params: { sessionKey: "main:abc" }, + respond: respond as never, + context: {} as never, + client: { + connect: { scopes: ["operator.admin"] }, + } as never, + req: { type: "req", id: "req-1", method: "tools.effective" }, + isWebchatConnect: () => false, + }); + expect(vi.mocked(resolveEffectiveToolInventory)).toHaveBeenCalledWith( + expect.objectContaining({ senderIsOwner: true }), + ); + }); + + it("rejects agent ids that do not match the session agent", async () => { + const { respond, invoke } = createInvokeParams({ + sessionKey: "main:abc", + agentId: "other", + }); + vi.mocked(loadSessionEntry).mockReturnValueOnce({ + cfg: {}, + canonicalKey: "main:abc", + entry: { + sessionId: "session-1", + updatedAt: 1, + }, + } as never); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain('unknown agent id "other"'); + }); +}); diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts new file mode 100644 index 0000000000000..0f661711e0108 --- /dev/null +++ b/src/gateway/server-methods/tools-effective.ts @@ -0,0 +1,159 @@ +import { listAgentIds, resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js"; +import { resolveReplyToMode } from "../../auto-reply/reply/reply-threading.js"; +import { loadConfig } from "../../config/config.js"; +import { deliveryContextFromSession } from "../../utils/delivery-context.js"; +import { ADMIN_SCOPE } from "../method-scopes.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateToolsEffectiveParams, +} from "../protocol/index.js"; +import { loadSessionEntry, resolveSessionModelRef } from "../session-utils.js"; +import type { GatewayRequestHandlers, RespondFn } from "./types.js"; + +function resolveRequestedAgentIdOrRespondError(params: { + rawAgentId: unknown; + cfg: ReturnType; + respond: RespondFn; +}) { + const knownAgents = listAgentIds(params.cfg); + const requestedAgentId = typeof params.rawAgentId === "string" ? params.rawAgentId.trim() : ""; + if (!requestedAgentId) { + return undefined; + } + if (!knownAgents.includes(requestedAgentId)) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${requestedAgentId}"`), + ); + return null; + } + return requestedAgentId; +} + +function resolveTrustedToolsEffectiveContext(params: { + sessionKey: string; + requestedAgentId?: string; + senderIsOwner: boolean; + respond: RespondFn; +}) { + const loaded = loadSessionEntry(params.sessionKey); + if (!loaded.entry) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown session key "${params.sessionKey}"`), + ); + return null; + } + + const sessionAgentId = resolveSessionAgentId({ + sessionKey: loaded.canonicalKey ?? params.sessionKey, + config: loaded.cfg, + }); + if (params.requestedAgentId && params.requestedAgentId !== sessionAgentId) { + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `agent id "${params.requestedAgentId}" does not match session agent "${sessionAgentId}"`, + ), + ); + return null; + } + + const delivery = deliveryContextFromSession(loaded.entry); + const resolvedModel = resolveSessionModelRef(loaded.cfg, loaded.entry, sessionAgentId); + return { + cfg: loaded.cfg, + agentId: sessionAgentId, + senderIsOwner: params.senderIsOwner, + modelProvider: resolvedModel.provider, + modelId: resolvedModel.model, + messageProvider: + delivery?.channel ?? + loaded.entry.lastChannel ?? + loaded.entry.channel ?? + loaded.entry.origin?.provider, + accountId: delivery?.accountId ?? loaded.entry.lastAccountId ?? loaded.entry.origin?.accountId, + currentChannelId: delivery?.to, + currentThreadTs: + delivery?.threadId != null + ? String(delivery.threadId) + : loaded.entry.lastThreadId != null + ? String(loaded.entry.lastThreadId) + : undefined, + groupId: loaded.entry.groupId, + groupChannel: loaded.entry.groupChannel, + groupSpace: loaded.entry.space, + replyToMode: resolveReplyToMode( + loaded.cfg, + delivery?.channel ?? + loaded.entry.lastChannel ?? + loaded.entry.channel ?? + loaded.entry.origin?.provider, + delivery?.accountId ?? loaded.entry.lastAccountId ?? loaded.entry.origin?.accountId, + loaded.entry.chatType ?? loaded.entry.origin?.chatType, + ), + }; +} + +export const toolsEffectiveHandlers: GatewayRequestHandlers = { + "tools.effective": ({ params, respond, client }) => { + if (!validateToolsEffectiveParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid tools.effective params: ${formatValidationErrors(validateToolsEffectiveParams.errors)}`, + ), + ); + return; + } + const cfg = loadConfig(); + const requestedAgentId = resolveRequestedAgentIdOrRespondError({ + rawAgentId: params.agentId, + cfg, + respond, + }); + if (requestedAgentId === null) { + return; + } + const trustedContext = resolveTrustedToolsEffectiveContext({ + sessionKey: params.sessionKey, + requestedAgentId, + senderIsOwner: Array.isArray(client?.connect?.scopes) + ? client.connect.scopes.includes(ADMIN_SCOPE) + : false, + respond, + }); + if (!trustedContext) { + return; + } + respond( + true, + resolveEffectiveToolInventory({ + cfg: trustedContext.cfg, + agentId: trustedContext.agentId, + sessionKey: params.sessionKey, + messageProvider: trustedContext.messageProvider, + modelProvider: trustedContext.modelProvider, + modelId: trustedContext.modelId, + senderIsOwner: trustedContext.senderIsOwner, + currentChannelId: trustedContext.currentChannelId, + currentThreadTs: trustedContext.currentThreadTs, + accountId: trustedContext.accountId, + groupId: trustedContext.groupId, + groupChannel: trustedContext.groupChannel, + groupSpace: trustedContext.groupSpace, + replyToMode: trustedContext.replyToMode, + }), + undefined, + ); + }, +}; diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index c5c57f4ecda3d..db25201b4817f 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -189,6 +189,46 @@ describe("sessions.usage", () => { } }); + it("prefers the deterministic store key when duplicate sessionIds exist", async () => { + const preferredKey = "agent:opus:acp:run-dup"; + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-")); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + const agentSessionsDir = path.join(stateDir, "agents", "opus", "sessions"); + fs.mkdirSync(agentSessionsDir, { recursive: true }); + const sessionFile = path.join(agentSessionsDir, "run-dup.jsonl"); + fs.writeFileSync(sessionFile, "", "utf-8"); + + vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({ + storePath: "(multiple)", + store: { + [preferredKey]: { + sessionId: "run-dup", + sessionFile: "run-dup.jsonl", + updatedAt: 1_000, + }, + "agent:other:main": { + sessionId: "run-dup", + sessionFile: "run-dup.jsonl", + updatedAt: 2_000, + }, + }, + }); + + const respond = await runSessionsUsage({ + ...BASE_USAGE_RANGE, + key: "agent:opus:run-dup", + }); + const sessions = expectSuccessfulSessionsUsage(respond); + expect(sessions).toHaveLength(1); + expect(sessions[0]?.key).toBe(preferredKey); + }); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + it("rejects traversal-style keys in specific session usage lookups", async () => { const respond = await runSessionsUsage({ ...BASE_USAGE_RANGE, diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 8298f34eb243c..83e96083f650f 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -21,6 +21,7 @@ import { type DiscoveredSession, } from "../../infra/session-cost-usage.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js"; import { buildUsageAggregateTail, mergeUsageDailyLatency, @@ -252,10 +253,25 @@ type DiscoveredSessionWithAgent = DiscoveredSession & { agentId: string }; function buildStoreBySessionId( store: Record, ): Map { - const storeBySessionId = new Map(); + const matchesBySessionId = new Map>(); for (const [key, entry] of Object.entries(store)) { - if (entry?.sessionId) { - storeBySessionId.set(entry.sessionId, { key, entry }); + if (!entry?.sessionId) { + continue; + } + const matches = matchesBySessionId.get(entry.sessionId) ?? []; + matches.push([key, entry]); + matchesBySessionId.set(entry.sessionId, matches); + } + + const storeBySessionId = new Map(); + for (const [sessionId, matches] of matchesBySessionId) { + const preferredKey = resolvePreferredSessionKeyForSessionIdMatches(matches, sessionId); + if (!preferredKey) { + continue; + } + const preferredEntry = store[preferredKey]; + if (preferredEntry) { + storeBySessionId.set(sessionId, { key: preferredKey, entry: preferredEntry }); } } return storeBySessionId; diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index cb345ee789410..73ae2bcfb702d 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -25,9 +25,13 @@ vi.mock("../plugins/channel-plugin-ids.js", () => ({ resolveGatewayStartupPluginIds, })); -vi.mock("../channels/plugins/binding-registry.js", () => ({ - primeConfiguredBindingRegistry, -})); +vi.mock("../channels/plugins/binding-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + primeConfiguredBindingRegistry, + }; +}); vi.mock("./server-methods.js", () => ({ handleGatewayRequest, @@ -616,4 +620,31 @@ describe("loadGatewayPlugins", () => { | undefined; expect(dispatched?.marker).toBe("after-mutation"); }); + + test("resolves fallback context lazily when a resolver is registered", async () => { + const serverPlugins = serverPluginsModule; + const runtime = await createSubagentRuntime(serverPlugins); + let currentContext = createTestContext("before-resolver-update"); + + serverPlugins.setFallbackGatewayContextResolver(() => currentContext); + await runtime.run({ sessionKey: "s-4", message: "before resolver update" }); + expect(getLastDispatchedContext()).toBe(currentContext); + + currentContext = createTestContext("after-resolver-update"); + await runtime.run({ sessionKey: "s-4", message: "after resolver update" }); + expect(getLastDispatchedContext()).toBe(currentContext); + }); + + test("prefers resolver output over an older fallback context snapshot", async () => { + const serverPlugins = serverPluginsModule; + const runtime = await createSubagentRuntime(serverPlugins); + const staleContext = createTestContext("stale-snapshot"); + const freshContext = createTestContext("fresh-resolver"); + + serverPlugins.setFallbackGatewayContext(staleContext); + serverPlugins.setFallbackGatewayContextResolver(() => freshContext); + + await runtime.run({ sessionKey: "s-5", message: "prefer resolver" }); + expect(getLastDispatchedContext()).toBe(freshContext); + }); }); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 6f72e5c67c7a8..af7d075516f2b 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -32,16 +32,32 @@ const FALLBACK_GATEWAY_CONTEXT_STATE_KEY: unique symbol = Symbol.for( type FallbackGatewayContextState = { context: GatewayRequestContext | undefined; + resolveContext: (() => GatewayRequestContext | undefined) | undefined; }; -const fallbackGatewayContextState = resolveGlobalSingleton( - FALLBACK_GATEWAY_CONTEXT_STATE_KEY, - () => ({ context: undefined }), -); +const getFallbackGatewayContextState = () => + resolveGlobalSingleton(FALLBACK_GATEWAY_CONTEXT_STATE_KEY, () => ({ + context: undefined, + resolveContext: undefined, + })); export function setFallbackGatewayContext(ctx: GatewayRequestContext): void { - // TODO: This startup snapshot can become stale if runtime config/context changes. + const fallbackGatewayContextState = getFallbackGatewayContextState(); fallbackGatewayContextState.context = ctx; + fallbackGatewayContextState.resolveContext = undefined; +} + +export function setFallbackGatewayContextResolver( + resolveContext: () => GatewayRequestContext | undefined, +): void { + const fallbackGatewayContextState = getFallbackGatewayContextState(); + fallbackGatewayContextState.resolveContext = resolveContext; +} + +function getFallbackGatewayContext(): GatewayRequestContext | undefined { + const fallbackGatewayContextState = getFallbackGatewayContextState(); + const resolved = fallbackGatewayContextState.resolveContext?.(); + return resolved ?? fallbackGatewayContextState.context; } type PluginSubagentOverridePolicy = { @@ -59,12 +75,10 @@ const PLUGIN_SUBAGENT_POLICY_STATE_KEY: unique symbol = Symbol.for( "openclaw.pluginSubagentOverridePolicyState", ); -const pluginSubagentPolicyState = resolveGlobalSingleton( - PLUGIN_SUBAGENT_POLICY_STATE_KEY, - () => ({ +const getPluginSubagentPolicyState = () => + resolveGlobalSingleton(PLUGIN_SUBAGENT_POLICY_STATE_KEY, () => ({ policies: {}, - }), -); + })); function normalizeAllowedModelRef(raw: string): string | null { const trimmed = raw.trim(); @@ -88,6 +102,7 @@ function normalizeAllowedModelRef(raw: string): string | null { } function setPluginSubagentOverridePolicies(cfg: ReturnType): void { + const pluginSubagentPolicyState = getPluginSubagentPolicyState(); const normalized = normalizePluginsConfig(cfg.plugins); const policies: PluginSubagentPolicyState["policies"] = {}; for (const [pluginId, entry] of Object.entries(normalized.entries)) { @@ -130,6 +145,7 @@ function authorizeFallbackModelOverride(params: { provider?: string; model?: string; }): { allowed: true } | { allowed: false; reason: string } { + const pluginSubagentPolicyState = getPluginSubagentPolicyState(); const pluginId = params.pluginId?.trim(); if (!pluginId) { return { @@ -238,7 +254,7 @@ async function dispatchGatewayMethod( }, ): Promise { const scope = getPluginRuntimeGatewayRequestScope(); - const context = scope?.context ?? fallbackGatewayContextState.context; + const context = scope?.context ?? getFallbackGatewayContext(); const isWebchatConnect = scope?.isWebchatConnect ?? (() => false); if (!context) { throw new Error( diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index 187698b06ed72..9b49a22efe91e 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -86,7 +86,7 @@ describe("scheduleRestartSentinelWake", () => { expect.objectContaining({ channel: "whatsapp", to: "+15550002", - session: { key: "agent:main:main", agentId: "agent-from-key" }, + session: { key: "agent:main:main", agentId: "main" }, }), ); expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled(); diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 08fea905cceb2..04819879c224c 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -48,7 +48,7 @@ const createMSTeamsPlugin = (params?: { aliases?: string[] }): ChannelPlugin => label: "Microsoft Teams", selectionLabel: "Microsoft Teams (Bot Framework)", docsPath: "/channels/msteams", - blurb: "Bot Framework; enterprise support.", + blurb: "Teams SDK; enterprise support.", aliases: params?.aliases, }, capabilities: { chatTypes: ["direct"] }, @@ -326,7 +326,7 @@ describe("gateway server agent", () => { expect(call.sessionId).not.toBe("sess-main-before-reset"); }); - test("write-scoped callers cannot use sessions.reset directly but can still reset conversations via agent", async () => { + test("write-scoped callers cannot reset conversations via agent", async () => { await withGatewayServer(async ({ port }) => { await useTempSessionStorePath(); const storePath = testState.sessionStorePath; @@ -358,19 +358,16 @@ describe("gateway server agent", () => { sessionKey: "main", idempotencyKey: "idem-agent-write-reset", }); - expect(viaAgent.ok).toBe(true); + expect(viaAgent.ok).toBe(false); + expect(viaAgent.error?.message).toContain("missing scope: operator.admin"); const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, { sessionId?: string } >; expect(store["agent:main:main"]?.sessionId).toBeDefined(); - expect(store["agent:main:main"]?.sessionId).not.toBe("sess-main-before-write-reset"); - - await vi.waitFor(() => expect(vi.mocked(agentCommand)).toHaveBeenCalled()); - const call = readAgentCommandCall(); - expect(typeof call.sessionId).toBe("string"); - expect(call.sessionId).not.toBe("sess-main-before-write-reset"); + expect(store["agent:main:main"]?.sessionId).toBe("sess-main-before-write-reset"); + expect(vi.mocked(agentCommand)).not.toHaveBeenCalled(); writeWs.close(); }); diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index a450fcddde280..3bf5b8bd0db0f 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -14,8 +14,10 @@ export function setRegistry(registry: PluginRegistry) { } vi.mock("./server-plugins.js", async () => { + const actual = await vi.importActual("./server-plugins.js"); const { setActivePluginRegistry } = await import("../plugins/runtime.js"); return { + ...actual, loadGatewayPlugins: (params: { baseMethods: string[] }) => { setActivePluginRegistry(registryState.registry); return { @@ -23,7 +25,6 @@ vi.mock("./server-plugins.js", async () => { gatewayMethods: params.baseMethods ?? [], }; }, - // server.impl.ts sets a fallback context before dispatch; tests only need the symbol to exist. - setFallbackGatewayContext: vi.fn(), + setFallbackGatewayContextResolver: vi.fn(), }; }); diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 804babd4d246a..c860674592c0d 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -409,6 +409,35 @@ export function registerControlUiAndPairingSuite(): void { } }); + test("preserves requested control ui scopes when dangerouslyDisableDeviceAuth bypasses device identity", async () => { + testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true }; + testState.gatewayAuth = { mode: "token", token: "secret" }; + const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; + try { + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { origin: originForPort(port) }); + const res = await connectReq(ws, { + token: "secret", + scopes: ["operator.read"], + client: { + ...CONTROL_UI_CLIENT, + }, + }); + expect(res.ok).toBe(true); + + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + + const talk = await rpcReq(ws, "chat.history", { sessionKey: "main", limit: 1 }); + expect(talk.ok).toBe(true); + ws.close(); + }); + } finally { + restoreGatewayToken(prevToken); + } + }); + test("device token auth matrix", async () => { const { server, ws, port, prevToken } = await startServerWithClient("secret"); const { deviceToken, deviceIdentityPath } = await ensurePairedDeviceTokenForCurrentIdentity(ws); diff --git a/src/gateway/server.auth.modes.suite.ts b/src/gateway/server.auth.modes.suite.ts index 77c23a0d0b29c..6c96281d0cde1 100644 --- a/src/gateway/server.auth.modes.suite.ts +++ b/src/gateway/server.auth.modes.suite.ts @@ -151,18 +151,19 @@ export function registerAuthModesSuite(): void { test("requires device identity when only tailscale auth is available", async () => { const ws = await openTailscaleWs(port); - const res = await connectReq(ws, { token: "dummy", device: null }); + const res = await connectReq(ws, { skipDefaultAuth: true, device: null }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("device identity required"); ws.close(); }); - test("allows shared token to skip device when tailscale auth is enabled", async () => { + test("connects with shared token but clears scopes when tailscale auth skips device", async () => { const ws = await openTailscaleWs(port); const res = await connectReq(ws, { token: "secret", device: null }); expect(res.ok).toBe(true); const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(true); + expect(status.ok).toBe(false); + expect(status.error?.message ?? "").toContain("missing scope"); const health = await rpcReq(ws, "health"); expect(health.ok).toBe(true); ws.close(); diff --git a/src/gateway/server.canvas-auth.test.ts b/src/gateway/server.canvas-auth.test.ts index 2562f09cbe6bf..d1a13b44abae7 100644 --- a/src/gateway/server.canvas-auth.test.ts +++ b/src/gateway/server.canvas-auth.test.ts @@ -263,7 +263,7 @@ describe("gateway canvas host auth", () => { const scopedA2ui = await fetch( `http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`, ); - expect(scopedA2ui.status).toBe(200); + expect([200, 503]).toContain(scopedA2ui.status); await expectWsConnected(`ws://${host}:${listener.port}${activeWsPath}`); @@ -305,6 +305,22 @@ describe("gateway canvas host auth", () => { }); }, 60_000); + test("denies canvas HTTP/WS on loopback without bearer or capability by default", async () => { + await withCanvasGatewayHarness({ + resolvedAuth: tokenResolvedAuth, + handleHttpRequest: allowCanvasHostHttp, + run: async ({ listener }) => { + const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`); + expect(res.status).toBe(401); + + const a2ui = await fetch(`http://127.0.0.1:${listener.port}${A2UI_PATH}/`); + expect(a2ui.status).toBe(401); + + await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {}); + }, + }); + }, 60_000); + test("accepts capability-scoped paths over IPv6 loopback", async () => { await withTempConfig({ cfg: { diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 943565a9b5071..9904ff1641442 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -119,8 +119,10 @@ describe("gateway server hooks", () => { expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true); const firstCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as { deliveryContract?: string; + job?: { payload?: { externalContentSource?: string } }; }; expect(firstCall?.deliveryContract).toBe("shared"); + expect(firstCall?.job?.payload?.externalContentSource).toBe("webhook"); drainSystemEvents(resolveMainKey()); mockIsolatedRunOkOnce(); @@ -208,6 +210,40 @@ describe("gateway server hooks", () => { }); }); + test("preserves mapped hook provenance across async dispatch", async () => { + testState.hooksConfig = { + enabled: true, + token: HOOK_TOKEN, + mappings: [ + { + match: { path: "gmail" }, + action: "agent", + messageTemplate: "New email from {{messages[0].from}}", + sessionKey: "main", + }, + ], + }; + setMainAndHooksAgents(); + + await withGatewayServer(async ({ port }) => { + mockIsolatedRunOkOnce(); + const response = await postHook(port, "/hooks/gmail", { + source: "gmail", + messages: [{ id: "msg-1", from: "Ada", subject: "Hello", snippet: "Hi", body: "Body" }], + }); + expect(response.status).toBe(200); + await waitForSystemEvent(); + + const call = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as { + sessionKey?: string; + job?: { payload?: { externalContentSource?: string } }; + }; + expect(call?.sessionKey).toBe("main"); + expect(call?.job?.payload?.externalContentSource).toBe("gmail"); + drainSystemEvents(resolveMainKey()); + }); + }); + test("rejects request sessionKey unless hooks.allowRequestSessionKey is enabled", async () => { testState.hooksConfig = { enabled: true, token: HOOK_TOKEN }; await withGatewayServer(async ({ port }) => { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index e04c85f0469ac..5011f708a12d3 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -102,7 +102,7 @@ import { createSecretsHandlers } from "./server-methods/secrets.js"; import { hasConnectedMobileNode } from "./server-mobile-nodes.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; -import { loadGatewayPlugins, setFallbackGatewayContext } from "./server-plugins.js"; +import { loadGatewayPlugins, setFallbackGatewayContextResolver } from "./server-plugins.js"; import { createGatewayReloadHandlers } from "./server-reload-handlers.js"; import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; import { createGatewayRuntimeState } from "./server-runtime-state.js"; @@ -1125,10 +1125,10 @@ export async function startGatewayServer( broadcastVoiceWakeChanged, }; - // Store the gateway context as a fallback for plugin subagent dispatch - // in non-WS paths (Telegram polling, WhatsApp, etc.) where no per-request - // scope is set via AsyncLocalStorage. - setFallbackGatewayContext(gatewayRequestContext); + // Register a lazy fallback for plugin subagent dispatch in non-WS paths + // (Telegram polling, WhatsApp, etc.) so later runtime swaps can expose the + // current gateway context without relying on a startup snapshot. + setFallbackGatewayContextResolver(() => gatewayRequestContext); attachGatewayWsHandlers({ wss, diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index 87dfc400cc5d7..a616bbbf6ebec 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { CONFIG_PATH } from "../config/config.js"; import type { DeviceIdentity } from "../infra/device-identity.js"; +import { resolveRestartSentinelPath } from "../infra/restart-sentinel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import type { GatewayClient } from "./client.js"; @@ -151,7 +152,7 @@ describe("gateway update.run", () => { }, FAST_WAIT_OPTS); expect(sigusr1).toHaveBeenCalled(); - const sentinelPath = path.join(os.homedir(), ".openclaw", "restart-sentinel.json"); + const sentinelPath = resolveRestartSentinelPath(); const raw = await fs.readFile(sentinelPath, "utf-8"); const parsed = JSON.parse(raw) as { payload?: { kind?: string; stats?: { mode?: string } }; diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index c53e3b27cd9b0..5b3a269b57b66 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -52,6 +52,10 @@ const acpRuntimeMocks = vi.hoisted(() => ({ getAcpRuntimeBackend: vi.fn(), requireAcpRuntimeBackend: vi.fn(), })); +const acpManagerMocks = vi.hoisted(() => ({ + cancelSession: vi.fn(async () => {}), + closeSession: vi.fn(async () => {}), +})); const browserSessionTabMocks = vi.hoisted(() => ({ closeTrackedBrowserTabsForSessions: vi.fn(async () => 0), })); @@ -139,6 +143,13 @@ vi.mock("../acp/runtime/registry.js", async (importOriginal) => { }; }); +vi.mock("../acp/control-plane/manager.js", () => ({ + getAcpSessionManager: () => ({ + cancelSession: acpManagerMocks.cancelSession, + closeSession: acpManagerMocks.closeSession, + }), +})); + vi.mock("../browser/session-tab-registry.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -242,6 +253,8 @@ describe("gateway server sessions", () => { acpRuntimeMocks.requireAcpRuntimeBackend.mockImplementation((backendId?: string) => acpRuntimeMocks.getAcpRuntimeBackend(backendId), ); + acpManagerMocks.cancelSession.mockClear(); + acpManagerMocks.closeSession.mockClear(); browserSessionTabMocks.closeTrackedBrowserTabsForSessions.mockClear(); browserSessionTabMocks.closeTrackedBrowserTabsForSessions.mockResolvedValue(0); }); @@ -1001,6 +1014,55 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.preview prefers the freshest duplicate row for a legacy mixed-case main alias", async () => { + const { dir, storePath } = await createSessionStoreDir(); + testState.agentsConfig = { list: [{ id: "ops", default: true }] }; + testState.sessionConfig = { mainKey: "work" }; + + const staleTranscriptPath = path.join(dir, "sess-stale-main.jsonl"); + const freshTranscriptPath = path.join(dir, "sess-fresh-main.jsonl"); + await fs.writeFile( + staleTranscriptPath, + [ + JSON.stringify({ type: "session", version: 1, id: "sess-stale-main" }), + JSON.stringify({ message: { role: "assistant", content: "stale preview" } }), + ].join("\n"), + "utf-8", + ); + await fs.writeFile( + freshTranscriptPath, + [ + JSON.stringify({ type: "session", version: 1, id: "sess-fresh-main" }), + JSON.stringify({ message: { role: "assistant", content: "fresh preview" } }), + ].join("\n"), + "utf-8", + ); + await fs.writeFile( + storePath, + JSON.stringify( + { + "agent:ops:work": { + sessionId: "sess-stale-main", + updatedAt: 1, + }, + "agent:ops:WORK": { + sessionId: "sess-fresh-main", + updatedAt: 2, + }, + }, + null, + 2, + ), + "utf-8", + ); + + const { ws } = await openClient(); + const entry = await getMainPreviewEntry(ws); + expect(entry?.items[0]?.text).toContain("fresh preview"); + + ws.close(); + }); + test("sessions.resolve and mutators clean legacy main-alias ghost keys", async () => { const { dir, storePath } = await createSessionStoreDir(); testState.agentsConfig = { list: [{ id: "ops", default: true }] }; @@ -1076,6 +1138,68 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.resolve by sessionId ignores fuzzy-search list limits and returns the exact match", async () => { + await createSessionStoreDir(); + const now = Date.now(); + const entries: Record = { + "agent:main:subagent:target": { + sessionId: "sess-target-exact", + updatedAt: now - 20_000, + }, + }; + for (let i = 0; i < 9; i += 1) { + entries[`agent:main:subagent:noisy-${i}`] = { + sessionId: `sess-noisy-${i}`, + updatedAt: now - i * 1_000, + label: `sess-target-exact noisy ${i}`, + }; + } + await writeSessionStore({ entries }); + + const { ws } = await openClient(); + const resolved = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", { + sessionId: "sess-target-exact", + }); + + expect(resolved.ok).toBe(true); + expect(resolved.payload?.key).toBe("agent:main:subagent:target"); + }); + + test("sessions.resolve by key respects spawnedBy visibility filters", async () => { + await createSessionStoreDir(); + const now = Date.now(); + await writeSessionStore({ + entries: { + "agent:main:subagent:visible-parent": { + sessionId: "sess-visible-parent", + updatedAt: now - 3_000, + spawnedBy: "agent:main:main", + }, + "agent:main:subagent:hidden-parent": { + sessionId: "sess-hidden-parent", + updatedAt: now - 2_000, + spawnedBy: "agent:main:main", + }, + "agent:main:subagent:shared-child-key-filter": { + sessionId: "sess-shared-child-key-filter", + updatedAt: now - 1_000, + spawnedBy: "agent:main:subagent:hidden-parent", + }, + }, + }); + + const { ws } = await openClient(); + const resolved = await rpcReq(ws, "sessions.resolve", { + key: "agent:main:subagent:shared-child-key-filter", + spawnedBy: "agent:main:subagent:visible-parent", + }); + + expect(resolved.ok).toBe(false); + expect(resolved.error?.message).toContain( + "No session found: agent:main:subagent:shared-child-key-filter", + ); + }); + test("sessions.delete rejects main and aborts active runs", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); @@ -1164,41 +1288,23 @@ describe("gateway server sessions", () => { }, }, }); - acpRuntimeMocks.getAcpRuntimeBackend.mockReturnValue({ - id: "acpx", - runtime: { - ensureSession: vi.fn(async () => ({ - sessionKey: "agent:main:discord:group:dev", - backend: "acpx", - runtimeSessionName: "runtime:delete", - })), - runTurn: vi.fn(async function* () {}), - cancel: acpRuntimeMocks.cancel, - close: acpRuntimeMocks.close, - }, - }); - const { ws } = await openClient(); const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", { key: "discord:group:dev", }); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); - expect(acpRuntimeMocks.close).toHaveBeenCalledWith({ - handle: { - sessionKey: "agent:main:discord:group:dev", - backend: "acpx", - runtimeSessionName: "runtime:delete", - }, + expect(acpManagerMocks.closeSession).toHaveBeenCalledWith({ + allowBackendUnavailable: true, + cfg: expect.any(Object), + requireAcpSession: false, reason: "session-delete", + sessionKey: "agent:main:discord:group:dev", }); - expect(acpRuntimeMocks.cancel).toHaveBeenCalledWith({ - handle: { - sessionKey: "agent:main:discord:group:dev", - backend: "acpx", - runtimeSessionName: "runtime:delete", - }, + expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({ + cfg: expect.any(Object), reason: "session-delete", + sessionKey: "agent:main:discord:group:dev", }); ws.close(); @@ -1403,32 +1509,17 @@ describe("gateway server sessions", () => { }, }, }); - acpRuntimeMocks.getAcpRuntimeBackend.mockReturnValue({ - id: "acpx", - runtime: { - ensureSession: vi.fn(async () => ({ - sessionKey: "agent:main:main", - backend: "acpx", - runtimeSessionName: "runtime:reset", - })), - runTurn: vi.fn(async function* () {}), - cancel: vi.fn(async () => {}), - close: acpRuntimeMocks.close, - }, - }); - const { ws } = await openClient(); const reset = await rpcReq<{ ok: true; key: string }>(ws, "sessions.reset", { key: "main", }); expect(reset.ok).toBe(true); - expect(acpRuntimeMocks.close).toHaveBeenCalledWith({ - handle: { - sessionKey: "agent:main:main", - backend: "acpx", - runtimeSessionName: "runtime:reset", - }, + expect(acpManagerMocks.closeSession).toHaveBeenCalledWith({ + allowBackendUnavailable: true, + cfg: expect.any(Object), + requireAcpSession: false, reason: "session-reset", + sessionKey: "agent:main:main", }); ws.close(); diff --git a/src/gateway/server.tools-effective.test.ts b/src/gateway/server.tools-effective.test.ts new file mode 100644 index 0000000000000..38df7fe4c3ba4 --- /dev/null +++ b/src/gateway/server.tools-effective.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js"; +import { withServer } from "./test-with-server.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway tools.effective", () => { + it("returns effective tool inventory data", async () => { + await withServer(async (ws) => { + await connectOk(ws, { token: "secret", scopes: ["operator.read", "operator.write"] }); + const created = await rpcReq<{ key?: string }>(ws, "sessions.create", { + label: "Tools Effective Test", + }); + expect(created.ok).toBe(true); + const sessionKey = created.payload?.key; + expect(sessionKey).toBeTruthy(); + const res = await rpcReq<{ + agentId?: string; + groups?: Array<{ + id?: "core" | "plugin" | "channel"; + source?: "core" | "plugin" | "channel"; + tools?: Array<{ id?: string; source?: "core" | "plugin" | "channel" }>; + }>; + }>(ws, "tools.effective", { sessionKey }); + + expect(res.ok).toBe(true); + expect(res.payload?.agentId).toBeTruthy(); + expect((res.payload?.groups ?? []).length).toBeGreaterThan(0); + expect( + (res.payload?.groups ?? []).some((group) => + (group.tools ?? []).some((tool) => tool.id === "exec"), + ), + ).toBe(true); + }); + }); + + it("rejects unknown agent ids", async () => { + await withServer(async (ws) => { + await connectOk(ws, { token: "secret", scopes: ["operator.read", "operator.write"] }); + const created = await rpcReq<{ key?: string }>(ws, "sessions.create", { + label: "Tools Effective Test", + }); + expect(created.ok).toBe(true); + const unknownAgent = await rpcReq(ws, "tools.effective", { + sessionKey: created.payload?.key, + agentId: "does-not-exist", + }); + expect(unknownAgent.ok).toBe(false); + expect(unknownAgent.error?.message ?? "").toContain("unknown agent id"); + }); + }); +}); diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 0ba718adcc3f5..330addbc03c1c 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -69,6 +69,7 @@ export function createGatewayHooksRequestHandler(params: { channel: value.channel, to: value.to, allowUnsafeExternalContent: value.allowUnsafeExternalContent, + externalContentSource: value.externalContentSource, }, state: { nextRunAtMs: now }, }; diff --git a/src/gateway/server/http-auth.ts b/src/gateway/server/http-auth.ts index f6e241f4f0b51..e459a4606a411 100644 --- a/src/gateway/server/http-auth.ts +++ b/src/gateway/server/http-auth.ts @@ -4,7 +4,6 @@ import { safeEqualSecret } from "../../security/secret-equal.js"; import type { AuthRateLimiter } from "../auth-rate-limit.js"; import { authorizeHttpGatewayConnect, - isLocalDirectRequest, type GatewayAuthResult, type ResolvedGatewayAuth, } from "../auth.js"; @@ -78,9 +77,6 @@ export async function authorizeCanvasRequest(params: { if (malformedScopedPath) { return { ok: false, reason: "unauthorized" }; } - if (isLocalDirectRequest(req, trustedProxies, allowRealIpFallback)) { - return { ok: true }; - } let lastAuthFailure: GatewayAuthResult | null = null; const token = getBearerToken(req); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 9f3a24f925fc9..f6c3847feced7 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -542,7 +542,8 @@ export function attachGatewayWsMessageHandler(params: { if ( !device && (decision.kind !== "allow" || - (!preserveInsecureLocalControlUiScopes && + (!controlUiAuthPolicy.allowBypass && + !preserveInsecureLocalControlUiScopes && (authMethod === "token" || authMethod === "password" || trustedProxyAuthOk))) ) { clearUnboundScopes(); diff --git a/src/gateway/session-archive.fs.ts b/src/gateway/session-archive.fs.ts index 214748730af2d..15f5171b8964a 100644 --- a/src/gateway/session-archive.fs.ts +++ b/src/gateway/session-archive.fs.ts @@ -1,154 +1,5 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { - formatSessionArchiveTimestamp, - parseSessionArchiveTimestamp, - type SessionArchiveReason, -} from "../config/sessions/artifacts.js"; -import { - resolveSessionFilePath, - resolveSessionTranscriptPath, - resolveSessionTranscriptPathInDir, -} from "../config/sessions/paths.js"; -import { resolveRequiredHomeDir } from "../infra/home-dir.js"; - -export type ArchiveFileReason = SessionArchiveReason; - -function canonicalizePathForComparison(filePath: string): string { - const resolved = path.resolve(filePath); - try { - return fs.realpathSync(resolved); - } catch { - return resolved; - } -} - -function resolveSessionTranscriptCandidates( - sessionId: string, - storePath: string | undefined, - sessionFile?: string, - agentId?: string, -): string[] { - const candidates: string[] = []; - const pushCandidate = (resolve: () => string): void => { - try { - candidates.push(resolve()); - } catch { - // Ignore invalid paths/IDs and keep scanning other safe candidates. - } - }; - - if (storePath) { - const sessionsDir = path.dirname(storePath); - if (sessionFile) { - pushCandidate(() => - resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), - ); - } - pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir)); - } else if (sessionFile) { - if (agentId) { - pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); - } else { - const trimmed = sessionFile.trim(); - if (trimmed) { - candidates.push(path.resolve(trimmed)); - } - } - } - - if (agentId) { - pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId)); - } - - const home = resolveRequiredHomeDir(process.env, os.homedir); - const legacyDir = path.join(home, ".openclaw", "sessions"); - pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); - - return Array.from(new Set(candidates)); -} - -export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string { - const ts = formatSessionArchiveTimestamp(); - const archived = `${filePath}.${reason}.${ts}`; - fs.renameSync(filePath, archived); - return archived; -} - -export function archiveSessionTranscripts(opts: { - sessionId: string; - storePath: string | undefined; - sessionFile?: string; - agentId?: string; - reason: "reset" | "deleted"; - restrictToStoreDir?: boolean; -}): string[] { - const archived: string[] = []; - const storeDir = - opts.restrictToStoreDir && opts.storePath - ? canonicalizePathForComparison(path.dirname(opts.storePath)) - : null; - for (const candidate of resolveSessionTranscriptCandidates( - opts.sessionId, - opts.storePath, - opts.sessionFile, - opts.agentId, - )) { - const candidatePath = canonicalizePathForComparison(candidate); - if (storeDir) { - const relative = path.relative(storeDir, candidatePath); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { - continue; - } - } - if (!fs.existsSync(candidatePath)) { - continue; - } - try { - archived.push(archiveFileOnDisk(candidatePath, opts.reason)); - } catch { - // Best-effort. - } - } - return archived; -} - -export async function cleanupArchivedSessionTranscripts(opts: { - directories: string[]; - olderThanMs: number; - reason?: ArchiveFileReason; - nowMs?: number; -}): Promise<{ removed: number; scanned: number }> { - if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) { - return { removed: 0, scanned: 0 }; - } - const now = opts.nowMs ?? Date.now(); - const reason: ArchiveFileReason = opts.reason ?? "deleted"; - const directories = Array.from(new Set(opts.directories.map((dir) => path.resolve(dir)))); - let removed = 0; - let scanned = 0; - - for (const dir of directories) { - const entries = await fs.promises.readdir(dir).catch(() => []); - for (const entry of entries) { - const timestamp = parseSessionArchiveTimestamp(entry, reason); - if (timestamp == null) { - continue; - } - scanned += 1; - if (now - timestamp <= opts.olderThanMs) { - continue; - } - const fullPath = path.join(dir, entry); - const stat = await fs.promises.stat(fullPath).catch(() => null); - if (!stat?.isFile()) { - continue; - } - await fs.promises.rm(fullPath).catch(() => undefined); - removed += 1; - } - } - - return { removed, scanned }; -} +export { + archiveFileOnDisk, + archiveSessionTranscripts, + cleanupArchivedSessionTranscripts, +} from "./session-transcript-files.fs.js"; diff --git a/src/gateway/session-kill-http.test.ts b/src/gateway/session-kill-http.test.ts index b313b289383de..a049b4c428ef5 100644 --- a/src/gateway/session-kill-http.test.ts +++ b/src/gateway/session-kill-http.test.ts @@ -8,7 +8,7 @@ let cfg: Record = {}; const authMock = vi.fn(async () => ({ ok: true }) as { ok: boolean; rateLimited?: boolean }); const isLocalDirectRequestMock = vi.fn(() => true); const loadSessionEntryMock = vi.fn(); -const getSubagentRunByChildSessionKeyMock = vi.fn(); +const getLatestSubagentRunByChildSessionKeyMock = vi.fn(); const resolveSubagentControllerMock = vi.fn(); const killControlledSubagentRunMock = vi.fn(); const killSubagentRunAdminMock = vi.fn(); @@ -27,7 +27,7 @@ vi.mock("./session-utils.js", () => ({ })); vi.mock("../agents/subagent-registry.js", () => ({ - getSubagentRunByChildSessionKey: getSubagentRunByChildSessionKeyMock, + getLatestSubagentRunByChildSessionKey: getLatestSubagentRunByChildSessionKeyMock, })); vi.mock("../agents/subagent-control.js", () => ({ @@ -80,7 +80,7 @@ beforeEach(() => { isLocalDirectRequestMock.mockReset(); isLocalDirectRequestMock.mockReturnValue(true); loadSessionEntryMock.mockReset(); - getSubagentRunByChildSessionKeyMock.mockReset(); + getLatestSubagentRunByChildSessionKeyMock.mockReset(); resolveSubagentControllerMock.mockReset(); resolveSubagentControllerMock.mockReturnValue({ controllerSessionKey: "agent:main:main" }); killControlledSubagentRunMock.mockReset(); @@ -190,7 +190,7 @@ describe("POST /sessions/:sessionKey/kill", () => { entry: { sessionId: "sess-worker", updatedAt: Date.now() }, canonicalKey: "agent:main:subagent:worker", }); - getSubagentRunByChildSessionKeyMock.mockReturnValue({ + getLatestSubagentRunByChildSessionKeyMock.mockReturnValue({ runId: "run-1", childSessionKey: "agent:main:subagent:worker", }); @@ -205,10 +205,41 @@ describe("POST /sessions/:sessionKey/kill", () => { cfg, agentSessionKey: "agent:main:main", }); - expect(getSubagentRunByChildSessionKeyMock).toHaveBeenCalledWith("agent:main:subagent:worker"); + expect(getLatestSubagentRunByChildSessionKeyMock).toHaveBeenCalledWith( + "agent:main:subagent:worker", + ); expect(killSubagentRunAdminMock).not.toHaveBeenCalled(); }); + it("uses the newest child-session row for requester-owned kills when stale rows still exist", async () => { + isLocalDirectRequestMock.mockReturnValue(false); + authMock.mockResolvedValueOnce({ ok: true }); + loadSessionEntryMock.mockReturnValue({ + entry: { sessionId: "sess-worker", updatedAt: Date.now() }, + canonicalKey: "agent:main:subagent:worker", + }); + getLatestSubagentRunByChildSessionKeyMock.mockReturnValue({ + runId: "run-current-ended", + childSessionKey: "agent:main:subagent:worker", + endedAt: Date.now() - 1, + }); + killControlledSubagentRunMock.mockResolvedValue({ status: "done" }); + + const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill", "", { + "x-openclaw-requester-session-key": "agent:main:main", + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, killed: false }); + expect(killControlledSubagentRunMock).toHaveBeenCalledWith({ + cfg, + controller: { controllerSessionKey: "agent:main:main" }, + entry: expect.objectContaining({ + runId: "run-current-ended", + childSessionKey: "agent:main:subagent:worker", + }), + }); + }); + it("prefers admin kill when a valid bearer token is present alongside requester headers", async () => { isLocalDirectRequestMock.mockReturnValue(false); loadSessionEntryMock.mockReturnValue({ diff --git a/src/gateway/session-kill-http.ts b/src/gateway/session-kill-http.ts index 04d411ffd9b0c..0d0b6e6ca70b9 100644 --- a/src/gateway/session-kill-http.ts +++ b/src/gateway/session-kill-http.ts @@ -4,15 +4,12 @@ import { killSubagentRunAdmin, resolveSubagentController, } from "../agents/subagent-control.js"; -import { getSubagentRunByChildSessionKey } from "../agents/subagent-registry.js"; +import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry.js"; import { loadConfig } from "../config/config.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { - authorizeHttpGatewayConnect, - isLocalDirectRequest, - type ResolvedGatewayAuth, -} from "./auth.js"; -import { sendGatewayAuthFailure, sendJson, sendMethodNotAllowed } from "./http-common.js"; +import { isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js"; +import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; +import { sendJson, sendMethodNotAllowed } from "./http-common.js"; import { getBearerToken } from "./http-utils.js"; import { ADMIN_SCOPE, WRITE_SCOPE, authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { loadSessionEntry } from "./session-utils.js"; @@ -69,16 +66,15 @@ export async function handleSessionKillHttpRequest( } const token = getBearerToken(req); - const authResult = await authorizeHttpGatewayConnect({ - auth: opts.auth, - connectAuth: token ? { token, password: token } : null, + const ok = await authorizeGatewayBearerRequestOrReply({ req, + res, + auth: opts.auth, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!authResult.ok) { - sendGatewayAuthFailure(res, authResult); + if (!ok) { return true; } @@ -98,7 +94,7 @@ export async function handleSessionKillHttpRequest( const allowRealIpFallback = opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback; const requesterSessionKey = req.headers[REQUESTER_SESSION_KEY_HEADER]?.toString().trim(); const allowLocalAdminKill = isLocalDirectRequest(req, trustedProxies, allowRealIpFallback); - const allowBearerOperatorKill = canBearerTokenKillSessions(token, authResult.ok); + const allowBearerOperatorKill = canBearerTokenKillSessions(token, true); if (!requesterSessionKey && !allowLocalAdminKill && !allowBearerOperatorKill) { sendJson(res, 403, { @@ -116,7 +112,7 @@ export async function handleSessionKillHttpRequest( let killed = false; if (!allowAdminKill && requesterSessionKey) { - const runEntry = getSubagentRunByChildSessionKey(canonicalKey); + const runEntry = getLatestSubagentRunByChildSessionKey(canonicalKey); if (runEntry) { const result = await killControlledSubagentRun({ cfg, diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index acaff645d8b63..b477942745ed9 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -386,4 +386,78 @@ describe("session.message websocket events", () => { await harness.close(); } }); + + test("routes transcript-only updates to the freshest session owner when different sessionIds share a transcript path", async () => { + const storePath = await createSessionStoreFile(); + const transcriptPath = path.join(path.dirname(storePath), "shared.jsonl"); + await writeSessionStore({ + entries: { + older: { + sessionId: "sess-old", + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + newer: { + sessionId: "sess-new", + sessionFile: transcriptPath, + updatedAt: Date.now() + 10, + }, + }, + storePath, + }); + await fs.writeFile( + transcriptPath, + [ + JSON.stringify({ type: "session", version: 1, id: "sess-new" }), + JSON.stringify({ + id: "msg-shared", + message: { + role: "assistant", + content: [{ type: "text", text: "shared transcript update" }], + timestamp: Date.now(), + }, + }), + ].join("\n"), + "utf-8", + ); + + const harness = await createGatewaySuiteHarness(); + try { + const ws = await harness.openWs(); + try { + await connectOk(ws, { scopes: ["operator.read"] }); + await rpcReq(ws, "sessions.subscribe"); + + const messageEventPromise = onceMessage( + ws, + (message) => + message.type === "event" && + message.event === "session.message" && + (message.payload as { sessionKey?: string } | undefined)?.sessionKey === + "agent:main:newer", + ); + + emitSessionTranscriptUpdate({ + sessionFile: transcriptPath, + message: { + role: "assistant", + content: [{ type: "text", text: "shared transcript update" }], + timestamp: Date.now(), + }, + messageId: "msg-shared", + }); + + const messageEvent = await messageEventPromise; + expect(messageEvent.payload).toMatchObject({ + sessionKey: "agent:main:newer", + messageId: "msg-shared", + messageSeq: 1, + }); + } finally { + ws.close(); + } + } finally { + await harness.close(); + } + }); }); diff --git a/src/gateway/session-subagent-reactivation.test.ts b/src/gateway/session-subagent-reactivation.test.ts new file mode 100644 index 0000000000000..6a5357b8ef221 --- /dev/null +++ b/src/gateway/session-subagent-reactivation.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getLatestSubagentRunByChildSessionKeyMock = vi.fn(); +const replaceSubagentRunAfterSteerMock = vi.fn(); + +vi.mock("../agents/subagent-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getLatestSubagentRunByChildSessionKey: (...args: unknown[]) => + getLatestSubagentRunByChildSessionKeyMock(...args), + replaceSubagentRunAfterSteer: (...args: unknown[]) => replaceSubagentRunAfterSteerMock(...args), + }; +}); + +import { reactivateCompletedSubagentSession } from "./session-subagent-reactivation.js"; + +describe("reactivateCompletedSubagentSession", () => { + beforeEach(() => { + getLatestSubagentRunByChildSessionKeyMock.mockReset(); + replaceSubagentRunAfterSteerMock.mockReset(); + }); + + it("reactivates the newest ended row even when stale active rows still exist for the same child session", () => { + const childSessionKey = "agent:main:subagent:followup-race"; + const latestEndedRun = { + runId: "run-current-ended", + childSessionKey, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "current ended task", + cleanup: "keep" as const, + createdAt: 20, + startedAt: 21, + endedAt: 22, + outcome: { status: "ok" as const }, + }; + + getLatestSubagentRunByChildSessionKeyMock.mockReturnValue(latestEndedRun); + replaceSubagentRunAfterSteerMock.mockReturnValue(true); + + expect( + reactivateCompletedSubagentSession({ + sessionKey: childSessionKey, + runId: "run-next", + }), + ).toBe(true); + + expect(getLatestSubagentRunByChildSessionKeyMock).toHaveBeenCalledWith(childSessionKey); + expect(replaceSubagentRunAfterSteerMock).toHaveBeenCalledWith({ + previousRunId: "run-current-ended", + nextRunId: "run-next", + fallback: latestEndedRun, + runTimeoutSeconds: 0, + }); + }); +}); diff --git a/src/gateway/session-subagent-reactivation.ts b/src/gateway/session-subagent-reactivation.ts index 3664739a1e584..5bc52999da7e7 100644 --- a/src/gateway/session-subagent-reactivation.ts +++ b/src/gateway/session-subagent-reactivation.ts @@ -1,5 +1,5 @@ import { - getSubagentRunByChildSessionKey, + getLatestSubagentRunByChildSessionKey, replaceSubagentRunAfterSteer, } from "../agents/subagent-registry.js"; @@ -11,7 +11,7 @@ export function reactivateCompletedSubagentSession(params: { if (!runId) { return false; } - const existing = getSubagentRunByChildSessionKey(params.sessionKey); + const existing = getLatestSubagentRunByChildSessionKey(params.sessionKey); if (!existing || typeof existing.endedAt !== "number") { return false; } diff --git a/src/gateway/session-transcript-files.fs.ts b/src/gateway/session-transcript-files.fs.ts new file mode 100644 index 0000000000000..5df99089792ba --- /dev/null +++ b/src/gateway/session-transcript-files.fs.ts @@ -0,0 +1,206 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + formatSessionArchiveTimestamp, + parseSessionArchiveTimestamp, + type SessionArchiveReason, + resolveSessionFilePath, + resolveSessionTranscriptPath, + resolveSessionTranscriptPathInDir, +} from "../config/sessions.js"; +import { resolveRequiredHomeDir } from "../infra/home-dir.js"; + +export type ArchiveFileReason = SessionArchiveReason; + +function classifySessionTranscriptCandidate( + sessionId: string, + sessionFile?: string, +): "current" | "stale" | "custom" { + const transcriptSessionId = extractGeneratedTranscriptSessionId(sessionFile); + if (!transcriptSessionId) { + return "custom"; + } + return transcriptSessionId === sessionId ? "current" : "stale"; +} + +function extractGeneratedTranscriptSessionId(sessionFile?: string): string | undefined { + const trimmed = sessionFile?.trim(); + if (!trimmed) { + return undefined; + } + const base = path.basename(trimmed); + if (!base.endsWith(".jsonl")) { + return undefined; + } + const withoutExt = base.slice(0, -".jsonl".length); + const topicIndex = withoutExt.indexOf("-topic-"); + if (topicIndex > 0) { + const topicSessionId = withoutExt.slice(0, topicIndex); + return looksLikeGeneratedSessionId(topicSessionId) ? topicSessionId : undefined; + } + const forkMatch = withoutExt.match( + /^(\d{4}-\d{2}-\d{2}T[\w-]+(?:Z|[+-]\d{2}(?:-\d{2})?)?)_(.+)$/, + ); + if (forkMatch?.[2]) { + return looksLikeGeneratedSessionId(forkMatch[2]) ? forkMatch[2] : undefined; + } + return looksLikeGeneratedSessionId(withoutExt) ? withoutExt : undefined; +} + +function looksLikeGeneratedSessionId(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function canonicalizePathForComparison(filePath: string): string { + const resolved = path.resolve(filePath); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + +export function resolveSessionTranscriptCandidates( + sessionId: string, + storePath: string | undefined, + sessionFile?: string, + agentId?: string, +): string[] { + const candidates: string[] = []; + const sessionFileState = classifySessionTranscriptCandidate(sessionId, sessionFile); + const pushCandidate = (resolve: () => string): void => { + try { + candidates.push(resolve()); + } catch { + // Ignore invalid paths/IDs and keep scanning other safe candidates. + } + }; + + if (storePath) { + const sessionsDir = path.dirname(storePath); + if (sessionFile && sessionFileState !== "stale") { + pushCandidate(() => + resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), + ); + } + pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir)); + if (sessionFile && sessionFileState === "stale") { + pushCandidate(() => + resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), + ); + } + } else if (sessionFile) { + if (agentId) { + if (sessionFileState !== "stale") { + pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); + } + } else { + const trimmed = sessionFile.trim(); + if (trimmed) { + candidates.push(path.resolve(trimmed)); + } + } + } + + if (agentId) { + pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId)); + if (sessionFile && sessionFileState === "stale") { + pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); + } + } + + const home = resolveRequiredHomeDir(process.env, os.homedir); + const legacyDir = path.join(home, ".openclaw", "sessions"); + pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); + + return Array.from(new Set(candidates)); +} + +export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string { + const ts = formatSessionArchiveTimestamp(); + const archived = `${filePath}.${reason}.${ts}`; + fs.renameSync(filePath, archived); + return archived; +} + +export function archiveSessionTranscripts(opts: { + sessionId: string; + storePath: string | undefined; + sessionFile?: string; + agentId?: string; + reason: "reset" | "deleted"; + /** + * When true, only archive files resolved under the session store directory. + * This prevents maintenance operations from mutating paths outside the agent sessions dir. + */ + restrictToStoreDir?: boolean; +}): string[] { + const archived: string[] = []; + const storeDir = + opts.restrictToStoreDir && opts.storePath + ? canonicalizePathForComparison(path.dirname(opts.storePath)) + : null; + for (const candidate of resolveSessionTranscriptCandidates( + opts.sessionId, + opts.storePath, + opts.sessionFile, + opts.agentId, + )) { + const candidatePath = canonicalizePathForComparison(candidate); + if (storeDir) { + const relative = path.relative(storeDir, candidatePath); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + continue; + } + } + if (!fs.existsSync(candidatePath)) { + continue; + } + try { + archived.push(archiveFileOnDisk(candidatePath, opts.reason)); + } catch { + // Best-effort. + } + } + return archived; +} + +export async function cleanupArchivedSessionTranscripts(opts: { + directories: string[]; + olderThanMs: number; + reason?: ArchiveFileReason; + nowMs?: number; +}): Promise<{ removed: number; scanned: number }> { + if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) { + return { removed: 0, scanned: 0 }; + } + const now = opts.nowMs ?? Date.now(); + const reason: ArchiveFileReason = opts.reason ?? "deleted"; + const directories = Array.from(new Set(opts.directories.map((dir) => path.resolve(dir)))); + let removed = 0; + let scanned = 0; + + for (const dir of directories) { + const entries = await fs.promises.readdir(dir).catch(() => []); + for (const entry of entries) { + const timestamp = parseSessionArchiveTimestamp(entry, reason); + if (timestamp == null) { + continue; + } + scanned += 1; + if (now - timestamp <= opts.olderThanMs) { + continue; + } + const fullPath = path.join(dir, entry); + const stat = await fs.promises.stat(fullPath).catch(() => null); + if (!stat?.isFile()) { + continue; + } + await fs.promises.rm(fullPath).catch(() => undefined); + removed += 1; + } + } + + return { removed, scanned }; +} diff --git a/src/gateway/session-transcript-key.test.ts b/src/gateway/session-transcript-key.test.ts index f9105f321e144..b2bda645f3563 100644 --- a/src/gateway/session-transcript-key.test.ts +++ b/src/gateway/session-transcript-key.test.ts @@ -113,4 +113,32 @@ describe("resolveSessionKeyForTranscriptFile", () => { expect(resolveSessionKeyForTranscriptFile(" ")).toBeUndefined(); expect(loadCombinedSessionStoreForGatewayMock).not.toHaveBeenCalled(); }); + + it("prefers the deterministic session key when duplicate sessionIds share a transcript path", () => { + const store = { + "agent:other:main": { sessionId: "run-dup", updatedAt: now + 1 }, + "agent:main:acp:run-dup": { sessionId: "run-dup", updatedAt: now }, + } satisfies Record; + loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(multiple)", + store, + }); + resolveSessionTranscriptCandidatesMock.mockReturnValue(["/tmp/shared.jsonl"]); + + expect(resolveSessionKeyForTranscriptFile("/tmp/shared.jsonl")).toBe("agent:main:acp:run-dup"); + }); + + it("prefers the freshest matching session when different sessionIds share a transcript path", () => { + const store = { + "agent:main:older": { sessionId: "sess-old", updatedAt: now }, + "agent:main:newer": { sessionId: "sess-new", updatedAt: now + 10 }, + } satisfies Record; + loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(multiple)", + store, + }); + resolveSessionTranscriptCandidatesMock.mockReturnValue(["/tmp/shared.jsonl"]); + + expect(resolveSessionKeyForTranscriptFile("/tmp/shared.jsonl")).toBe("agent:main:newer"); + }); }); diff --git a/src/gateway/session-transcript-key.ts b/src/gateway/session-transcript-key.ts index 1fee2348a648b..f84e728a7d0f6 100644 --- a/src/gateway/session-transcript-key.ts +++ b/src/gateway/session-transcript-key.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { loadConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; import { loadCombinedSessionStoreForGateway, resolveGatewaySessionStoreTarget, @@ -74,6 +75,7 @@ export function resolveSessionKeyForTranscriptFile(sessionFile: string): string return cachedKey; } + const matchingEntries: Array<[string, SessionEntry]> = []; for (const [key, entry] of Object.entries(store)) { if (!entry?.sessionId || key === cachedKey) { continue; @@ -86,8 +88,54 @@ export function resolveSessionKeyForTranscriptFile(sessionFile: string): string targetPath, }) ) { - TRANSCRIPT_SESSION_KEY_CACHE.set(targetPath, key); - return key; + matchingEntries.push([key, entry]); + } + } + + if (matchingEntries.length > 0) { + const matchesBySessionId = new Map>(); + for (const entry of matchingEntries) { + const sessionId = entry[1].sessionId; + if (!sessionId) { + continue; + } + const group = matchesBySessionId.get(sessionId); + if (group) { + group.push(entry); + } else { + matchesBySessionId.set(sessionId, [entry]); + } + } + + const resolvedMatches = Array.from(matchesBySessionId.entries()) + .map(([sessionId, matches]) => { + const resolvedKey = + resolvePreferredSessionKeyForSessionIdMatches(matches, sessionId) ?? matches[0]?.[0]; + const resolvedEntry = resolvedKey + ? matches.find(([key]) => key === resolvedKey)?.[1] + : undefined; + return resolvedKey && resolvedEntry + ? { + key: resolvedKey, + updatedAt: resolvedEntry.updatedAt ?? 0, + } + : undefined; + }) + .filter((match): match is { key: string; updatedAt: number } => match !== undefined); + + const sortedResolvedMatches = [...resolvedMatches].toSorted( + (a, b) => b.updatedAt - a.updatedAt, + ); + const [freshestMatch, secondFreshestMatch] = sortedResolvedMatches; + const resolvedKey = + resolvedMatches.length === 1 + ? freshestMatch?.key + : (freshestMatch?.updatedAt ?? 0) > (secondFreshestMatch?.updatedAt ?? 0) + ? freshestMatch?.key + : undefined; + if (resolvedKey) { + TRANSCRIPT_SESSION_KEY_CACHE.set(targetPath, resolvedKey); + return resolvedKey; } } diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index ca95b86aca1a1..ee6b0da66df7a 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -865,6 +865,78 @@ describe("resolveSessionTranscriptCandidates safety", () => { expect(candidates.some((value) => value.includes("etc/passwd"))).toBe(false); expect(normalizedCandidates).toContain(expectedFallback); }); + + test("prefers the current sessionId transcript before a stale sessionFile candidate", () => { + const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; + const candidates = resolveSessionTranscriptCandidates( + "11111111-1111-4111-8111-111111111111", + storePath, + "/tmp/openclaw/agents/main/sessions/22222222-2222-4222-8222-222222222222.jsonl", + ); + + expect(candidates[0]).toBe( + path.resolve("/tmp/openclaw/agents/main/sessions/11111111-1111-4111-8111-111111111111.jsonl"), + ); + expect(candidates).toContain( + path.resolve("/tmp/openclaw/agents/main/sessions/22222222-2222-4222-8222-222222222222.jsonl"), + ); + }); + + test("keeps explicit custom sessionFile ahead of synthesized fallback", () => { + const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; + const sessionFile = "/tmp/openclaw/agents/main/sessions/custom-transcript.jsonl"; + const candidates = resolveSessionTranscriptCandidates( + "11111111-1111-4111-8111-111111111111", + storePath, + sessionFile, + ); + + expect(candidates[0]).toBe(path.resolve(sessionFile)); + }); + + test("keeps custom topic-like transcript paths ahead of synthesized fallback", () => { + const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; + const sessionFile = "/tmp/openclaw/agents/main/sessions/custom-topic-notes.jsonl"; + const candidates = resolveSessionTranscriptCandidates( + "11111111-1111-4111-8111-111111111111", + storePath, + sessionFile, + ); + + expect(candidates[0]).toBe(path.resolve(sessionFile)); + }); + + test("keeps forked transcript paths ahead of synthesized fallback", () => { + const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; + const sessionId = "11111111-1111-4111-8111-111111111111"; + const sessionFile = + "/tmp/openclaw/agents/main/sessions/2026-03-23T16-30-00-000Z_11111111-1111-4111-8111-111111111111.jsonl"; + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile); + + expect(candidates[0]).toBe(path.resolve(sessionFile)); + }); + + test("keeps timestamped custom transcript paths ahead of synthesized fallback", () => { + const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; + const sessionId = "11111111-1111-4111-8111-111111111111"; + const sessionFile = "/tmp/openclaw/agents/main/sessions/2026-03-23T16-30-00-000Z_notes.jsonl"; + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile); + + expect(candidates[0]).toBe(path.resolve(sessionFile)); + }); + + test("still treats generated topic transcripts from another session as stale", () => { + const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; + const sessionId = "11111111-1111-4111-8111-111111111111"; + const staleSessionFile = + "/tmp/openclaw/agents/main/sessions/22222222-2222-4222-8222-222222222222-topic-thread.jsonl"; + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, staleSessionFile); + + expect(candidates[0]).toBe( + path.resolve("/tmp/openclaw/agents/main/sessions/11111111-1111-4111-8111-111111111111.jsonl"), + ); + expect(candidates).toContain(path.resolve(staleSessionFile)); + }); }); describe("archiveSessionTranscripts", () => { diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 82bfd55323cbf..8482d1da7ac90 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -1,24 +1,16 @@ import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { deriveSessionTotalTokens, hasNonzeroUsage, normalizeUsage } from "../agents/usage.js"; -import { - resolveSessionFilePath, - resolveSessionTranscriptPath, - resolveSessionTranscriptPathInDir, -} from "../config/sessions.js"; -export { - archiveFileOnDisk, - archiveSessionTranscripts, - cleanupArchivedSessionTranscripts, - type ArchiveFileReason, -} from "../gateway/session-archive.fs.js"; -import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; import { stripEnvelope } from "./chat-sanitize.js"; +import { + resolveSessionTranscriptCandidates, + archiveFileOnDisk, + archiveSessionTranscripts, + cleanupArchivedSessionTranscripts, +} from "./session-transcript-files.fs.js"; import type { SessionPreviewItem } from "./session-utils.types.js"; type SessionTitleFields = { @@ -152,50 +144,12 @@ export function readSessionMessages( return messages; } -export function resolveSessionTranscriptCandidates( - sessionId: string, - storePath: string | undefined, - sessionFile?: string, - agentId?: string, -): string[] { - const candidates: string[] = []; - const pushCandidate = (resolve: () => string): void => { - try { - candidates.push(resolve()); - } catch { - // Ignore invalid paths/IDs and keep scanning other safe candidates. - } - }; - - if (storePath) { - const sessionsDir = path.dirname(storePath); - if (sessionFile) { - pushCandidate(() => - resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), - ); - } - pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir)); - } else if (sessionFile) { - if (agentId) { - pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); - } else { - const trimmed = sessionFile.trim(); - if (trimmed) { - candidates.push(path.resolve(trimmed)); - } - } - } - - if (agentId) { - pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId)); - } - - const home = resolveRequiredHomeDir(process.env, os.homedir); - const legacyDir = path.join(home, ".openclaw", "sessions"); - pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); - - return Array.from(new Set(candidates)); -} +export { + archiveFileOnDisk, + archiveSessionTranscripts, + cleanupArchivedSessionTranscripts, + resolveSessionTranscriptCandidates, +} from "./session-transcript-files.fs.js"; export function capArrayByJsonBytes( items: T[], diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 7f26059b813f8..77fc9277178af 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -414,12 +414,12 @@ describe("resolveSessionModelRef", () => { sessionId: "s1", updatedAt: Date.now(), modelProvider: "openai-codex", - model: "gpt-5.3-codex", + model: "gpt-5.4", modelOverride: "claude-opus-4-6", providerOverride: "anthropic", }); - expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); + expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.4" }); }); test("preserves openrouter provider when model contains vendor prefix", () => { @@ -448,10 +448,10 @@ describe("resolveSessionModelRef", () => { const resolved = resolveSessionModelRef(cfg, { sessionId: "s2", updatedAt: Date.now(), - modelOverride: "openai-codex/gpt-5.3-codex", + modelOverride: "openai-codex/gpt-5.4", }); - expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); + expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.4" }); }); test("falls back to resolved provider for unprefixed legacy runtime model", () => { @@ -1277,6 +1277,367 @@ describe("listSessionsFromStore subagent metadata", () => { expect(failed?.runtimeMs).toBe(5_000); }); + test("does not keep childSessions attached to a stale older controller row", () => { + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + } as SessionEntry, + "agent:main:subagent:old-parent": { + sessionId: "sess-old-parent", + updatedAt: now - 4_000, + spawnedBy: "agent:main:main", + } as SessionEntry, + "agent:main:subagent:new-parent": { + sessionId: "sess-new-parent", + updatedAt: now - 3_000, + spawnedBy: "agent:main:main", + } as SessionEntry, + "agent:main:subagent:shared-child": { + sessionId: "sess-shared-child", + updatedAt: now - 1_000, + spawnedBy: "agent:main:subagent:new-parent", + } as SessionEntry, + }; + + addSubagentRunForTests({ + runId: "run-old-parent", + childSessionKey: "agent:main:subagent:old-parent", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "old parent task", + cleanup: "keep", + createdAt: now - 10_000, + startedAt: now - 9_000, + }); + addSubagentRunForTests({ + runId: "run-new-parent", + childSessionKey: "agent:main:subagent:new-parent", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "new parent task", + cleanup: "keep", + createdAt: now - 8_000, + startedAt: now - 7_000, + }); + addSubagentRunForTests({ + runId: "run-child-stale-parent", + childSessionKey: "agent:main:subagent:shared-child", + controllerSessionKey: "agent:main:subagent:old-parent", + requesterSessionKey: "agent:main:subagent:old-parent", + requesterDisplayKey: "old-parent", + task: "shared child stale parent", + cleanup: "keep", + createdAt: now - 6_000, + startedAt: now - 5_500, + endedAt: now - 4_500, + outcome: { status: "ok" }, + }); + addSubagentRunForTests({ + runId: "run-child-current-parent", + childSessionKey: "agent:main:subagent:shared-child", + controllerSessionKey: "agent:main:subagent:new-parent", + requesterSessionKey: "agent:main:subagent:new-parent", + requesterDisplayKey: "new-parent", + task: "shared child current parent", + cleanup: "keep", + createdAt: now - 2_000, + startedAt: now - 1_500, + }); + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + const oldParent = result.sessions.find( + (session) => session.key === "agent:main:subagent:old-parent", + ); + const newParent = result.sessions.find( + (session) => session.key === "agent:main:subagent:new-parent", + ); + + expect(oldParent?.childSessions).toBeUndefined(); + expect(newParent?.childSessions).toEqual(["agent:main:subagent:shared-child"]); + }); + + test("does not reattach moved children through stale spawnedBy store metadata", () => { + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + } as SessionEntry, + "agent:main:subagent:old-parent-store": { + sessionId: "sess-old-parent-store", + updatedAt: now - 4_000, + spawnedBy: "agent:main:main", + } as SessionEntry, + "agent:main:subagent:new-parent-store": { + sessionId: "sess-new-parent-store", + updatedAt: now - 3_000, + spawnedBy: "agent:main:main", + } as SessionEntry, + "agent:main:subagent:shared-child-store": { + sessionId: "sess-shared-child-store", + updatedAt: now - 1_000, + spawnedBy: "agent:main:subagent:old-parent-store", + } as SessionEntry, + }; + + addSubagentRunForTests({ + runId: "run-old-parent-store", + childSessionKey: "agent:main:subagent:old-parent-store", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "old parent store task", + cleanup: "keep", + createdAt: now - 10_000, + startedAt: now - 9_000, + }); + addSubagentRunForTests({ + runId: "run-new-parent-store", + childSessionKey: "agent:main:subagent:new-parent-store", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "new parent store task", + cleanup: "keep", + createdAt: now - 8_000, + startedAt: now - 7_000, + }); + addSubagentRunForTests({ + runId: "run-child-store-stale-parent", + childSessionKey: "agent:main:subagent:shared-child-store", + controllerSessionKey: "agent:main:subagent:old-parent-store", + requesterSessionKey: "agent:main:subagent:old-parent-store", + requesterDisplayKey: "old-parent-store", + task: "shared child stale store parent", + cleanup: "keep", + createdAt: now - 6_000, + startedAt: now - 5_500, + endedAt: now - 4_500, + outcome: { status: "ok" }, + }); + addSubagentRunForTests({ + runId: "run-child-store-current-parent", + childSessionKey: "agent:main:subagent:shared-child-store", + controllerSessionKey: "agent:main:subagent:new-parent-store", + requesterSessionKey: "agent:main:subagent:new-parent-store", + requesterDisplayKey: "new-parent-store", + task: "shared child current store parent", + cleanup: "keep", + createdAt: now - 2_000, + startedAt: now - 1_500, + }); + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + const oldParent = result.sessions.find( + (session) => session.key === "agent:main:subagent:old-parent-store", + ); + const newParent = result.sessions.find( + (session) => session.key === "agent:main:subagent:new-parent-store", + ); + + expect(oldParent?.childSessions).toBeUndefined(); + expect(newParent?.childSessions).toEqual(["agent:main:subagent:shared-child-store"]); + }); + + test("does not return moved child sessions from stale spawnedBy filters", () => { + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + } as SessionEntry, + "agent:main:subagent:old-parent-filter": { + sessionId: "sess-old-parent-filter", + updatedAt: now - 4_000, + spawnedBy: "agent:main:main", + } as SessionEntry, + "agent:main:subagent:new-parent-filter": { + sessionId: "sess-new-parent-filter", + updatedAt: now - 3_000, + spawnedBy: "agent:main:main", + } as SessionEntry, + "agent:main:subagent:shared-child-filter": { + sessionId: "sess-shared-child-filter", + updatedAt: now - 1_000, + spawnedBy: "agent:main:subagent:old-parent-filter", + } as SessionEntry, + }; + + addSubagentRunForTests({ + runId: "run-old-parent-filter", + childSessionKey: "agent:main:subagent:old-parent-filter", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "old parent filter task", + cleanup: "keep", + createdAt: now - 10_000, + startedAt: now - 9_000, + }); + addSubagentRunForTests({ + runId: "run-new-parent-filter", + childSessionKey: "agent:main:subagent:new-parent-filter", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "new parent filter task", + cleanup: "keep", + createdAt: now - 8_000, + startedAt: now - 7_000, + }); + addSubagentRunForTests({ + runId: "run-child-filter-stale-parent", + childSessionKey: "agent:main:subagent:shared-child-filter", + controllerSessionKey: "agent:main:subagent:old-parent-filter", + requesterSessionKey: "agent:main:subagent:old-parent-filter", + requesterDisplayKey: "old-parent-filter", + task: "shared child stale filter parent", + cleanup: "keep", + createdAt: now - 6_000, + startedAt: now - 5_500, + endedAt: now - 4_500, + outcome: { status: "ok" }, + }); + addSubagentRunForTests({ + runId: "run-child-filter-current-parent", + childSessionKey: "agent:main:subagent:shared-child-filter", + controllerSessionKey: "agent:main:subagent:new-parent-filter", + requesterSessionKey: "agent:main:subagent:new-parent-filter", + requesterDisplayKey: "new-parent-filter", + task: "shared child current filter parent", + cleanup: "keep", + createdAt: now - 2_000, + startedAt: now - 1_500, + }); + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: { + spawnedBy: "agent:main:subagent:old-parent-filter", + }, + }); + + expect(result.sessions.map((session) => session.key)).toEqual([]); + }); + + test("reports the newest run owner for moved child session rows", () => { + const now = Date.now(); + const childSessionKey = "agent:main:subagent:shared-child-owner"; + const store: Record = { + [childSessionKey]: { + sessionId: "sess-shared-child-owner", + updatedAt: now, + spawnedBy: "agent:main:subagent:old-parent-owner", + } as SessionEntry, + }; + + addSubagentRunForTests({ + runId: "run-child-owner-stale-parent", + childSessionKey, + controllerSessionKey: "agent:main:subagent:old-parent-owner", + requesterSessionKey: "agent:main:subagent:old-parent-owner", + requesterDisplayKey: "old-parent-owner", + task: "shared child stale owner parent", + cleanup: "keep", + createdAt: now - 6_000, + startedAt: now - 5_500, + endedAt: now - 4_500, + outcome: { status: "ok" }, + }); + addSubagentRunForTests({ + runId: "run-child-owner-current-parent", + childSessionKey, + controllerSessionKey: "agent:main:subagent:new-parent-owner", + requesterSessionKey: "agent:main:subagent:new-parent-owner", + requesterDisplayKey: "new-parent-owner", + task: "shared child current owner parent", + cleanup: "keep", + createdAt: now - 2_000, + startedAt: now - 1_500, + }); + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + key: childSessionKey, + spawnedBy: "agent:main:subagent:new-parent-owner", + }); + }); + + test("reports the newest parentSessionKey for moved child session rows", () => { + const now = Date.now(); + const childSessionKey = "agent:main:subagent:shared-child-parent"; + const store: Record = { + [childSessionKey]: { + sessionId: "sess-shared-child-parent", + updatedAt: now, + parentSessionKey: "agent:main:subagent:old-parent-parent", + } as SessionEntry, + }; + + addSubagentRunForTests({ + runId: "run-child-parent-stale-parent", + childSessionKey, + controllerSessionKey: "agent:main:subagent:old-parent-parent", + requesterSessionKey: "agent:main:subagent:old-parent-parent", + requesterDisplayKey: "old-parent-parent", + task: "shared child stale parentSessionKey parent", + cleanup: "keep", + createdAt: now - 6_000, + startedAt: now - 5_500, + endedAt: now - 4_500, + outcome: { status: "ok" }, + }); + addSubagentRunForTests({ + runId: "run-child-parent-current-parent", + childSessionKey, + controllerSessionKey: "agent:main:subagent:new-parent-parent", + requesterSessionKey: "agent:main:subagent:new-parent-parent", + requesterDisplayKey: "new-parent-parent", + task: "shared child current parentSessionKey parent", + cleanup: "keep", + createdAt: now - 2_000, + startedAt: now - 1_500, + }); + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + key: childSessionKey, + parentSessionKey: "agent:main:subagent:new-parent-parent", + }); + }); + test("preserves original session timing across follow-up replacement runs", () => { const now = Date.now(); const store: Record = { @@ -1317,8 +1678,65 @@ describe("listSessionsFromStore subagent metadata", () => { expect(followup?.runtimeMs).toBeGreaterThanOrEqual(150_000); }); + test("uses the newest child-session row for stale/current replacement pairs", () => { + const now = Date.now(); + const childSessionKey = "agent:main:subagent:stale-current"; + const store: Record = { + [childSessionKey]: { + sessionId: "sess-stale-current", + updatedAt: now, + spawnedBy: "agent:main:main", + } as SessionEntry, + }; + + addSubagentRunForTests({ + runId: "run-stale-active", + childSessionKey, + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "stale active row", + cleanup: "keep", + createdAt: now - 5_000, + startedAt: now - 4_500, + model: "openai/gpt-5.4", + }); + addSubagentRunForTests({ + runId: "run-current-ended", + childSessionKey, + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "current ended row", + cleanup: "keep", + createdAt: now - 1_000, + startedAt: now - 900, + endedAt: now - 200, + outcome: { status: "ok" }, + model: "openai/gpt-5.4", + }); + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + key: childSessionKey, + status: "done", + startedAt: now - 900, + endedAt: now - 200, + }); + }); + test("uses persisted active subagent runs when the local worker only has terminal snapshots", async () => { - await withStateDirEnv("openclaw-session-utils-subagent-", async ({ stateDir }) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-subagent-")); + const stateDir = path.join(tempRoot, "state"); + fs.mkdirSync(stateDir, { recursive: true }); + try { const now = Date.now(); const childSessionKey = "agent:main:subagent:disk-live"; const registryPath = path.join(stateDir, "subagents", "runs.json"); @@ -1359,30 +1777,38 @@ describe("listSessionsFromStore subagent metadata", () => { "utf-8", ); - const row = withEnv({ VITEST: undefined, NODE_ENV: "development" }, () => { - const result = listSessionsFromStore({ - cfg, - storePath: "/tmp/sessions.json", - store: { - [childSessionKey]: { - sessionId: "sess-disk-live", - updatedAt: now, - spawnedBy: "agent:main:main", - status: "done", - endedAt: now - 1_800, - runtimeMs: 100, - } as SessionEntry, - }, - opts: {}, - }); - return result.sessions.find((session) => session.key === childSessionKey); - }); + const row = withEnv( + { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK: "1", + }, + () => { + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store: { + [childSessionKey]: { + sessionId: "sess-disk-live", + updatedAt: now, + spawnedBy: "agent:main:main", + status: "done", + endedAt: now - 1_800, + runtimeMs: 100, + } as SessionEntry, + }, + opts: {}, + }); + return result.sessions.find((session) => session.key === childSessionKey); + }, + ); expect(row?.status).toBe("running"); expect(row?.startedAt).toBe(now - 9_000); expect(row?.endedAt).toBeUndefined(); expect(row?.runtimeMs).toBeGreaterThanOrEqual(9_000); - }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } }); test("includes explicit parentSessionKey relationships for dashboard child sessions", () => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 52c6f54b1cacf..d94c1956b55fc 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -10,7 +10,7 @@ import { resolveDefaultModelForAgent, } from "../agents/model-selection.js"; import { - getSubagentRunByChildSessionKey, + getLatestSubagentRunByChildSessionKey, getSubagentSessionRuntimeMs, getSubagentSessionStartedAt, listSubagentRunsForController, @@ -257,20 +257,38 @@ function resolveChildSessionKeys( controllerSessionKey: string, store: Record, ): string[] | undefined { - const childSessionKeys = new Set( - listSubagentRunsForController(controllerSessionKey) - .map((entry) => entry.childSessionKey) - .filter((value) => typeof value === "string" && value.trim().length > 0), - ); + const childSessionKeys = new Set(); + for (const entry of listSubagentRunsForController(controllerSessionKey)) { + const childSessionKey = entry.childSessionKey?.trim(); + if (!childSessionKey) { + continue; + } + const latest = getLatestSubagentRunByChildSessionKey(childSessionKey); + const latestControllerSessionKey = + latest?.controllerSessionKey?.trim() || latest?.requesterSessionKey?.trim(); + if (latestControllerSessionKey !== controllerSessionKey) { + continue; + } + childSessionKeys.add(childSessionKey); + } for (const [key, entry] of Object.entries(store)) { if (!entry || key === controllerSessionKey) { continue; } const spawnedBy = entry.spawnedBy?.trim(); const parentSessionKey = entry.parentSessionKey?.trim(); - if (spawnedBy === controllerSessionKey || parentSessionKey === controllerSessionKey) { - childSessionKeys.add(key); + if (spawnedBy !== controllerSessionKey && parentSessionKey !== controllerSessionKey) { + continue; + } + const latest = getLatestSubagentRunByChildSessionKey(key); + if (latest) { + const latestControllerSessionKey = + latest.controllerSessionKey?.trim() || latest.requesterSessionKey?.trim(); + if (latestControllerSessionKey !== controllerSessionKey) { + continue; + } } + childSessionKeys.add(key); } const childSessions = Array.from(childSessionKeys); return childSessions.length > 0 ? childSessions : undefined; @@ -312,6 +330,8 @@ function resolveTranscriptUsageFallback(params: { cfg: params.cfg, provider: modelProvider, model, + // Gateway/session listing is read-only; don't start async model discovery. + allowAsyncLoad: false, }); const estimatedCostUsd = resolveEstimatedSessionCostUsd({ cfg: params.cfg, @@ -347,6 +367,22 @@ export function loadSessionEntry(sessionKey: string) { return { cfg, storePath, store, entry: match?.entry, canonicalKey, legacyKey }; } +export function resolveFreshestSessionEntryFromStoreKeys( + store: Record, + storeKeys: string[], +): SessionEntry | undefined { + const matches = storeKeys + .map((key) => store[key]) + .filter((entry): entry is SessionEntry => Boolean(entry)); + if (matches.length === 0) { + return undefined; + } + if (matches.length === 1) { + return matches[0]; + } + return [...matches].toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]; +} + /** * Find a session entry by exact or case-insensitive key match. * Returns both the entry and the actual store key it was found under, @@ -899,7 +935,7 @@ export function getSessionDefaults(cfg: OpenClawConfig): GatewaySessionsDefaults }); const contextTokens = cfg.agents?.defaults?.contextTokens ?? - lookupContextTokens(resolved.model) ?? + lookupContextTokens(resolved.model, { allowAsyncLoad: false }) ?? DEFAULT_CONTEXT_TOKENS; return { modelProvider: resolved.provider ?? null, @@ -1053,7 +1089,9 @@ export function buildGatewaySessionRow(params: { const deliveryFields = normalizeSessionDeliveryFields(entry); const parsedAgent = parseAgentSessionKey(key); const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); - const subagentRun = getSubagentRunByChildSessionKey(key); + const subagentRun = getLatestSubagentRunByChildSessionKey(key); + const subagentOwner = + subagentRun?.controllerSessionKey?.trim() || subagentRun?.requesterSessionKey?.trim(); const subagentStatus = subagentRun ? resolveSubagentSessionStatus(subagentRun) : undefined; const subagentStartedAt = subagentRun ? getSubagentSessionStartedAt(subagentRun) : undefined; const subagentEndedAt = subagentRun ? subagentRun.endedAt : undefined; @@ -1107,6 +1145,8 @@ export function buildGatewaySessionRow(params: { cfg, provider: modelProvider, model, + // Gateway/session listing is read-only; don't start async model discovery. + allowAsyncLoad: false, }), ); @@ -1129,7 +1169,7 @@ export function buildGatewaySessionRow(params: { return { key, - spawnedBy: entry?.spawnedBy, + spawnedBy: subagentOwner || entry?.spawnedBy, kind: classifySessionKey(key, entry), label: entry?.label, displayName, @@ -1159,7 +1199,7 @@ export function buildGatewaySessionRow(params: { startedAt: subagentRun ? subagentStartedAt : entry?.startedAt, endedAt: subagentRun ? subagentEndedAt : entry?.endedAt, runtimeMs: subagentRun ? subagentRuntimeMs : entry?.runtimeMs, - parentSessionKey: entry?.parentSessionKey, + parentSessionKey: subagentOwner || entry?.parentSessionKey, childSessions, responseUsage: entry?.responseUsage, modelProvider, @@ -1244,6 +1284,12 @@ export function listSessionsFromStore(params: { if (key === "unknown" || key === "global") { return false; } + const latest = getLatestSubagentRunByChildSessionKey(key); + if (latest) { + const latestControllerSessionKey = + latest.controllerSessionKey?.trim() || latest.requesterSessionKey?.trim(); + return latestControllerSessionKey === spawnedBy; + } return entry?.spawnedBy === spawnedBy; }) .filter(([, entry]) => { diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index 39ff47f679a16..d08b11029676a 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -144,6 +144,73 @@ describe("session history HTTP endpoints", () => { } }); + test("prefers the freshest duplicate row for direct history reads", async () => { + const storePath = await createSessionStoreFile(); + const dir = path.dirname(storePath); + const staleTranscriptPath = path.join(dir, "sess-stale-main.jsonl"); + const freshTranscriptPath = path.join(dir, "sess-fresh-main.jsonl"); + await fs.writeFile( + staleTranscriptPath, + [ + JSON.stringify({ type: "session", version: 1, id: "sess-stale-main" }), + JSON.stringify({ + message: { role: "assistant", content: [{ type: "text", text: "stale history" }] }, + }), + ].join("\n"), + "utf-8", + ); + await fs.writeFile( + freshTranscriptPath, + [ + JSON.stringify({ type: "session", version: 1, id: "sess-fresh-main" }), + JSON.stringify({ + message: { role: "assistant", content: [{ type: "text", text: "fresh history" }] }, + }), + ].join("\n"), + "utf-8", + ); + await fs.writeFile( + storePath, + JSON.stringify( + { + "agent:main:main": { + sessionId: "sess-stale-main", + sessionFile: staleTranscriptPath, + updatedAt: 1, + }, + "agent:main:MAIN": { + sessionId: "sess-fresh-main", + sessionFile: freshTranscriptPath, + updatedAt: 2, + }, + }, + null, + 2, + ), + "utf-8", + ); + + const harness = await createGatewaySuiteHarness(); + try { + const res = await fetch( + `http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history`, + { + headers: AUTH_HEADER, + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + sessionKey?: string; + messages?: Array<{ content?: Array<{ text?: string }> }>; + }; + expect(body.sessionKey).toBe("agent:main:main"); + expect(body.messages?.[0]?.content?.[0]?.text).toBe("fresh history"); + } finally { + await harness.close(); + } + }); + test("supports cursor pagination over direct REST while preserving the messages field", async () => { const { storePath } = await seedSession({ text: "first message" }); const second = await appendAssistantMessageToSessionTranscript({ diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index 8e7f060c8240b..6da4d8fb2180b 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -5,18 +5,19 @@ import { loadConfig } from "../config/config.js"; import { loadSessionStore } from "../config/sessions.js"; import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; import { - sendGatewayAuthFailure, sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, } from "./http-common.js"; -import { getBearerToken, getHeader } from "./http-utils.js"; +import { getHeader } from "./http-utils.js"; import { attachOpenClawTranscriptMeta, readSessionMessages, + resolveFreshestSessionEntryFromStoreKeys, resolveGatewaySessionStoreTarget, resolveSessionTranscriptCandidates, } from "./session-utils.js"; @@ -154,23 +155,21 @@ export async function handleSessionHistoryHttpRequest( } const cfg = loadConfig(); - const token = getBearerToken(req); - const authResult = await authorizeHttpGatewayConnect({ - auth: opts.auth, - connectAuth: token ? { token, password: token } : null, + const ok = await authorizeGatewayBearerRequestOrReply({ req, + res, + auth: opts.auth, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!authResult.ok) { - sendGatewayAuthFailure(res, authResult); + if (!ok) { return true; } const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey }); const store = loadSessionStore(target.storePath); - const entry = target.storeKeys.map((key) => store[key]).find(Boolean); + const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys); if (!entry?.sessionId) { sendJson(res, 404, { ok: false, diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index 47ca47b86e398..dbb2bbf21bc95 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -48,6 +48,25 @@ export async function resolveSessionKeyFromResolveParams(params: { const target = resolveGatewaySessionStoreTarget({ cfg, key }); const store = loadSessionStore(target.storePath); if (store[target.canonicalKey]) { + if (typeof p.spawnedBy === "string" && p.spawnedBy.trim().length > 0) { + const visible = listSessionsFromStore({ + cfg, + storePath: target.storePath, + store, + opts: { + includeGlobal: p.includeGlobal === true, + includeUnknown: p.includeUnknown === true, + spawnedBy: p.spawnedBy, + agentId: p.agentId, + }, + }).sessions.some((session) => session.key === target.canonicalKey); + if (!visible) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`), + }; + } + } return { ok: true, key: target.canonicalKey }; } const legacyKey = target.storeKeys.find((candidate) => store[candidate]); @@ -63,6 +82,25 @@ export async function resolveSessionKeyFromResolveParams(params: { s[primaryKey] = s[legacyKey]; } }); + if (typeof p.spawnedBy === "string" && p.spawnedBy.trim().length > 0) { + const visible = listSessionsFromStore({ + cfg, + storePath: target.storePath, + store: loadSessionStore(target.storePath), + opts: { + includeGlobal: p.includeGlobal === true, + includeUnknown: p.includeUnknown === true, + spawnedBy: p.spawnedBy, + agentId: p.agentId, + }, + }).sessions.some((session) => session.key === target.canonicalKey); + if (!visible) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`), + }; + } + } return { ok: true, key: target.canonicalKey }; } @@ -77,8 +115,6 @@ export async function resolveSessionKeyFromResolveParams(params: { includeUnknown: p.includeUnknown === true, spawnedBy: p.spawnedBy, agentId: p.agentId, - search: sessionId, - limit: 8, }, }); const matches = list.sessions.filter( diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index 67dd4e005249d..fce94fea1cf78 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -14,10 +14,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -import { - assertHooksTokenSeparateFromGatewayAuth, - ensureGatewayStartupAuth, -} from "./startup-auth.js"; +let assertHooksTokenSeparateFromGatewayAuth: typeof import("./startup-auth.js").assertHooksTokenSeparateFromGatewayAuth; +let ensureGatewayStartupAuth: typeof import("./startup-auth.js").ensureGatewayStartupAuth; + +async function loadFreshStartupAuthModuleForTest() { + vi.resetModules(); + ({ assertHooksTokenSeparateFromGatewayAuth, ensureGatewayStartupAuth } = + await import("./startup-auth.js")); +} describe("ensureGatewayStartupAuth", () => { async function expectEphemeralGeneratedTokenWhenOverridden(cfg: OpenClawConfig) { @@ -35,9 +39,10 @@ describe("ensureGatewayStartupAuth", () => { expect(mocks.writeConfigFile).not.toHaveBeenCalled(); } - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); mocks.writeConfigFile.mockClear(); + await loadFreshStartupAuthModuleForTest(); }); async function expectNoTokenGeneration(cfg: OpenClawConfig, mode: string) { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 367be024f3664..e1f6a683a9557 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -4,6 +4,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Mock, vi } from "vitest"; +import { buildElevenLabsSpeechProvider } from "../../extensions/elevenlabs/speech-provider.ts"; +import { buildOpenAISpeechProvider } from "../../extensions/openai/speech-provider.ts"; import type { MsgContext } from "../auto-reply/templating.js"; import type { GetReplyOptions } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -148,7 +150,18 @@ const createStubPluginRegistry = (): PluginRegistry => ({ ], channelSetups: [], providers: [], - speechProviders: [], + speechProviders: [ + { + pluginId: "openai", + source: "test", + provider: buildOpenAISpeechProvider(), + }, + { + pluginId: "elevenlabs", + source: "test", + provider: buildElevenLabsSpeechProvider(), + }, + ], mediaUnderstandingProviders: [], imageGenerationProviders: [], webSearchProviders: [], @@ -310,6 +323,34 @@ export const testIsNixMode = hoisted.testIsNixMode; export const sessionStoreSaveDelayMs = hoisted.sessionStoreSaveDelayMs; export const embeddedRunMock = hoisted.embeddedRunMock; +function createEmbeddedRunMockExports() { + return { + isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), + abortEmbeddedPiRun: (sessionId: string) => { + embeddedRunMock.abortCalls.push(sessionId); + return embeddedRunMock.activeIds.has(sessionId); + }, + waitForEmbeddedPiRunEnd: async (sessionId: string) => { + embeddedRunMock.waitCalls.push(sessionId); + return embeddedRunMock.waitResults.get(sessionId) ?? true; + }, + }; +} + +async function importEmbeddedRunMockModule( + actualPath: string, + opts?: { includeActiveCount?: boolean }, +): Promise { + const actual = await vi.importActual(actualPath); + return { + ...actual, + ...createEmbeddedRunMockExports(), + ...(opts?.includeActiveCount + ? { getActiveEmbeddedRunCount: () => embeddedRunMock.activeIds.size } + : {}), + }; +} + vi.mock("../agents/pi-model-discovery.js", async () => { const actual = await vi.importActual( "../agents/pi-model-discovery.js", @@ -621,77 +662,29 @@ vi.mock("../config/config.js", async () => { }); vi.mock("../agents/pi-embedded.js", async () => { - const actual = await vi.importActual( + return await importEmbeddedRunMockModule( "../agents/pi-embedded.js", ); - return { - ...actual, - isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), - abortEmbeddedPiRun: (sessionId: string) => { - embeddedRunMock.abortCalls.push(sessionId); - return embeddedRunMock.activeIds.has(sessionId); - }, - waitForEmbeddedPiRunEnd: async (sessionId: string) => { - embeddedRunMock.waitCalls.push(sessionId); - return embeddedRunMock.waitResults.get(sessionId) ?? true; - }, - }; }); vi.mock("/src/agents/pi-embedded.js", async () => { - const actual = await vi.importActual( + return await importEmbeddedRunMockModule( "../agents/pi-embedded.js", ); - return { - ...actual, - isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), - abortEmbeddedPiRun: (sessionId: string) => { - embeddedRunMock.abortCalls.push(sessionId); - return embeddedRunMock.activeIds.has(sessionId); - }, - waitForEmbeddedPiRunEnd: async (sessionId: string) => { - embeddedRunMock.waitCalls.push(sessionId); - return embeddedRunMock.waitResults.get(sessionId) ?? true; - }, - }; }); vi.mock("../agents/pi-embedded-runner/runs.js", async () => { - const actual = await vi.importActual( + return await importEmbeddedRunMockModule( "../agents/pi-embedded-runner/runs.js", + { includeActiveCount: true }, ); - return { - ...actual, - isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), - abortEmbeddedPiRun: (sessionId: string) => { - embeddedRunMock.abortCalls.push(sessionId); - return embeddedRunMock.activeIds.has(sessionId); - }, - waitForEmbeddedPiRunEnd: async (sessionId: string) => { - embeddedRunMock.waitCalls.push(sessionId); - return embeddedRunMock.waitResults.get(sessionId) ?? true; - }, - getActiveEmbeddedRunCount: () => embeddedRunMock.activeIds.size, - }; }); vi.mock("/src/agents/pi-embedded-runner/runs.js", async () => { - const actual = await vi.importActual( + return await importEmbeddedRunMockModule( "../agents/pi-embedded-runner/runs.js", + { includeActiveCount: true }, ); - return { - ...actual, - isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), - abortEmbeddedPiRun: (sessionId: string) => { - embeddedRunMock.abortCalls.push(sessionId); - return embeddedRunMock.activeIds.has(sessionId); - }, - waitForEmbeddedPiRunEnd: async (sessionId: string) => { - embeddedRunMock.waitCalls.push(sessionId); - return embeddedRunMock.waitResults.get(sessionId) ?? true; - }, - getActiveEmbeddedRunCount: () => embeddedRunMock.activeIds.size, - }; }); vi.mock("../commands/health.js", () => ({ diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 58c30ada99a7d..d08b4dbca0cc1 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -24,7 +24,12 @@ import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { rawDataToString } from "../infra/ws.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { clearGatewaySubagentRuntime } from "../plugins/runtime/index.js"; -import { DEFAULT_AGENT_ID, toAgentStoreSessionKey } from "../routing/session-key.js"; +import { + DEFAULT_AGENT_ID, + normalizeMainKey, + parseAgentSessionKey, + toAgentStoreSessionKey, +} from "../routing/session-key.js"; import { captureEnv } from "../test-utils/env.js"; import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -74,9 +79,44 @@ const GATEWAY_TEST_ENV_KEYS = [ let gatewayEnvSnapshot: ReturnType | undefined; let tempHome: string | undefined; let tempConfigRoot: string | undefined; +let tempControlUiRoot: string | undefined; let suiteConfigRootSeq = 0; +let lastSyncedSessionStorePath: string | undefined; +let lastSyncedSessionConfigJson: string | undefined; + +function resolveGatewayTestMainSessionKeys(): string[] { + const resolved = resolveMainSessionKeyFromConfig(); + const keys = new Set(); + if (resolved) { + keys.add(resolved); + } + if (resolved !== "global") { + const parsed = parseAgentSessionKey(resolved); + const agentId = parsed?.agentId ?? DEFAULT_AGENT_ID; + keys.add(`agent:${agentId}:main`); + const configuredMainKey = normalizeMainKey( + (testState.sessionConfig as { mainKey?: unknown } | undefined)?.mainKey as string | undefined, + ); + keys.add(`agent:${agentId}:${configuredMainKey}`); + } + return [...keys]; +} + +function serializeGatewayTestSessionConfig(): string | undefined { + if (!testState.sessionConfig) { + return undefined; + } + return JSON.stringify(testState.sessionConfig); +} + +function hasUnsyncedGatewayTestSessionConfig(): boolean { + return ( + testState.sessionStorePath !== lastSyncedSessionStorePath || + serializeGatewayTestSessionConfig() !== lastSyncedSessionConfigJson + ); +} -async function persistTestSessionStorePath(storePath: string): Promise { +async function persistTestSessionConfig(): Promise { const configPaths = new Set(); if (process.env.OPENCLAW_CONFIG_PATH) { configPaths.add(process.env.OPENCLAW_CONFIG_PATH); @@ -112,20 +152,30 @@ async function persistTestSessionStorePath(storePath: string): Promise { preservedTemplateStore = existingStore; } } - const nextStoreValue = preservedTemplateStore || storePath; + const nextStoreValue = + typeof testState.sessionStorePath === "string" + ? preservedTemplateStore || testState.sessionStorePath + : preservedTemplateStore; for (const configPath of configPaths) { const config = { ...parsedConfigs.get(configPath) }; const session = config.session && typeof config.session === "object" && !Array.isArray(config.session) ? { ...(config.session as Record) } : {}; - session.store = nextStoreValue; + if (typeof nextStoreValue === "string" && nextStoreValue.trim().length > 0) { + session.store = nextStoreValue; + } + if (testState.sessionConfig) { + Object.assign(session, testState.sessionConfig); + } config.session = session; await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); } clearRuntimeConfigSnapshot(); clearConfigCache(); + lastSyncedSessionStorePath = testState.sessionStorePath; + lastSyncedSessionConfigJson = serializeGatewayTestSessionConfig(); } export async function writeSessionStore(params: { @@ -155,7 +205,7 @@ export async function writeSessionStore(params: { // Gateway suites often reuse the same store path across tests while writing the // file directly; clear the in-process cache so handlers reload the seeded state. clearSessionStoreCacheForTest(); - await persistTestSessionStorePath(storePath); + await persistTestSessionConfig(); await fs.mkdir(path.dirname(storePath), { recursive: true }); await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); clearSessionStoreCacheForTest(); @@ -222,6 +272,19 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { }); await fs.mkdir(tempConfigRoot, { recursive: true }); } + tempControlUiRoot = path.join(tempHome, ".openclaw-test-control-ui"); + await fs.rm(tempControlUiRoot, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 25, + }); + await fs.mkdir(tempControlUiRoot, { recursive: true }); + await fs.writeFile( + path.join(tempControlUiRoot, "index.html"), + "openclaw-test-control-ui\n", + "utf-8", + ); setTestConfigRoot(tempConfigRoot); clearRuntimeConfigSnapshot(); clearConfigCache(); @@ -248,6 +311,8 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { testState.bindingsConfig = undefined; testState.channelsConfig = undefined; testState.allowFrom = undefined; + lastSyncedSessionStorePath = testState.sessionStorePath; + lastSyncedSessionConfigJson = serializeGatewayTestSessionConfig(); testIsNixMode.value = false; cronIsolatedRun.mockReset(); cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "ok" }); @@ -261,7 +326,9 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { embeddedRunMock.abortCalls = []; embeddedRunMock.waitCalls = []; embeddedRunMock.waitResults.clear(); - drainSystemEvents(resolveMainSessionKeyFromConfig()); + for (const sessionKey of resolveGatewayTestMainSessionKeys()) { + drainSystemEvents(sessionKey); + } resetAgentRunContextForTest(); const mod = await getServerModule(); mod.__resetModelCatalogCacheForTest(); @@ -288,6 +355,7 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) { tempHome = undefined; } tempConfigRoot = undefined; + tempControlUiRoot = undefined; if (options.restoreEnv) { suiteConfigRootSeq = 0; } @@ -415,6 +483,17 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio const mod = await getServerModule(); const resolvedOpts = opts?.controlUiEnabled === undefined ? { ...opts, controlUiEnabled: false } : opts; + if ( + resolvedOpts?.controlUiEnabled === true && + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1" && + tempControlUiRoot && + typeof (testState.gatewayControlUi as { root?: unknown } | undefined)?.root !== "string" + ) { + testState.gatewayControlUi = { + ...testState.gatewayControlUi, + root: tempControlUiRoot, + }; + } return await mod.startGatewayServer(port, resolvedOpts); } @@ -761,7 +840,7 @@ export async function connectReq( export async function connectOk(ws: WebSocket, opts?: Parameters[1]) { const res = await connectReq(ws, opts); - expect(res.ok).toBe(true); + expect(res.ok, JSON.stringify(res)).toBe(true); expect((res.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok"); return res.payload as { type: "hello-ok" }; } @@ -810,6 +889,15 @@ export async function rpcReq>( params?: unknown, timeoutMs?: number, ) { + if (hasUnsyncedGatewayTestSessionConfig()) { + await persistTestSessionConfig(); + } + // Gateway suites often mutate testState-backed config/session inputs between + // RPCs while reusing one server instance; flush caches so the next request + // observes the updated test fixture state. + clearRuntimeConfigSnapshot(); + clearConfigCache(); + clearSessionStoreCacheForTest(); const { randomUUID } = await import("node:crypto"); const id = randomUUID(); ws.send(JSON.stringify({ type: "req", id, method, params })); @@ -833,12 +921,14 @@ export async function rpcReq>( } export async function waitForSystemEvent(timeoutMs = 2000) { - const sessionKey = resolveMainSessionKeyFromConfig(); + const sessionKeys = resolveGatewayTestMainSessionKeys(); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const events = peekSystemEvents(sessionKey); - if (events.length > 0) { - return events; + for (const sessionKey of sessionKeys) { + const events = peekSystemEvents(sessionKey); + if (events.length > 0) { + return events; + } } await new Promise((resolve) => setTimeout(resolve, 10)); } diff --git a/src/gateway/test-with-server.ts b/src/gateway/test-with-server.ts index 25872770c5612..37e52b821b30e 100644 --- a/src/gateway/test-with-server.ts +++ b/src/gateway/test-with-server.ts @@ -1,6 +1,5 @@ -import { afterAll, beforeAll } from "vitest"; -import { startServerWithClient } from "./test-helpers.js"; -import { connectOk } from "./test-helpers.js"; +import { afterAll, beforeAll, beforeEach } from "vitest"; +import { connectOk, startServerWithClient, testState } from "./test-helpers.js"; type StartServerWithClient = typeof startServerWithClient; export type GatewayWs = Awaited>["ws"]; @@ -21,9 +20,10 @@ export function installConnectedControlUiServerSuite( onReady: (started: { server: GatewayServer; ws: GatewayWs; port: number }) => void, ): void { let started: Awaited> | null = null; + const token = "secret"; beforeAll(async () => { - started = await startServerWithClient(undefined, { controlUiEnabled: true }); + started = await startServerWithClient(token, { controlUiEnabled: true }); onReady({ server: started.server, ws: started.ws, @@ -32,10 +32,16 @@ export function installConnectedControlUiServerSuite( await connectOk(started.ws); }); + beforeEach(() => { + process.env.OPENCLAW_GATEWAY_TOKEN = token; + testState.gatewayAuth = { mode: "token", token }; + }); + afterAll(async () => { started?.ws.close(); if (started?.server) { await started.server.close(); } + started?.envSnapshot.restore(); }); } diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 80b6dc3773362..a67aa3b38a38e 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -26,15 +26,15 @@ import { isSubagentSessionKey } from "../routing/session-key.js"; import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; import { readJsonBodyOrError, - sendGatewayAuthFailure, sendInvalidRequest, sendJson, sendMethodNotAllowed, } from "./http-common.js"; -import { getBearerToken, getHeader } from "./http-utils.js"; +import { getHeader } from "./http-utils.js"; const DEFAULT_BODY_BYTES = 2 * 1024 * 1024; const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]); @@ -155,17 +155,15 @@ export async function handleToolsInvokeHttpRequest( } const cfg = loadConfig(); - const token = getBearerToken(req); - const authResult = await authorizeHttpGatewayConnect({ - auth: opts.auth, - connectAuth: token ? { token, password: token } : null, + const ok = await authorizeGatewayBearerRequestOrReply({ req, + res, + auth: opts.auth, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!authResult.ok) { - sendGatewayAuthFailure(res, authResult); + if (!ok) { return true; } diff --git a/src/global-state.ts b/src/global-state.ts new file mode 100644 index 0000000000000..545783ef0a4ab --- /dev/null +++ b/src/global-state.ts @@ -0,0 +1,18 @@ +let globalVerbose = false; +let globalYes = false; + +export function setVerbose(v: boolean) { + globalVerbose = v; +} + +export function isVerbose() { + return globalVerbose; +} + +export function setYes(v: boolean) { + globalYes = v; +} + +export function isYes() { + return globalYes; +} diff --git a/src/globals.ts b/src/globals.ts index 5523c41ec7298..9cbb534219fb9 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -1,19 +1,10 @@ +export { isVerbose, isYes, setVerbose, setYes } from "./global-state.js"; +import { isVerbose } from "./global-state.js"; import { getLogger, isFileLogLevelEnabled } from "./logging/logger.js"; import { theme } from "./terminal/theme.js"; -let globalVerbose = false; -let globalYes = false; - -export function setVerbose(v: boolean) { - globalVerbose = v; -} - -export function isVerbose() { - return globalVerbose; -} - export function shouldLogVerbose() { - return globalVerbose || isFileLogLevelEnabled("debug"); + return isVerbose() || isFileLogLevelEnabled("debug"); } export function logVerbose(message: string) { @@ -25,27 +16,19 @@ export function logVerbose(message: string) { } catch { // ignore logger failures to avoid breaking verbose printing } - if (!globalVerbose) { + if (!isVerbose()) { return; } console.log(theme.muted(message)); } export function logVerboseConsole(message: string) { - if (!globalVerbose) { + if (!isVerbose()) { return; } console.log(theme.muted(message)); } -export function setYes(v: boolean) { - globalYes = v; -} - -export function isYes() { - return globalYes; -} - export const success = theme.success; export const warn = theme.warn; export const info = theme.info; diff --git a/src/image-generation/model-ref.ts b/src/image-generation/model-ref.ts new file mode 100644 index 0000000000000..d76b230996f67 --- /dev/null +++ b/src/image-generation/model-ref.ts @@ -0,0 +1,16 @@ +export function parseImageGenerationModelRef( + raw: string | undefined, +): { provider: string; model: string } | null { + const trimmed = raw?.trim(); + if (!trimmed) { + return null; + } + const slashIndex = trimmed.indexOf("/"); + if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { + return null; + } + return { + provider: trimmed.slice(0, slashIndex).trim(), + model: trimmed.slice(slashIndex + 1).trim(), + }; +} diff --git a/src/image-generation/provider-registry.test.ts b/src/image-generation/provider-registry.test.ts index 65ea49b1b05aa..2183630aaa8e8 100644 --- a/src/image-generation/provider-registry.test.ts +++ b/src/image-generation/provider-registry.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; @@ -10,7 +10,8 @@ vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins: loadOpenClawPluginsMock, })); -import { getImageGenerationProvider, listImageGenerationProviders } from "./provider-registry.js"; +let getImageGenerationProvider: typeof import("./provider-registry.js").getImageGenerationProvider; +let listImageGenerationProviders: typeof import("./provider-registry.js").listImageGenerationProviders; describe("image-generation provider registry", () => { afterEach(() => { @@ -19,6 +20,12 @@ describe("image-generation provider registry", () => { resetPluginRuntimeStateForTest(); }); + beforeEach(async () => { + vi.resetModules(); + ({ getImageGenerationProvider, listImageGenerationProviders } = + await import("./provider-registry.js")); + }); + it("does not load plugins when listing without config", () => { expect(listImageGenerationProviders()).toEqual([]); expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); diff --git a/src/image-generation/provider-registry.ts b/src/image-generation/provider-registry.ts index d91bcba6c7c54..8369644593310 100644 --- a/src/image-generation/provider-registry.ts +++ b/src/image-generation/provider-registry.ts @@ -2,10 +2,11 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; -import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { getActivePluginRegistry, getActivePluginRegistryKey } from "../plugins/runtime.js"; import type { ImageGenerationProviderPlugin } from "../plugins/types.js"; const BUILTIN_IMAGE_GENERATION_PROVIDERS: readonly ImageGenerationProviderPlugin[] = []; +const UNSAFE_PROVIDER_IDS = new Set(["__proto__", "constructor", "prototype"]); function normalizeImageGenerationProviderId(id: string | undefined): string | undefined { const normalized = normalizeProviderId(id ?? ""); @@ -15,17 +16,19 @@ function normalizeImageGenerationProviderId(id: string | undefined): string | un return normalized; } +function isSafeImageGenerationProviderId(id: string | undefined): id is string { + return Boolean(id && !UNSAFE_PROVIDER_IDS.has(id)); +} + function resolvePluginImageGenerationProviders( cfg?: OpenClawConfig, ): ImageGenerationProviderPlugin[] { const active = getActivePluginRegistry(); - const activeEntries = active?.imageGenerationProviders?.map((entry) => entry.provider) ?? []; - if (activeEntries.length > 0 || !cfg) { - return activeEntries; - } - return loadOpenClawPlugins({ config: cfg }).imageGenerationProviders.map( - (entry) => entry.provider, - ); + const registry = + (active?.imageGenerationProviders?.length ?? 0) > 0 || getActivePluginRegistryKey() || !cfg + ? active + : loadOpenClawPlugins({ config: cfg }); + return registry?.imageGenerationProviders?.map((entry) => entry.provider) ?? []; } function buildProviderMaps(cfg?: OpenClawConfig): { @@ -36,14 +39,14 @@ function buildProviderMaps(cfg?: OpenClawConfig): { const aliases = new Map(); const register = (provider: ImageGenerationProviderPlugin) => { const id = normalizeImageGenerationProviderId(provider.id); - if (!id) { + if (!isSafeImageGenerationProviderId(id)) { return; } canonical.set(id, provider); aliases.set(id, provider); for (const alias of provider.aliases ?? []) { const normalizedAlias = normalizeImageGenerationProviderId(alias); - if (normalizedAlias) { + if (isSafeImageGenerationProviderId(normalizedAlias)) { aliases.set(normalizedAlias, provider); } } diff --git a/src/image-generation/runtime.live.test.ts b/src/image-generation/runtime.live.test.ts index f0132414a6c3c..dd579ca4b7243 100644 --- a/src/image-generation/runtime.live.test.ts +++ b/src/image-generation/runtime.live.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { collectProviderApiKeys } from "../agents/live-auth-keys.js"; +import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import { resolveApiKeyForProvider } from "../agents/model-auth.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; @@ -22,7 +23,7 @@ import { } from "./live-test-helpers.js"; import { generateImage } from "./runtime.js"; -const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); +const LIVE = isLiveTestEnabled(); const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS); const describeLive = LIVE ? describe : describe.skip; diff --git a/src/image-generation/runtime.test.ts b/src/image-generation/runtime.test.ts index 9178026015262..eee02083f2c49 100644 --- a/src/image-generation/runtime.test.ts +++ b/src/image-generation/runtime.test.ts @@ -1,14 +1,24 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { generateImage, listRuntimeImageGenerationProviders } from "./runtime.js"; + +let generateImage: typeof import("./runtime.js").generateImage; +let listRuntimeImageGenerationProviders: typeof import("./runtime.js").listRuntimeImageGenerationProviders; describe("image-generation runtime helpers", () => { afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); }); + beforeEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + beforeAll(async () => { + ({ generateImage, listRuntimeImageGenerationProviders } = await import("./runtime.js")); + }); + it("generates images through the active image-generation registry", async () => { const pluginRegistry = createEmptyPluginRegistry(); const authStore = { version: 1, profiles: {} } as const; diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index 60ff86f24cebd..5b2739889667b 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -8,6 +8,7 @@ import { } from "../config/model-input.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; +import { parseImageGenerationModelRef } from "./model-ref.js"; import { getImageGenerationProvider, listImageGenerationProviders } from "./provider-registry.js"; import type { GeneratedImageAsset, @@ -39,21 +40,6 @@ export type GenerateImageRuntimeResult = { metadata?: Record; }; -function parseModelRef(raw: string | undefined): { provider: string; model: string } | null { - const trimmed = raw?.trim(); - if (!trimmed) { - return null; - } - const slashIndex = trimmed.indexOf("/"); - if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { - return null; - } - return { - provider: trimmed.slice(0, slashIndex).trim(), - model: trimmed.slice(slashIndex + 1).trim(), - }; -} - function resolveImageGenerationCandidates(params: { cfg: OpenClawConfig; modelOverride?: string; @@ -61,7 +47,7 @@ function resolveImageGenerationCandidates(params: { const candidates: Array<{ provider: string; model: string }> = []; const seen = new Set(); const add = (raw: string | undefined) => { - const parsed = parseModelRef(raw); + const parsed = parseImageGenerationModelRef(raw); if (!parsed) { return; } diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index c7f4ba5d205bf..1884774a3c2ce 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -29,16 +29,19 @@ type AgentEventState = { const AGENT_EVENT_STATE_KEY = Symbol.for("openclaw.agentEvents.state"); -const state = resolveGlobalSingleton(AGENT_EVENT_STATE_KEY, () => ({ - seqByRun: new Map(), - listeners: new Set<(evt: AgentEventPayload) => void>(), - runContextById: new Map(), -})); +function getAgentEventState(): AgentEventState { + return resolveGlobalSingleton(AGENT_EVENT_STATE_KEY, () => ({ + seqByRun: new Map(), + listeners: new Set<(evt: AgentEventPayload) => void>(), + runContextById: new Map(), + })); +} export function registerAgentRunContext(runId: string, context: AgentRunContext) { if (!runId) { return; } + const state = getAgentEventState(); const existing = state.runContextById.get(runId); if (!existing) { state.runContextById.set(runId, { ...context }); @@ -59,18 +62,19 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext) } export function getAgentRunContext(runId: string) { - return state.runContextById.get(runId); + return getAgentEventState().runContextById.get(runId); } export function clearAgentRunContext(runId: string) { - state.runContextById.delete(runId); + getAgentEventState().runContextById.delete(runId); } export function resetAgentRunContextForTest() { - state.runContextById.clear(); + getAgentEventState().runContextById.clear(); } export function emitAgentEvent(event: Omit) { + const state = getAgentEventState(); const nextSeq = (state.seqByRun.get(event.runId) ?? 0) + 1; state.seqByRun.set(event.runId, nextSeq); const context = state.runContextById.get(event.runId); @@ -88,10 +92,12 @@ export function emitAgentEvent(event: Omit) { } export function onAgentEvent(listener: (evt: AgentEventPayload) => void) { + const state = getAgentEventState(); return registerListener(state.listeners, listener); } export function resetAgentEventsForTest() { + const state = getAgentEventState(); state.seqByRun.clear(); state.listeners.clear(); state.runContextById.clear(); diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index 426d4eb514118..32600e5ad3549 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -20,6 +20,43 @@ export type GatewayBonjourBeacon = { txt?: Record; }; +export type GatewayDiscoveryResolvedEndpoint = { + host: string; + port: number; + gatewayTls: boolean; + gatewayTlsFingerprintSha256?: string; + scheme: "ws" | "wss"; + wsUrl: string; +}; + +export function resolveGatewayDiscoveryEndpoint( + beacon: GatewayBonjourBeacon, +): GatewayDiscoveryResolvedEndpoint | null { + const host = beacon.host?.trim(); + const port = beacon.port; + if (!host || typeof port !== "number" || !Number.isFinite(port) || port <= 0) { + return null; + } + const gatewayTls = beacon.gatewayTls === true; + const scheme = gatewayTls ? "wss" : "ws"; + return { + host, + port, + gatewayTls, + gatewayTlsFingerprintSha256: beacon.gatewayTlsFingerprintSha256, + scheme, + wsUrl: `${scheme}://${host}:${port}`, + }; +} + +export function pickResolvedGatewayHost(beacon: GatewayBonjourBeacon): string | null { + return resolveGatewayDiscoveryEndpoint(beacon)?.host ?? null; +} + +export function pickResolvedGatewayPort(beacon: GatewayBonjourBeacon): number | null { + return resolveGatewayDiscoveryEndpoint(beacon)?.port ?? null; +} + export type GatewayBonjourDiscoverOpts = { timeoutMs?: number; domains?: string[]; diff --git a/src/infra/build-stamp.test.ts b/src/infra/build-stamp.test.ts new file mode 100644 index 0000000000000..a03dfc8e63035 --- /dev/null +++ b/src/infra/build-stamp.test.ts @@ -0,0 +1,35 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { writeBuildStamp } from "../../scripts/build-stamp.mjs"; + +async function withTempDir(run: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-build-stamp-")); + try { + return await run(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +describe("build-stamp script", () => { + it("writes dist/.buildstamp with the current git head", async () => { + await withTempDir(async (tmp) => { + const stampPath = writeBuildStamp({ + cwd: tmp, + now: () => 1_700_000_000_000, + spawnSync: (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + return { status: 1, stdout: "" }; + }, + }); + + await expect(fs.readFile(stampPath, "utf8")).resolves.toBe( + '{"builtAt":1700000000000,"head":"abc123"}\n', + ); + }); + }); +}); diff --git a/src/infra/channel-summary.test.ts b/src/infra/channel-summary.test.ts index 01a4450a64021..04e43866230ae 100644 --- a/src/infra/channel-summary.test.ts +++ b/src/infra/channel-summary.test.ts @@ -1,12 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; -import { listChannelPlugins } from "../channels/plugins/index.js"; +import { afterEach, describe, expect, it } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { buildChannelSummary } from "./channel-summary.js"; -vi.mock("../channels/plugins/index.js", () => ({ - listChannelPlugins: vi.fn(), -})); - function makeSlackHttpSummaryPlugin(): ChannelPlugin { return { id: "slack", @@ -207,8 +204,16 @@ function makeFallbackSummaryPlugin(params: { } describe("buildChannelSummary", () => { + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + }); + it("preserves Slack HTTP signing-secret unavailable state from source config", async () => { - vi.mocked(listChannelPlugins).mockReturnValue([makeSlackHttpSummaryPlugin()]); + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "slack", plugin: makeSlackHttpSummaryPlugin(), source: "test" }, + ]), + ); const lines = await buildChannelSummary({ marker: "resolved", channels: {} } as never, { colorize: false, @@ -223,9 +228,15 @@ describe("buildChannelSummary", () => { }); it("shows disabled status without configured account detail lines", async () => { - vi.mocked(listChannelPlugins).mockReturnValue([ - makeTelegramSummaryPlugin({ enabled: false, configured: false }), - ]); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: makeTelegramSummaryPlugin({ enabled: false, configured: false }), + source: "test", + }, + ]), + ); const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, @@ -236,15 +247,21 @@ describe("buildChannelSummary", () => { }); it("includes linked summary metadata and truncates allow-from details", async () => { - vi.mocked(listChannelPlugins).mockReturnValue([ - makeTelegramSummaryPlugin({ - enabled: true, - configured: true, - linked: true, - authAgeMs: 300_000, - allowFrom: ["alice", "bob", "carol"], - }), - ]); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: makeTelegramSummaryPlugin({ + enabled: true, + configured: true, + linked: true, + authAgeMs: 300_000, + allowFrom: ["alice", "bob", "carol"], + }), + source: "test", + }, + ]), + ); const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, @@ -256,13 +273,19 @@ describe("buildChannelSummary", () => { }); it("shows not-linked status when linked metadata is explicitly false", async () => { - vi.mocked(listChannelPlugins).mockReturnValue([ - makeTelegramSummaryPlugin({ - enabled: true, - configured: true, - linked: false, - }), - ]); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: makeTelegramSummaryPlugin({ + enabled: true, + configured: true, + linked: false, + }), + source: "test", + }, + ]), + ); const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, @@ -274,9 +297,15 @@ describe("buildChannelSummary", () => { }); it("renders non-slack account detail fields for configured accounts", async () => { - vi.mocked(listChannelPlugins).mockReturnValue([ - makeSignalSummaryPlugin({ enabled: false, configured: true }), - ]); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "signal", + plugin: makeSignalSummaryPlugin({ enabled: false, configured: true }), + source: "test", + }, + ]), + ); const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, @@ -290,14 +319,20 @@ describe("buildChannelSummary", () => { }); it("uses the channel label and default account id when no accounts exist", async () => { - vi.mocked(listChannelPlugins).mockReturnValue([ - makeFallbackSummaryPlugin({ - enabled: true, - configured: true, - accountIds: [], - defaultAccountId: "fallback-account", - }), - ]); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "fallback-plugin", + plugin: makeFallbackSummaryPlugin({ + enabled: true, + configured: true, + accountIds: [], + defaultAccountId: "fallback-account", + }), + source: "test", + }, + ]), + ); const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, @@ -308,13 +343,19 @@ describe("buildChannelSummary", () => { }); it("shows not-configured status when enabled accounts exist without configured ones", async () => { - vi.mocked(listChannelPlugins).mockReturnValue([ - makeFallbackSummaryPlugin({ - enabled: true, - configured: false, - accountIds: ["fallback-account"], - }), - ]); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "fallback-plugin", + plugin: makeFallbackSummaryPlugin({ + enabled: true, + configured: false, + accountIds: ["fallback-account"], + }), + source: "test", + }, + ]), + ); const lines = await buildChannelSummary({ channels: {} } as never, { colorize: false, diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 6c92a7d342ca6..ef591ca81070b 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -1,12 +1,34 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { parseClawHubPluginSpec, + resolveClawHubAuthToken, + searchClawHubSkills, resolveLatestVersionFromPackage, satisfiesGatewayMinimum, satisfiesPluginApiRange, } from "./clawhub.js"; describe("clawhub helpers", () => { + const originalHome = process.env.HOME; + + afterEach(() => { + delete process.env.OPENCLAW_CLAWHUB_TOKEN; + delete process.env.CLAWHUB_TOKEN; + delete process.env.CLAWHUB_AUTH_TOKEN; + delete process.env.OPENCLAW_CLAWHUB_CONFIG_PATH; + delete process.env.CLAWHUB_CONFIG_PATH; + delete process.env.CLAWDHUB_CONFIG_PATH; + delete process.env.XDG_CONFIG_HOME; + if (originalHome == null) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + }); + it("parses explicit ClawHub package specs", () => { expect(parseClawHubPluginSpec("clawhub:demo")).toEqual({ name: "demo", @@ -55,6 +77,8 @@ describe("clawhub helpers", () => { expect(satisfiesPluginApiRange("1.9.0", ">=1.2.0 <2.0.0")).toBe(true); expect(satisfiesPluginApiRange("2.0.0", "^1.2.0")).toBe(false); expect(satisfiesPluginApiRange("1.1.9", ">=1.2.0")).toBe(false); + expect(satisfiesPluginApiRange("2026.3.22", ">=2026.3.22")).toBe(true); + expect(satisfiesPluginApiRange("2026.3.21", ">=2026.3.22")).toBe(false); expect(satisfiesPluginApiRange("invalid", "^1.2.0")).toBe(false); }); @@ -64,4 +88,80 @@ describe("clawhub helpers", () => { expect(satisfiesGatewayMinimum("2026.2.9", "2026.3.0")).toBe(false); expect(satisfiesGatewayMinimum("unknown", "2026.3.0")).toBe(false); }); + + it("resolves ClawHub auth token from config.json", async () => { + const configRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-config-")); + const configPath = path.join(configRoot, "clawhub", "config.json"); + process.env.OPENCLAW_CLAWHUB_CONFIG_PATH = configPath; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ auth: { token: "cfg-token-123" } }), "utf8"); + + await expect(resolveClawHubAuthToken()).resolves.toBe("cfg-token-123"); + }); + + it("resolves ClawHub auth token from the legacy config path override", async () => { + const configRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawdhub-config-")); + const configPath = path.join(configRoot, "config.json"); + process.env.CLAWDHUB_CONFIG_PATH = configPath; + await fs.writeFile(configPath, JSON.stringify({ token: "legacy-token-123" }), "utf8"); + + await expect(resolveClawHubAuthToken()).resolves.toBe("legacy-token-123"); + }); + + it.runIf(process.platform === "darwin")( + "resolves ClawHub auth token from the macOS Application Support path", + async () => { + const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-home-")); + const configPath = path.join( + fakeHome, + "Library", + "Application Support", + "clawhub", + "config.json", + ); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + try { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ token: "macos-token-123" }), "utf8"); + + await expect(resolveClawHubAuthToken()).resolves.toBe("macos-token-123"); + } finally { + homedirSpy.mockRestore(); + } + }, + ); + + it.runIf(process.platform === "darwin")( + "falls back to XDG_CONFIG_HOME on macOS when Application Support has no config", + async () => { + const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-home-")); + const xdgRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-xdg-")); + const configPath = path.join(xdgRoot, "clawhub", "config.json"); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + process.env.XDG_CONFIG_HOME = xdgRoot; + try { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ token: "xdg-token-123" }), "utf8"); + + await expect(resolveClawHubAuthToken()).resolves.toBe("xdg-token-123"); + } finally { + homedirSpy.mockRestore(); + } + }, + ); + + it("injects resolved auth token into ClawHub requests", async () => { + process.env.OPENCLAW_CLAWHUB_TOKEN = "env-token-123"; + const fetchImpl = async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + expect(url).toContain("/api/v1/search"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer env-token-123"); + return new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + await expect(searchClawHubSkills({ query: "calendar", fetchImpl })).resolves.toEqual([]); + }); }); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 2705f925d3f61..902c561f81bde 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -3,12 +3,18 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { isAtLeast, parseSemver } from "./runtime-guard.js"; +import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js"; const DEFAULT_CLAWHUB_URL = "https://clawhub.ai"; const DEFAULT_FETCH_TIMEOUT_MS = 30_000; export type ClawHubPackageFamily = "skill" | "code-plugin" | "bundle-plugin"; export type ClawHubPackageChannel = "official" | "community" | "private"; +export type ClawHubPackageCompatibility = { + pluginApiRange?: string; + builtWithOpenClawVersion?: string; + minGatewayVersion?: string; +}; export type ClawHubPackageListItem = { name: string; @@ -31,11 +37,7 @@ export type ClawHubPackageDetail = { package: | (ClawHubPackageListItem & { tags?: Record; - compatibility?: { - pluginApiRange?: string; - builtWithOpenClawVersion?: string; - minGatewayVersion?: string; - } | null; + compatibility?: ClawHubPackageCompatibility | null; capabilities?: { executesCode?: boolean; runtimeId?: string; @@ -78,11 +80,7 @@ export type ClawHubPackageVersion = { changelog: string; distTags?: string[]; files?: unknown; - compatibility?: ClawHubPackageDetail["package"] extends infer T - ? T extends { compatibility?: infer C } - ? C - : never - : never; + compatibility?: ClawHubPackageCompatibility | null; capabilities?: ClawHubPackageDetail["package"] extends infer T ? T extends { capabilities?: infer C } ? C @@ -163,13 +161,6 @@ export type ClawHubDownloadResult = { type FetchLike = typeof fetch; -type ComparableSemver = { - major: number; - minor: number; - patch: number; - prerelease: string[] | null; -}; - type ClawHubRequestParams = { baseUrl?: string; path: string; @@ -179,6 +170,17 @@ type ClawHubRequestParams = { fetchImpl?: FetchLike; }; +type ClawHubConfigLike = { + token?: unknown; + accessToken?: unknown; + authToken?: unknown; + apiToken?: unknown; + auth?: ClawHubConfigLike | null; + session?: ClawHubConfigLike | null; + credentials?: ClawHubConfigLike | null; + user?: ClawHubConfigLike | null; +}; + export class ClawHubRequestError extends Error { readonly status: number; readonly requestPath: string; @@ -202,90 +204,76 @@ function normalizeBaseUrl(baseUrl?: string): string { return value || DEFAULT_CLAWHUB_URL; } -function parseComparableSemver(version: string | null | undefined): ComparableSemver | null { - if (!version) { - return null; - } - const normalized = version.trim(); - const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( - normalized, +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function extractTokenFromClawHubConfig(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as ClawHubConfigLike; + return ( + readNonEmptyString(record.accessToken) ?? + readNonEmptyString(record.authToken) ?? + readNonEmptyString(record.apiToken) ?? + readNonEmptyString(record.token) ?? + extractTokenFromClawHubConfig(record.auth) ?? + extractTokenFromClawHubConfig(record.session) ?? + extractTokenFromClawHubConfig(record.credentials) ?? + extractTokenFromClawHubConfig(record.user) ); - if (!match) { - return null; - } - const [, major, minor, patch, prereleaseRaw] = match; - if (!major || !minor || !patch) { - return null; - } - return { - major: Number.parseInt(major, 10), - minor: Number.parseInt(minor, 10), - patch: Number.parseInt(patch, 10), - prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null, - }; } -function comparePrerelease(a: string[] | null, b: string[] | null): number { - if (!a?.length && !b?.length) { - return 0; - } - if (!a?.length) { - return 1; +function resolveClawHubConfigPaths(): string[] { + const explicit = + process.env.OPENCLAW_CLAWHUB_CONFIG_PATH?.trim() || + process.env.CLAWHUB_CONFIG_PATH?.trim() || + process.env.CLAWDHUB_CONFIG_PATH?.trim(); // legacy misspelling from older clawhub CLI builds; keep for back-compat + if (explicit) { + return [explicit]; } - if (!b?.length) { - return -1; + + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); + const configHome = + xdgConfigHome && xdgConfigHome.length > 0 ? xdgConfigHome : path.join(os.homedir(), ".config"); + const xdgPath = path.join(configHome, "clawhub", "config.json"); + + if (process.platform === "darwin") { + return [ + path.join(os.homedir(), "Library", "Application Support", "clawhub", "config.json"), + xdgPath, + ]; } - const max = Math.max(a.length, b.length); - for (let i = 0; i < max; i += 1) { - const ai = a[i]; - const bi = b[i]; - if (ai == null && bi == null) { - return 0; - } - if (ai == null) { - return -1; - } - if (bi == null) { - return 1; - } - const aNum = /^[0-9]+$/.test(ai) ? Number.parseInt(ai, 10) : null; - const bNum = /^[0-9]+$/.test(bi) ? Number.parseInt(bi, 10) : null; - if (aNum != null && bNum != null) { - if (aNum !== bNum) { - return aNum < bNum ? -1 : 1; + return [xdgPath]; +} + +export async function resolveClawHubAuthToken(): Promise { + const envToken = + process.env.OPENCLAW_CLAWHUB_TOKEN?.trim() || + process.env.CLAWHUB_TOKEN?.trim() || + process.env.CLAWHUB_AUTH_TOKEN?.trim(); + if (envToken) { + return envToken; + } + + for (const configPath of resolveClawHubConfigPaths()) { + try { + const raw = await fs.readFile(configPath, "utf8"); + const token = extractTokenFromClawHubConfig(JSON.parse(raw)); + if (token) { + return token; } - continue; - } - if (aNum != null) { - return -1; - } - if (bNum != null) { - return 1; - } - if (ai !== bi) { - return ai < bi ? -1 : 1; + } catch { + // Try the next candidate path. } } - return 0; + return undefined; } function compareSemver(left: string, right: string): number | null { - const a = parseComparableSemver(left); - const b = parseComparableSemver(right); - if (!a || !b) { - return null; - } - if (a.major !== b.major) { - return a.major < b.major ? -1 : 1; - } - if (a.minor !== b.minor) { - return a.minor < b.minor ? -1 : 1; - } - if (a.patch !== b.patch) { - return a.patch < b.patch ? -1 : 1; - } - return comparePrerelease(a.prerelease, b.prerelease); + return compareComparableSemver(parseComparableSemver(left), parseComparableSemver(right)); } function upperBoundForCaret(version: string): string | null { @@ -370,6 +358,7 @@ async function clawhubRequest( params: ClawHubRequestParams, ): Promise<{ response: Response; url: URL }> { const url = buildUrl(params); + const token = params.token?.trim() || (await resolveClawHubAuthToken()); const controller = new AbortController(); const timeout = setTimeout( () => @@ -382,7 +371,7 @@ async function clawhubRequest( ); try { const response = await (params.fetchImpl ?? fetch)(url, { - headers: params.token ? { Authorization: `Bearer ${params.token}` } : undefined, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, signal: controller.signal, }); return { response, url }; diff --git a/src/infra/cli-root-options.test.ts b/src/infra/cli-root-options.test.ts index 6d7461a39e5b2..b9987bb9ce9e3 100644 --- a/src/infra/cli-root-options.test.ts +++ b/src/infra/cli-root-options.test.ts @@ -22,7 +22,9 @@ describe("consumeRootOptionToken", () => { { args: ["--dev"], index: 0, expected: 1 }, { args: ["--profile=work"], index: 0, expected: 1 }, { args: ["--log-level=debug"], index: 0, expected: 1 }, + { args: ["--container=openclaw-demo"], index: 0, expected: 1 }, { args: ["--profile", "work"], index: 0, expected: 2 }, + { args: ["--container", "openclaw-demo"], index: 0, expected: 2 }, { args: ["--profile", "-1"], index: 0, expected: 2 }, { args: ["--log-level", "-1.5"], index: 0, expected: 2 }, { args: ["--profile", "--no-color"], index: 0, expected: 1 }, diff --git a/src/infra/cli-root-options.ts b/src/infra/cli-root-options.ts index 9522e114966df..f59efefb27809 100644 --- a/src/infra/cli-root-options.ts +++ b/src/infra/cli-root-options.ts @@ -1,7 +1,7 @@ export const FLAG_TERMINATOR = "--"; const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]); -const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level"]); +const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level", "--container"]); export function isValueToken(arg: string | undefined): boolean { if (!arg || arg === FLAG_TERMINATOR) { @@ -21,7 +21,11 @@ export function consumeRootOptionToken(args: ReadonlyArray, index: numbe if (ROOT_BOOLEAN_FLAGS.has(arg)) { return 1; } - if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) { + if ( + arg.startsWith("--profile=") || + arg.startsWith("--log-level=") || + arg.startsWith("--container=") + ) { return 1; } if (ROOT_VALUE_FLAGS.has(arg)) { diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index ea834d093a8fc..e21ab5329ab8c 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -214,6 +214,16 @@ describe("control UI assets helpers (fs-mocked)", () => { expect(resolveControlUiRootSync({ moduleUrl })).toBe(uiDir); }); + it("prefers packaged app Control UI assets in Contents/Resources", () => { + const execPath = abs("fixtures/OpenClaw.app/Contents/MacOS/OpenClaw"); + const bundledUiDir = abs("fixtures/OpenClaw.app/Contents/Resources/control-ui"); + setFile(path.join(bundledUiDir, "index.html"), "\n"); + + state.realpaths.set(execPath, execPath); + + expect(resolveControlUiRootSync({ execPath })).toBe(bundledUiDir); + }); + it("resolves control-ui root for symlinked argv1 via realpath", () => { const pkgRoot = abs("fixtures/bun-global/openclaw"); const wrapperArgv1 = abs("fixtures/bin/openclaw"); diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index 90cdd7c31a207..c9f05ff1c3429 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -215,7 +215,8 @@ export function resolveControlUiRootSync(opts: ControlUiRootResolveOptions = {}) cwd, }); - // Packaged app: control-ui lives alongside the executable. + // Packaged app: prefer bundled resources, then support legacy alongside-executable layout. + addCandidate(candidates, execDir ? path.join(execDir, "../Resources/control-ui") : null); addCandidate(candidates, execDir ? path.join(execDir, "control-ui") : null); if (moduleDir) { // dist/.js -> dist/control-ui diff --git a/src/infra/detect-binary.ts b/src/infra/detect-binary.ts new file mode 100644 index 0000000000000..94b47cbbe448c --- /dev/null +++ b/src/infra/detect-binary.ts @@ -0,0 +1,36 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { resolveUserPath } from "../utils.js"; +import { isSafeExecutableValue } from "./exec-safety.js"; + +export async function detectBinary(name: string): Promise { + if (!name?.trim()) { + return false; + } + if (!isSafeExecutableValue(name)) { + return false; + } + const resolved = name.startsWith("~") ? resolveUserPath(name) : name; + if ( + path.isAbsolute(resolved) || + resolved.startsWith(".") || + resolved.includes("/") || + resolved.includes("\\") + ) { + try { + await fs.access(resolved); + return true; + } catch { + return false; + } + } + + const command = process.platform === "win32" ? ["where", name] : ["/usr/bin/env", "which", name]; + try { + const result = await runCommandWithTimeout(command, { timeoutMs: 2000 }); + return result.code === 0 && result.stdout.trim().length > 0; + } catch { + return false; + } +} diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 6136074d4b2a4..0f0ad503d351d 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -26,8 +26,8 @@ async function verifyBootstrapToken( token, deviceId: "device-123", publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], + role: "node", + scopes: [], baseDir, ...overrides, }); @@ -52,12 +52,21 @@ describe("device bootstrap tokens", () => { const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); const parsed = JSON.parse(raw) as Record< string, - { token: string; ts: number; issuedAtMs: number } + { + token: string; + ts: number; + issuedAtMs: number; + profile: { roles: string[]; scopes: string[] }; + } >; expect(parsed[issued.token]).toMatchObject({ token: issued.token, ts: Date.now(), issuedAtMs: Date.now(), + profile: { + roles: ["node"], + scopes: [], + }, }); }); @@ -124,6 +133,10 @@ describe("device bootstrap tokens", () => { token: issued.token, ts: issuedAtMs, issuedAtMs, + profile: { + roles: ["node"], + scopes: [], + }, }, }, null, @@ -151,6 +164,49 @@ describe("device bootstrap tokens", () => { expect(raw).toContain(issued.token); }); + it("rejects bootstrap verification when role or scopes exceed the issued profile", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.admin"], + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + + const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); + expect(raw).toContain(issued.token); + }); + + it("supports explicitly bound bootstrap profiles", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ + baseDir, + profile: { + roles: [" operator ", "operator"], + scopes: ["operator.read", " operator.read "], + }, + }); + + const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); + const parsed = JSON.parse(raw) as Record< + string, + { profile: { roles: string[]; scopes: string[] } } + >; + expect(parsed[issued.token]?.profile).toEqual({ + roles: ["operator"], + scopes: ["operator.read"], + }); + + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.read"], + }), + ).resolves.toEqual({ ok: true }); + }); + it("accepts trimmed bootstrap tokens and still consumes them once", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); @@ -176,8 +232,8 @@ describe("device bootstrap tokens", () => { token: "missing-token", deviceId: "device-123", publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], + role: "node", + scopes: [], baseDir, }), ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); @@ -200,7 +256,7 @@ describe("device bootstrap tokens", () => { expect(parsed[issued.token]?.token).toBe(issued.token); }); - it("accepts legacy records that only stored issuedAtMs and prunes expired tokens", async () => { + it("fails closed for unbound legacy records and prunes expired tokens", async () => { vi.useFakeTimers(); const baseDir = await createTempDir(); const bootstrapPath = resolveBootstrapPath(baseDir); @@ -226,7 +282,10 @@ describe("device bootstrap tokens", () => { "utf8", ); - await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({ ok: false, diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 6a38c16d1ea1d..d430700131475 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -1,4 +1,11 @@ import path from "node:path"; +import { + normalizeDeviceBootstrapProfile, + PAIRING_SETUP_BOOTSTRAP_PROFILE, + sameDeviceBootstrapProfile, + type DeviceBootstrapProfile, + type DeviceBootstrapProfileInput, +} from "../shared/device-bootstrap-profile.js"; import { resolvePairingPaths } from "./pairing-files.js"; import { createAsyncLock, @@ -15,6 +22,7 @@ export type DeviceBootstrapTokenRecord = { ts: number; deviceId?: string; publicKey?: string; + profile?: DeviceBootstrapProfile; roles?: string[]; scopes?: string[]; issuedAtMs: number; @@ -29,6 +37,29 @@ function resolveBootstrapPath(baseDir?: string): string { return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json"); } +function resolvePersistedBootstrapProfile( + record: Partial, +): DeviceBootstrapProfile { + return normalizeDeviceBootstrapProfile(record.profile ?? record); +} + +function resolveIssuedBootstrapProfile(params: { + profile?: DeviceBootstrapProfileInput; + roles?: readonly string[]; + scopes?: readonly string[]; +}): DeviceBootstrapProfile { + if (params.profile) { + return normalizeDeviceBootstrapProfile(params.profile); + } + if (params.roles || params.scopes) { + return normalizeDeviceBootstrapProfile({ + roles: params.roles, + scopes: params.scopes, + }); + } + return PAIRING_SETUP_BOOTSTRAP_PROFILE; +} + async function loadState(baseDir?: string): Promise { const bootstrapPath = resolveBootstrapPath(baseDir); const rawState = (await readJsonFile(bootstrapPath)) ?? {}; @@ -44,11 +75,15 @@ async function loadState(baseDir?: string): Promise { const token = typeof record.token === "string" && record.token.trim().length > 0 ? record.token : tokenKey; const issuedAtMs = typeof record.issuedAtMs === "number" ? record.issuedAtMs : 0; + const profile = resolvePersistedBootstrapProfile(record); state[tokenKey] = { - ...record, token, + profile, + deviceId: typeof record.deviceId === "string" ? record.deviceId : undefined, + publicKey: typeof record.publicKey === "string" ? record.publicKey : undefined, issuedAtMs, ts: typeof record.ts === "number" ? record.ts : issuedAtMs, + lastUsedAtMs: typeof record.lastUsedAtMs === "number" ? record.lastUsedAtMs : undefined, }; } pruneExpiredPending(state, Date.now(), DEVICE_BOOTSTRAP_TOKEN_TTL_MS); @@ -63,15 +98,20 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string): export async function issueDeviceBootstrapToken( params: { baseDir?: string; + profile?: DeviceBootstrapProfileInput; + roles?: readonly string[]; + scopes?: readonly string[]; } = {}, ): Promise<{ token: string; expiresAtMs: number }> { return await withLock(async () => { const state = await loadState(params.baseDir); const token = generatePairingToken(); const issuedAtMs = Date.now(); + const profile = resolveIssuedBootstrapProfile(params); state[token] = { token, ts: issuedAtMs, + profile, issuedAtMs, }; await persistState(state, params.baseDir); @@ -134,7 +174,7 @@ export async function verifyDeviceBootstrapToken(params: { if (!found) { return { ok: false, reason: "bootstrap_token_invalid" }; } - const [tokenKey] = found; + const [tokenKey, record] = found; const deviceId = params.deviceId.trim(); const publicKey = params.publicKey.trim(); @@ -142,6 +182,19 @@ export async function verifyDeviceBootstrapToken(params: { if (!deviceId || !publicKey || !role) { return { ok: false, reason: "bootstrap_token_invalid" }; } + const requestedProfile = normalizeDeviceBootstrapProfile({ + roles: [role], + scopes: params.scopes, + }); + const allowedProfile = resolvePersistedBootstrapProfile(record); + // Fail closed for unbound legacy setup codes and for any attempt to redeem + // the token outside the exact role/scope profile it was issued for. + if ( + allowedProfile.roles.length === 0 || + !sameDeviceBootstrapProfile(requestedProfile, allowedProfile) + ) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } // Bootstrap setup codes are single-use. Consume the record before returning // success so the same token cannot be replayed to mutate a pending request. diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 34cb3f1ecf3d8..b0eeda1984558 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -196,7 +196,11 @@ describe("device pairing tokens", () => { test("rejects bootstrap token replay before pending scope escalation can be approved", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); - const issued = await issueDeviceBootstrapToken({ baseDir }); + const issued = await issueDeviceBootstrapToken({ + baseDir, + roles: ["operator"], + scopes: ["operator.read"], + }); await expect( verifyDeviceBootstrapToken({ @@ -290,7 +294,11 @@ describe("device pairing tokens", () => { const paired = await getPairedDevice("device-1", baseDir); expect(paired?.scopes).toEqual(["operator.admin"]); expect(paired?.approvedScopes).toEqual(["operator.admin"]); - expect(paired?.tokens?.operator?.scopes).toEqual(["operator.admin"]); + expect(paired?.tokens?.operator?.scopes).toEqual([ + "operator.admin", + "operator.read", + "operator.write", + ]); }); test("rejects scope escalation when rotating a token and leaves state unchanged", async () => { diff --git a/src/infra/diagnostic-events.ts b/src/infra/diagnostic-events.ts index 4379f975a3b8b..5acf0483a8f0e 100644 --- a/src/infra/diagnostic-events.ts +++ b/src/infra/diagnostic-events.ts @@ -1,6 +1,4 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveGlobalSingleton } from "../shared/global-singleton.js"; -import { notifyListeners, registerListener } from "../shared/listeners.js"; export type DiagnosticSessionState = "idle" | "processing" | "waiting"; @@ -176,22 +174,26 @@ type DiagnosticEventsGlobalState = { dispatchDepth: number; }; -const DIAGNOSTIC_EVENTS_STATE_KEY = Symbol.for("openclaw.diagnosticEvents.state"); - -const state = resolveGlobalSingleton( - DIAGNOSTIC_EVENTS_STATE_KEY, - () => ({ - seq: 0, - listeners: new Set<(evt: DiagnosticEventPayload) => void>(), - dispatchDepth: 0, - }), -); +function getDiagnosticEventsState(): DiagnosticEventsGlobalState { + const globalStore = globalThis as typeof globalThis & { + __openclawDiagnosticEventsState?: DiagnosticEventsGlobalState; + }; + if (!globalStore.__openclawDiagnosticEventsState) { + globalStore.__openclawDiagnosticEventsState = { + seq: 0, + listeners: new Set<(evt: DiagnosticEventPayload) => void>(), + dispatchDepth: 0, + }; + } + return globalStore.__openclawDiagnosticEventsState; +} export function isDiagnosticsEnabled(config?: OpenClawConfig): boolean { return config?.diagnostics?.enabled === true; } export function emitDiagnosticEvent(event: DiagnosticEventInput) { + const state = getDiagnosticEventsState(); if (state.dispatchDepth > 100) { console.error( `[diagnostic-events] recursion guard tripped at depth=${state.dispatchDepth}, dropping type=${event.type}`, @@ -205,26 +207,35 @@ export function emitDiagnosticEvent(event: DiagnosticEventInput) { ts: Date.now(), } satisfies DiagnosticEventPayload; state.dispatchDepth += 1; - notifyListeners(state.listeners, enriched, (err) => { - const errorMessage = - err instanceof Error - ? (err.stack ?? err.message) - : typeof err === "string" - ? err - : String(err); - console.error( - `[diagnostic-events] listener error type=${enriched.type} seq=${enriched.seq}: ${errorMessage}`, - ); - // Ignore listener failures. - }); + for (const listener of state.listeners) { + try { + listener(enriched); + } catch (err) { + const errorMessage = + err instanceof Error + ? (err.stack ?? err.message) + : typeof err === "string" + ? err + : String(err); + console.error( + `[diagnostic-events] listener error type=${enriched.type} seq=${enriched.seq}: ${errorMessage}`, + ); + // Ignore listener failures. + } + } state.dispatchDepth -= 1; } export function onDiagnosticEvent(listener: (evt: DiagnosticEventPayload) => void): () => void { - return registerListener(state.listeners, listener); + const state = getDiagnosticEventsState(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; } export function resetDiagnosticEventsForTest(): void { + const state = getDiagnosticEventsState(); state.seq = 0; state.listeners.clear(); state.dispatchDepth = 0; diff --git a/src/infra/dispatch-wrapper-resolution.ts b/src/infra/dispatch-wrapper-resolution.ts new file mode 100644 index 0000000000000..fe7eaa11c28f6 --- /dev/null +++ b/src/infra/dispatch-wrapper-resolution.ts @@ -0,0 +1,436 @@ +import { normalizeExecutableToken } from "./exec-wrapper-tokens.js"; + +export const MAX_DISPATCH_WRAPPER_DEPTH = 4; + +const ENV_OPTIONS_WITH_VALUE = new Set([ + "-u", + "--unset", + "-c", + "--chdir", + "-s", + "--split-string", + "--default-signal", + "--ignore-signal", + "--block-signal", +]); +const ENV_INLINE_VALUE_PREFIXES = [ + "-u", + "-c", + "-s", + "--unset=", + "--chdir=", + "--split-string=", + "--default-signal=", + "--ignore-signal=", + "--block-signal=", +] as const; +const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]); +const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]); +const STDBUF_OPTIONS_WITH_VALUE = new Set(["-i", "--input", "-o", "--output", "-e", "--error"]); +const TIME_FLAG_OPTIONS = new Set([ + "-a", + "--append", + "-h", + "--help", + "-l", + "-p", + "-q", + "--quiet", + "-v", + "--verbose", + "-V", + "--version", +]); +const TIME_OPTIONS_WITH_VALUE = new Set(["-f", "--format", "-o", "--output"]); +const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]); +const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]); + +type WrapperScanDirective = "continue" | "consume-next" | "stop" | "invalid"; + +function withWindowsExeAliases(names: readonly string[]): string[] { + const expanded = new Set(); + for (const name of names) { + expanded.add(name); + expanded.add(`${name}.exe`); + } + return Array.from(expanded); +} + +export function isEnvAssignment(token: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); +} + +function hasEnvInlineValuePrefix(lower: string): boolean { + for (const prefix of ENV_INLINE_VALUE_PREFIXES) { + if (lower.startsWith(prefix)) { + return true; + } + } + return false; +} + +function scanWrapperInvocation( + argv: string[], + params: { + separators?: ReadonlySet; + onToken: (token: string, lowerToken: string) => WrapperScanDirective; + adjustCommandIndex?: (commandIndex: number, argv: string[]) => number | null; + }, +): string[] | null { + let idx = 1; + let expectsOptionValue = false; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (expectsOptionValue) { + expectsOptionValue = false; + idx += 1; + continue; + } + if (params.separators?.has(token)) { + idx += 1; + break; + } + const directive = params.onToken(token, token.toLowerCase()); + if (directive === "stop") { + break; + } + if (directive === "invalid") { + return null; + } + if (directive === "consume-next") { + expectsOptionValue = true; + } + idx += 1; + } + if (expectsOptionValue) { + return null; + } + const commandIndex = params.adjustCommandIndex ? params.adjustCommandIndex(idx, argv) : idx; + if (commandIndex === null || commandIndex >= argv.length) { + return null; + } + return argv.slice(commandIndex); +} + +export function unwrapEnvInvocation(argv: string[]): string[] | null { + return scanWrapperInvocation(argv, { + separators: new Set(["--", "-"]), + onToken: (token, lower) => { + if (isEnvAssignment(token)) { + return "continue"; + } + if (!token.startsWith("-") || token === "-") { + return "stop"; + } + const [flag] = lower.split("=", 2); + if (ENV_FLAG_OPTIONS.has(flag)) { + return "continue"; + } + if (ENV_OPTIONS_WITH_VALUE.has(flag)) { + return lower.includes("=") ? "continue" : "consume-next"; + } + if (hasEnvInlineValuePrefix(lower)) { + return "continue"; + } + return "invalid"; + }, + }); +} + +function envInvocationUsesModifiers(argv: string[]): boolean { + let idx = 1; + let expectsOptionValue = false; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (expectsOptionValue) { + return true; + } + if (token === "--" || token === "-") { + idx += 1; + break; + } + if (isEnvAssignment(token)) { + return true; + } + if (!token.startsWith("-") || token === "-") { + break; + } + const lower = token.toLowerCase(); + const [flag] = lower.split("=", 2); + if (ENV_FLAG_OPTIONS.has(flag)) { + return true; + } + if (ENV_OPTIONS_WITH_VALUE.has(flag)) { + if (lower.includes("=")) { + return true; + } + expectsOptionValue = true; + idx += 1; + continue; + } + if (hasEnvInlineValuePrefix(lower)) { + return true; + } + return true; + } + + return false; +} + +function unwrapDashOptionInvocation( + argv: string[], + params: { + onFlag: (flag: string, lowerToken: string) => WrapperScanDirective; + adjustCommandIndex?: (commandIndex: number, argv: string[]) => number | null; + }, +): string[] | null { + return scanWrapperInvocation(argv, { + separators: new Set(["--"]), + onToken: (token, lower) => { + if (!token.startsWith("-") || token === "-") { + return "stop"; + } + const [flag] = lower.split("=", 2); + return params.onFlag(flag, lower); + }, + adjustCommandIndex: params.adjustCommandIndex, + }); +} + +function unwrapNiceInvocation(argv: string[]): string[] | null { + return unwrapDashOptionInvocation(argv, { + onFlag: (flag, lower) => { + if (/^-\d+$/.test(lower)) { + return "continue"; + } + if (NICE_OPTIONS_WITH_VALUE.has(flag)) { + return lower.includes("=") || lower !== flag ? "continue" : "consume-next"; + } + if (lower.startsWith("-n") && lower.length > 2) { + return "continue"; + } + return "invalid"; + }, + }); +} + +function unwrapNohupInvocation(argv: string[]): string[] | null { + return scanWrapperInvocation(argv, { + separators: new Set(["--"]), + onToken: (token, lower) => { + if (!token.startsWith("-") || token === "-") { + return "stop"; + } + return lower === "--help" || lower === "--version" ? "continue" : "invalid"; + }, + }); +} + +function unwrapStdbufInvocation(argv: string[]): string[] | null { + return unwrapDashOptionInvocation(argv, { + onFlag: (flag, lower) => { + if (!STDBUF_OPTIONS_WITH_VALUE.has(flag)) { + return "invalid"; + } + return lower.includes("=") ? "continue" : "consume-next"; + }, + }); +} + +function unwrapTimeInvocation(argv: string[]): string[] | null { + return unwrapDashOptionInvocation(argv, { + onFlag: (flag, lower) => { + if (TIME_FLAG_OPTIONS.has(flag)) { + return "continue"; + } + if (TIME_OPTIONS_WITH_VALUE.has(flag)) { + return lower.includes("=") ? "continue" : "consume-next"; + } + return "invalid"; + }, + }); +} + +function unwrapTimeoutInvocation(argv: string[]): string[] | null { + return unwrapDashOptionInvocation(argv, { + onFlag: (flag, lower) => { + if (TIMEOUT_FLAG_OPTIONS.has(flag)) { + return "continue"; + } + if (TIMEOUT_OPTIONS_WITH_VALUE.has(flag)) { + return lower.includes("=") ? "continue" : "consume-next"; + } + return "invalid"; + }, + adjustCommandIndex: (commandIndex, currentArgv) => { + const wrappedCommandIndex = commandIndex + 1; + return wrappedCommandIndex < currentArgv.length ? wrappedCommandIndex : null; + }, + }); +} + +type DispatchWrapperSpec = { + name: string; + unwrap?: (argv: string[]) => string[] | null; + transparentUsage?: boolean | ((argv: string[]) => boolean); +}; + +const DISPATCH_WRAPPER_SPECS: readonly DispatchWrapperSpec[] = [ + { name: "chrt" }, + { name: "doas" }, + { + name: "env", + unwrap: unwrapEnvInvocation, + transparentUsage: (argv) => !envInvocationUsesModifiers(argv), + }, + { name: "ionice" }, + { name: "nice", unwrap: unwrapNiceInvocation, transparentUsage: true }, + { name: "nohup", unwrap: unwrapNohupInvocation, transparentUsage: true }, + { name: "setsid" }, + { name: "stdbuf", unwrap: unwrapStdbufInvocation, transparentUsage: true }, + { name: "sudo" }, + { name: "taskset" }, + { name: "time", unwrap: unwrapTimeInvocation, transparentUsage: true }, + { name: "timeout", unwrap: unwrapTimeoutInvocation, transparentUsage: true }, +]; + +const DISPATCH_WRAPPER_SPEC_BY_NAME = new Map( + DISPATCH_WRAPPER_SPECS.map((spec) => [spec.name, spec] as const), +); + +export const DISPATCH_WRAPPER_EXECUTABLES = new Set( + withWindowsExeAliases(DISPATCH_WRAPPER_SPECS.map((spec) => spec.name)), +); + +export type DispatchWrapperUnwrapResult = + | { kind: "not-wrapper" } + | { kind: "blocked"; wrapper: string } + | { kind: "unwrapped"; wrapper: string; argv: string[] }; + +export type DispatchWrapperTrustPlan = { + argv: string[]; + wrappers: string[]; + policyBlocked: boolean; + blockedWrapper?: string; +}; + +function blockDispatchWrapper(wrapper: string): DispatchWrapperUnwrapResult { + return { kind: "blocked", wrapper }; +} + +function unwrapDispatchWrapper( + wrapper: string, + unwrapped: string[] | null, +): DispatchWrapperUnwrapResult { + return unwrapped + ? { kind: "unwrapped", wrapper, argv: unwrapped } + : blockDispatchWrapper(wrapper); +} + +export function isDispatchWrapperExecutable(token: string): boolean { + return DISPATCH_WRAPPER_SPEC_BY_NAME.has(normalizeExecutableToken(token)); +} + +export function unwrapKnownDispatchWrapperInvocation(argv: string[]): DispatchWrapperUnwrapResult { + const token0 = argv[0]?.trim(); + if (!token0) { + return { kind: "not-wrapper" }; + } + const wrapper = normalizeExecutableToken(token0); + const spec = DISPATCH_WRAPPER_SPEC_BY_NAME.get(wrapper); + if (!spec) { + return { kind: "not-wrapper" }; + } + return spec.unwrap + ? unwrapDispatchWrapper(wrapper, spec.unwrap(argv)) + : blockDispatchWrapper(wrapper); +} + +export function unwrapDispatchWrappersForResolution( + argv: string[], + maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, +): string[] { + const plan = resolveDispatchWrapperTrustPlan(argv, maxDepth); + return plan.argv; +} + +function isSemanticDispatchWrapperUsage(wrapper: string, argv: string[]): boolean { + const spec = DISPATCH_WRAPPER_SPEC_BY_NAME.get(wrapper); + if (!spec?.unwrap) { + return true; + } + const transparentUsage = spec.transparentUsage; + if (typeof transparentUsage === "function") { + return !transparentUsage(argv); + } + return transparentUsage !== true; +} + +function blockedDispatchWrapperPlan(params: { + argv: string[]; + wrappers: string[]; + blockedWrapper: string; +}): DispatchWrapperTrustPlan { + return { + argv: params.argv, + wrappers: params.wrappers, + policyBlocked: true, + blockedWrapper: params.blockedWrapper, + }; +} + +export function resolveDispatchWrapperTrustPlan( + argv: string[], + maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, +): DispatchWrapperTrustPlan { + let current = argv; + const wrappers: string[] = []; + for (let depth = 0; depth < maxDepth; depth += 1) { + const unwrap = unwrapKnownDispatchWrapperInvocation(current); + if (unwrap.kind === "blocked") { + return blockedDispatchWrapperPlan({ + argv: current, + wrappers, + blockedWrapper: unwrap.wrapper, + }); + } + if (unwrap.kind !== "unwrapped" || unwrap.argv.length === 0) { + break; + } + wrappers.push(unwrap.wrapper); + if (isSemanticDispatchWrapperUsage(unwrap.wrapper, current)) { + return blockedDispatchWrapperPlan({ + argv: current, + wrappers, + blockedWrapper: unwrap.wrapper, + }); + } + current = unwrap.argv; + } + if (wrappers.length >= maxDepth) { + const overflow = unwrapKnownDispatchWrapperInvocation(current); + if (overflow.kind === "blocked" || overflow.kind === "unwrapped") { + return blockedDispatchWrapperPlan({ + argv: current, + wrappers, + blockedWrapper: overflow.wrapper, + }); + } + } + return { argv: current, wrappers, policyBlocked: false }; +} + +export function hasDispatchEnvManipulation(argv: string[]): boolean { + const unwrap = unwrapKnownDispatchWrapperInvocation(argv); + return ( + unwrap.kind === "unwrapped" && unwrap.wrapper === "env" && envInvocationUsesModifiers(argv) + ); +} diff --git a/src/infra/env.ts b/src/infra/env.ts index 47c16fed1df2f..4b299bac6992e 100644 --- a/src/infra/env.ts +++ b/src/infra/env.ts @@ -1,9 +1,16 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { parseBooleanValue } from "../utils/boolean.js"; -const log = createSubsystemLogger("env"); +let log: ReturnType | null = null; const loggedEnv = new Set(); +function getLog(): ReturnType { + if (!log) { + log = createSubsystemLogger("env"); + } + return log; +} + type AcceptedEnvOption = { key: string; description: string; @@ -34,7 +41,9 @@ export function logAcceptedEnvOption(option: AcceptedEnvOption): void { return; } loggedEnv.add(option.key); - log.info(`env: ${option.key}=${formatEnvValue(rawValue, option.redact)} (${option.description})`); + getLog().info( + `env: ${option.key}=${formatEnvValue(rawValue, option.redact)} (${option.description})`, + ); } export function normalizeZaiEnv(): void { diff --git a/src/infra/exec-approval-command-display.test.ts b/src/infra/exec-approval-command-display.test.ts index 9fefeec1aedac..f89c66ccb1a11 100644 --- a/src/infra/exec-approval-command-display.test.ts +++ b/src/infra/exec-approval-command-display.test.ts @@ -8,6 +8,12 @@ describe("sanitizeExecApprovalDisplayText", () => { it("escapes unicode format characters but leaves other text intact", () => { expect(sanitizeExecApprovalDisplayText("echo hi\u200Bthere")).toBe("echo hi\\u{200B}there"); }); + + it("escapes visually blank hangul filler characters used for spoofing", () => { + expect(sanitizeExecApprovalDisplayText("date\u3164\uFFA0\u115F\u1160가")).toBe( + "date\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가", + ); + }); }); describe("resolveExecApprovalCommandDisplay", () => { diff --git a/src/infra/exec-approval-command-display.ts b/src/infra/exec-approval-command-display.ts index 9ab62e556691c..318968b565905 100644 --- a/src/infra/exec-approval-command-display.ts +++ b/src/infra/exec-approval-command-display.ts @@ -1,13 +1,14 @@ import type { ExecApprovalRequestPayload } from "./exec-approvals.js"; -const UNICODE_FORMAT_CHAR_REGEX = /\p{Cf}/gu; +// Escape invisible characters that can spoof approval prompts in common UIs. +const EXEC_APPROVAL_INVISIBLE_CHAR_REGEX = /[\p{Cf}\u115F\u1160\u3164\uFFA0]/gu; function formatCodePointEscape(char: string): string { return `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`; } export function sanitizeExecApprovalDisplayText(commandText: string): string { - return commandText.replace(UNICODE_FORMAT_CHAR_REGEX, formatCodePointEscape); + return commandText.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, formatCodePointEscape); } function normalizePreview(commandText: string, commandPreview?: string | null): string | null { diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index a0ba77ecb6b52..7f372683939e0 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -1,7 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; +import { + makeMockCommandResolution, + makeMockExecutableResolution, + makePathEnv, + makeTempDir, +} from "./exec-approvals-test-helpers.js"; import { evaluateShellAllowlist, requiresExecApproval, @@ -122,7 +127,13 @@ describe("resolveAllowAlwaysPatterns", () => { { raw: exe, argv: [exe], - resolution: { rawExecutable: exe, resolvedPath: exe, executableName: "openclaw-tool" }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: exe, + resolvedPath: exe, + executableName: "openclaw-tool", + }), + }), }, ], }); @@ -140,11 +151,13 @@ describe("resolveAllowAlwaysPatterns", () => { { raw: "/bin/zsh -lc 'whoami'", argv: ["/bin/zsh", "-lc", "whoami"], - resolution: { - rawExecutable: "/bin/zsh", - resolvedPath: "/bin/zsh", - executableName: "zsh", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "/bin/zsh", + resolvedPath: "/bin/zsh", + executableName: "zsh", + }), + }), }, ], cwd: dir, @@ -167,11 +180,13 @@ describe("resolveAllowAlwaysPatterns", () => { { raw: "/bin/zsh -lc 'whoami && ls && whoami'", argv: ["/bin/zsh", "-lc", "whoami && ls && whoami"], - resolution: { - rawExecutable: "/bin/zsh", - resolvedPath: "/bin/zsh", - executableName: "zsh", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "/bin/zsh", + resolvedPath: "/bin/zsh", + executableName: "zsh", + }), + }), }, ], cwd: dir, @@ -221,6 +236,151 @@ describe("resolveAllowAlwaysPatterns", () => { }); }); + it("persists carried executables for shell-wrapper positional argv carriers", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const touch = makeExecutable(dir, "touch"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const safeBins = resolveSafeBins(undefined); + + const { persisted } = resolvePersistedPatterns({ + command: `sh -lc '$0 "$1"' touch ${path.join(dir, "marker")}`, + dir, + env, + safeBins, + }); + expect(persisted).toEqual([touch]); + + const second = evaluateShellAllowlist({ + command: `sh -lc '$0 "$1"' touch ${path.join(dir, "second-marker")}`, + allowlist: [{ pattern: touch }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(true); + }); + + it("persists carried executables for exec -- positional argv carriers", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const touch = makeExecutable(dir, "touch"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const safeBins = resolveSafeBins(undefined); + + const { persisted } = resolvePersistedPatterns({ + command: `sh -lc 'exec -- "$0" "$1"' touch ${path.join(dir, "marker")}`, + dir, + env, + safeBins, + }); + expect(persisted).toEqual([touch]); + + const second = evaluateShellAllowlist({ + command: `sh -lc 'exec -- "$0" "$1"' touch ${path.join(dir, "second-marker")}`, + allowlist: [{ pattern: touch }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(true); + }); + + it("rejects positional argv carriers when $0 is single-quoted", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const touch = makeExecutable(dir, "touch"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const safeBins = resolveSafeBins(undefined); + const marker = path.join(dir, "marker"); + + const { persisted } = resolvePersistedPatterns({ + command: `sh -lc "'$0' "$1"" touch ${marker}`, + dir, + env, + safeBins, + }); + expect(persisted).not.toContain(touch); + + const second = evaluateShellAllowlist({ + command: `sh -lc "'$0' "$1"" touch ${marker}`, + allowlist: [{ pattern: touch }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(false); + }); + + it("rejects positional argv carriers when exec is separated from $0 by a newline", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const touch = makeExecutable(dir, "touch"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const safeBins = resolveSafeBins(undefined); + const marker = path.join(dir, "marker"); + + const { persisted } = resolvePersistedPatterns({ + command: `sh -lc "exec +$0 \\"$1\\"" touch ${marker}`, + dir, + env, + safeBins, + }); + expect(persisted).not.toContain(touch); + + const second = evaluateShellAllowlist({ + command: `sh -lc "exec +$0 \\"$1\\"" touch ${marker}`, + allowlist: [{ pattern: touch }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(false); + }); + + it("rejects positional argv carriers when inline command contains extra shell operations", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const touch = makeExecutable(dir, "touch"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const safeBins = resolveSafeBins(undefined); + const marker = path.join(dir, "marker"); + + const { persisted } = resolvePersistedPatterns({ + command: `sh -lc 'echo blocked; $0 "$1"' touch ${marker}`, + dir, + env, + safeBins, + }); + expect(persisted).not.toContain(touch); + + const second = evaluateShellAllowlist({ + command: `sh -lc 'echo blocked; $0 "$1"' touch ${marker}`, + allowlist: [{ pattern: touch }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(false); + }); + it("does not treat inline shell commands as persisted script paths", () => { if (process.platform === "win32") { return; @@ -255,11 +415,13 @@ describe("resolveAllowAlwaysPatterns", () => { { raw: "/bin/zsh -s", argv: ["/bin/zsh", "-s"], - resolution: { - rawExecutable: "/bin/zsh", - resolvedPath: "/bin/zsh", - executableName: "zsh", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "/bin/zsh", + resolvedPath: "/bin/zsh", + executableName: "zsh", + }), + }), }, ], platform: process.platform, @@ -278,11 +440,13 @@ describe("resolveAllowAlwaysPatterns", () => { { raw: "/usr/local/bin/zsh -lc whoami", argv: ["/usr/local/bin/zsh", "-lc", "whoami"], - resolution: { - rawExecutable: "/usr/local/bin/zsh", - resolvedPath: undefined, - executableName: "/usr/local/bin/zsh", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "/usr/local/bin/zsh", + resolvedPath: undefined, + executableName: "/usr/local/bin/zsh", + }), + }), }, ], cwd: dir, @@ -303,11 +467,13 @@ describe("resolveAllowAlwaysPatterns", () => { { raw: "/usr/bin/nice /bin/zsh -lc whoami", argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"], - resolution: { - rawExecutable: "/usr/bin/nice", - resolvedPath: "/usr/bin/nice", - executableName: "nice", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "/usr/bin/nice", + resolvedPath: "/usr/bin/nice", + executableName: "nice", + }), + }), }, ], cwd: dir, @@ -318,6 +484,34 @@ describe("resolveAllowAlwaysPatterns", () => { expect(patterns).not.toContain("/usr/bin/nice"); }); + it("unwraps time wrappers and persists the inner executable instead", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const whoami = makeExecutable(dir, "whoami"); + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: "/usr/bin/time -p /bin/zsh -lc whoami", + argv: ["/usr/bin/time", "-p", "/bin/zsh", "-lc", "whoami"], + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "/usr/bin/time", + resolvedPath: "/usr/bin/time", + executableName: "time", + }), + }), + }, + ], + cwd: dir, + env: makePathEnv(dir), + platform: process.platform, + }); + expect(patterns).toEqual([whoami]); + expect(patterns).not.toContain("/usr/bin/time"); + }); + it("unwraps busybox/toybox shell applets and persists inner executables", () => { if (process.platform === "win32") { return; @@ -332,11 +526,13 @@ describe("resolveAllowAlwaysPatterns", () => { { raw: `${busybox} sh -lc whoami`, argv: [busybox, "sh", "-lc", "whoami"], - resolution: { - rawExecutable: busybox, - resolvedPath: busybox, - executableName: "busybox", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: busybox, + resolvedPath: busybox, + executableName: "busybox", + }), + }), }, ], cwd: dir, @@ -358,11 +554,13 @@ describe("resolveAllowAlwaysPatterns", () => { { raw: `${busybox} sed -n 1p`, argv: [busybox, "sed", "-n", "1p"], - resolution: { - rawExecutable: busybox, - resolvedPath: busybox, - executableName: "busybox", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: busybox, + resolvedPath: busybox, + executableName: "busybox", + }), + }), }, ], cwd: dir, @@ -378,11 +576,13 @@ describe("resolveAllowAlwaysPatterns", () => { { raw: "sudo /bin/zsh -lc whoami", argv: ["sudo", "/bin/zsh", "-lc", "whoami"], - resolution: { - rawExecutable: "sudo", - resolvedPath: "/usr/bin/sudo", - executableName: "sudo", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "sudo", + resolvedPath: "/usr/bin/sudo", + executableName: "sudo", + }), + }), }, ], platform: process.platform, @@ -425,6 +625,23 @@ describe("resolveAllowAlwaysPatterns", () => { }); }); + it("prevents allow-always bypass for time wrapper chains", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const echo = makeExecutable(dir, "echo"); + makeExecutable(dir, "id"); + const env = makePathEnv(dir); + expectAllowAlwaysBypassBlocked({ + dir, + firstCommand: "/usr/bin/time -p /bin/zsh -lc 'echo warmup-ok'", + secondCommand: "/usr/bin/time -p /bin/zsh -lc 'id > marker'", + env, + persistedPattern: echo, + }); + }); + it("does not persist comment-tailed payload paths that never execute", () => { if (process.platform === "win32") { return; diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 113e959958d8e..4226ebe216235 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -3,12 +3,15 @@ import { analyzeShellCommand, isWindowsPlatform, matchAllowlist, - resolveAllowlistCandidatePath, + resolveExecutionTargetCandidatePath, + resolveExecutionTargetResolution, resolveCommandResolutionFromArgv, + resolvePolicyTargetCandidatePath, + resolvePolicyTargetResolution, splitCommandChain, type ExecCommandAnalysis, - type CommandResolution, type ExecCommandSegment, + type ExecutableResolution, } from "./exec-approvals-analysis.js"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { @@ -20,12 +23,12 @@ import { import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js"; import { extractShellWrapperInlineCommand, - isDispatchWrapperExecutable, isShellWrapperExecutable, - unwrapKnownShellMultiplexerInvocation, - unwrapKnownDispatchWrapperInvocation, + normalizeExecutableToken, } from "./exec-wrapper-resolution.js"; +import { resolveExecWrapperTrustPlan } from "./exec-wrapper-trust-plan.js"; import { expandHomePrefix } from "./home-dir.js"; +import { POSIX_INLINE_COMMAND_FLAGS, resolveInlineCommandMatch } from "./shell-inline-command.js"; function hasShellLineContinuation(command: string): boolean { return /\\(?:\r\n|\n|\r)/.test(command); @@ -50,7 +53,7 @@ export function resolveSafeBins(entries?: readonly string[] | null): Set export function isSafeBinUsage(params: { argv: string[]; - resolution: CommandResolution | null; + resolution: ExecutableResolution | null; safeBins: Set; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; @@ -115,6 +118,7 @@ type ExecAllowlistContext = { safeBins: Set; safeBinProfiles?: Readonly>; cwd?: string; + env?: NodeJS.ProcessEnv; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; skillBins?: readonly SkillBinTrustEntry[]; @@ -127,6 +131,7 @@ function pickExecAllowlistContext(params: ExecAllowlistContext): ExecAllowlistCo safeBins: params.safeBins, safeBinProfiles: params.safeBinProfiles, cwd: params.cwd, + env: params.env, platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, skillBins: params.skillBins, @@ -180,15 +185,16 @@ function isSkillAutoAllowedSegment(params: { return false; } const resolution = params.segment.resolution; - if (!resolution?.resolvedPath) { + const execution = resolveExecutionTargetResolution(resolution); + if (!execution?.resolvedPath) { return false; } - const rawExecutable = resolution.rawExecutable?.trim() ?? ""; + const rawExecutable = execution.rawExecutable?.trim() ?? ""; if (!rawExecutable || isPathScopedExecutableToken(rawExecutable)) { return false; } - const executableName = normalizeSkillBinName(resolution.executableName); - const resolvedPath = normalizeSkillBinResolvedPath(resolution.resolvedPath); + const executableName = normalizeSkillBinName(execution.executableName); + const resolvedPath = normalizeSkillBinResolvedPath(execution.resolvedPath); if (!executableName || !resolvedPath) { return false; } @@ -219,13 +225,26 @@ function evaluateSegments( : segment.argv; const allowlistSegment = effectiveArgv === segment.argv ? segment : { ...segment, argv: effectiveArgv }; - const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd); + const executableResolution = resolvePolicyTargetResolution(segment.resolution); + const candidatePath = resolvePolicyTargetCandidatePath(segment.resolution, params.cwd); const candidateResolution = - candidatePath && segment.resolution - ? { ...segment.resolution, resolvedPath: candidatePath } - : segment.resolution; + candidatePath && executableResolution + ? { ...executableResolution, resolvedPath: candidatePath } + : executableResolution; const executableMatch = matchAllowlist(params.allowlist, candidateResolution); const inlineCommand = extractShellWrapperInlineCommand(allowlistSegment.argv); + const shellPositionalArgvCandidatePath = resolveShellWrapperPositionalArgvCandidatePath({ + segment: allowlistSegment, + cwd: params.cwd, + env: params.env, + }); + const shellPositionalArgvMatch = shellPositionalArgvCandidatePath + ? matchAllowlist(params.allowlist, { + rawExecutable: shellPositionalArgvCandidatePath, + resolvedPath: shellPositionalArgvCandidatePath, + executableName: path.basename(shellPositionalArgvCandidatePath), + }) + : null; const shellScriptCandidatePath = inlineCommand === null ? resolveShellWrapperScriptCandidatePath({ @@ -240,13 +259,13 @@ function evaluateSegments( executableName: path.basename(shellScriptCandidatePath), }) : null; - const match = executableMatch ?? shellScriptMatch; + const match = executableMatch ?? shellPositionalArgvMatch ?? shellScriptMatch; if (match) { matches.push(match); } const safe = isSafeBinUsage({ argv: effectiveArgv, - resolution: segment.resolution, + resolution: resolveExecutionTargetResolution(segment.resolution), safeBins: params.safeBins, safeBinProfiles: params.safeBinProfiles, platform: params.platform, @@ -321,11 +340,8 @@ function hasSegmentExecutableMatch( segment: ExecCommandSegment, predicate: (token: string) => boolean, ): boolean { - const candidates = [ - segment.resolution?.executableName, - segment.resolution?.rawExecutable, - segment.argv[0], - ]; + const execution = resolveExecutionTargetResolution(segment.resolution); + const candidates = [execution?.executableName, execution?.rawExecutable, segment.argv[0]]; for (const candidate of candidates) { const trimmed = candidate?.trim(); if (!trimmed) { @@ -342,10 +358,6 @@ function isShellWrapperSegment(segment: ExecCommandSegment): boolean { return hasSegmentExecutableMatch(segment, isShellWrapperExecutable); } -function isDispatchWrapperSegment(segment: ExecCommandSegment): boolean { - return hasSegmentExecutableMatch(segment, isDispatchWrapperExecutable); -} - const SHELL_WRAPPER_OPTIONS_WITH_VALUE = new Set([ "-c", "--command", @@ -414,6 +426,64 @@ function resolveShellWrapperScriptCandidatePath(params: { return path.resolve(base, expanded); } +function resolveShellWrapperPositionalArgvCandidatePath(params: { + segment: ExecCommandSegment; + cwd?: string; + env?: NodeJS.ProcessEnv; +}): string | undefined { + if (!isShellWrapperSegment(params.segment)) { + return undefined; + } + + const argv = params.segment.argv; + if (!Array.isArray(argv) || argv.length < 4) { + return undefined; + } + + const wrapper = normalizeExecutableToken(argv[0] ?? ""); + if (!["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"].includes(wrapper)) { + return undefined; + } + + const inlineMatch = resolveInlineCommandMatch(argv, POSIX_INLINE_COMMAND_FLAGS, { + allowCombinedC: true, + }); + if (inlineMatch.valueTokenIndex === null || !inlineMatch.command) { + return undefined; + } + if (!isDirectShellPositionalCarrierInvocation(inlineMatch.command)) { + return undefined; + } + + const carriedExecutable = argv + .slice(inlineMatch.valueTokenIndex + 1) + .map((token) => token.trim()) + .find((token) => token.length > 0); + if (!carriedExecutable) { + return undefined; + } + + const resolution = resolveCommandResolutionFromArgv([carriedExecutable], params.cwd, params.env); + return resolveExecutionTargetCandidatePath(resolution, params.cwd); +} + +function isDirectShellPositionalCarrierInvocation(command: string): boolean { + const trimmed = command.trim(); + if (trimmed.length === 0) { + return false; + } + + // Keep carrier matching strict: only allow direct `$0` execution with positional arguments. + // This prevents payloads like `echo blocked; $0 "$1"` from satisfying allowlist checks. + const shellWhitespace = String.raw`[^\S\r\n]+`; + const positionalZero = String.raw`(?:\$(?:0|\{0\})|"\$(?:0|\{0\})")`; + const positionalArg = String.raw`(?:\$(?:[@*]|[1-9]|\{[@*1-9]\})|"\$(?:[@*]|[1-9]|\{[@*1-9]\})")`; + return new RegExp( + `^(?:exec${shellWhitespace}(?:--${shellWhitespace})?)?${positionalZero}(?:${shellWhitespace}${positionalArg})*$`, + "u", + ).test(trimmed); +} + function collectAllowAlwaysPatterns(params: { segment: ExecCommandSegment; cwd?: string; @@ -426,51 +496,41 @@ function collectAllowAlwaysPatterns(params: { return; } - const recurseWithArgv = (argv: string[]): void => { - collectAllowAlwaysPatterns({ - segment: { - raw: argv.join(" "), - argv, - resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env), - }, - cwd: params.cwd, - env: params.env, - platform: params.platform, - depth: params.depth + 1, - out: params.out, - }); - }; - - if (isDispatchWrapperSegment(params.segment)) { - const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(params.segment.argv); - if (dispatchUnwrap.kind !== "unwrapped" || dispatchUnwrap.argv.length === 0) { - return; - } - recurseWithArgv(dispatchUnwrap.argv); - return; - } - - const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(params.segment.argv); - if (shellMultiplexerUnwrap.kind === "blocked") { - return; - } - if (shellMultiplexerUnwrap.kind === "unwrapped") { - recurseWithArgv(shellMultiplexerUnwrap.argv); + const trustPlan = resolveExecWrapperTrustPlan(params.segment.argv); + if (trustPlan.policyBlocked) { return; } + const segment = + trustPlan.argv === params.segment.argv + ? params.segment + : { + raw: trustPlan.argv.join(" "), + argv: trustPlan.argv, + resolution: resolveCommandResolutionFromArgv(trustPlan.argv, params.cwd, params.env), + }; - const candidatePath = resolveAllowlistCandidatePath(params.segment.resolution, params.cwd); + const candidatePath = resolveExecutionTargetCandidatePath(segment.resolution, params.cwd); if (!candidatePath) { return; } - if (!isShellWrapperSegment(params.segment)) { + if (!trustPlan.shellWrapperExecutable) { params.out.add(candidatePath); return; } - const inlineCommand = extractShellWrapperInlineCommand(params.segment.argv); + const positionalArgvPath = resolveShellWrapperPositionalArgvCandidatePath({ + segment, + cwd: params.cwd, + env: params.env, + }); + if (positionalArgvPath) { + params.out.add(positionalArgvPath); + return; + } + const inlineCommand = + trustPlan.shellInlineCommand ?? extractShellWrapperInlineCommand(segment.argv); if (!inlineCommand) { const scriptPath = resolveShellWrapperScriptCandidatePath({ - segment: params.segment, + segment, cwd: params.cwd, }); if (scriptPath) { diff --git a/src/infra/exec-approvals-analysis.test.ts b/src/infra/exec-approvals-analysis.test.ts index f1083eaa08075..38505010a1005 100644 --- a/src/infra/exec-approvals-analysis.test.ts +++ b/src/infra/exec-approvals-analysis.test.ts @@ -7,6 +7,7 @@ import { analyzeShellCommand, buildEnforcedShellCommand, buildSafeBinsShellCommand, + resolvePlannedSegmentArgv, } from "./exec-approvals-analysis.js"; import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; @@ -70,6 +71,36 @@ describe("exec approvals shell analysis", () => { expect(res.command).toMatch(/'(?:[^']*\/)?rg' '-n' 'needle'/); expect(res.command).not.toContain("'env'"); }); + + it("keeps shell multiplexer rebuilds as coherent execution argv", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = path.join(dir, "busybox"); + fs.writeFileSync(busybox, ""); + fs.chmodSync(busybox, 0o755); + + const analysis = analyzeArgvCommand({ + argv: [busybox, "sh", "-lc", "echo hi"], + cwd: dir, + env: { PATH: `/bin:/usr/bin${path.delimiter}${process.env.PATH ?? ""}` }, + }); + expect(analysis.ok).toBe(true); + const segment = analysis.segments[0]; + if (!segment) { + throw new Error("expected first segment"); + } + + const planned = resolvePlannedSegmentArgv(segment); + expect(planned).toEqual([ + segment.resolution?.execution.resolvedRealPath ?? + segment.resolution?.execution.resolvedPath, + "-lc", + "echo hi", + ]); + expect(planned?.[0]).not.toBe(busybox); + }); }); describe("shell parsing", () => { diff --git a/src/infra/exec-approvals-analysis.ts b/src/infra/exec-approvals-analysis.ts index 8f2a6a690085a..1a169f154dba2 100644 --- a/src/infra/exec-approvals-analysis.ts +++ b/src/infra/exec-approvals-analysis.ts @@ -9,9 +9,16 @@ export { matchAllowlist, parseExecArgvToken, resolveAllowlistCandidatePath, + resolveApprovalAuditCandidatePath, resolveCommandResolution, resolveCommandResolutionFromArgv, + resolveExecutionTargetCandidatePath, + resolveExecutionTargetResolution, + resolvePolicyAllowlistCandidatePath, + resolvePolicyTargetCandidatePath, + resolvePolicyTargetResolution, type CommandResolution, + type ExecutableResolution, type ExecArgvToken, } from "./exec-command-resolution.js"; @@ -665,8 +672,9 @@ export function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[] return null; } const argv = [...baseArgv]; + const execution = segment.resolution?.execution; const resolvedExecutable = - segment.resolution?.resolvedRealPath?.trim() ?? segment.resolution?.resolvedPath?.trim() ?? ""; + execution?.resolvedRealPath?.trim() ?? execution?.resolvedPath?.trim() ?? ""; if (resolvedExecutable) { argv[0] = resolvedExecutable; } diff --git a/src/infra/exec-approvals-parity.test.ts b/src/infra/exec-approvals-parity.test.ts index 177e64c8c9920..d86c04107711c 100644 --- a/src/infra/exec-approvals-parity.test.ts +++ b/src/infra/exec-approvals-parity.test.ts @@ -31,7 +31,7 @@ describe("exec approvals wrapper resolution parity fixture", () => { for (const fixture of fixtures) { it(`matches wrapper fixture: ${fixture.id}`, () => { const resolution = resolveCommandResolutionFromArgv(fixture.argv); - expect(resolution?.rawExecutable ?? null).toBe(fixture.expectedRawExecutable); + expect(resolution?.execution.rawExecutable ?? null).toBe(fixture.expectedRawExecutable); }); } }); diff --git a/src/infra/exec-approvals-safe-bins.test.ts b/src/infra/exec-approvals-safe-bins.test.ts index e8fb40877a6ec..4306e89a68e3c 100644 --- a/src/infra/exec-approvals-safe-bins.test.ts +++ b/src/infra/exec-approvals-safe-bins.test.ts @@ -1,7 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; +import { + makeMockCommandResolution, + makeMockExecutableResolution, + makePathEnv, + makeTempDir, +} from "./exec-approvals-test-helpers.js"; import { evaluateExecAllowlist, evaluateShellAllowlist, @@ -428,11 +433,13 @@ describe("exec approvals safe bins", () => { { raw: "jq .foo", argv: ["jq", ".foo"], - resolution: { - rawExecutable: "jq", - resolvedPath: "/custom/bin/jq", - executableName: "jq", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "jq", + resolvedPath: "/custom/bin/jq", + executableName: "jq", + }), + }), }, ], }; @@ -476,7 +483,7 @@ describe("exec approvals safe bins", () => { expect(result.analysisOk).toBe(true); expect(result.allowlistSatisfied).toBe(false); expect(result.segmentSatisfiedBy).toEqual([null]); - expect(result.segments[0]?.resolution?.resolvedPath).toBe(fakeHead); + expect(result.segments[0]?.resolution?.execution.resolvedPath).toBe(fakeHead); }); it("fails closed for semantic env wrappers in allowlist mode", () => { diff --git a/src/infra/exec-approvals-test-helpers.ts b/src/infra/exec-approvals-test-helpers.ts index fb616410b35c7..e39e93c5e3ce3 100644 --- a/src/infra/exec-approvals-test-helpers.ts +++ b/src/infra/exec-approvals-test-helpers.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { CommandResolution, ExecutableResolution } from "./exec-command-resolution.js"; export function makePathEnv(binDir: string): NodeJS.ProcessEnv { if (process.platform !== "win32") { @@ -13,6 +14,56 @@ export function makeTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-")); } +export function makeMockExecutableResolution(params: { + rawExecutable: string; + executableName: string; + resolvedPath?: string; + resolvedRealPath?: string; +}): ExecutableResolution { + return { + rawExecutable: params.rawExecutable, + resolvedPath: params.resolvedPath, + resolvedRealPath: params.resolvedRealPath, + executableName: params.executableName, + }; +} + +export function makeMockCommandResolution(params: { + execution: ExecutableResolution; + policy?: ExecutableResolution; + effectiveArgv?: string[]; + wrapperChain?: string[]; + policyBlocked?: boolean; + blockedWrapper?: string; +}): CommandResolution { + const policy = params.policy ?? params.execution; + const resolution: CommandResolution = { + execution: params.execution, + policy, + effectiveArgv: params.effectiveArgv, + wrapperChain: params.wrapperChain, + policyBlocked: params.policyBlocked, + blockedWrapper: params.blockedWrapper, + }; + return Object.defineProperties(resolution, { + rawExecutable: { + get: () => params.execution.rawExecutable, + }, + resolvedPath: { + get: () => params.execution.resolvedPath, + }, + resolvedRealPath: { + get: () => params.execution.resolvedRealPath, + }, + executableName: { + get: () => params.execution.executableName, + }, + policyResolution: { + get: () => (policy === params.execution ? undefined : policy), + }, + }); +} + export type ShellParserParityFixtureCase = { id: string; command: string; diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 75cf2b115b646..c98a337b966f5 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; import { normalizeSafeBins } from "./exec-approvals-allowlist.js"; +import { + makeMockCommandResolution, + makeMockExecutableResolution, +} from "./exec-approvals-test-helpers.js"; import { evaluateExecAllowlist, type ExecAllowlistEntry } from "./exec-approvals.js"; describe("exec approvals allowlist evaluation", () => { @@ -9,11 +13,7 @@ describe("exec approvals allowlist evaluation", () => { segments: Array<{ raw: string; argv: string[]; - resolution: { - rawExecutable: string; - executableName: string; - resolvedPath?: string; - }; + resolution: ReturnType; }>; }; resolvedPath: string; @@ -40,11 +40,13 @@ describe("exec approvals allowlist evaluation", () => { { raw: "tool", argv: ["tool"], - resolution: { - rawExecutable: "tool", - resolvedPath: "/usr/bin/tool", - executableName: "tool", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "tool", + resolvedPath: "/usr/bin/tool", + executableName: "tool", + }), + }), }, ], }; @@ -66,11 +68,13 @@ describe("exec approvals allowlist evaluation", () => { { raw: "jq .foo", argv: ["jq", ".foo"], - resolution: { - rawExecutable: "jq", - resolvedPath: "/usr/bin/jq", - executableName: "jq", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "jq", + resolvedPath: "/usr/bin/jq", + executableName: "jq", + }), + }), }, ], }; @@ -96,11 +100,13 @@ describe("exec approvals allowlist evaluation", () => { { raw: "skill-bin", argv: ["skill-bin", "--help"], - resolution: { - rawExecutable: "skill-bin", - resolvedPath: "/opt/skills/skill-bin", - executableName: "skill-bin", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "skill-bin", + resolvedPath: "/opt/skills/skill-bin", + executableName: "skill-bin", + }), + }), }, ], }; @@ -118,11 +124,13 @@ describe("exec approvals allowlist evaluation", () => { { raw: "./skill-bin", argv: ["./skill-bin", "--help"], - resolution: { - rawExecutable: "./skill-bin", - resolvedPath: "/tmp/skill-bin", - executableName: "skill-bin", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "./skill-bin", + resolvedPath: "/tmp/skill-bin", + executableName: "skill-bin", + }), + }), }, ], }; @@ -140,10 +148,12 @@ describe("exec approvals allowlist evaluation", () => { { raw: "skill-bin --help", argv: ["skill-bin", "--help"], - resolution: { - rawExecutable: "skill-bin", - executableName: "skill-bin", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "skill-bin", + executableName: "skill-bin", + }), + }), }, ], }; @@ -158,11 +168,13 @@ describe("exec approvals allowlist evaluation", () => { const segment = { raw: "tool", argv: ["tool"], - resolution: { - rawExecutable: "tool", - resolvedPath: "/usr/bin/tool", - executableName: "tool", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "tool", + resolvedPath: "/usr/bin/tool", + executableName: "tool", + }), + }), }; const analysis = { ok: true, @@ -184,20 +196,24 @@ describe("exec approvals allowlist evaluation", () => { const allowlistSegment = { raw: "tool", argv: ["tool"], - resolution: { - rawExecutable: "tool", - resolvedPath: "/usr/bin/tool", - executableName: "tool", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "tool", + resolvedPath: "/usr/bin/tool", + executableName: "tool", + }), + }), }; const safeBinSegment = { raw: "jq .foo", argv: ["jq", ".foo"], - resolution: { - rawExecutable: "jq", - resolvedPath: "/usr/bin/jq", - executableName: "jq", - }, + resolution: makeMockCommandResolution({ + execution: makeMockExecutableResolution({ + rawExecutable: "jq", + resolvedPath: "/usr/bin/jq", + executableName: "jq", + }), + }), }; const analysis = { ok: true, diff --git a/src/infra/exec-command-resolution.test.ts b/src/infra/exec-command-resolution.test.ts index 4bdff0947a944..8fb67ebccc49f 100644 --- a/src/infra/exec-command-resolution.test.ts +++ b/src/infra/exec-command-resolution.test.ts @@ -4,11 +4,13 @@ import { describe, expect, it } from "vitest"; import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; import { evaluateExecAllowlist, + resolvePlannedSegmentArgv, normalizeSafeBins, parseExecArgvToken, - resolveAllowlistCandidatePath, resolveCommandResolution, resolveCommandResolutionFromArgv, + resolveExecutionTargetCandidatePath, + resolvePolicyTargetCandidatePath, } from "./exec-approvals.js"; function buildNestedEnvShellCommand(params: { @@ -118,9 +120,9 @@ describe("exec-command-resolution", () => { for (const testCase of cases) { const setup = testCase.setup(); const res = resolveCommandResolution(setup.command, setup.cwd, setup.envPath); - expect(res?.resolvedPath, testCase.name).toBe(setup.expectedPath); + expect(res?.execution.resolvedPath, testCase.name).toBe(setup.expectedPath); if (setup.expectedExecutableName) { - expect(res?.executableName, testCase.name).toBe(setup.expectedExecutableName); + expect(res?.execution.executableName, testCase.name).toBe(setup.expectedExecutableName); } } }); @@ -133,8 +135,8 @@ describe("exec-command-resolution", () => { undefined, makePathEnv(fixture.binDir), ); - expect(envResolution?.resolvedPath).toBe(fixture.exePath); - expect(envResolution?.executableName).toBe(fixture.exeName); + expect(envResolution?.execution.resolvedPath).toBe(fixture.exePath); + expect(envResolution?.execution.executableName).toBe(fixture.exeName); const niceResolution = resolveCommandResolutionFromArgv([ "/usr/bin/nice", @@ -142,8 +144,67 @@ describe("exec-command-resolution", () => { "-lc", "echo hi", ]); - expect(niceResolution?.rawExecutable).toBe("bash"); - expect(niceResolution?.executableName.toLowerCase()).toContain("bash"); + expect(niceResolution?.execution.rawExecutable).toBe("bash"); + expect(niceResolution?.execution.executableName.toLowerCase()).toContain("bash"); + + const timeResolution = resolveCommandResolutionFromArgv( + ["/usr/bin/time", "-p", "rg", "-n", "needle"], + undefined, + makePathEnv(fixture.binDir), + ); + expect(timeResolution?.execution.resolvedPath).toBe(fixture.exePath); + expect(timeResolution?.execution.executableName).toBe(fixture.exeName); + }); + + it("keeps shell multiplexer wrappers as a separate policy target", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = path.join(dir, "busybox"); + fs.writeFileSync(busybox, ""); + fs.chmodSync(busybox, 0o755); + + const resolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]); + expect(resolution?.execution.rawExecutable).toBe("sh"); + expect(resolution?.effectiveArgv).toEqual(["sh", "-lc", "echo hi"]); + expect(resolution?.wrapperChain).toEqual(["busybox"]); + expect(resolution?.policy.rawExecutable).toBe(busybox); + expect(resolution?.policy.resolvedPath).toBe(busybox); + expect(resolvePolicyTargetCandidatePath(resolution ?? null, dir)).toBe(busybox); + expect(resolution?.execution.executableName.toLowerCase()).toContain("sh"); + }); + + it("does not satisfy inner-shell allowlists when invoked through busybox wrappers", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = path.join(dir, "busybox"); + fs.writeFileSync(busybox, ""); + fs.chmodSync(busybox, 0o755); + + const shellResolution = resolveCommandResolutionFromArgv(["sh", "-lc", "echo hi"]); + expect(shellResolution?.execution.resolvedPath).toBeTruthy(); + + const wrappedResolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]); + const evalResult = evaluateExecAllowlist({ + analysis: { + ok: true, + segments: [ + { + raw: `${busybox} sh -lc echo hi`, + argv: [busybox, "sh", "-lc", "echo hi"], + resolution: wrappedResolution, + }, + ], + }, + allowlist: [{ pattern: shellResolution?.execution.resolvedPath ?? "" }], + safeBins: normalizeSafeBins([]), + cwd: dir, + }); + + expect(evalResult.allowlistSatisfied).toBe(false); }); it("blocks semantic env wrappers, env -S, and deep transparent-wrapper chains", () => { @@ -155,7 +216,7 @@ describe("exec-command-resolution", () => { "needle", ]); expect(blockedEnv?.policyBlocked).toBe(true); - expect(blockedEnv?.rawExecutable).toBe("/usr/bin/env"); + expect(blockedEnv?.execution.rawExecutable).toBe("/usr/bin/env"); if (process.platform === "win32") { return; @@ -192,7 +253,7 @@ describe("exec-command-resolution", () => { it("resolves allowlist candidate paths from unresolved raw executables", () => { expect( - resolveAllowlistCandidatePath( + resolveExecutionTargetCandidatePath( { rawExecutable: "~/bin/tool", executableName: "tool", @@ -202,7 +263,7 @@ describe("exec-command-resolution", () => { ).toContain("/bin/tool"); expect( - resolveAllowlistCandidatePath( + resolveExecutionTargetCandidatePath( { rawExecutable: "./scripts/run.sh", executableName: "run.sh", @@ -212,7 +273,7 @@ describe("exec-command-resolution", () => { ).toBe(path.resolve("/repo", "./scripts/run.sh")); expect( - resolveAllowlistCandidatePath( + resolveExecutionTargetCandidatePath( { rawExecutable: "rg", executableName: "rg", @@ -222,6 +283,102 @@ describe("exec-command-resolution", () => { ).toBeUndefined(); }); + it("keeps execution and policy targets coherent across wrapper classes", () => { + if (process.platform === "win32") { + return; + } + + const dir = makeTempDir(); + const binDir = path.join(dir, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const envPath = path.join(binDir, "env"); + const rgPath = path.join(binDir, "rg"); + const busybox = path.join(dir, "busybox"); + const resolvedShPath = fs.realpathSync("/bin/sh"); + for (const file of [envPath, rgPath, busybox]) { + fs.writeFileSync(file, ""); + fs.chmodSync(file, 0o755); + } + + const cases = [ + { + name: "transparent env wrapper", + argv: [envPath, "rg", "-n", "needle"], + env: makePathEnv(binDir), + expectedExecutionPath: rgPath, + expectedPolicyPath: rgPath, + expectedPlannedArgv: [fs.realpathSync(rgPath), "-n", "needle"], + allowlistPattern: rgPath, + allowlistSatisfied: true, + }, + { + name: "busybox shell multiplexer", + argv: [busybox, "sh", "-lc", "echo hi"], + env: { PATH: `${binDir}${path.delimiter}/bin:/usr/bin` }, + expectedExecutionPath: "/bin/sh", + expectedPolicyPath: busybox, + expectedPlannedArgv: [resolvedShPath, "-lc", "echo hi"], + allowlistPattern: busybox, + allowlistSatisfied: true, + }, + { + name: "semantic env wrapper", + argv: [envPath, "FOO=bar", "rg", "-n", "needle"], + env: makePathEnv(binDir), + expectedExecutionPath: envPath, + expectedPolicyPath: envPath, + expectedPlannedArgv: null, + allowlistPattern: envPath, + allowlistSatisfied: false, + }, + { + name: "wrapper depth overflow", + argv: buildNestedEnvShellCommand({ + envExecutable: envPath, + depth: 5, + payload: "echo hi", + }), + env: makePathEnv(binDir), + expectedExecutionPath: envPath, + expectedPolicyPath: envPath, + expectedPlannedArgv: null, + allowlistPattern: envPath, + allowlistSatisfied: false, + }, + ] as const; + + for (const testCase of cases) { + const argv = [...testCase.argv]; + const resolution = resolveCommandResolutionFromArgv(argv, dir, testCase.env); + const segment = { + raw: argv.join(" "), + argv, + resolution, + }; + expect( + resolveExecutionTargetCandidatePath(resolution ?? null, dir), + `${testCase.name} execution`, + ).toBe(testCase.expectedExecutionPath); + expect( + resolvePolicyTargetCandidatePath(resolution ?? null, dir), + `${testCase.name} policy`, + ).toBe(testCase.expectedPolicyPath); + expect(resolvePlannedSegmentArgv(segment), `${testCase.name} planned argv`).toEqual( + testCase.expectedPlannedArgv, + ); + const evaluation = evaluateExecAllowlist({ + analysis: { ok: true, segments: [segment] }, + allowlist: [{ pattern: testCase.allowlistPattern }], + safeBins: normalizeSafeBins([]), + cwd: dir, + env: testCase.env, + }); + expect(evaluation.allowlistSatisfied, `${testCase.name} allowlist`).toBe( + testCase.allowlistSatisfied, + ); + } + }); + it("normalizes argv tokens for short clusters, long options, and special sentinels", () => { expect(parseExecArgvToken("")).toEqual({ kind: "empty", raw: "" }); expect(parseExecArgvToken("--")).toEqual({ kind: "terminator", raw: "--" }); diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index 971f197c8ff50..242e904a043f1 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -2,21 +2,32 @@ import fs from "node:fs"; import path from "node:path"; import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; -import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.js"; +import { resolveExecWrapperTrustPlan } from "./exec-wrapper-trust-plan.js"; import { resolveExecutablePath as resolveExecutableCandidatePath } from "./executable-path.js"; import { expandHomePrefix } from "./home-dir.js"; -export type CommandResolution = { +export type ExecutableResolution = { rawExecutable: string; resolvedPath?: string; resolvedRealPath?: string; executableName: string; +}; + +export type CommandResolution = { + execution: ExecutableResolution; + policy: ExecutableResolution; effectiveArgv?: string[]; wrapperChain?: string[]; policyBlocked?: boolean; blockedWrapper?: string; }; +function isCommandResolution( + resolution: CommandResolution | ExecutableResolution | null, +): resolution is CommandResolution { + return Boolean(resolution && "execution" in resolution && "policy" in resolution); +} + function parseFirstToken(command: string): string | null { const trimmed = command.trim(); if (!trimmed) { @@ -45,8 +56,30 @@ function tryResolveRealpath(filePath: string | undefined): string | undefined { } } +function buildExecutableResolution( + rawExecutable: string, + params: { + cwd?: string; + env?: NodeJS.ProcessEnv; + }, +): ExecutableResolution { + const resolvedPath = resolveExecutableCandidatePath(rawExecutable, { + cwd: params.cwd, + env: params.env, + }); + const resolvedRealPath = tryResolveRealpath(resolvedPath); + const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; + return { + rawExecutable, + resolvedPath, + resolvedRealPath, + executableName, + }; +} + function buildCommandResolution(params: { rawExecutable: string; + policyRawExecutable?: string; cwd?: string; env?: NodeJS.ProcessEnv; effectiveArgv: string[]; @@ -54,22 +87,36 @@ function buildCommandResolution(params: { policyBlocked: boolean; blockedWrapper?: string; }): CommandResolution { - const resolvedPath = resolveExecutableCandidatePath(params.rawExecutable, { - cwd: params.cwd, - env: params.env, - }); - const resolvedRealPath = tryResolveRealpath(resolvedPath); - const executableName = resolvedPath ? path.basename(resolvedPath) : params.rawExecutable; - return { - rawExecutable: params.rawExecutable, - resolvedPath, - resolvedRealPath, - executableName, + const execution = buildExecutableResolution(params.rawExecutable, params); + const policy = params.policyRawExecutable + ? buildExecutableResolution(params.policyRawExecutable, params) + : execution; + const resolution: CommandResolution = { + execution, + policy, effectiveArgv: params.effectiveArgv, wrapperChain: params.wrapperChain, policyBlocked: params.policyBlocked, blockedWrapper: params.blockedWrapper, }; + // Compatibility getters for JS/tests while TS callers migrate to explicit targets. + return Object.defineProperties(resolution, { + rawExecutable: { + get: () => execution.rawExecutable, + }, + resolvedPath: { + get: () => execution.resolvedPath, + }, + resolvedRealPath: { + get: () => execution.resolvedRealPath, + }, + executableName: { + get: () => execution.executableName, + }, + policyResolution: { + get: () => (policy === execution ? undefined : policy), + }, + }); } export function resolveCommandResolution( @@ -96,7 +143,7 @@ export function resolveCommandResolutionFromArgv( cwd?: string, env?: NodeJS.ProcessEnv, ): CommandResolution | null { - const plan = resolveDispatchWrapperExecutionPlan(argv); + const plan = resolveExecWrapperTrustPlan(argv); const effectiveArgv = plan.argv; const rawExecutable = effectiveArgv[0]?.trim(); if (!rawExecutable) { @@ -104,8 +151,9 @@ export function resolveCommandResolutionFromArgv( } return buildCommandResolution({ rawExecutable, + policyRawExecutable: plan.policyArgv[0]?.trim(), effectiveArgv, - wrapperChain: plan.wrappers, + wrapperChain: plan.wrapperChain, policyBlocked: plan.policyBlocked, blockedWrapper: plan.blockedWrapper, cwd, @@ -113,8 +161,8 @@ export function resolveCommandResolutionFromArgv( }); } -export function resolveAllowlistCandidatePath( - resolution: CommandResolution | null, +function resolveExecutableCandidatePathFromResolution( + resolution: ExecutableResolution | null | undefined, cwd?: string, ): string | undefined { if (!resolution) { @@ -138,9 +186,69 @@ export function resolveAllowlistCandidatePath( return path.resolve(base, expanded); } +export function resolveExecutionTargetResolution( + resolution: CommandResolution | ExecutableResolution | null, +): ExecutableResolution | null { + if (!resolution) { + return null; + } + return isCommandResolution(resolution) ? resolution.execution : resolution; +} + +export function resolvePolicyTargetResolution( + resolution: CommandResolution | ExecutableResolution | null, +): ExecutableResolution | null { + if (!resolution) { + return null; + } + return isCommandResolution(resolution) ? resolution.policy : resolution; +} + +export function resolveExecutionTargetCandidatePath( + resolution: CommandResolution | ExecutableResolution | null, + cwd?: string, +): string | undefined { + return resolveExecutableCandidatePathFromResolution( + isCommandResolution(resolution) ? resolution.execution : resolution, + cwd, + ); +} + +export function resolvePolicyTargetCandidatePath( + resolution: CommandResolution | ExecutableResolution | null, + cwd?: string, +): string | undefined { + return resolveExecutableCandidatePathFromResolution( + isCommandResolution(resolution) ? resolution.policy : resolution, + cwd, + ); +} + +export function resolveApprovalAuditCandidatePath( + resolution: CommandResolution | null, + cwd?: string, +): string | undefined { + return resolvePolicyTargetCandidatePath(resolution, cwd); +} + +// Legacy alias kept while callers migrate to explicit target naming. +export function resolveAllowlistCandidatePath( + resolution: CommandResolution | ExecutableResolution | null, + cwd?: string, +): string | undefined { + return resolveExecutionTargetCandidatePath(resolution, cwd); +} + +export function resolvePolicyAllowlistCandidatePath( + resolution: CommandResolution | ExecutableResolution | null, + cwd?: string, +): string | undefined { + return resolvePolicyTargetCandidatePath(resolution, cwd); +} + export function matchAllowlist( entries: ExecAllowlistEntry[], - resolution: CommandResolution | null, + resolution: ExecutableResolution | null, ): ExecAllowlistEntry | null { if (!entries.length) { return null; diff --git a/src/infra/exec-wrapper-resolution.test.ts b/src/infra/exec-wrapper-resolution.test.ts index 001d0ca251487..f71ea5510322c 100644 --- a/src/infra/exec-wrapper-resolution.test.ts +++ b/src/infra/exec-wrapper-resolution.test.ts @@ -7,7 +7,7 @@ import { isDispatchWrapperExecutable, isShellWrapperExecutable, normalizeExecutableToken, - resolveDispatchWrapperExecutionPlan, + resolveDispatchWrapperTrustPlan, unwrapEnvInvocation, unwrapKnownDispatchWrapperInvocation, unwrapKnownShellMultiplexerInvocation, @@ -40,6 +40,7 @@ describe("normalizeExecutableToken", () => { describe("wrapper classification", () => { test.each([ { token: "sudo", dispatch: true, shell: false }, + { token: "time", dispatch: true, shell: false }, { token: "timeout.exe", dispatch: true, shell: false }, { token: "bash", dispatch: false, shell: true }, { token: "pwsh.exe", dispatch: false, shell: true }, @@ -106,6 +107,10 @@ describe("unwrapEnvInvocation", () => { describe("unwrapKnownDispatchWrapperInvocation", () => { test.each([ + { + argv: ["env", "--", "bash", "-lc", "echo hi"], + expected: { kind: "unwrapped", wrapper: "env", argv: ["bash", "-lc", "echo hi"] }, + }, { argv: ["nice", "-n", "5", "bash", "-lc", "echo hi"], expected: { kind: "unwrapped", wrapper: "nice", argv: ["bash", "-lc", "echo hi"] }, @@ -118,6 +123,10 @@ describe("unwrapKnownDispatchWrapperInvocation", () => { argv: ["stdbuf", "-o", "L", "bash", "-lc", "echo hi"], expected: { kind: "unwrapped", wrapper: "stdbuf", argv: ["bash", "-lc", "echo hi"] }, }, + { + argv: ["time", "-p", "bash", "-lc", "echo hi"], + expected: { kind: "unwrapped", wrapper: "time", argv: ["bash", "-lc", "echo hi"] }, + }, { argv: ["timeout", "--signal=TERM", "5s", "bash", "-lc", "echo hi"], expected: { kind: "unwrapped", wrapper: "timeout", argv: ["bash", "-lc", "echo hi"] }, @@ -133,12 +142,70 @@ describe("unwrapKnownDispatchWrapperInvocation", () => { ])("unwraps known dispatch wrappers for %j", ({ argv, expected }) => { expect(unwrapKnownDispatchWrapperInvocation(argv)).toEqual(expected); }); + + test.each(["chrt", "doas", "ionice", "setsid", "sudo", "taskset"])( + "fails closed for blocked dispatch wrapper %s", + (wrapper) => { + expect(unwrapKnownDispatchWrapperInvocation([wrapper, "bash", "-lc", "echo hi"])).toEqual({ + kind: "blocked", + wrapper, + }); + }, + ); }); -describe("resolveDispatchWrapperExecutionPlan", () => { +describe("resolveDispatchWrapperTrustPlan", () => { + test("allows non-semantic env passthrough", () => { + expect(resolveDispatchWrapperTrustPlan(["env", "--", "bash", "-lc", "echo hi"])).toEqual({ + argv: ["bash", "-lc", "echo hi"], + wrappers: ["env"], + policyBlocked: false, + }); + }); + + test.each([ + { + argv: ["nice", "-n", "5", "bash", "-lc", "echo hi"], + wrapper: "nice", + effectiveArgv: ["bash", "-lc", "echo hi"], + }, + { + argv: ["nohup", "--", "bash", "-lc", "echo hi"], + wrapper: "nohup", + effectiveArgv: ["bash", "-lc", "echo hi"], + }, + { + argv: ["stdbuf", "-o", "L", "bash", "-lc", "echo hi"], + wrapper: "stdbuf", + effectiveArgv: ["bash", "-lc", "echo hi"], + }, + { + argv: ["time", "-p", "bash", "-lc", "echo hi"], + wrapper: "time", + effectiveArgv: ["bash", "-lc", "echo hi"], + }, + { + argv: ["timeout", "--signal=TERM", "5s", "bash", "-lc", "echo hi"], + wrapper: "timeout", + effectiveArgv: ["bash", "-lc", "echo hi"], + }, + ])("keeps transparent wrapper handling in sync for %s", ({ argv, wrapper, effectiveArgv }) => { + expect(isDispatchWrapperExecutable(wrapper)).toBe(true); + expect(unwrapKnownDispatchWrapperInvocation(argv)).toEqual({ + kind: "unwrapped", + wrapper, + argv: effectiveArgv, + }); + expect(resolveDispatchWrapperTrustPlan(argv)).toEqual({ + argv: effectiveArgv, + wrappers: [wrapper], + policyBlocked: false, + }); + }); + test("unwraps transparent wrapper chains", () => { expect( - resolveDispatchWrapperExecutionPlan(["nohup", "nice", "-n", "5", "bash", "-lc", "echo hi"]), + resolveDispatchWrapperTrustPlan(["nohup", "nice", "-n", "5", "bash", "-lc", "echo hi"]), ).toEqual({ argv: ["bash", "-lc", "echo hi"], wrappers: ["nohup", "nice"], @@ -147,9 +214,7 @@ describe("resolveDispatchWrapperExecutionPlan", () => { }); test("blocks semantic env usage even when it reaches a shell wrapper", () => { - expect( - resolveDispatchWrapperExecutionPlan(["env", "FOO=bar", "bash", "-lc", "echo hi"]), - ).toEqual({ + expect(resolveDispatchWrapperTrustPlan(["env", "FOO=bar", "bash", "-lc", "echo hi"])).toEqual({ argv: ["env", "FOO=bar", "bash", "-lc", "echo hi"], wrappers: ["env"], policyBlocked: true, @@ -159,7 +224,7 @@ describe("resolveDispatchWrapperExecutionPlan", () => { test("blocks wrapper overflow beyond the configured depth", () => { expect( - resolveDispatchWrapperExecutionPlan(["nohup", "timeout", "5s", "bash", "-lc", "echo hi"], 1), + resolveDispatchWrapperTrustPlan(["nohup", "timeout", "5s", "bash", "-lc", "echo hi"], 1), ).toEqual({ argv: ["timeout", "5s", "bash", "-lc", "echo hi"], wrappers: ["nohup"], diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 0cb423a11b3e8..6f435f849434a 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -1,668 +1,4 @@ -import path from "node:path"; -import { - POSIX_INLINE_COMMAND_FLAGS, - POWERSHELL_INLINE_COMMAND_FLAGS, - resolveInlineCommandMatch, -} from "./shell-inline-command.js"; - -export const MAX_DISPATCH_WRAPPER_DEPTH = 4; - -const WINDOWS_EXECUTABLE_SUFFIXES = [".exe", ".cmd", ".bat", ".com"] as const; - -const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const; -const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const; -const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const; -const SHELL_MULTIPLEXER_WRAPPER_NAMES = ["busybox", "toybox"] as const; -const DISPATCH_WRAPPER_NAMES = [ - "chrt", - "doas", - "env", - "ionice", - "nice", - "nohup", - "setsid", - "stdbuf", - "sudo", - "taskset", - "timeout", -] as const; - -function withWindowsExeAliases(names: readonly string[]): string[] { - const expanded = new Set(); - for (const name of names) { - expanded.add(name); - expanded.add(`${name}.exe`); - } - return Array.from(expanded); -} - -function stripWindowsExecutableSuffix(value: string): string { - for (const suffix of WINDOWS_EXECUTABLE_SUFFIXES) { - if (value.endsWith(suffix)) { - return value.slice(0, -suffix.length); - } - } - return value; -} - -export const POSIX_SHELL_WRAPPERS = new Set(POSIX_SHELL_WRAPPER_NAMES); -export const WINDOWS_CMD_WRAPPERS = new Set(withWindowsExeAliases(WINDOWS_CMD_WRAPPER_NAMES)); -export const POWERSHELL_WRAPPERS = new Set(withWindowsExeAliases(POWERSHELL_WRAPPER_NAMES)); -export const DISPATCH_WRAPPER_EXECUTABLES = new Set(withWindowsExeAliases(DISPATCH_WRAPPER_NAMES)); - -const POSIX_SHELL_WRAPPER_CANONICAL = new Set(POSIX_SHELL_WRAPPER_NAMES); -const WINDOWS_CMD_WRAPPER_CANONICAL = new Set(WINDOWS_CMD_WRAPPER_NAMES); -const POWERSHELL_WRAPPER_CANONICAL = new Set(POWERSHELL_WRAPPER_NAMES); -const SHELL_MULTIPLEXER_WRAPPER_CANONICAL = new Set(SHELL_MULTIPLEXER_WRAPPER_NAMES); -const DISPATCH_WRAPPER_CANONICAL = new Set(DISPATCH_WRAPPER_NAMES); -const SHELL_WRAPPER_CANONICAL = new Set([ - ...POSIX_SHELL_WRAPPER_NAMES, - ...WINDOWS_CMD_WRAPPER_NAMES, - ...POWERSHELL_WRAPPER_NAMES, -]); - -const ENV_OPTIONS_WITH_VALUE = new Set([ - "-u", - "--unset", - "-c", - "--chdir", - "-s", - "--split-string", - "--default-signal", - "--ignore-signal", - "--block-signal", -]); -const ENV_INLINE_VALUE_PREFIXES = [ - "-u", - "-c", - "-s", - "--unset=", - "--chdir=", - "--split-string=", - "--default-signal=", - "--ignore-signal=", - "--block-signal=", -] as const; -const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]); -const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]); -const STDBUF_OPTIONS_WITH_VALUE = new Set(["-i", "--input", "-o", "--output", "-e", "--error"]); -const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]); -const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]); -const TRANSPARENT_DISPATCH_WRAPPERS = new Set(["nice", "nohup", "stdbuf", "timeout"]); - -type ShellWrapperKind = "posix" | "cmd" | "powershell"; - -type ShellWrapperSpec = { - kind: ShellWrapperKind; - names: ReadonlySet; -}; - -const SHELL_WRAPPER_SPECS: ReadonlyArray = [ - { kind: "posix", names: POSIX_SHELL_WRAPPER_CANONICAL }, - { kind: "cmd", names: WINDOWS_CMD_WRAPPER_CANONICAL }, - { kind: "powershell", names: POWERSHELL_WRAPPER_CANONICAL }, -]; - -export type ShellWrapperCommand = { - isWrapper: boolean; - command: string | null; -}; - -function isWithinDispatchClassificationDepth(depth: number): boolean { - return depth <= MAX_DISPATCH_WRAPPER_DEPTH; -} - -export function basenameLower(token: string): string { - const win = path.win32.basename(token); - const posix = path.posix.basename(token); - const base = win.length < posix.length ? win : posix; - return base.trim().toLowerCase(); -} - -export function normalizeExecutableToken(token: string): string { - return stripWindowsExecutableSuffix(basenameLower(token)); -} - -export function isDispatchWrapperExecutable(token: string): boolean { - return DISPATCH_WRAPPER_CANONICAL.has(normalizeExecutableToken(token)); -} - -export function isShellWrapperExecutable(token: string): boolean { - return SHELL_WRAPPER_CANONICAL.has(normalizeExecutableToken(token)); -} - -function normalizeRawCommand(rawCommand?: string | null): string | null { - const trimmed = rawCommand?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null { - const canonicalBase = stripWindowsExecutableSuffix(baseExecutable); - for (const spec of SHELL_WRAPPER_SPECS) { - if (spec.names.has(canonicalBase)) { - return spec; - } - } - return null; -} - -export type ShellMultiplexerUnwrapResult = - | { kind: "not-wrapper" } - | { kind: "blocked"; wrapper: string } - | { kind: "unwrapped"; wrapper: string; argv: string[] }; - -export function unwrapKnownShellMultiplexerInvocation( - argv: string[], -): ShellMultiplexerUnwrapResult { - const token0 = argv[0]?.trim(); - if (!token0) { - return { kind: "not-wrapper" }; - } - const wrapper = normalizeExecutableToken(token0); - if (!SHELL_MULTIPLEXER_WRAPPER_CANONICAL.has(wrapper)) { - return { kind: "not-wrapper" }; - } - - let appletIndex = 1; - if (argv[appletIndex]?.trim() === "--") { - appletIndex += 1; - } - const applet = argv[appletIndex]?.trim(); - if (!applet || !isShellWrapperExecutable(applet)) { - return { kind: "blocked", wrapper }; - } - - const unwrapped = argv.slice(appletIndex); - if (unwrapped.length === 0) { - return { kind: "blocked", wrapper }; - } - return { kind: "unwrapped", wrapper, argv: unwrapped }; -} - -export function isEnvAssignment(token: string): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); -} - -function hasEnvInlineValuePrefix(lower: string): boolean { - for (const prefix of ENV_INLINE_VALUE_PREFIXES) { - if (lower.startsWith(prefix)) { - return true; - } - } - return false; -} - -type WrapperScanDirective = "continue" | "consume-next" | "stop" | "invalid"; - -function scanWrapperInvocation( - argv: string[], - params: { - separators?: ReadonlySet; - onToken: (token: string, lowerToken: string) => WrapperScanDirective; - adjustCommandIndex?: (commandIndex: number, argv: string[]) => number | null; - }, -): string[] | null { - let idx = 1; - let expectsOptionValue = false; - while (idx < argv.length) { - const token = argv[idx]?.trim() ?? ""; - if (!token) { - idx += 1; - continue; - } - if (expectsOptionValue) { - expectsOptionValue = false; - idx += 1; - continue; - } - if (params.separators?.has(token)) { - idx += 1; - break; - } - const directive = params.onToken(token, token.toLowerCase()); - if (directive === "stop") { - break; - } - if (directive === "invalid") { - return null; - } - if (directive === "consume-next") { - expectsOptionValue = true; - } - idx += 1; - } - if (expectsOptionValue) { - return null; - } - const commandIndex = params.adjustCommandIndex ? params.adjustCommandIndex(idx, argv) : idx; - if (commandIndex === null || commandIndex >= argv.length) { - return null; - } - return argv.slice(commandIndex); -} - -export function unwrapEnvInvocation(argv: string[]): string[] | null { - return scanWrapperInvocation(argv, { - separators: new Set(["--", "-"]), - onToken: (token, lower) => { - if (isEnvAssignment(token)) { - return "continue"; - } - if (!token.startsWith("-") || token === "-") { - return "stop"; - } - const [flag] = lower.split("=", 2); - if (ENV_FLAG_OPTIONS.has(flag)) { - return "continue"; - } - if (ENV_OPTIONS_WITH_VALUE.has(flag)) { - return lower.includes("=") ? "continue" : "consume-next"; - } - if (hasEnvInlineValuePrefix(lower)) { - return "continue"; - } - return "invalid"; - }, - }); -} - -function envInvocationUsesModifiers(argv: string[]): boolean { - let idx = 1; - let expectsOptionValue = false; - while (idx < argv.length) { - const token = argv[idx]?.trim() ?? ""; - if (!token) { - idx += 1; - continue; - } - if (expectsOptionValue) { - return true; - } - if (token === "--" || token === "-") { - idx += 1; - break; - } - if (isEnvAssignment(token)) { - return true; - } - if (!token.startsWith("-") || token === "-") { - break; - } - const lower = token.toLowerCase(); - const [flag] = lower.split("=", 2); - if (ENV_FLAG_OPTIONS.has(flag)) { - return true; - } - if (ENV_OPTIONS_WITH_VALUE.has(flag)) { - if (lower.includes("=")) { - return true; - } - expectsOptionValue = true; - idx += 1; - continue; - } - if (hasEnvInlineValuePrefix(lower)) { - return true; - } - // Unknown env flags are treated conservatively as modifiers. - return true; - } - - return false; -} - -function unwrapNiceInvocation(argv: string[]): string[] | null { - return unwrapDashOptionInvocation(argv, { - onFlag: (flag, lower) => { - if (/^-\d+$/.test(lower)) { - return "continue"; - } - if (NICE_OPTIONS_WITH_VALUE.has(flag)) { - return lower.includes("=") || lower !== flag ? "continue" : "consume-next"; - } - if (lower.startsWith("-n") && lower.length > 2) { - return "continue"; - } - return "invalid"; - }, - }); -} - -function unwrapNohupInvocation(argv: string[]): string[] | null { - return scanWrapperInvocation(argv, { - separators: new Set(["--"]), - onToken: (token, lower) => { - if (!token.startsWith("-") || token === "-") { - return "stop"; - } - return lower === "--help" || lower === "--version" ? "continue" : "invalid"; - }, - }); -} - -function unwrapDashOptionInvocation( - argv: string[], - params: { - onFlag: (flag: string, lowerToken: string) => WrapperScanDirective; - adjustCommandIndex?: (commandIndex: number, argv: string[]) => number | null; - }, -): string[] | null { - return scanWrapperInvocation(argv, { - separators: new Set(["--"]), - onToken: (token, lower) => { - if (!token.startsWith("-") || token === "-") { - return "stop"; - } - const [flag] = lower.split("=", 2); - return params.onFlag(flag, lower); - }, - adjustCommandIndex: params.adjustCommandIndex, - }); -} - -function unwrapStdbufInvocation(argv: string[]): string[] | null { - return unwrapDashOptionInvocation(argv, { - onFlag: (flag, lower) => { - if (!STDBUF_OPTIONS_WITH_VALUE.has(flag)) { - return "invalid"; - } - return lower.includes("=") ? "continue" : "consume-next"; - }, - }); -} - -function unwrapTimeoutInvocation(argv: string[]): string[] | null { - return unwrapDashOptionInvocation(argv, { - onFlag: (flag, lower) => { - if (TIMEOUT_FLAG_OPTIONS.has(flag)) { - return "continue"; - } - if (TIMEOUT_OPTIONS_WITH_VALUE.has(flag)) { - return lower.includes("=") ? "continue" : "consume-next"; - } - return "invalid"; - }, - adjustCommandIndex: (commandIndex, currentArgv) => { - // timeout consumes a required duration token before the wrapped command. - const wrappedCommandIndex = commandIndex + 1; - return wrappedCommandIndex < currentArgv.length ? wrappedCommandIndex : null; - }, - }); -} - -export type DispatchWrapperUnwrapResult = - | { kind: "not-wrapper" } - | { kind: "blocked"; wrapper: string } - | { kind: "unwrapped"; wrapper: string; argv: string[] }; - -export type DispatchWrapperExecutionPlan = { - argv: string[]; - wrappers: string[]; - policyBlocked: boolean; - blockedWrapper?: string; -}; - -function blockDispatchWrapper(wrapper: string): DispatchWrapperUnwrapResult { - return { kind: "blocked", wrapper }; -} - -function unwrapDispatchWrapper( - wrapper: string, - unwrapped: string[] | null, -): DispatchWrapperUnwrapResult { - return unwrapped - ? { kind: "unwrapped", wrapper, argv: unwrapped } - : blockDispatchWrapper(wrapper); -} - -export function unwrapKnownDispatchWrapperInvocation(argv: string[]): DispatchWrapperUnwrapResult { - const token0 = argv[0]?.trim(); - if (!token0) { - return { kind: "not-wrapper" }; - } - const wrapper = normalizeExecutableToken(token0); - switch (wrapper) { - case "env": - return unwrapDispatchWrapper(wrapper, unwrapEnvInvocation(argv)); - case "nice": - return unwrapDispatchWrapper(wrapper, unwrapNiceInvocation(argv)); - case "nohup": - return unwrapDispatchWrapper(wrapper, unwrapNohupInvocation(argv)); - case "stdbuf": - return unwrapDispatchWrapper(wrapper, unwrapStdbufInvocation(argv)); - case "timeout": - return unwrapDispatchWrapper(wrapper, unwrapTimeoutInvocation(argv)); - case "chrt": - case "doas": - case "ionice": - case "setsid": - case "sudo": - case "taskset": - return blockDispatchWrapper(wrapper); - default: - return { kind: "not-wrapper" }; - } -} - -export function unwrapDispatchWrappersForResolution( - argv: string[], - maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, -): string[] { - const plan = resolveDispatchWrapperExecutionPlan(argv, maxDepth); - return plan.argv; -} - -function isSemanticDispatchWrapperUsage(wrapper: string, argv: string[]): boolean { - if (wrapper === "env") { - return envInvocationUsesModifiers(argv); - } - return !TRANSPARENT_DISPATCH_WRAPPERS.has(wrapper); -} - -function blockedDispatchWrapperPlan(params: { - argv: string[]; - wrappers: string[]; - blockedWrapper: string; -}): DispatchWrapperExecutionPlan { - return { - argv: params.argv, - wrappers: params.wrappers, - policyBlocked: true, - blockedWrapper: params.blockedWrapper, - }; -} - -export function resolveDispatchWrapperExecutionPlan( - argv: string[], - maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, -): DispatchWrapperExecutionPlan { - let current = argv; - const wrappers: string[] = []; - for (let depth = 0; depth < maxDepth; depth += 1) { - const unwrap = unwrapKnownDispatchWrapperInvocation(current); - if (unwrap.kind === "blocked") { - return blockedDispatchWrapperPlan({ - argv: current, - wrappers, - blockedWrapper: unwrap.wrapper, - }); - } - if (unwrap.kind !== "unwrapped" || unwrap.argv.length === 0) { - break; - } - wrappers.push(unwrap.wrapper); - if (isSemanticDispatchWrapperUsage(unwrap.wrapper, current)) { - return blockedDispatchWrapperPlan({ - argv: current, - wrappers, - blockedWrapper: unwrap.wrapper, - }); - } - current = unwrap.argv; - } - if (wrappers.length >= maxDepth) { - const overflow = unwrapKnownDispatchWrapperInvocation(current); - if (overflow.kind === "blocked" || overflow.kind === "unwrapped") { - return blockedDispatchWrapperPlan({ - argv: current, - wrappers, - blockedWrapper: overflow.wrapper, - }); - } - } - return { argv: current, wrappers, policyBlocked: false }; -} - -function hasEnvManipulationBeforeShellWrapperInternal( - argv: string[], - depth: number, - envManipulationSeen: boolean, -): boolean { - if (!isWithinDispatchClassificationDepth(depth)) { - return false; - } - - const token0 = argv[0]?.trim(); - if (!token0) { - return false; - } - - const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(argv); - if (dispatchUnwrap.kind === "blocked") { - return false; - } - if (dispatchUnwrap.kind === "unwrapped") { - const nextEnvManipulationSeen = - envManipulationSeen || (dispatchUnwrap.wrapper === "env" && envInvocationUsesModifiers(argv)); - return hasEnvManipulationBeforeShellWrapperInternal( - dispatchUnwrap.argv, - depth + 1, - nextEnvManipulationSeen, - ); - } - - const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); - if (shellMultiplexerUnwrap.kind === "blocked") { - return false; - } - if (shellMultiplexerUnwrap.kind === "unwrapped") { - return hasEnvManipulationBeforeShellWrapperInternal( - shellMultiplexerUnwrap.argv, - depth + 1, - envManipulationSeen, - ); - } - - const wrapper = findShellWrapperSpec(normalizeExecutableToken(token0)); - if (!wrapper) { - return false; - } - const payload = extractShellWrapperPayload(argv, wrapper); - if (!payload) { - return false; - } - return envManipulationSeen; -} - -export function hasEnvManipulationBeforeShellWrapper(argv: string[]): boolean { - return hasEnvManipulationBeforeShellWrapperInternal(argv, 0, false); -} - -function extractPosixShellInlineCommand(argv: string[]): string | null { - return extractInlineCommandByFlags(argv, POSIX_INLINE_COMMAND_FLAGS, { allowCombinedC: true }); -} - -function extractCmdInlineCommand(argv: string[]): string | null { - const idx = argv.findIndex((item) => { - const token = item.trim().toLowerCase(); - return token === "/c" || token === "/k"; - }); - if (idx === -1) { - return null; - } - const tail = argv.slice(idx + 1); - if (tail.length === 0) { - return null; - } - const cmd = tail.join(" ").trim(); - return cmd.length > 0 ? cmd : null; -} - -function extractPowerShellInlineCommand(argv: string[]): string | null { - return extractInlineCommandByFlags(argv, POWERSHELL_INLINE_COMMAND_FLAGS); -} - -function extractInlineCommandByFlags( - argv: string[], - flags: ReadonlySet, - options: { allowCombinedC?: boolean } = {}, -): string | null { - return resolveInlineCommandMatch(argv, flags, options).command; -} - -function extractShellWrapperPayload(argv: string[], spec: ShellWrapperSpec): string | null { - switch (spec.kind) { - case "posix": - return extractPosixShellInlineCommand(argv); - case "cmd": - return extractCmdInlineCommand(argv); - case "powershell": - return extractPowerShellInlineCommand(argv); - } -} - -function extractShellWrapperCommandInternal( - argv: string[], - rawCommand: string | null, - depth: number, -): ShellWrapperCommand { - if (!isWithinDispatchClassificationDepth(depth)) { - return { isWrapper: false, command: null }; - } - - const token0 = argv[0]?.trim(); - if (!token0) { - return { isWrapper: false, command: null }; - } - - const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(argv); - if (dispatchUnwrap.kind === "blocked") { - return { isWrapper: false, command: null }; - } - if (dispatchUnwrap.kind === "unwrapped") { - return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1); - } - - const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); - if (shellMultiplexerUnwrap.kind === "blocked") { - return { isWrapper: false, command: null }; - } - if (shellMultiplexerUnwrap.kind === "unwrapped") { - return extractShellWrapperCommandInternal(shellMultiplexerUnwrap.argv, rawCommand, depth + 1); - } - - const base0 = normalizeExecutableToken(token0); - const wrapper = findShellWrapperSpec(base0); - if (!wrapper) { - return { isWrapper: false, command: null }; - } - - const payload = extractShellWrapperPayload(argv, wrapper); - if (!payload) { - return { isWrapper: false, command: null }; - } - - return { isWrapper: true, command: rawCommand ?? payload }; -} - -export function extractShellWrapperInlineCommand(argv: string[]): string | null { - const extracted = extractShellWrapperCommandInternal(argv, null, 0); - return extracted.isWrapper ? extracted.command : null; -} - -export function extractShellWrapperCommand( - argv: string[], - rawCommand?: string | null, -): ShellWrapperCommand { - return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0); -} +export { basenameLower, normalizeExecutableToken } from "./exec-wrapper-tokens.js"; +export * from "./dispatch-wrapper-resolution.js"; +export * from "./shell-wrapper-resolution.js"; +export * from "./exec-wrapper-trust-plan.js"; diff --git a/src/infra/exec-wrapper-tokens.ts b/src/infra/exec-wrapper-tokens.ts new file mode 100644 index 0000000000000..725fef2b4ef1d --- /dev/null +++ b/src/infra/exec-wrapper-tokens.ts @@ -0,0 +1,23 @@ +import path from "node:path"; + +const WINDOWS_EXECUTABLE_SUFFIXES = [".exe", ".cmd", ".bat", ".com"] as const; + +function stripWindowsExecutableSuffix(value: string): string { + for (const suffix of WINDOWS_EXECUTABLE_SUFFIXES) { + if (value.endsWith(suffix)) { + return value.slice(0, -suffix.length); + } + } + return value; +} + +export function basenameLower(token: string): string { + const win = path.win32.basename(token); + const posix = path.posix.basename(token); + const base = win.length < posix.length ? win : posix; + return base.trim().toLowerCase(); +} + +export function normalizeExecutableToken(token: string): string { + return stripWindowsExecutableSuffix(basenameLower(token)); +} diff --git a/src/infra/exec-wrapper-trust-plan.test.ts b/src/infra/exec-wrapper-trust-plan.test.ts new file mode 100644 index 0000000000000..8cea75de5c912 --- /dev/null +++ b/src/infra/exec-wrapper-trust-plan.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "vitest"; +import { resolveExecWrapperTrustPlan } from "./exec-wrapper-trust-plan.js"; + +describe("resolveExecWrapperTrustPlan", () => { + test("unwraps dispatch wrappers and shell multiplexers into one trust plan", () => { + if (process.platform === "win32") { + return; + } + expect( + resolveExecWrapperTrustPlan(["/usr/bin/time", "-p", "busybox", "sh", "-lc", "echo hi"]), + ).toEqual({ + argv: ["sh", "-lc", "echo hi"], + policyArgv: ["busybox", "sh", "-lc", "echo hi"], + wrapperChain: ["time", "busybox"], + policyBlocked: false, + shellWrapperExecutable: true, + shellInlineCommand: "echo hi", + }); + }); + + test("fails closed for unsupported shell multiplexer applets", () => { + expect(resolveExecWrapperTrustPlan(["busybox", "sed", "-n", "1p"])).toEqual({ + argv: ["busybox", "sed", "-n", "1p"], + policyArgv: ["busybox", "sed", "-n", "1p"], + wrapperChain: [], + policyBlocked: true, + blockedWrapper: "busybox", + shellWrapperExecutable: false, + shellInlineCommand: null, + }); + }); + + test("fails closed when outer-wrapper depth overflows", () => { + expect( + resolveExecWrapperTrustPlan(["nohup", "timeout", "5s", "busybox", "sh", "-lc", "echo hi"], 2), + ).toEqual({ + argv: ["busybox", "sh", "-lc", "echo hi"], + policyArgv: ["busybox", "sh", "-lc", "echo hi"], + wrapperChain: ["nohup", "timeout"], + policyBlocked: true, + blockedWrapper: "busybox", + shellWrapperExecutable: false, + shellInlineCommand: null, + }); + }); + + test("keeps the blocked dispatch argv as the policy target after transparent unwraps", () => { + if (process.platform === "win32") { + return; + } + expect( + resolveExecWrapperTrustPlan([ + "/usr/bin/time", + "-p", + "/usr/bin/env", + "FOO=bar", + "sh", + "-lc", + "echo hi", + ]), + ).toEqual({ + argv: ["/usr/bin/env", "FOO=bar", "sh", "-lc", "echo hi"], + policyArgv: ["/usr/bin/env", "FOO=bar", "sh", "-lc", "echo hi"], + wrapperChain: [], + policyBlocked: true, + blockedWrapper: "env", + shellWrapperExecutable: false, + shellInlineCommand: null, + }); + }); +}); diff --git a/src/infra/exec-wrapper-trust-plan.ts b/src/infra/exec-wrapper-trust-plan.ts new file mode 100644 index 0000000000000..104db97e133d9 --- /dev/null +++ b/src/infra/exec-wrapper-trust-plan.ts @@ -0,0 +1,141 @@ +import { + MAX_DISPATCH_WRAPPER_DEPTH, + resolveDispatchWrapperTrustPlan, + unwrapKnownDispatchWrapperInvocation, +} from "./dispatch-wrapper-resolution.js"; +import { + extractShellWrapperInlineCommand, + isShellWrapperExecutable, + unwrapKnownShellMultiplexerInvocation, +} from "./shell-wrapper-resolution.js"; + +export type ExecWrapperTrustPlan = { + argv: string[]; + policyArgv: string[]; + wrapperChain: string[]; + policyBlocked: boolean; + blockedWrapper?: string; + shellWrapperExecutable: boolean; + shellInlineCommand: string | null; +}; + +function blockedExecWrapperTrustPlan(params: { + argv: string[]; + policyArgv?: string[]; + wrapperChain: string[]; + blockedWrapper: string; +}): ExecWrapperTrustPlan { + return { + argv: params.argv, + policyArgv: params.policyArgv ?? params.argv, + wrapperChain: params.wrapperChain, + policyBlocked: true, + blockedWrapper: params.blockedWrapper, + shellWrapperExecutable: false, + shellInlineCommand: null, + }; +} + +function finalizeExecWrapperTrustPlan( + argv: string[], + policyArgv: string[], + wrapperChain: string[], + policyBlocked: boolean, + blockedWrapper?: string, +): ExecWrapperTrustPlan { + const rawExecutable = argv[0]?.trim() ?? ""; + const shellWrapperExecutable = + !policyBlocked && rawExecutable.length > 0 && isShellWrapperExecutable(rawExecutable); + return { + argv, + policyArgv, + wrapperChain, + policyBlocked, + blockedWrapper, + shellWrapperExecutable, + shellInlineCommand: shellWrapperExecutable ? extractShellWrapperInlineCommand(argv) : null, + }; +} + +export function resolveExecWrapperTrustPlan( + argv: string[], + maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, +): ExecWrapperTrustPlan { + let current = argv; + let policyArgv = argv; + let sawShellMultiplexer = false; + const wrapperChain: string[] = []; + for (let depth = 0; depth < maxDepth; depth += 1) { + const dispatchPlan = resolveDispatchWrapperTrustPlan(current, maxDepth - wrapperChain.length); + if (dispatchPlan.policyBlocked) { + return blockedExecWrapperTrustPlan({ + argv: dispatchPlan.argv, + policyArgv: dispatchPlan.argv, + wrapperChain, + blockedWrapper: dispatchPlan.blockedWrapper ?? current[0] ?? "unknown", + }); + } + if (dispatchPlan.wrappers.length > 0) { + wrapperChain.push(...dispatchPlan.wrappers); + current = dispatchPlan.argv; + if (!sawShellMultiplexer) { + policyArgv = current; + } + if (wrapperChain.length >= maxDepth) { + break; + } + continue; + } + + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(current); + if (shellMultiplexerUnwrap.kind === "blocked") { + return blockedExecWrapperTrustPlan({ + argv: current, + policyArgv, + wrapperChain, + blockedWrapper: shellMultiplexerUnwrap.wrapper, + }); + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + wrapperChain.push(shellMultiplexerUnwrap.wrapper); + if (!sawShellMultiplexer) { + // Preserve the real executable target for trust checks. + policyArgv = current; + sawShellMultiplexer = true; + } + current = shellMultiplexerUnwrap.argv; + if (wrapperChain.length >= maxDepth) { + break; + } + continue; + } + + break; + } + + if (wrapperChain.length >= maxDepth) { + const dispatchOverflow = unwrapKnownDispatchWrapperInvocation(current); + if (dispatchOverflow.kind === "blocked" || dispatchOverflow.kind === "unwrapped") { + return blockedExecWrapperTrustPlan({ + argv: current, + policyArgv, + wrapperChain, + blockedWrapper: dispatchOverflow.wrapper, + }); + } + const shellMultiplexerOverflow = unwrapKnownShellMultiplexerInvocation(current); + if ( + shellMultiplexerOverflow.kind === "blocked" || + shellMultiplexerOverflow.kind === "unwrapped" + ) { + return blockedExecWrapperTrustPlan({ + argv: current, + policyArgv, + wrapperChain, + blockedWrapper: shellMultiplexerOverflow.wrapper, + }); + } + } + + return finalizeExecWrapperTrustPlan(current, policyArgv, wrapperChain, false); +} diff --git a/src/infra/file-lock.ts b/src/infra/file-lock.ts index 44e6bc07157e1..1e544006e72a8 100644 --- a/src/infra/file-lock.ts +++ b/src/infra/file-lock.ts @@ -1,2 +1,7 @@ export type { FileLockHandle, FileLockOptions } from "../plugin-sdk/file-lock.js"; -export { acquireFileLock, withFileLock } from "../plugin-sdk/file-lock.js"; +export { + acquireFileLock, + drainFileLockStateForTest, + resetFileLockStateForTest, + withFileLock, +} from "../plugin-sdk/file-lock.js"; diff --git a/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts b/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts new file mode 100644 index 0000000000000..6136e063f2f14 --- /dev/null +++ b/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + isOffsetlessIsoDateTime, + parseOffsetlessIsoDateTimeInTimeZone, +} from "./parse-offsetless-zoned-datetime.js"; + +describe("parseOffsetlessIsoDateTimeInTimeZone", () => { + it("detects offset-less ISO datetimes", () => { + expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00")).toBe(true); + expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00+02:00")).toBe(false); + expect(isOffsetlessIsoDateTime("+20m")).toBe(false); + }); + + it("converts offset-less datetimes in the requested timezone", () => { + expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Europe/Oslo")).toBe( + "2026-03-23T22:00:00.000Z", + ); + }); + + it("keeps DST boundary conversions on the intended wall-clock time", () => { + expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-29T01:30:00", "Europe/Oslo")).toBe( + "2026-03-29T00:30:00.000Z", + ); + }); + + it("returns null for nonexistent DST gap wall-clock times", () => { + expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-29T02:30:00", "Europe/Oslo")).toBe(null); + }); + + it("returns null for invalid input", () => { + expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00+02:00", "Europe/Oslo")).toBe( + null, + ); + expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Invalid/Timezone")).toBe( + null, + ); + }); +}); diff --git a/src/infra/format-time/parse-offsetless-zoned-datetime.ts b/src/infra/format-time/parse-offsetless-zoned-datetime.ts new file mode 100644 index 0000000000000..b8110368d9300 --- /dev/null +++ b/src/infra/format-time/parse-offsetless-zoned-datetime.ts @@ -0,0 +1,121 @@ +const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/; +const OFFSETLESS_ISO_DATETIME_PARTS_RE = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/; + +type OffsetlessIsoDateTimeParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; + millisecond: number; +}; + +export function isOffsetlessIsoDateTime(raw: string): boolean { + return OFFSETLESS_ISO_DATETIME_RE.test(raw); +} + +export function parseOffsetlessIsoDateTimeInTimeZone(raw: string, timeZone: string): string | null { + const expectedParts = parseOffsetlessIsoDateTimeParts(raw); + if (!expectedParts) { + return null; + } + try { + getZonedDateTimeParts(Date.now(), timeZone); + + const naiveMs = new Date(`${raw}Z`).getTime(); + if (Number.isNaN(naiveMs)) { + return null; + } + + // Re-check the offset at the first candidate instant so DST boundaries + // land on the intended wall-clock time instead of drifting by one hour. + const firstOffsetMs = getTimeZoneOffsetMs(naiveMs, timeZone); + const candidateMs = naiveMs - firstOffsetMs; + const finalOffsetMs = getTimeZoneOffsetMs(candidateMs, timeZone); + const resolvedMs = naiveMs - finalOffsetMs; + if (!matchesOffsetlessIsoDateTimeParts(resolvedMs, timeZone, expectedParts)) { + return null; + } + return new Date(resolvedMs).toISOString(); + } catch { + return null; + } +} + +function parseOffsetlessIsoDateTimeParts(raw: string): OffsetlessIsoDateTimeParts | null { + const match = OFFSETLESS_ISO_DATETIME_PARTS_RE.exec(raw); + if (!match) { + return null; + } + const fractionalMs = (match[7] ?? "").padEnd(3, "0").slice(0, 3); + return { + year: Number.parseInt(match[1] ?? "0", 10), + month: Number.parseInt(match[2] ?? "0", 10), + day: Number.parseInt(match[3] ?? "0", 10), + hour: Number.parseInt(match[4] ?? "0", 10), + minute: Number.parseInt(match[5] ?? "0", 10), + second: Number.parseInt(match[6] ?? "0", 10), + millisecond: Number.parseInt(fractionalMs || "0", 10), + }; +} + +function matchesOffsetlessIsoDateTimeParts( + utcMs: number, + timeZone: string, + expected: OffsetlessIsoDateTimeParts, +): boolean { + const actual = getZonedDateTimeParts(utcMs, timeZone); + return ( + actual.year === expected.year && + actual.month === expected.month && + actual.day === expected.day && + actual.hour === expected.hour && + actual.minute === expected.minute && + actual.second === expected.second && + actual.millisecond === expected.millisecond + ); +} + +function getTimeZoneOffsetMs(utcMs: number, timeZone: string): number { + const parts = getZonedDateTimeParts(utcMs, timeZone); + const localAsUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ); + + return localAsUtc - utcMs; +} + +function getZonedDateTimeParts(utcMs: number, timeZone: string): OffsetlessIsoDateTimeParts { + const utcDate = new Date(utcMs); + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + hourCycle: "h23", + }).formatToParts(utcDate); + const getNumericPart = (type: string) => { + const part = parts.find((candidate) => candidate.type === type); + return Number.parseInt(part?.value ?? "0", 10); + }; + return { + year: getNumericPart("year"), + month: getNumericPart("month"), + day: getNumericPart("day"), + hour: getNumericPart("hour"), + minute: getNumericPart("minute"), + second: getNumericPart("second"), + millisecond: utcDate.getUTCMilliseconds(), + }; +} diff --git a/src/infra/gateway-discovery-targets.ts b/src/infra/gateway-discovery-targets.ts new file mode 100644 index 0000000000000..169eed62a0748 --- /dev/null +++ b/src/infra/gateway-discovery-targets.ts @@ -0,0 +1,61 @@ +import { + resolveGatewayDiscoveryEndpoint, + type GatewayBonjourBeacon, + type GatewayDiscoveryResolvedEndpoint, +} from "./bonjour-discovery.js"; + +export type GatewayDiscoveryTarget = { + title: string; + domain: string; + endpoint: GatewayDiscoveryResolvedEndpoint | null; + wsUrl: string | null; + sshPort: number | null; + sshTarget: string | null; +}; + +function pickSshPort(beacon: GatewayBonjourBeacon): number | null { + return typeof beacon.sshPort === "number" && Number.isFinite(beacon.sshPort) && beacon.sshPort > 0 + ? beacon.sshPort + : null; +} + +export function buildGatewayDiscoveryTarget( + beacon: GatewayBonjourBeacon, + opts?: { sshUser?: string | null }, +): GatewayDiscoveryTarget { + const endpoint = resolveGatewayDiscoveryEndpoint(beacon); + const sshPort = pickSshPort(beacon); + const sshUser = opts?.sshUser?.trim() ?? ""; + const baseSshTarget = endpoint ? (sshUser ? `${sshUser}@${endpoint.host}` : endpoint.host) : null; + const sshTarget = + baseSshTarget && sshPort && sshPort !== 22 ? `${baseSshTarget}:${sshPort}` : baseSshTarget; + return { + title: (beacon.displayName || beacon.instanceName || "Gateway").trim(), + domain: (beacon.domain || "local.").trim(), + endpoint, + wsUrl: endpoint?.wsUrl ?? null, + sshPort, + sshTarget, + }; +} + +export function buildGatewayDiscoveryLabel(beacon: GatewayBonjourBeacon): string { + const target = buildGatewayDiscoveryTarget(beacon); + const hint = target.endpoint ? `${target.endpoint.host}:${target.endpoint.port}` : "host unknown"; + return `${target.title} (${hint})`; +} + +export function serializeGatewayDiscoveryBeacon(beacon: GatewayBonjourBeacon) { + const target = buildGatewayDiscoveryTarget(beacon); + return { + instanceName: beacon.instanceName, + displayName: beacon.displayName ?? null, + domain: beacon.domain ?? null, + host: beacon.host ?? null, + lanHost: beacon.lanHost ?? null, + tailnetDns: beacon.tailnetDns ?? null, + gatewayPort: beacon.gatewayPort ?? null, + sshPort: beacon.sshPort ?? null, + wsUrl: target.wsUrl, + }; +} diff --git a/src/infra/gaxios-fetch-compat.test.ts b/src/infra/gaxios-fetch-compat.test.ts index fd32cd07b9692..692547bafa72e 100644 --- a/src/infra/gaxios-fetch-compat.test.ts +++ b/src/infra/gaxios-fetch-compat.test.ts @@ -1,23 +1,31 @@ import { createRequire } from "node:module"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const TEST_GAXIOS_CONSTRUCTOR_OVERRIDE = "__OPENCLAW_TEST_GAXIOS_CONSTRUCTOR__"; type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; let ProxyAgent: typeof import("undici").ProxyAgent; +let __testing: typeof import("./gaxios-fetch-compat.js").__testing; +let createGaxiosCompatFetch: typeof import("./gaxios-fetch-compat.js").createGaxiosCompatFetch; +let installGaxiosFetchCompat: typeof import("./gaxios-fetch-compat.js").installGaxiosFetchCompat; -beforeEach(async () => { - vi.useRealTimers(); - vi.doUnmock("undici"); - vi.resetModules(); +beforeAll(async () => { const require = createRequire(import.meta.url); ({ ProxyAgent } = require("undici") as typeof import("undici")); + ({ __testing, createGaxiosCompatFetch, installGaxiosFetchCompat } = + await import("./gaxios-fetch-compat.js")); +}); + +beforeEach(() => { + vi.useRealTimers(); + vi.doUnmock("undici"); + __testing.resetGaxiosFetchCompatForTests(); }); describe("gaxios fetch compat", () => { afterEach(() => { vi.doUnmock("undici"); Reflect.deleteProperty(globalThis as object, TEST_GAXIOS_CONSTRUCTOR_OVERRIDE); - vi.resetModules(); + __testing.resetGaxiosFetchCompatForTests(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -58,8 +66,6 @@ describe("gaxios fetch compat", () => { }; (globalThis as Record)[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE] = MockGaxios; - const { installGaxiosFetchCompat } = await import("./gaxios-fetch-compat.js"); - await installGaxiosFetchCompat(); const res = await new MockGaxiosCtor().request({ @@ -77,8 +83,6 @@ describe("gaxios fetch compat", () => { vi.stubGlobal("fetch", vi.fn()); Reflect.deleteProperty(globalThis as object, "window"); (globalThis as Record)[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE] = null; - const { installGaxiosFetchCompat } = await import("./gaxios-fetch-compat.js"); - try { await expect(installGaxiosFetchCompat()).resolves.toBeUndefined(); expect((globalThis as { window?: { fetch?: FetchLike } }).window?.fetch).toBe(fetch); @@ -98,8 +102,6 @@ describe("gaxios fetch compat", () => { status: 200, }); }); - const { createGaxiosCompatFetch } = await import("./gaxios-fetch-compat.js"); - const compatFetch = createGaxiosCompatFetch(fetchMock); await compatFetch("https://example.com", { agent: { proxy: new URL("http://proxy.example:8080") }, diff --git a/src/infra/gaxios-fetch-compat.ts b/src/infra/gaxios-fetch-compat.ts index da7e16a2ea59e..aa2ea3db691f3 100644 --- a/src/infra/gaxios-fetch-compat.ts +++ b/src/infra/gaxios-fetch-compat.ts @@ -321,3 +321,9 @@ export async function installGaxiosFetchCompat(): Promise { throw err; } } + +export const __testing = { + resetGaxiosFetchCompatForTests(): void { + installState = "not-installed"; + }, +}; diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index 03af9a053ac6f..56679f5e1b13e 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; async function makeTempDir(label: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`)); @@ -43,12 +43,16 @@ describe("git commit resolution", () => { let resolveCommitHash: (typeof import("./git-commit.js"))["resolveCommitHash"]; let __testing: (typeof import("./git-commit.js"))["__testing"]; - beforeEach(async () => { - vi.restoreAllMocks(); + beforeAll(async () => { vi.doUnmock("node:fs"); vi.doUnmock("node:module"); - vi.resetModules(); ({ resolveCommitHash, __testing } = await import("./git-commit.js")); + }); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.doUnmock("node:fs"); + vi.doUnmock("node:module"); __testing.clearCachedGitCommits(); }); @@ -57,7 +61,6 @@ describe("git commit resolution", () => { vi.doUnmock("node:fs"); vi.doUnmock("node:module"); __testing.clearCachedGitCommits(); - vi.resetModules(); }); it("resolves commit metadata from the caller module root instead of the caller cwd", async () => { diff --git a/src/infra/google-api-base-url.test.ts b/src/infra/google-api-base-url.test.ts new file mode 100644 index 0000000000000..78110ab5ee276 --- /dev/null +++ b/src/infra/google-api-base-url.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_GOOGLE_API_BASE_URL, normalizeGoogleApiBaseUrl } from "./google-api-base-url.js"; + +describe("normalizeGoogleApiBaseUrl", () => { + it("defaults to the Gemini v1beta API root", () => { + expect(normalizeGoogleApiBaseUrl()).toBe(DEFAULT_GOOGLE_API_BASE_URL); + }); + + it("normalizes the bare Google API host to the Gemini v1beta root", () => { + expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com")).toBe( + DEFAULT_GOOGLE_API_BASE_URL, + ); + expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/")).toBe( + DEFAULT_GOOGLE_API_BASE_URL, + ); + }); + + it("preserves explicit Google API paths", () => { + expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/v1beta")).toBe( + DEFAULT_GOOGLE_API_BASE_URL, + ); + expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/v1")).toBe( + "https://generativelanguage.googleapis.com/v1", + ); + }); + + it("preserves custom proxy paths", () => { + expect(normalizeGoogleApiBaseUrl("https://proxy.example.com/google/v1beta/")).toBe( + "https://proxy.example.com/google/v1beta", + ); + }); +}); diff --git a/src/infra/google-api-base-url.ts b/src/infra/google-api-base-url.ts new file mode 100644 index 0000000000000..369ff1e1a2152 --- /dev/null +++ b/src/infra/google-api-base-url.ts @@ -0,0 +1,28 @@ +const DEFAULT_GOOGLE_API_HOST = "generativelanguage.googleapis.com"; + +export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; + +function trimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ""); +} + +export function normalizeGoogleApiBaseUrl(baseUrl?: string): string { + const raw = trimTrailingSlashes(baseUrl?.trim() || DEFAULT_GOOGLE_API_BASE_URL); + try { + const url = new URL(raw); + url.hash = ""; + url.search = ""; + if ( + url.hostname.toLowerCase() === DEFAULT_GOOGLE_API_HOST && + trimTrailingSlashes(url.pathname || "") === "" + ) { + url.pathname = "/v1beta"; + } + return trimTrailingSlashes(url.toString()); + } catch { + if (/^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(raw)) { + return DEFAULT_GOOGLE_API_BASE_URL; + } + return raw; + } +} diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index 413cced0bf26f..ec5e8cd983c9c 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -199,7 +199,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { defaults: { heartbeat: { every: "30m", - model: "openai/gpt-4o-mini", + model: "openai/gpt-5.4", }, }, list: [ diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts index 6b758ab874029..60aa6280eb3ff 100644 --- a/src/infra/json-files.ts +++ b/src/infra/json-files.ts @@ -37,15 +37,32 @@ export async function writeTextAtomic( mkdirOptions.mode = options.ensureDirMode; } await fs.mkdir(path.dirname(filePath), mkdirOptions); + const parentDir = path.dirname(filePath); const tmp = `${filePath}.${randomUUID()}.tmp`; try { - await fs.writeFile(tmp, payload, { encoding: "utf8", mode }); + const tmpHandle = await fs.open(tmp, "w", mode); + try { + await tmpHandle.writeFile(payload, { encoding: "utf8" }); + await tmpHandle.sync(); + } finally { + await tmpHandle.close().catch(() => undefined); + } try { await fs.chmod(tmp, mode); } catch { // best-effort; ignore on platforms without chmod } await fs.rename(tmp, filePath); + try { + const dirHandle = await fs.open(parentDir, "r"); + try { + await dirHandle.sync(); + } finally { + await dirHandle.close().catch(() => undefined); + } + } catch { + // best-effort; some platforms/filesystems do not support syncing directories. + } try { await fs.chmod(filePath, mode); } catch { diff --git a/src/infra/local-file-access.ts b/src/infra/local-file-access.ts new file mode 100644 index 0000000000000..8d54136151028 --- /dev/null +++ b/src/infra/local-file-access.ts @@ -0,0 +1,64 @@ +import path from "node:path"; +import { fileURLToPath, URL } from "node:url"; + +function isLocalFileUrlHost(hostname: string): boolean { + return hostname === "" || hostname.toLowerCase() === "localhost"; +} + +export function isWindowsNetworkPath(filePath: string): boolean { + if (process.platform !== "win32") { + return false; + } + const normalized = filePath.replace(/\//g, "\\"); + return normalized.startsWith("\\\\?\\UNC\\") || normalized.startsWith("\\\\"); +} + +export function assertNoWindowsNetworkPath(filePath: string, label = "Path"): void { + if (isWindowsNetworkPath(filePath)) { + throw new Error(`${label} cannot use Windows network paths: ${filePath}`); + } +} + +export function safeFileURLToPath(fileUrl: string): string { + let parsed: URL; + try { + parsed = new URL(fileUrl); + } catch { + throw new Error(`Invalid file:// URL: ${fileUrl}`); + } + if (parsed.protocol !== "file:") { + throw new Error(`Invalid file:// URL: ${fileUrl}`); + } + if (!isLocalFileUrlHost(parsed.hostname)) { + throw new Error(`file:// URLs with remote hosts are not allowed: ${fileUrl}`); + } + const filePath = fileURLToPath(parsed); + assertNoWindowsNetworkPath(filePath, "Local file URL"); + return filePath; +} + +export function trySafeFileURLToPath(fileUrl: string): string | undefined { + try { + return safeFileURLToPath(fileUrl); + } catch { + return undefined; + } +} + +export function basenameFromMediaSource(source?: string): string | undefined { + if (!source) { + return undefined; + } + if (source.startsWith("file://")) { + const filePath = trySafeFileURLToPath(source); + return filePath ? path.basename(filePath) || undefined : undefined; + } + if (/^https?:\/\//i.test(source)) { + try { + return path.basename(new URL(source).pathname) || undefined; + } catch { + return undefined; + } + } + return path.basename(source) || undefined; +} diff --git a/src/infra/matrix-legacy-crypto.test.ts b/src/infra/matrix-legacy-crypto.test.ts index 085012609435c..9766d516ad9ca 100644 --- a/src/infra/matrix-legacy-crypto.test.ts +++ b/src/infra/matrix-legacy-crypto.test.ts @@ -1,12 +1,14 @@ import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; import type { OpenClawConfig } from "../config/config.js"; import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js"; +vi.unmock("../version.js"); + function writeFile(filePath: string, value: string) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, value, "utf8"); @@ -39,9 +41,16 @@ function writeMatrixPluginFixture(rootDir: string): void { const matrixHelperEnv = { OPENCLAW_BUNDLED_PLUGINS_DIR: (home: string) => path.join(home, "bundled"), + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_VERSION: undefined, + VITEST: "true", }; describe("matrix legacy encrypted-state migration", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("extracts a saved backup key into the new recovery-key path", async () => { await withTempHome( async (home) => { diff --git a/src/infra/matrix-plugin-helper.test.ts b/src/infra/matrix-plugin-helper.test.ts index 602d151c853f9..0f6bba9963db1 100644 --- a/src/infra/matrix-plugin-helper.test.ts +++ b/src/infra/matrix-plugin-helper.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { withTempHome } from "../../test/helpers/temp-home.js"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -8,6 +8,8 @@ import { loadMatrixLegacyCryptoInspector, } from "./matrix-plugin-helper.js"; +vi.unmock("../version.js"); + function writeMatrixPluginFixture(rootDir: string, helperBody: string): void { fs.mkdirSync(rootDir, { recursive: true }); fs.writeFileSync( @@ -42,6 +44,17 @@ function writeMatrixPluginManifest(rootDir: string): void { } describe("matrix plugin helper resolution", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const helperEnv = { + OPENCLAW_BUNDLED_PLUGINS_DIR: (home: string) => path.join(home, "bundled"), + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_VERSION: undefined, + VITEST: "true", + } as const; + it("loads the legacy crypto inspector from the bundled matrix plugin", async () => { await withTempHome( async (home) => { @@ -76,11 +89,7 @@ describe("matrix plugin helper resolution", () => { decryptionKeyBase64: "YWJjZA==", }); }, - { - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "bundled"), - }, - }, + { env: helperEnv }, ); }); @@ -133,11 +142,7 @@ describe("matrix plugin helper resolution", () => { decryptionKeyBase64: null, }); }, - { - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "bundled"), - }, - }, + { env: helperEnv }, ); }); diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 7f05f755e3897..626e29f082498 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -140,6 +140,21 @@ describe("fetchWithSsrFGuard hardening", () => { expect(result.response.status).toBe(200); }); + it("fails closed for plain HTTP targets when explicit proxy mode requires pinned DNS", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "http://public.example/resource", + fetchImpl, + dispatcherPolicy: { + mode: "explicit-proxy", + proxyUrl: "http://127.0.0.1:7890", + }, + }), + ).rejects.toThrow(/explicit proxy ssrf pinning requires https targets/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = createPublicLookup(); const fetchImpl = await expectRedirectFailure({ diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 90198881621d7..09678ed495a4e 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -1,6 +1,6 @@ import type { Dispatcher } from "undici"; import { logWarn } from "../../logger.js"; -import { bindAbortRelay } from "../../utils/fetch-timeout.js"; +import { buildTimeoutAbortSignal } from "../../utils/fetch-timeout.js"; import { hasProxyEnvConfigured } from "./proxy-env.js"; import { closeDispatcher, @@ -91,6 +91,22 @@ function resolveGuardedFetchMode(params: GuardedFetchOptions): GuardedFetchMode return GUARDED_FETCH_MODE.STRICT; } +function assertExplicitProxySupportsPinnedDns( + url: URL, + dispatcherPolicy?: PinnedDispatcherPolicy, + pinDns?: boolean, +): void { + if ( + pinDns !== false && + dispatcherPolicy?.mode === "explicit-proxy" && + url.protocol !== "https:" + ) { + throw new Error( + "Explicit proxy SSRF pinning requires HTTPS targets; plain HTTP targets are not supported", + ); + } +} + function isRedirectStatus(status: number): boolean { return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; } @@ -109,40 +125,6 @@ function retainSafeHeadersForCrossOriginRedirect(init?: RequestInit): RequestIni return { ...init, headers }; } -function buildAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { - signal?: AbortSignal; - cleanup: () => void; -} { - const { timeoutMs, signal } = params; - if (!timeoutMs && !signal) { - return { signal: undefined, cleanup: () => {} }; - } - - if (!timeoutMs) { - return { signal, cleanup: () => {} }; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs); - const onAbort = bindAbortRelay(controller); - if (signal) { - if (signal.aborted) { - controller.abort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); - } - } - - const cleanup = () => { - clearTimeout(timeoutId); - if (signal) { - signal.removeEventListener("abort", onAbort); - } - }; - - return { signal: controller.signal, cleanup }; -} - export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { const fetcher: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch; if (!fetcher) { @@ -155,7 +137,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); }); +function createPinnedTelegramHost(lookup: PinnedHostname["lookup"]): PinnedHostname { + return { + hostname: "api.telegram.org", + addresses: ["149.154.167.221"], + lookup, + }; +} + +function createDispatcherWithPinnedOverride(lookup: PinnedHostname["lookup"]) { + createPinnedDispatcher(createPinnedTelegramHost(lookup), { + mode: "direct", + pinnedHostname: { + hostname: "api.telegram.org", + addresses: ["149.154.167.220"], + }, + }); + + return (agentCtor.mock.calls.at(-1)?.[0] as { connect?: { lookup?: PinnedHostname["lookup"] } }) + ?.connect?.lookup; +} + describe("createPinnedDispatcher", () => { it("uses pinned lookup without overriding global family policy", () => { const lookup = vi.fn() as unknown as PinnedHostname["lookup"]; @@ -89,26 +110,9 @@ describe("createPinnedDispatcher", () => { it("replaces the pinned lookup when a dispatcher override hostname is provided", () => { const originalLookup = vi.fn() as unknown as PinnedHostname["lookup"]; - const pinned: PinnedHostname = { - hostname: "api.telegram.org", - addresses: ["149.154.167.221"], - lookup: originalLookup, - }; - - createPinnedDispatcher(pinned, { - mode: "direct", - pinnedHostname: { - hostname: "api.telegram.org", - addresses: ["149.154.167.220"], - }, - }); + const lookup = createDispatcherWithPinnedOverride(originalLookup); - const firstCallArg = agentCtor.mock.calls.at(-1)?.[0] as - | { connect?: { lookup?: PinnedHostname["lookup"] } } - | undefined; - expect(firstCallArg?.connect?.lookup).toBeTypeOf("function"); - - const lookup = firstCallArg?.connect?.lookup; + expect(lookup).toBeTypeOf("function"); const callback = vi.fn(); lookup?.("api.telegram.org", callback); @@ -122,24 +126,7 @@ describe("createPinnedDispatcher", () => { callback(null, "93.184.216.34", 4); }, ) as unknown as PinnedHostname["lookup"]; - const pinned: PinnedHostname = { - hostname: "api.telegram.org", - addresses: ["149.154.167.221"], - lookup: originalLookup, - }; - - createPinnedDispatcher(pinned, { - mode: "direct", - pinnedHostname: { - hostname: "api.telegram.org", - addresses: ["149.154.167.220"], - }, - }); - - const firstCallArg = agentCtor.mock.calls.at(-1)?.[0] as - | { connect?: { lookup?: PinnedHostname["lookup"] } } - | undefined; - const lookup = firstCallArg?.connect?.lookup; + const lookup = createDispatcherWithPinnedOverride(originalLookup); const callback = vi.fn(); lookup?.("example.com", callback); @@ -217,8 +204,9 @@ describe("createPinnedDispatcher", () => { expect(proxyAgentCtor).toHaveBeenCalledWith({ uri: "http://127.0.0.1:7890", - proxyTls: { + requestTls: { autoSelectFamily: false, + lookup, }, }); }); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index a07bba526b6b7..8e6cedf80f160 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -418,12 +418,16 @@ export function createPinnedDispatcher( } const proxyUrl = policy.proxyUrl.trim(); - if (!policy.proxyTls) { + const requestTls = withPinnedLookup(lookup, policy.proxyTls); + if (!requestTls) { return new ProxyAgent(proxyUrl); } return new ProxyAgent({ uri: proxyUrl, - proxyTls: { ...policy.proxyTls }, + // `PinnedDispatcherPolicy.proxyTls` historically carried target-hop + // transport hints for explicit proxies. Translate that to undici's + // `requestTls` so HTTPS proxy tunnels keep the pinned DNS lookup. + requestTls, }); } diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts index abb3bb67d16fd..f31fb34006eb3 100644 --- a/src/infra/outbound/agent-delivery.test.ts +++ b/src/infra/outbound/agent-delivery.test.ts @@ -70,9 +70,12 @@ vi.mock("./targets.js", () => ({ })); import type { OpenClawConfig } from "../../config/config.js"; -import { resolveAgentDeliveryPlan, resolveAgentOutboundTarget } from "./agent-delivery.js"; +let resolveAgentDeliveryPlan: typeof import("./agent-delivery.js").resolveAgentDeliveryPlan; +let resolveAgentOutboundTarget: typeof import("./agent-delivery.js").resolveAgentOutboundTarget; -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + ({ resolveAgentDeliveryPlan, resolveAgentOutboundTarget } = await import("./agent-delivery.js")); mocks.resolveOutboundTarget.mockClear(); mocks.resolveSessionDeliveryTarget.mockClear(); }); diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 15372daa2a119..72ab497111ce9 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -13,6 +13,10 @@ import { const bootstrapAttempts = new Set(); +export function resetOutboundChannelResolutionStateForTest(): void { + bootstrapAttempts.clear(); +} + export function normalizeDeliverableOutboundChannel( raw?: string | null, ): DeliverableMessageChannel | undefined { diff --git a/src/infra/outbound/deliver.lifecycle.test.ts b/src/infra/outbound/deliver.lifecycle.test.ts index dd32b1648d823..c8ce22b826b04 100644 --- a/src/infra/outbound/deliver.lifecycle.test.ts +++ b/src/infra/outbound/deliver.lifecycle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; @@ -77,11 +77,9 @@ function expectSuccessfulWhatsAppInternalHookPayload( } describe("deliverOutboundPayloads lifecycle", () => { - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ deliverOutboundPayloads } = await import("./deliver.js")); - }); - - beforeEach(() => { resetDeliverTestState(); resetDeliverTestMocks({ includeSessionMocks: true }); }); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 2f97906e892c4..4e659ca3d397d 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -7,7 +7,6 @@ import { whatsappOutbound, } from "../../../test/channel-outbounds.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { STATE_DIR } from "../../config/paths.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { withEnvAsync } from "../../test-utils/env.js"; @@ -202,6 +201,7 @@ function expectSuccessfulWhatsAppInternalHookPayload( describe("deliverOutboundPayloads", () => { beforeAll(async () => { + vi.resetModules(); ({ deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js")); }); @@ -454,14 +454,19 @@ describe("deliverOutboundPayloads", () => { payload: { text: "hi", mediaUrl: "file:///tmp/f.png" }, }); + const sendOpts = sendTelegram.mock.calls[0]?.[2] as { mediaLocalRoots?: string[] } | undefined; expect(sendTelegram).toHaveBeenCalledWith( "123", "hi", expect.objectContaining({ mediaUrl: "file:///tmp/f.png", - mediaLocalRoots: expect.arrayContaining([path.join(STATE_DIR, "workspace-work")]), }), ); + expect( + sendOpts?.mediaLocalRoots?.some((root) => + root.endsWith(path.join(".openclaw", "workspace-work")), + ), + ).toBe(true); }); it("includes OpenClaw tmp root in telegram mediaLocalRoots", async () => { diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index 309a237af52aa..510051f1cf133 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -184,6 +184,104 @@ describe("message action media helpers", () => { await fs.rm(sandboxRoot, { recursive: true, force: true }); } }); + + maybeIt("normalizes mediaUrl and fileUrl sandbox media params", async () => { + const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-alias-")); + try { + const args: Record = { + mediaUrl: " file:///workspace/assets/photo.png ", + fileUrl: "/workspace/docs/report.pdf", + }; + + await normalizeSandboxMediaParams({ + args, + mediaPolicy: { + mode: "sandbox", + sandboxRoot: ` ${sandboxRoot} `, + }, + }); + + expect(args).toMatchObject({ + mediaUrl: path.join(sandboxRoot, "assets", "photo.png"), + fileUrl: path.join(sandboxRoot, "docs", "report.pdf"), + }); + } finally { + await fs.rm(sandboxRoot, { recursive: true, force: true }); + } + }); + + maybeIt( + "keeps remote HTTP mediaUrl and fileUrl aliases unchanged under sandbox normalization", + async () => { + const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-remote-alias-")); + try { + const args: Record = { + mediaUrl: "https://example.com/assets/photo.png?sig=1", + fileUrl: "https://example.com/docs/report.pdf?sig=2", + }; + + await normalizeSandboxMediaParams({ + args, + mediaPolicy: { + mode: "sandbox", + sandboxRoot, + }, + }); + + expect(args).toMatchObject({ + mediaUrl: "https://example.com/assets/photo.png?sig=1", + fileUrl: "https://example.com/docs/report.pdf?sig=2", + }); + } finally { + await fs.rm(sandboxRoot, { recursive: true, force: true }); + } + }, + ); + + it("uses mediaUrl and fileUrl aliases when inferring attachment filenames", async () => { + const mediaArgs: Record = { + mediaUrl: "https://example.com/pic.png", + }; + await hydrateAttachmentParamsForAction({ + cfg, + channel: "slack", + args: mediaArgs, + action: "sendAttachment", + dryRun: true, + mediaPolicy: { mode: "host" }, + }); + expect(mediaArgs.filename).toBe("pic.png"); + + const fileArgs: Record = { + fileUrl: "https://example.com/docs/report.pdf", + }; + await hydrateAttachmentParamsForAction({ + cfg, + channel: "slack", + args: fileArgs, + action: "sendAttachment", + dryRun: true, + mediaPolicy: { mode: "host" }, + }); + expect(fileArgs.filename).toBe("report.pdf"); + }); + + it("falls back to extension-based attachment names for remote-host file URLs", async () => { + const args: Record = { + media: "file://attacker/share/photo.png", + }; + + await hydrateAttachmentParamsForAction({ + cfg, + channel: "slack", + args, + action: "sendAttachment", + dryRun: true, + mediaPolicy: { mode: "host" }, + }); + + expect(args.filename).toBe("attachment"); + }); }); describe("message action sandbox media hydration", () => { diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index 234bb18f8a6f8..37119615520c7 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -1,16 +1,36 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js"; import { readStringParam } from "../../agents/tools/common.js"; import type { ChannelId, ChannelMessageActionName } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createRootScopedReadFile } from "../../infra/fs-safe.js"; +import { basenameFromMediaSource } from "../../infra/local-file-access.js"; import { extensionForMime } from "../../media/mime.js"; import { loadWebMedia } from "../../media/web-media.js"; import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boolean-param.js"; export const readBooleanParam = readBooleanParamShared; +const SANDBOX_MEDIA_PARAM_KEYS = ["media", "path", "filePath", "mediaUrl", "fileUrl"] as const; + +function readMediaParam( + args: Record, + key: (typeof SANDBOX_MEDIA_PARAM_KEYS)[number], +): string | undefined { + return readStringParam(args, key, { trim: false }); +} + +function readAttachmentMediaHint(args: Record): string | undefined { + return readMediaParam(args, "media") ?? readMediaParam(args, "mediaUrl"); +} + +function readAttachmentFileHint(args: Record): string | undefined { + return ( + readMediaParam(args, "path") ?? + readMediaParam(args, "filePath") ?? + readMediaParam(args, "fileUrl") + ); +} + function resolveAttachmentMaxBytes(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -47,27 +67,9 @@ function inferAttachmentFilename(params: { }): string | undefined { const mediaHint = params.mediaHint?.trim(); if (mediaHint) { - try { - if (mediaHint.startsWith("file://")) { - const filePath = fileURLToPath(mediaHint); - const base = path.basename(filePath); - if (base) { - return base; - } - } else if (/^https?:\/\//i.test(mediaHint)) { - const url = new URL(mediaHint); - const base = path.basename(url.pathname); - if (base) { - return base; - } - } else { - const base = path.basename(mediaHint); - if (base) { - return base; - } - } - } catch { - // fall through to content-type based default + const base = basenameFromMediaSource(mediaHint); + if (base) { + return base; } } const ext = params.contentType ? extensionForMime(params.contentType) : undefined; @@ -209,9 +211,8 @@ export async function normalizeSandboxMediaParams(params: { }): Promise { const sandboxRoot = params.mediaPolicy.mode === "sandbox" ? params.mediaPolicy.sandboxRoot.trim() : undefined; - const mediaKeys: Array<"media" | "path" | "filePath"> = ["media", "path", "filePath"]; - for (const key of mediaKeys) { - const raw = readStringParam(params.args, key, { trim: false }); + for (const key of SANDBOX_MEDIA_PARAM_KEYS) { + const raw = readMediaParam(params.args, key); if (!raw) { continue; } @@ -261,10 +262,8 @@ async function hydrateAttachmentActionPayload(params: { allowMessageCaptionFallback?: boolean; mediaPolicy: AttachmentMediaPolicy; }): Promise { - const mediaHint = readStringParam(params.args, "media", { trim: false }); - const fileHint = - readStringParam(params.args, "path", { trim: false }) ?? - readStringParam(params.args, "filePath", { trim: false }); + const mediaHint = readAttachmentMediaHint(params.args); + const fileHint = readAttachmentFileHint(params.args); const contentTypeParam = readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index 9665e44f558ba..2d341b861a586 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -56,6 +56,7 @@ const runDrySend = (params: { async function expectSandboxMediaRewrite(params: { sandboxDir: string; media?: string; + mediaField?: "media" | "mediaUrl" | "fileUrl"; message?: string; expectedRelativePath: string; }) { @@ -64,7 +65,11 @@ async function expectSandboxMediaRewrite(params: { actionParams: { channel: "slack", target: "#C12345678", - ...(params.media ? { media: params.media } : {}), + ...(params.media + ? { + [params.mediaField ?? "media"]: params.media, + } + : {}), ...(params.message ? { message: params.message } : {}), }, sandboxRoot: params.sandboxDir, @@ -117,12 +122,10 @@ const slackPlugin: ChannelPlugin = { }; describe("runMessageAction media behavior", () => { - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ runMessageAction } = await import("./message-action-runner.js")); ({ loadWebMedia } = await import("../../media/web-media.js")); - }); - - beforeEach(() => { vi.clearAllMocks(); }); @@ -198,6 +201,7 @@ describe("runMessageAction media behavior", () => { async function expectRejectsLocalAbsolutePathWithoutSandbox(params: { action: "sendAttachment" | "setGroupIcon"; target: string; + mediaField?: "media" | "mediaUrl" | "fileUrl"; message?: string; tempPrefix: string; }) { @@ -211,7 +215,7 @@ describe("runMessageAction media behavior", () => { const actionParams: Record = { channel: "bluebubbles", target: params.target, - media: outsidePath, + [params.mediaField ?? "media"]: outsidePath, }; if (params.message) { actionParams.message = params.message; @@ -272,6 +276,24 @@ describe("runMessageAction media behavior", () => { message: "caption", expectedPath: path.join("data", "pic.png"), }, + { + name: "sendAttachment mediaUrl rewrite", + action: "sendAttachment" as const, + target: "+15551234567", + mediaField: "mediaUrl" as const, + media: "./data/pic.png", + message: "caption", + expectedPath: path.join("data", "pic.png"), + }, + { + name: "sendAttachment fileUrl rewrite", + action: "sendAttachment" as const, + target: "+15551234567", + mediaField: "fileUrl" as const, + media: "/workspace/files/report.pdf", + message: "caption", + expectedPath: path.join("files", "report.pdf"), + }, { name: "setGroupIcon rewrite", action: "setGroupIcon" as const, @@ -288,7 +310,7 @@ describe("runMessageAction media behavior", () => { params: { channel: "bluebubbles", target: testCase.target, - media: testCase.media, + [testCase.mediaField ?? "media"]: testCase.media, ...(testCase.message ? { message: testCase.message } : {}), }, sandboxRoot: sandboxDir, @@ -311,6 +333,20 @@ describe("runMessageAction media behavior", () => { message: "caption", tempPrefix: "msg-attachment-", }, + { + action: "sendAttachment" as const, + target: "+15551234567", + mediaField: "mediaUrl" as const, + message: "caption", + tempPrefix: "msg-attachment-media-url-", + }, + { + action: "sendAttachment" as const, + target: "+15551234567", + mediaField: "fileUrl" as const, + message: "caption", + tempPrefix: "msg-attachment-file-url-", + }, { action: "setGroupIcon" as const, target: "group:123", @@ -339,25 +375,43 @@ describe("runMessageAction media behavior", () => { setActivePluginRegistry(createTestRegistry([])); }); - it.each(["/etc/passwd", "file:///etc/passwd"])( - "rejects out-of-sandbox media reference: %s", - async (media) => { - await withSandbox(async (sandboxDir) => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - media, - message: "", - }, - sandboxRoot: sandboxDir, - }), - ).rejects.toThrow(/sandbox/i); - }); + it.each([ + { + name: "media absolute path", + mediaField: "media" as const, + media: "/etc/passwd", }, - ); + { + name: "mediaUrl absolute path", + mediaField: "mediaUrl" as const, + media: "/etc/passwd", + }, + { + name: "mediaUrl file URL", + mediaField: "mediaUrl" as const, + media: "file:///etc/passwd", + }, + { + name: "fileUrl file URL", + mediaField: "fileUrl" as const, + media: "file:///etc/passwd", + }, + ])("rejects out-of-sandbox media reference: $name", async ({ mediaField, media }) => { + await withSandbox(async (sandboxDir) => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + [mediaField]: media, + message: "", + }, + sandboxRoot: sandboxDir, + }), + ).rejects.toThrow(/sandbox/i); + }); + }); it("rejects data URLs in media params", async () => { await expect( @@ -381,6 +435,20 @@ describe("runMessageAction media behavior", () => { message: "", expectedRelativePath: path.join("data", "file.txt"), }, + { + name: "relative mediaUrl path", + mediaField: "mediaUrl" as const, + media: "./data/file.txt", + message: "", + expectedRelativePath: path.join("data", "file.txt"), + }, + { + name: "/workspace fileUrl path", + mediaField: "fileUrl" as const, + media: "/workspace/data/file.txt", + message: "", + expectedRelativePath: path.join("data", "file.txt"), + }, { name: "/workspace media path", media: "/workspace/data/file.txt", @@ -392,11 +460,12 @@ describe("runMessageAction media behavior", () => { message: "Hello\nMEDIA: ./data/note.ogg", expectedRelativePath: path.join("data", "note.ogg"), }, - ]) { + ] as const) { await withSandbox(async (sandboxDir) => { await expectSandboxMediaRewrite({ sandboxDir, media: testCase.media, + mediaField: testCase.mediaField, message: testCase.message, expectedRelativePath: testCase.expectedRelativePath, }); @@ -404,6 +473,62 @@ describe("runMessageAction media behavior", () => { } }); + it("prefers media over mediaUrl when both aliases are present", async () => { + await withSandbox(async (sandboxDir) => { + const result = await runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + media: "./data/primary.txt", + mediaUrl: "./data/secondary.txt", + message: "", + }, + sandboxRoot: sandboxDir, + }); + + expect(result.kind).toBe("send"); + if (result.kind !== "send") { + throw new Error("expected send result"); + } + expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "primary.txt")); + }); + }); + + it.each([ + { + name: "mediaUrl", + mediaField: "mediaUrl" as const, + }, + { + name: "fileUrl", + mediaField: "fileUrl" as const, + }, + ])( + "keeps remote HTTP $name aliases unchanged under sandbox validation", + async ({ mediaField }) => { + await withSandbox(async (sandboxDir) => { + const remoteUrl = "https://example.com/files/report.pdf?sig=1"; + const result = await runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + [mediaField]: remoteUrl, + message: "", + }, + sandboxRoot: sandboxDir, + }); + + expect(result.kind).toBe("send"); + if (result.kind !== "send") { + throw new Error("expected send result"); + } + expect(result.sendResult?.mediaUrl).toBe(remoteUrl); + }); + }, + ); + it("allows media paths under preferred OpenClaw tmp root", async () => { const tmpRoot = resolvePreferredOpenClawTmpDir(); await fs.mkdir(tmpRoot, { recursive: true }); diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 88f816c2d88b3..224e4a7f2ccf1 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; @@ -58,6 +59,7 @@ describe("runMessageAction plugin dispatch", () => { afterEach(() => { setActivePluginRegistry(createTestRegistry([])); vi.clearAllMocks(); + vi.unstubAllEnvs(); }); it("dispatches messageId/chatId-based Feishu actions through the shared runner", async () => { @@ -114,6 +116,10 @@ describe("runMessageAction plugin dispatch", () => { }); it("routes execution context ids into plugin handleAction", async () => { + const stateDir = path.join("/tmp", "openclaw-plugin-dispatch-media-roots"); + const expectedWorkspaceRoot = path.resolve(stateDir, "workspace-alpha"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + await runMessageAction({ cfg: { channels: { @@ -148,6 +154,7 @@ describe("runMessageAction plugin dispatch", () => { sessionKey: "agent:alpha:main", sessionId: "session-123", agentId: "alpha", + mediaLocalRoots: expect.arrayContaining([expectedWorkspaceRoot]), toolContext: expect.objectContaining({ currentChannelId: "chat:oc_123", currentThreadTs: "thread-456", diff --git a/src/infra/outbound/message-action-runner.poll.test.ts b/src/infra/outbound/message-action-runner.poll.test.ts index 7581be956e263..b1ff7fbeefa2e 100644 --- a/src/infra/outbound/message-action-runner.poll.test.ts +++ b/src/infra/outbound/message-action-runner.poll.test.ts @@ -3,22 +3,12 @@ import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { runMessageAction } from "./message-action-runner.js"; + +let runMessageAction: typeof import("./message-action-runner.js").runMessageAction; const mocks = vi.hoisted(() => ({ executePollAction: vi.fn(), })); - -vi.mock("./outbound-send-service.js", async () => { - const actual = await vi.importActual( - "./outbound-send-service.js", - ); - return { - ...actual, - executePollAction: mocks.executePollAction, - }; -}); - const telegramConfig = { channels: { telegram: { @@ -42,6 +32,12 @@ const telegramPollTestPlugin: ChannelPlugin = { resolveAccount: () => ({ botToken: "telegram-test" }), isConfigured: () => true, }, + outbound: { + deliveryMode: "gateway", + sendPoll: async () => ({ + messageId: "poll-test", + }), + }, messaging: { targetResolver: { looksLikeId: () => true, @@ -96,7 +92,17 @@ async function runPollAction(params: { } describe("runMessageAction poll handling", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("./outbound-send-service.js", async () => { + const actual = await vi.importActual( + "./outbound-send-service.js", + ); + return { + ...actual, + executePollAction: mocks.executePollAction, + }; + }); setActivePluginRegistry( createTestRegistry([ { @@ -112,6 +118,7 @@ describe("runMessageAction poll handling", () => { payload: { ok: true, corePoll: input.resolveCorePoll() }, pollResult: { ok: true }, })); + ({ runMessageAction } = await import("./message-action-runner.js")); }); afterEach(() => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 5ce8612997038..bee834a4b073c 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -263,6 +263,7 @@ type ResolvedActionContext = { cfg: OpenClawConfig; params: Record; channel: ChannelId; + mediaLocalRoots: readonly string[]; accountId?: string | null; dryRun: boolean; gateway?: MessageActionRunnerGateway; @@ -387,8 +388,10 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 0; const hasCard = params.card != null && typeof params.card === "object"; const hasComponents = params.components != null && typeof params.components === "object"; @@ -477,7 +480,8 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise { - const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal, agentId } = ctx; + const { + cfg, + params, + channel, + mediaLocalRoots, + accountId, + dryRun, + gateway, + input, + abortSignal, + agentId, + } = ctx; throwIfAborted(abortSignal); const action = input.action as Exclude; if (dryRun) { @@ -648,6 +663,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise ({ let sendMessage: typeof import("./message.js").sendMessage; let sendPoll: typeof import("./message.js").sendPoll; -beforeAll(async () => { +beforeEach(async () => { + vi.resetModules(); ({ sendMessage, sendPoll } = await import("./message.js")); -}); - -beforeEach(() => { callGatewayMock.mockClear(); setRegistry(emptyRegistry); }); diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index 200d4d587e116..4c48f0260b94e 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -7,49 +7,45 @@ const mocks = vi.hoisted(() => ({ loadOpenClawPlugins: vi.fn(), })); -vi.mock("../../channels/plugins/index.js", () => ({ - normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined, - getChannelPlugin: mocks.getChannelPlugin, - listChannelPlugins: () => [], -})); - -vi.mock("../../agents/agent-scope.js", () => ({ - resolveDefaultAgentId: () => "main", - resolveSessionAgentId: ({ - sessionKey, - }: { - sessionKey?: string; - config?: unknown; - agentId?: string; - }) => { - const match = sessionKey?.match(/^agent:([^:]+)/i); - return match?.[1] ?? "main"; - }, - resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", -})); - -vi.mock("../../config/plugin-auto-enable.js", () => ({ - applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), -})); - -vi.mock("../../plugins/loader.js", () => ({ - loadOpenClawPlugins: mocks.loadOpenClawPlugins, -})); - -vi.mock("./targets.js", () => ({ - resolveOutboundTarget: mocks.resolveOutboundTarget, -})); - -vi.mock("./deliver.js", () => ({ - deliverOutboundPayloads: mocks.deliverOutboundPayloads, -})); - import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { sendMessage } from "./message.js"; + +let sendMessage: typeof import("./message.js").sendMessage; describe("sendMessage", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("../../channels/plugins/index.js", () => ({ + normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined, + getChannelPlugin: mocks.getChannelPlugin, + listChannelPlugins: () => [], + })); + vi.doMock("../../agents/agent-scope.js", () => ({ + resolveDefaultAgentId: () => "main", + resolveSessionAgentId: ({ + sessionKey, + }: { + sessionKey?: string; + config?: unknown; + agentId?: string; + }) => { + const match = sessionKey?.match(/^agent:([^:]+)/i); + return match?.[1] ?? "main"; + }, + resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", + })); + vi.doMock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), + })); + vi.doMock("../../plugins/loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, + })); + vi.doMock("./targets.js", () => ({ + resolveOutboundTarget: mocks.resolveOutboundTarget, + })); + vi.doMock("./deliver.js", () => ({ + deliverOutboundPayloads: mocks.deliverOutboundPayloads, + })); setActivePluginRegistry(createTestRegistry([])); mocks.getChannelPlugin.mockClear(); mocks.resolveOutboundTarget.mockClear(); @@ -61,6 +57,8 @@ describe("sendMessage", () => { }); mocks.resolveOutboundTarget.mockImplementation(({ to }: { to: string }) => ({ ok: true, to })); mocks.deliverOutboundPayloads.mockResolvedValue([{ channel: "mattermost", messageId: "m1" }]); + + ({ sendMessage } = await import("./message.js")); }); it("passes explicit agentId to outbound delivery for scoped media roots", async () => { diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index e4b78e791ac0d..6ce6db530ddbd 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -2,12 +2,11 @@ import { Container, Separator, TextDisplay } from "@buape/carbon"; import { beforeEach, describe, expect, it } from "vitest"; import { vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { - applyCrossContextDecoration, - buildCrossContextDecoration, - enforceCrossContextPolicy, - shouldApplyCrossContextMarker, -} from "./outbound-policy.js"; + +let applyCrossContextDecoration: typeof import("./outbound-policy.js").applyCrossContextDecoration; +let buildCrossContextDecoration: typeof import("./outbound-policy.js").buildCrossContextDecoration; +let enforceCrossContextPolicy: typeof import("./outbound-policy.js").enforceCrossContextPolicy; +let shouldApplyCrossContextMarker: typeof import("./outbound-policy.js").shouldApplyCrossContextMarker; class TestDiscordUiContainer extends Container {} @@ -53,19 +52,6 @@ const mocks = vi.hoisted(() => ({ ), })); -vi.mock("./channel-adapters.js", () => ({ - getChannelMessageAdapter: mocks.getChannelMessageAdapter, -})); - -vi.mock("./target-normalization.js", () => ({ - normalizeTargetForProvider: mocks.normalizeTargetForProvider, -})); - -vi.mock("./target-resolver.js", () => ({ - formatTargetDisplay: mocks.formatTargetDisplay, - lookupDirectoryDisplay: mocks.lookupDirectoryDisplay, -})); - const slackConfig = { channels: { slack: { @@ -82,8 +68,25 @@ const discordConfig = { } as OpenClawConfig; describe("outbound policy helpers", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); + vi.doMock("./channel-adapters.js", () => ({ + getChannelMessageAdapter: mocks.getChannelMessageAdapter, + })); + vi.doMock("./target-normalization.js", () => ({ + normalizeTargetForProvider: mocks.normalizeTargetForProvider, + })); + vi.doMock("./target-resolver.js", () => ({ + formatTargetDisplay: mocks.formatTargetDisplay, + lookupDirectoryDisplay: mocks.lookupDirectoryDisplay, + })); + ({ + applyCrossContextDecoration, + buildCrossContextDecoration, + enforceCrossContextPolicy, + shouldApplyCrossContextMarker, + } = await import("./outbound-policy.js")); }); it("allows cross-provider sends when enabled", () => { diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index 6f0cf32e6e56a..76fec66fb64a5 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -45,7 +45,7 @@ describe("executeSendAction", () => { continuePrompt: "", output: "", sessionId: "s1", - model: "gpt-5.2", + model: "gpt-5.4", usage: {}, }; } diff --git a/src/infra/outbound/outbound-session.test.ts b/src/infra/outbound/outbound-session.test.ts index 7a45f938bf82e..a51e7e9d91d0f 100644 --- a/src/infra/outbound/outbound-session.test.ts +++ b/src/infra/outbound/outbound-session.test.ts @@ -265,6 +265,30 @@ describe("resolveOutboundSessionRoute", () => { chatType: "direct", }, }, + { + name: "Slack user DM target", + cfg: perChannelPeerCfg, + channel: "slack", + target: "user:U12345ABC", + expected: { + sessionKey: "agent:main:slack:direct:u12345abc", + from: "slack:U12345ABC", + to: "user:U12345ABC", + chatType: "direct", + }, + }, + { + name: "Slack channel target without thread", + cfg: baseConfig, + channel: "slack", + target: "channel:C999XYZ", + expected: { + sessionKey: "agent:main:slack:channel:c999xyz", + from: "slack:channel:C999XYZ", + to: "channel:C999XYZ", + chatType: "channel", + }, + }, ]; for (const testCase of cases) { diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index 6ed3a74a8eada..069d61fdec70e 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -1,17 +1,7 @@ import { parseDiscordTarget } from "../../../extensions/discord/api.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "../../../extensions/imessage/api.js"; -import { - looksLikeUuid, - resolveSignalPeerId, - resolveSignalRecipient, - resolveSignalSender, -} from "../../../extensions/signal/api.js"; -import { - createSlackWebClient, - normalizeAllowListLower, - parseSlackTarget, - resolveSlackAccount, -} from "../../../extensions/slack/api.js"; +import { resolveSignalOutboundTarget } from "../../../extensions/signal/api.js"; +import { parseSlackTarget, resolveSlackChannelType } from "../../../extensions/slack/api.js"; import { buildTelegramGroupPeerId, parseTelegramTarget, @@ -50,9 +40,6 @@ export type ResolveOutboundSessionRouteParams = { threadId?: string | number | null; }; -// Cache Slack channel type lookups to avoid repeated API calls. -const SLACK_CHANNEL_TYPE_CACHE = new Map(); - function normalizeThreadId(value?: string | number | null): string | undefined { if (value == null) { return undefined; @@ -122,69 +109,6 @@ function buildBaseSessionKey(params: { }); } -// Best-effort mpim detection: allowlist/config, then Slack API (if token available). -async function resolveSlackChannelType(params: { - cfg: OpenClawConfig; - accountId?: string | null; - channelId: string; -}): Promise<"channel" | "group" | "dm" | "unknown"> { - const channelId = params.channelId.trim(); - if (!channelId) { - return "unknown"; - } - const cached = SLACK_CHANNEL_TYPE_CACHE.get(`${params.accountId ?? "default"}:${channelId}`); - if (cached) { - return cached; - } - - const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const groupChannels = normalizeAllowListLower(account.dm?.groupChannels); - const channelIdLower = channelId.toLowerCase(); - if ( - groupChannels.includes(channelIdLower) || - groupChannels.includes(`slack:${channelIdLower}`) || - groupChannels.includes(`channel:${channelIdLower}`) || - groupChannels.includes(`group:${channelIdLower}`) || - groupChannels.includes(`mpim:${channelIdLower}`) - ) { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "group"); - return "group"; - } - - const channelKeys = Object.keys(account.channels ?? {}); - if ( - channelKeys.some((key) => { - const normalized = key.trim().toLowerCase(); - return ( - normalized === channelIdLower || - normalized === `channel:${channelIdLower}` || - normalized.replace(/^#/, "") === channelIdLower - ); - }) - ) { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "channel"); - return "channel"; - } - - const token = account.botToken?.trim() || account.userToken || ""; - if (!token) { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown"); - return "unknown"; - } - - try { - const client = createSlackWebClient(token); - const info = await client.conversations.info({ channel: channelId }); - const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined; - const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, type); - return type; - } catch { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown"); - return "unknown"; - } -} - async function resolveSlackSession( params: ResolveOutboundSessionRouteParams, ): Promise { @@ -386,65 +310,21 @@ function resolveWhatsAppSession( function resolveSignalSession( params: ResolveOutboundSessionRouteParams, ): OutboundSessionRoute | null { - const stripped = stripProviderPrefix(params.target, "signal"); - const lowered = stripped.toLowerCase(); - if (lowered.startsWith("group:")) { - const groupId = stripped.slice("group:".length).trim(); - if (!groupId) { - return null; - } - const peer: RoutePeer = { kind: "group", id: groupId }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - channel: "signal", - accountId: params.accountId, - peer, - }); - return { - sessionKey: baseSessionKey, - baseSessionKey, - peer, - chatType: "group", - from: `group:${groupId}`, - to: `group:${groupId}`, - }; - } - - let recipient = stripped.trim(); - if (lowered.startsWith("username:")) { - recipient = stripped.slice("username:".length).trim(); - } else if (lowered.startsWith("u:")) { - recipient = stripped.slice("u:".length).trim(); - } - if (!recipient) { + const resolved = resolveSignalOutboundTarget(params.target); + if (!resolved) { return null; } - - const uuidCandidate = recipient.toLowerCase().startsWith("uuid:") - ? recipient.slice("uuid:".length) - : recipient; - const sender = resolveSignalSender({ - sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null, - sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient, - }); - const peerId = sender ? resolveSignalPeerId(sender) : recipient; - const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient; - const peer: RoutePeer = { kind: "direct", id: peerId }; const baseSessionKey = buildBaseSessionKey({ cfg: params.cfg, agentId: params.agentId, channel: "signal", accountId: params.accountId, - peer, + peer: resolved.peer, }); return { sessionKey: baseSessionKey, baseSessionKey, - peer, - chatType: "direct", - from: `signal:${displayRecipient}`, - to: `signal:${displayRecipient}`, + ...resolved, }; } diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 9de658e2b7e40..9158530871035 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; @@ -8,22 +6,6 @@ import { typedCases } from "../../test-utils/typed-cases.js"; import { DirectoryCache } from "./directory-cache.js"; import { buildOutboundResultEnvelope } from "./envelope.js"; import type { OutboundDeliveryJson } from "./format.js"; -import { - buildOutboundDeliveryJson, - formatGatewaySummary, - formatOutboundDeliverySummary, -} from "./format.js"; -import { - applyCrossContextDecoration, - buildCrossContextDecoration, - enforceCrossContextPolicy, -} from "./outbound-policy.js"; -import { resolveOutboundSessionRoute } from "./outbound-session.js"; -import { - formatOutboundPayloadLog, - normalizeOutboundPayloads, - normalizeOutboundPayloadsForJson, -} from "./payloads.js"; import { runResolveOutboundTargetCoreTests } from "./targets.shared-test.js"; beforeEach(() => { @@ -149,700 +131,4 @@ describe("buildOutboundResultEnvelope", () => { }); }); -describe("formatOutboundDeliverySummary", () => { - it("formats fallback and channel-specific detail variants", () => { - const cases = [ - { - name: "fallback telegram", - channel: "telegram" as const, - result: undefined, - expected: "✅ Sent via Telegram. Message ID: unknown", - }, - { - name: "fallback imessage", - channel: "imessage" as const, - result: undefined, - expected: "✅ Sent via iMessage. Message ID: unknown", - }, - { - name: "telegram with chat detail", - channel: "telegram" as const, - result: { - channel: "telegram" as const, - messageId: "m1", - chatId: "c1", - }, - expected: "✅ Sent via Telegram. Message ID: m1 (chat c1)", - }, - { - name: "discord with channel detail", - channel: "discord" as const, - result: { - channel: "discord" as const, - messageId: "d1", - channelId: "chan", - }, - expected: "✅ Sent via Discord. Message ID: d1 (channel chan)", - }, - ]; - - for (const testCase of cases) { - expect(formatOutboundDeliverySummary(testCase.channel, testCase.result), testCase.name).toBe( - testCase.expected, - ); - } - }); -}); - -describe("buildOutboundDeliveryJson", () => { - it("builds direct delivery payloads across provider-specific fields", () => { - const cases = [ - { - name: "telegram direct payload", - input: { - channel: "telegram" as const, - to: "123", - result: { channel: "telegram" as const, messageId: "m1", chatId: "c1" }, - mediaUrl: "https://example.com/a.png", - }, - expected: { - channel: "telegram", - via: "direct", - to: "123", - messageId: "m1", - mediaUrl: "https://example.com/a.png", - chatId: "c1", - }, - }, - { - name: "whatsapp metadata", - input: { - channel: "whatsapp" as const, - to: "+1", - result: { channel: "whatsapp" as const, messageId: "w1", toJid: "jid" }, - }, - expected: { - channel: "whatsapp", - via: "direct", - to: "+1", - messageId: "w1", - mediaUrl: null, - toJid: "jid", - }, - }, - { - name: "signal timestamp", - input: { - channel: "signal" as const, - to: "+1", - result: { channel: "signal" as const, messageId: "s1", timestamp: 123 }, - }, - expected: { - channel: "signal", - via: "direct", - to: "+1", - messageId: "s1", - mediaUrl: null, - timestamp: 123, - }, - }, - ]; - - for (const testCase of cases) { - expect(buildOutboundDeliveryJson(testCase.input), testCase.name).toEqual(testCase.expected); - } - }); -}); - -describe("formatGatewaySummary", () => { - it("formats default and custom gateway action summaries", () => { - const cases = [ - { - name: "default send action", - input: { channel: "whatsapp", messageId: "m1" }, - expected: "✅ Sent via gateway (whatsapp). Message ID: m1", - }, - { - name: "custom action", - input: { action: "Poll sent", channel: "discord", messageId: "p1" }, - expected: "✅ Poll sent via gateway (discord). Message ID: p1", - }, - ]; - - for (const testCase of cases) { - expect(formatGatewaySummary(testCase.input), testCase.name).toBe(testCase.expected); - } - }); -}); - -const slackConfig = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - }, - }, -} as OpenClawConfig; - -const discordConfig = { - channels: { - discord: {}, - }, -} as OpenClawConfig; - -describe("outbound policy", () => { - beforeEach(() => { - setDefaultChannelPluginRegistryForTests(); - }); - - it("allows cross-provider sends when enabled", () => { - const cfg = { - ...slackConfig, - tools: { - message: { crossContext: { allowAcrossProviders: true } }, - }, - } as OpenClawConfig; - - expect(() => - enforceCrossContextPolicy({ - cfg, - channel: "telegram", - action: "send", - args: { to: "telegram:@ops" }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }), - ).not.toThrow(); - }); - - it("uses components when available and preferred", async () => { - const decoration = await buildCrossContextDecoration({ - cfg: discordConfig, - channel: "discord", - target: "123", - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "discord" }, - }); - - expect(decoration).not.toBeNull(); - const applied = applyCrossContextDecoration({ - message: "hello", - decoration: decoration!, - preferComponents: true, - }); - - expect(applied.usedComponents).toBe(true); - expect(applied.componentsBuilder).toBeDefined(); - expect(applied.componentsBuilder?.("hello").length).toBeGreaterThan(0); - expect(applied.message).toBe("hello"); - }); -}); - -describe("resolveOutboundSessionRoute", () => { - beforeEach(() => { - setDefaultChannelPluginRegistryForTests(); - }); - - const baseConfig = {} as OpenClawConfig; - - it("resolves provider-specific session routes", async () => { - const perChannelPeerCfg = { session: { dmScope: "per-channel-peer" } } as OpenClawConfig; - const identityLinksCfg = { - session: { - dmScope: "per-peer", - identityLinks: { - alice: ["discord:123"], - }, - }, - } as OpenClawConfig; - const slackMpimCfg = { - channels: { - slack: { - dm: { - groupChannels: ["G123"], - }, - }, - }, - } as OpenClawConfig; - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - channel: string; - target: string; - replyToId?: string; - threadId?: string; - expected: { - sessionKey: string; - from?: string; - to?: string; - threadId?: string | number; - chatType?: "channel" | "direct" | "group"; - }; - }> = [ - { - name: "WhatsApp group jid", - cfg: baseConfig, - channel: "whatsapp", - target: "120363040000000000@g.us", - expected: { - sessionKey: "agent:main:whatsapp:group:120363040000000000@g.us", - from: "120363040000000000@g.us", - to: "120363040000000000@g.us", - chatType: "group", - }, - }, - { - name: "Matrix room target", - cfg: baseConfig, - channel: "matrix", - target: "room:!ops:matrix.example", - expected: { - sessionKey: "agent:main:matrix:channel:!ops:matrix.example", - from: "matrix:channel:!ops:matrix.example", - to: "room:!ops:matrix.example", - chatType: "channel", - }, - }, - { - name: "MSTeams conversation target", - cfg: baseConfig, - channel: "msteams", - target: "conversation:19:meeting_abc@thread.tacv2", - expected: { - sessionKey: "agent:main:msteams:channel:19:meeting_abc@thread.tacv2", - from: "msteams:channel:19:meeting_abc@thread.tacv2", - to: "conversation:19:meeting_abc@thread.tacv2", - chatType: "channel", - }, - }, - { - name: "Slack thread", - cfg: baseConfig, - channel: "slack", - target: "channel:C123", - replyToId: "456", - expected: { - sessionKey: "agent:main:slack:channel:c123:thread:456", - from: "slack:channel:C123", - to: "channel:C123", - threadId: "456", - }, - }, - { - name: "Telegram topic group", - cfg: baseConfig, - channel: "telegram", - target: "-100123456:topic:42", - expected: { - sessionKey: "agent:main:telegram:group:-100123456:topic:42", - from: "telegram:group:-100123456:topic:42", - to: "telegram:-100123456", - threadId: 42, - }, - }, - { - name: "Telegram DM with topic", - cfg: perChannelPeerCfg, - channel: "telegram", - target: "123456789:topic:99", - expected: { - sessionKey: "agent:main:telegram:direct:123456789:thread:99", - from: "telegram:123456789:topic:99", - to: "telegram:123456789", - threadId: 99, - chatType: "direct", - }, - }, - { - name: "Telegram unresolved username DM", - cfg: perChannelPeerCfg, - channel: "telegram", - target: "@alice", - expected: { - sessionKey: "agent:main:telegram:direct:@alice", - chatType: "direct", - }, - }, - { - name: "Telegram DM scoped threadId fallback", - cfg: perChannelPeerCfg, - channel: "telegram", - target: "12345", - threadId: "12345:99", - expected: { - sessionKey: "agent:main:telegram:direct:12345:thread:99", - from: "telegram:12345:topic:99", - to: "telegram:12345", - threadId: 99, - chatType: "direct", - }, - }, - { - name: "identity-links per-peer", - cfg: identityLinksCfg, - channel: "discord", - target: "user:123", - expected: { - sessionKey: "agent:main:direct:alice", - }, - }, - { - name: "Nextcloud Talk room target", - cfg: baseConfig, - channel: "nextcloud-talk", - target: "room:opsroom42", - expected: { - sessionKey: "agent:main:nextcloud-talk:group:opsroom42", - from: "nextcloud-talk:room:opsroom42", - to: "nextcloud-talk:opsroom42", - chatType: "group", - }, - }, - { - name: "BlueBubbles chat_* prefix stripping", - cfg: baseConfig, - channel: "bluebubbles", - target: "chat_guid:ABC123", - expected: { - sessionKey: "agent:main:bluebubbles:group:abc123", - from: "group:ABC123", - }, - }, - { - name: "Zalo direct target", - cfg: perChannelPeerCfg, - channel: "zalo", - target: "zl:123456", - expected: { - sessionKey: "agent:main:zalo:direct:123456", - from: "zalo:123456", - to: "zalo:123456", - chatType: "direct", - }, - }, - { - name: "Zalo Personal DM target", - cfg: perChannelPeerCfg, - channel: "zalouser", - target: "123456", - expected: { - sessionKey: "agent:main:zalouser:direct:123456", - chatType: "direct", - }, - }, - { - name: "Nostr prefixed target", - cfg: perChannelPeerCfg, - channel: "nostr", - target: "nostr:npub1example", - expected: { - sessionKey: "agent:main:nostr:direct:npub1example", - from: "nostr:npub1example", - to: "nostr:npub1example", - chatType: "direct", - }, - }, - { - name: "Tlon group target", - cfg: baseConfig, - channel: "tlon", - target: "group:~zod/main", - expected: { - sessionKey: "agent:main:tlon:group:chat/~zod/main", - from: "tlon:group:chat/~zod/main", - to: "tlon:chat/~zod/main", - chatType: "group", - }, - }, - { - name: "Slack mpim allowlist -> group key", - cfg: slackMpimCfg, - channel: "slack", - target: "channel:G123", - expected: { - sessionKey: "agent:main:slack:group:g123", - from: "slack:group:G123", - }, - }, - { - name: "Feishu explicit group prefix keeps group routing", - cfg: baseConfig, - channel: "feishu", - target: "group:oc_group_chat", - expected: { - sessionKey: "agent:main:feishu:group:oc_group_chat", - from: "feishu:group:oc_group_chat", - to: "oc_group_chat", - chatType: "group", - }, - }, - { - name: "Feishu explicit dm prefix keeps direct routing", - cfg: perChannelPeerCfg, - channel: "feishu", - target: "dm:oc_dm_chat", - expected: { - sessionKey: "agent:main:feishu:direct:oc_dm_chat", - from: "feishu:oc_dm_chat", - to: "oc_dm_chat", - chatType: "direct", - }, - }, - { - name: "Feishu bare oc_ target defaults to direct routing", - cfg: perChannelPeerCfg, - channel: "feishu", - target: "oc_ambiguous_chat", - expected: { - sessionKey: "agent:main:feishu:direct:oc_ambiguous_chat", - from: "feishu:oc_ambiguous_chat", - to: "oc_ambiguous_chat", - chatType: "direct", - }, - }, - { - name: "Slack user DM target", - cfg: perChannelPeerCfg, - channel: "slack", - target: "user:U12345ABC", - expected: { - sessionKey: "agent:main:slack:direct:u12345abc", - from: "slack:U12345ABC", - to: "user:U12345ABC", - chatType: "direct", - }, - }, - { - name: "Slack channel target without thread", - cfg: baseConfig, - channel: "slack", - target: "channel:C999XYZ", - expected: { - sessionKey: "agent:main:slack:channel:c999xyz", - from: "slack:channel:C999XYZ", - to: "channel:C999XYZ", - chatType: "channel", - }, - }, - ]; - - for (const testCase of cases) { - const route = await resolveOutboundSessionRoute({ - cfg: testCase.cfg, - channel: testCase.channel, - agentId: "main", - target: testCase.target, - replyToId: testCase.replyToId, - threadId: testCase.threadId, - }); - expect(route?.sessionKey, testCase.name).toBe(testCase.expected.sessionKey); - if (testCase.expected.from !== undefined) { - expect(route?.from, testCase.name).toBe(testCase.expected.from); - } - if (testCase.expected.to !== undefined) { - expect(route?.to, testCase.name).toBe(testCase.expected.to); - } - if (testCase.expected.threadId !== undefined) { - expect(route?.threadId, testCase.name).toBe(testCase.expected.threadId); - } - if (testCase.expected.chatType !== undefined) { - expect(route?.chatType, testCase.name).toBe(testCase.expected.chatType); - } - } - }); - - it("uses resolved Discord user targets to route bare numeric ids as DMs", async () => { - const route = await resolveOutboundSessionRoute({ - cfg: { session: { dmScope: "per-channel-peer" } } as OpenClawConfig, - channel: "discord", - agentId: "main", - target: "123", - resolvedTarget: { - to: "user:123", - kind: "user", - source: "directory", - }, - }); - - expect(route).toMatchObject({ - sessionKey: "agent:main:discord:direct:123", - from: "discord:123", - to: "user:123", - chatType: "direct", - }); - }); - - it("uses resolved Mattermost user targets to route bare ids as DMs", async () => { - const userId = "dthcxgoxhifn3pwh65cut3ud3w"; - const route = await resolveOutboundSessionRoute({ - cfg: { session: { dmScope: "per-channel-peer" } } as OpenClawConfig, - channel: "mattermost", - agentId: "main", - target: userId, - resolvedTarget: { - to: `user:${userId}`, - kind: "user", - source: "directory", - }, - }); - - expect(route).toMatchObject({ - sessionKey: `agent:main:mattermost:direct:${userId}`, - from: `mattermost:${userId}`, - to: `user:${userId}`, - chatType: "direct", - }); - }); - - it("rejects bare numeric Discord targets when the caller has no kind hint", async () => { - await expect( - resolveOutboundSessionRoute({ - cfg: { session: { dmScope: "per-channel-peer" } } as OpenClawConfig, - channel: "discord", - agentId: "main", - target: "123", - }), - ).rejects.toThrow(/Ambiguous Discord recipient/); - }); -}); - -describe("normalizeOutboundPayloadsForJson", () => { - it("normalizes payloads for JSON output", () => { - const cases = typedCases<{ - input: Parameters[0]; - expected: ReturnType; - }>([ - { - input: [ - { text: "hi" }, - { text: "photo", mediaUrl: "https://x.test/a.jpg", audioAsVoice: true }, - { text: "multi", mediaUrls: ["https://x.test/1.png"] }, - ], - expected: [ - { - text: "hi", - mediaUrl: null, - mediaUrls: undefined, - audioAsVoice: undefined, - channelData: undefined, - }, - { - text: "photo", - mediaUrl: "https://x.test/a.jpg", - mediaUrls: ["https://x.test/a.jpg"], - audioAsVoice: true, - channelData: undefined, - }, - { - text: "multi", - mediaUrl: null, - mediaUrls: ["https://x.test/1.png"], - audioAsVoice: undefined, - channelData: undefined, - }, - ], - }, - { - input: [ - { - text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", - }, - ], - expected: [ - { - text: "", - mediaUrl: null, - mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], - audioAsVoice: undefined, - channelData: undefined, - }, - ], - }, - ]); - - for (const testCase of cases) { - const input: ReplyPayload[] = testCase.input.map((payload) => - "mediaUrls" in payload - ? ({ - ...payload, - mediaUrls: payload.mediaUrls ? [...payload.mediaUrls] : undefined, - } as ReplyPayload) - : ({ ...payload } as ReplyPayload), - ); - expect(normalizeOutboundPayloadsForJson(input)).toEqual(testCase.expected); - } - }); - - it("suppresses reasoning payloads", () => { - const normalized = normalizeOutboundPayloadsForJson([ - { text: "Reasoning:\n_step_", isReasoning: true }, - { text: "final answer" }, - ]); - expect(normalized).toEqual([ - { text: "final answer", mediaUrl: null, mediaUrls: undefined, audioAsVoice: undefined }, - ]); - }); -}); - -describe("normalizeOutboundPayloads", () => { - it("keeps channelData-only payloads", () => { - const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } }; - const normalized = normalizeOutboundPayloads([{ channelData }]); - expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]); - }); - - it("suppresses reasoning payloads", () => { - const normalized = normalizeOutboundPayloads([ - { text: "Reasoning:\n_step_", isReasoning: true }, - { text: "final answer" }, - ]); - expect(normalized).toEqual([{ text: "final answer", mediaUrls: [] }]); - }); - - it("formats BTW replies prominently for external delivery", () => { - const normalized = normalizeOutboundPayloads([ - { - text: "323", - btw: { question: "what is 17 * 19?" }, - }, - ]); - expect(normalized).toEqual([{ text: "BTW\nQuestion: what is 17 * 19?\n\n323", mediaUrls: [] }]); - }); -}); - -describe("formatOutboundPayloadLog", () => { - it("formats text+media and media-only logs", () => { - const cases = typedCases<{ - name: string; - input: Parameters[0]; - expected: string; - }>([ - { - name: "text with media lines", - input: { - text: "hello ", - mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], - }, - expected: "hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", - }, - { - name: "media only", - input: { - text: "", - mediaUrls: ["https://x.test/a.png"], - }, - expected: "MEDIA:https://x.test/a.png", - }, - ]); - - for (const testCase of cases) { - expect( - formatOutboundPayloadLog({ - ...testCase.input, - mediaUrls: [...testCase.input.mediaUrls], - }), - testCase.name, - ).toBe(testCase.expected); - } - }); -}); - runResolveOutboundTargetCoreTests(); diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index 3aaf1a2f61ede..7faae74042be0 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -169,6 +169,17 @@ describe("normalizeOutboundPayloads", () => { ]), ).toEqual([{ text: "final answer", mediaUrls: [] }]); }); + + it("formats BTW replies prominently for external delivery", () => { + expect( + normalizeOutboundPayloads([ + { + text: "323", + btw: { question: "what is 17 * 19?" }, + }, + ]), + ).toEqual([{ text: "BTW\nQuestion: what is 17 * 19?\n\n323", mediaUrls: [] }]); + }); }); describe("formatOutboundPayloadLog", () => { diff --git a/src/infra/outbound/target-normalization.test.ts b/src/infra/outbound/target-normalization.test.ts index 33b4fd8f08cfc..9e6401d465dff 100644 --- a/src/infra/outbound/target-normalization.test.ts +++ b/src/infra/outbound/target-normalization.test.ts @@ -10,47 +10,34 @@ let buildTargetResolverSignature: TargetNormalizationModule["buildTargetResolver let normalizeChannelTargetInput: TargetNormalizationModule["normalizeChannelTargetInput"]; let normalizeTargetForProvider: TargetNormalizationModule["normalizeTargetForProvider"]; -describe("normalizeChannelTargetInput", () => { - beforeEach(async () => { - vi.resetModules(); - normalizeChannelIdMock.mockReset(); - getChannelPluginMock.mockReset(); - getActivePluginRegistryVersionMock.mockReset(); - vi.doMock("../../channels/plugins/index.js", () => ({ - normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args), - getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), - })); - vi.doMock("../../plugins/runtime.js", () => ({ - getActivePluginRegistryVersion: (...args: unknown[]) => - getActivePluginRegistryVersionMock(...args), - })); - ({ buildTargetResolverSignature, normalizeChannelTargetInput, normalizeTargetForProvider } = - await import("./target-normalization.js")); - }); +async function loadTargetNormalizationModule() { + vi.doMock("../../channels/plugins/index.js", () => ({ + normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args), + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + })); + vi.doMock("../../plugins/runtime.js", () => ({ + getActivePluginRegistryVersion: (...args: unknown[]) => + getActivePluginRegistryVersionMock(...args), + })); + ({ buildTargetResolverSignature, normalizeChannelTargetInput, normalizeTargetForProvider } = + await import("./target-normalization.js")); +} + +beforeEach(async () => { + vi.resetModules(); + normalizeChannelIdMock.mockReset(); + getChannelPluginMock.mockReset(); + getActivePluginRegistryVersionMock.mockReset(); + await loadTargetNormalizationModule(); +}); +describe("normalizeChannelTargetInput", () => { it("trims raw target input", () => { expect(normalizeChannelTargetInput(" channel:C1 ")).toBe("channel:C1"); }); }); describe("normalizeTargetForProvider", () => { - beforeEach(async () => { - vi.resetModules(); - normalizeChannelIdMock.mockReset(); - getChannelPluginMock.mockReset(); - getActivePluginRegistryVersionMock.mockReset(); - vi.doMock("../../channels/plugins/index.js", () => ({ - normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args), - getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), - })); - vi.doMock("../../plugins/runtime.js", () => ({ - getActivePluginRegistryVersion: (...args: unknown[]) => - getActivePluginRegistryVersionMock(...args), - })); - ({ buildTargetResolverSignature, normalizeChannelTargetInput, normalizeTargetForProvider } = - await import("./target-normalization.js")); - }); - it("returns undefined for missing or blank raw input", () => { expect(normalizeTargetForProvider("telegram")).toBeUndefined(); expect(normalizeTargetForProvider("telegram", " ")).toBeUndefined(); @@ -105,23 +92,6 @@ describe("normalizeTargetForProvider", () => { }); describe("buildTargetResolverSignature", () => { - beforeEach(async () => { - vi.resetModules(); - normalizeChannelIdMock.mockReset(); - getChannelPluginMock.mockReset(); - getActivePluginRegistryVersionMock.mockReset(); - vi.doMock("../../channels/plugins/index.js", () => ({ - normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args), - getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), - })); - vi.doMock("../../plugins/runtime.js", () => ({ - getActivePluginRegistryVersion: (...args: unknown[]) => - getActivePluginRegistryVersionMock(...args), - })); - ({ buildTargetResolverSignature, normalizeChannelTargetInput, normalizeTargetForProvider } = - await import("./target-normalization.js")); - }); - it("builds stable signatures from resolver hint and looksLikeId source", () => { const looksLikeId = (value: string) => value.startsWith("C"); getChannelPluginMock.mockReturnValueOnce({ diff --git a/src/infra/outbound/targets.channel-resolution.test.ts b/src/infra/outbound/targets.channel-resolution.test.ts index f7e38e0bfef90..e0cfb3cd2408d 100644 --- a/src/infra/outbound/targets.channel-resolution.test.ts +++ b/src/infra/outbound/targets.channel-resolution.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getChannelPlugin: vi.fn(), @@ -46,9 +46,9 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({ }, })); -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; - +let setActivePluginRegistry: typeof import("../../plugins/runtime.js").setActivePluginRegistry; +let createTestRegistry: typeof import("../../test-utils/channel-plugins.js").createTestRegistry; +let resetOutboundChannelResolutionStateForTest: typeof import("./channel-resolution.js").resetOutboundChannelResolutionStateForTest; let resolveOutboundTarget: typeof import("./targets.js").resolveOutboundTarget; describe("resolveOutboundTarget channel resolution", () => { @@ -61,10 +61,17 @@ describe("resolveOutboundTarget channel resolution", () => { mode: "explicit", }); - beforeEach(async () => { + beforeAll(async () => { vi.resetModules(); + ({ setActivePluginRegistry } = await import("../../plugins/runtime.js")); + ({ createTestRegistry } = await import("../../test-utils/channel-plugins.js")); + ({ resetOutboundChannelResolutionStateForTest } = await import("./channel-resolution.js")); ({ resolveOutboundTarget } = await import("./targets.js")); + }); + + beforeEach(() => { registrySeq += 1; + resetOutboundChannelResolutionStateForTest(); setActivePluginRegistry(createTestRegistry([]), `targets-test-${registrySeq}`); mocks.getChannelPlugin.mockReset(); mocks.loadOpenClawPlugins.mockReset(); diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 2408a28a9bd4e..05f3c6fbd8e9a 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -1,8 +1,11 @@ +import nodeFs from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; const resolveProviderUsageAuthWithPluginMock = vi.fn(async (..._args: unknown[]) => null); @@ -10,8 +13,15 @@ vi.mock("../plugins/provider-runtime.js", () => ({ resolveProviderUsageAuthWithPlugin: resolveProviderUsageAuthWithPluginMock, })); +vi.mock("../agents/cli-credentials.js", () => ({ + readCodexCliCredentialsCached: () => null, + readMiniMaxCliCredentialsCached: () => null, + readQwenCliCredentialsCached: () => null, +})); + let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths; -type ProviderAuth = import("./provider-usage.auth.js").ProviderAuth; +let clearRuntimeAuthProfileStoreSnapshots: typeof import("../agents/auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots; +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; describe("resolveProviderAuths key normalization", () => { let suiteRoot = ""; @@ -26,7 +36,6 @@ describe("resolveProviderAuths key normalization", () => { beforeAll(async () => { suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-suite-")); - ({ resolveProviderAuths } = await import("./provider-usage.auth.js")); }); afterAll(async () => { @@ -35,66 +44,57 @@ describe("resolveProviderAuths key normalization", () => { suiteCase = 0; }); - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resolveProviderAuths } = await import("./provider-usage.auth.js")); + ({ clearRuntimeAuthProfileStoreSnapshots } = await import("../agents/auth-profiles.js")); + ({ clearConfigCache } = await import("../config/config.js")); + clearConfigCache(); + clearRuntimeAuthProfileStoreSnapshots(); resolveProviderUsageAuthWithPluginMock.mockReset(); resolveProviderUsageAuthWithPluginMock.mockResolvedValue(null); }); - async function withSuiteHome( - fn: (home: string) => Promise, - env: Record, - ): Promise { + afterEach(() => { + clearConfigCache(); + clearRuntimeAuthProfileStoreSnapshots(); + vi.restoreAllMocks(); + }); + + async function withSuiteHome(fn: (home: string) => Promise): Promise { const base = path.join(suiteRoot, `case-${++suiteCase}`); - await fs.mkdir(base, { recursive: true }); - await fs.mkdir(path.join(base, ".openclaw", "agents", "main", "sessions"), { recursive: true }); - - const keysToRestore = new Set([ - "HOME", - "USERPROFILE", - "HOMEDRIVE", - "HOMEPATH", - "OPENCLAW_HOME", - "OPENCLAW_STATE_DIR", - ...Object.keys(env), - ]); - const snapshot: Record = {}; - for (const key of keysToRestore) { - snapshot[key] = process.env[key]; - } + nodeFs.mkdirSync(base, { recursive: true }); + nodeFs.mkdirSync(path.join(base, ".openclaw", "agents", "main", "sessions"), { + recursive: true, + }); + return await fn(base); + } - process.env.HOME = base; - process.env.USERPROFILE = base; - if (process.platform === "win32") { - const match = base.match(/^([A-Za-z]:)(.*)$/); - if (match) { - process.env.HOMEDRIVE = match[1]; - process.env.HOMEPATH = match[2] || "\\"; - } - } - delete process.env.OPENCLAW_HOME; - process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw"); - for (const [key, value] of Object.entries(env)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - try { - return await fn(base); - } finally { - for (const [key, value] of Object.entries(snapshot)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } + function agentDirForHome(home: string): string { + return path.join(home, ".openclaw", "agents", "main", "agent"); + } + + function buildSuiteEnv( + home: string, + env: Record = {}, + ): NodeJS.ProcessEnv { + const suiteEnv: NodeJS.ProcessEnv = { + ...EMPTY_PROVIDER_ENV, + HOME: home, + USERPROFILE: home, + OPENCLAW_STATE_DIR: path.join(home, ".openclaw"), + ...env, + }; + const match = home.match(/^([A-Za-z]:)(.*)$/); + if (match) { + suiteEnv.HOMEDRIVE = match[1]; + suiteEnv.HOMEPATH = match[2] || "\\"; } + return suiteEnv; } async function writeAuthProfiles(home: string, profiles: Record) { - const agentDir = path.join(home, ".openclaw", "agents", "main", "agent"); + const agentDir = agentDirForHome(home); await fs.mkdir(agentDir, { recursive: true }); await fs.writeFile( path.join(agentDir, "auth-profiles.json"), @@ -114,7 +114,7 @@ describe("resolveProviderAuths key normalization", () => { } async function writeProfileOrder(home: string, provider: string, profileIds: string[]) { - const agentDir = path.join(home, ".openclaw", "agents", "main", "agent"); + const agentDir = agentDirForHome(home); const parsed = JSON.parse( await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"), ) as Record; @@ -136,7 +136,7 @@ describe("resolveProviderAuths key normalization", () => { await fs.writeFile(path.join(legacyDir, "auth.json"), raw, "utf8"); } - function createTestModelDefinition() { + function createTestModelDefinition(): ModelDefinitionConfig { return { id: "test-model", name: "Test Model", @@ -149,55 +149,54 @@ describe("resolveProviderAuths key normalization", () => { } async function resolveMinimaxAuthFromConfiguredKey(apiKey: string) { - return await withSuiteHome( - async (home) => { - await writeConfig(home, { - models: { - providers: { - minimax: { - baseUrl: "https://api.minimaxi.com", - models: [createTestModelDefinition()], - apiKey, - }, + return await withSuiteHome(async (home) => { + const config = { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimaxi.com", + models: [createTestModelDefinition()], + apiKey, }, }, - }); - - return await resolveProviderAuths({ - providers: ["minimax"], - }); - }, - { - ...EMPTY_PROVIDER_ENV, - }, - ); + }, + } satisfies OpenClawConfig; + await writeConfig(home, config); + + return await resolveProviderAuths({ + providers: ["minimax"], + agentDir: agentDirForHome(home), + config, + env: buildSuiteEnv(home), + }); + }); } async function expectResolvedAuthsFromSuiteHome(params: { providers: Parameters[0]["providers"]; expected: Awaited>; env?: Record; + config?: OpenClawConfig; setup?: (home: string) => Promise; }) { - await withSuiteHome( - async (home) => { - await params.setup?.(home); - const auths = await resolveProviderAuths({ - providers: params.providers, - }); - expect(auths).toEqual(params.expected); - }, - { - ...EMPTY_PROVIDER_ENV, - ...params.env, - }, - ); + await withSuiteHome(async (home) => { + if (params.setup) { + await params.setup(home); + } + const config = params.config ?? {}; + const auths = await resolveProviderAuths({ + providers: params.providers, + agentDir: agentDirForHome(home), + config, + env: buildSuiteEnv(home, params.env), + }); + expect(auths).toEqual(params.expected); + }); } - it.each([ - { - name: "strips embedded CR/LF from env keys", - providers: ["zai", "minimax", "xiaomi"] as const, + it("strips embedded CR/LF from env keys", async () => { + await expectResolvedAuthsFromSuiteHome({ + providers: ["zai", "minimax", "xiaomi"], env: { ZAI_API_KEY: "zai-\r\nkey", MINIMAX_API_KEY: "minimax-\r\nkey", @@ -208,40 +207,39 @@ describe("resolveProviderAuths key normalization", () => { { provider: "minimax", token: "minimax-key" }, { provider: "xiaomi", token: "xiaomi-key" }, ], - }, - { - name: "accepts z-ai env alias and normalizes embedded CR/LF", - providers: ["zai"] as const, + }); + }, 300_000); + + it("accepts z-ai env alias and normalizes embedded CR/LF", async () => { + await expectResolvedAuthsFromSuiteHome({ + providers: ["zai"], env: { Z_AI_API_KEY: "zai-\r\nkey", }, expected: [{ provider: "zai", token: "zai-key" }], - }, - { - name: "prefers ZAI_API_KEY over the z-ai alias when both are set", - providers: ["zai"] as const, + }); + }); + + it("prefers ZAI_API_KEY over the z-ai alias when both are set", async () => { + await expectResolvedAuthsFromSuiteHome({ + providers: ["zai"], env: { ZAI_API_KEY: "direct-zai-key", Z_AI_API_KEY: "alias-zai-key", }, expected: [{ provider: "zai", token: "direct-zai-key" }], - }, - { - name: "prefers MINIMAX_CODE_PLAN_KEY over MINIMAX_API_KEY", - providers: ["minimax"] as const, + }); + }); + + it("prefers MINIMAX_CODE_PLAN_KEY over MINIMAX_API_KEY", async () => { + await expectResolvedAuthsFromSuiteHome({ + providers: ["minimax"], env: { MINIMAX_CODE_PLAN_KEY: "code-plan-key", MINIMAX_API_KEY: "api-key", }, expected: [{ provider: "minimax", token: "code-plan-key" }], - }, - ] satisfies Array<{ - name: string; - providers: readonly Parameters[0]["providers"][number][]; - env: Record; - expected: ProviderAuth[]; - }>)("$name", async ({ providers, env, expected }) => { - await expectResolvedAuthsFromSuiteHome({ providers: [...providers], env, expected }); + }); }); it("strips embedded CR/LF from stored auth profiles (token + api_key)", async () => { @@ -311,40 +309,33 @@ describe("resolveProviderAuths key normalization", () => { }); it("uses config api keys when env and profiles are missing", async () => { + const config = { + models: { + providers: { + zai: { + baseUrl: "https://api.z.ai", + models: [createTestModelDefinition()], + apiKey: "cfg-zai-key", // pragma: allowlist secret + }, + minimax: { + baseUrl: "https://api.minimaxi.com", + models: [createTestModelDefinition()], + apiKey: "cfg-minimax-key", // pragma: allowlist secret + }, + xiaomi: { + baseUrl: "https://api.xiaomi.example", + models: [createTestModelDefinition()], + apiKey: "cfg-xiaomi-key", // pragma: allowlist secret + }, + }, + }, + } satisfies OpenClawConfig; await expectResolvedAuthsFromSuiteHome({ providers: ["zai", "minimax", "xiaomi"], setup: async (home) => { - const modelDef = { - id: "test-model", - name: "Test Model", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1024, - maxTokens: 256, - }; - await writeConfig(home, { - models: { - providers: { - zai: { - baseUrl: "https://api.z.ai", - models: [modelDef], - apiKey: "cfg-zai-key", // pragma: allowlist secret - }, - minimax: { - baseUrl: "https://api.minimaxi.com", - models: [modelDef], - apiKey: "cfg-minimax-key", // pragma: allowlist secret - }, - xiaomi: { - baseUrl: "https://api.xiaomi.example", - models: [modelDef], - apiKey: "cfg-xiaomi-key", // pragma: allowlist secret - }, - }, - }, - }); + await writeConfig(home, config); }, + config, expected: [ { provider: "zai", token: "cfg-zai-key" }, { provider: "minimax", token: "cfg-minimax-key" }, @@ -384,13 +375,14 @@ describe("resolveProviderAuths key normalization", () => { it("discovers oauth provider from config but skips mismatched profile providers", async () => { await withSuiteHome(async (home) => { - await writeConfig(home, { + const config = { auth: { profiles: { "anthropic:default": { provider: "anthropic", mode: "token" }, }, }, - }); + } satisfies OpenClawConfig; + await writeConfig(home, config); await writeAuthProfiles(home, { "anthropic:default": { type: "token", @@ -401,18 +393,24 @@ describe("resolveProviderAuths key normalization", () => { const auths = await resolveProviderAuths({ providers: ["anthropic"], + agentDir: agentDirForHome(home), + config, + env: buildSuiteEnv(home), }); expect(auths).toEqual([]); - }, {}); + }); }); it("skips providers without oauth-compatible profiles", async () => { - await withSuiteHome(async () => { + await withSuiteHome(async (home) => { const auths = await resolveProviderAuths({ providers: ["anthropic"], + agentDir: agentDirForHome(home), + config: {}, + env: buildSuiteEnv(home), }); expect(auths).toEqual([]); - }, {}); + }); }); it("skips oauth profiles that resolve without an api key and uses later profiles", async () => { @@ -430,9 +428,12 @@ describe("resolveProviderAuths key normalization", () => { const auths = await resolveProviderAuths({ providers: ["anthropic"], + agentDir: agentDirForHome(home), + config: {}, + env: buildSuiteEnv(home), }); expect(auths).toEqual([{ provider: "anthropic", token: "anthropic-token" }]); - }, {}); + }); }); it("skips api_key entries in oauth token resolution order", async () => { @@ -445,9 +446,12 @@ describe("resolveProviderAuths key normalization", () => { const auths = await resolveProviderAuths({ providers: ["anthropic"], + agentDir: agentDirForHome(home), + config: {}, + env: buildSuiteEnv(home), }); expect(auths).toEqual([{ provider: "anthropic", token: "token-1" }]); - }, {}); + }); }); it("ignores marker-backed config keys for provider usage auth resolution", async () => { diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts index b8fa75afc5f16..2f71aaafc556c 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const resolveProviderUsageAuthWithPluginMock = vi.fn(); +const resolveProviderUsageAuthWithPluginMock = vi.fn( + async (..._args: unknown[]): Promise => null, +); vi.mock("../plugins/provider-runtime.js", () => ({ - resolveProviderUsageAuthWithPlugin: (...args: unknown[]) => - resolveProviderUsageAuthWithPluginMock(...args), + resolveProviderUsageAuthWithPlugin: resolveProviderUsageAuthWithPluginMock, })); let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths; diff --git a/src/infra/restart.test.ts b/src/infra/restart.test.ts index 30d21414c7912..6fc9ebbdecb8c 100644 --- a/src/infra/restart.test.ts +++ b/src/infra/restart.test.ts @@ -4,9 +4,13 @@ const spawnSyncMock = vi.hoisted(() => vi.fn()); const resolveLsofCommandSyncMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn()); -vi.mock("node:child_process", () => ({ - spawnSync: (...args: unknown[]) => spawnSyncMock(...args), -})); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnSync: (...args: Parameters) => spawnSyncMock(...args), + }; +}); vi.mock("./ports-lsof.js", () => ({ resolveLsofCommandSync: (...args: unknown[]) => resolveLsofCommandSyncMock(...args), @@ -16,15 +20,29 @@ vi.mock("../config/paths.js", () => ({ resolveGatewayPort: (...args: unknown[]) => resolveGatewayPortMock(...args), })); -import { - __testing, - cleanStaleGatewayProcessesSync, - findGatewayPidsOnPortSync, -} from "./restart-stale-pids.js"; +let __testing: typeof import("./restart-stale-pids.js").__testing; +let cleanStaleGatewayProcessesSync: typeof import("./restart-stale-pids.js").cleanStaleGatewayProcessesSync; +let findGatewayPidsOnPortSync: typeof import("./restart-stale-pids.js").findGatewayPidsOnPortSync; let currentTimeMs = 0; -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + vi.doMock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnSync: (...args: Parameters) => spawnSyncMock(...args), + }; + }); + vi.doMock("./ports-lsof.js", () => ({ + resolveLsofCommandSync: (...args: unknown[]) => resolveLsofCommandSyncMock(...args), + })); + vi.doMock("../config/paths.js", () => ({ + resolveGatewayPort: (...args: unknown[]) => resolveGatewayPortMock(...args), + })); + ({ __testing, cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } = + await import("./restart-stale-pids.js")); spawnSyncMock.mockReset(); resolveLsofCommandSyncMock.mockReset(); resolveGatewayPortMock.mockReset(); diff --git a/src/infra/retry.test.ts b/src/infra/retry.test.ts index 32eedca21afc7..a320da5bac7d2 100644 --- a/src/infra/retry.test.ts +++ b/src/infra/retry.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveRetryConfig, retryAsync } from "./retry.js"; async function runRetryAfterCase(params: { @@ -33,6 +33,11 @@ afterEach(() => { vi.useRealTimers(); }); +beforeEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); +}); + describe("retryAsync", () => { it("returns on first success", async () => { const fn = vi.fn().mockResolvedValue("ok"); diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 9b6c871379b39..9651cdabca843 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -5,6 +5,30 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { runNodeMain } from "../../scripts/run-node.mjs"; +const ROOT_SRC = "src/index.ts"; +const ROOT_TSCONFIG = "tsconfig.json"; +const ROOT_PACKAGE = "package.json"; +const ROOT_TSDOWN = "tsdown.config.ts"; +const DIST_ENTRY = "dist/entry.js"; +const BUILD_STAMP = "dist/.buildstamp"; +const EXTENSION_SRC = "extensions/demo/src/index.ts"; +const EXTENSION_MANIFEST = "extensions/demo/openclaw.plugin.json"; +const EXTENSION_PACKAGE = "extensions/demo/package.json"; +const EXTENSION_README = "extensions/demo/README.md"; +const DIST_EXTENSION_MANIFEST = "dist/extensions/demo/openclaw.plugin.json"; +const DIST_EXTENSION_PACKAGE = "dist/extensions/demo/package.json"; + +const OLD_TIME = new Date("2026-03-13T10:00:00.000Z"); +const BUILD_TIME = new Date("2026-03-13T12:00:00.000Z"); +const NEW_TIME = new Date("2026-03-13T12:00:01.000Z"); + +const BASE_PROJECT_FILES = { + [ROOT_TSCONFIG]: "{}\n", + [ROOT_PACKAGE]: '{"name":"openclaw-test"}\n', + [DIST_ENTRY]: "console.log('built');\n", + [BUILD_STAMP]: '{"head":"abc123"}\n', +} as const; + async function withTempDir(run: (dir: string) => Promise): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-run-node-")); try { @@ -30,21 +54,112 @@ async function writeRuntimePostBuildScaffold(tmp: string): Promise { await fs.mkdir(path.dirname(pluginSdkAliasPath), { recursive: true }); await fs.mkdir(path.join(tmp, "extensions"), { recursive: true }); await fs.writeFile(pluginSdkAliasPath, "module.exports = {};\n", "utf-8"); - const baselineTime = new Date("2026-03-13T09:00:00.000Z"); - await fs.utimes(pluginSdkAliasPath, baselineTime, baselineTime); + await fs.utimes(pluginSdkAliasPath, BUILD_TIME, BUILD_TIME); } function expectedBuildSpawn() { return [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"]; } +function statusCommandSpawn() { + return [process.execPath, "openclaw.mjs", "status"]; +} + +function resolvePath(tmp: string, relativePath: string) { + return path.join(tmp, relativePath); +} + +async function writeProjectFiles(tmp: string, files: Record) { + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = resolvePath(tmp, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, contents, "utf-8"); + } +} + +async function touchProjectFiles(tmp: string, relativePaths: string[], time: Date) { + for (const relativePath of relativePaths) { + const absolutePath = resolvePath(tmp, relativePath); + await fs.utimes(absolutePath, time, time); + } +} + +async function setupTrackedProject( + tmp: string, + options: { + files?: Record; + oldPaths?: string[]; + buildPaths?: string[]; + newPaths?: string[]; + } = {}, +) { + await writeRuntimePostBuildScaffold(tmp); + await writeProjectFiles(tmp, { + ...BASE_PROJECT_FILES, + ...options.files, + }); + await touchProjectFiles(tmp, options.oldPaths ?? [], OLD_TIME); + await touchProjectFiles(tmp, options.buildPaths ?? [], BUILD_TIME); + await touchProjectFiles(tmp, options.newPaths ?? [], NEW_TIME); +} + +function createSpawnRecorder( + options: { + gitHead?: string; + gitStatus?: string; + } = {}, +) { + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse" && options.gitHead !== undefined) { + return { status: 0, stdout: options.gitHead }; + } + if (cmd === "git" && args[0] === "status" && options.gitStatus !== undefined) { + return { status: 0, stdout: options.gitStatus }; + } + return { status: 1, stdout: "" }; + }; + return { spawnCalls, spawn, spawnSync }; +} + +async function runStatusCommand(params: { + tmp: string; + spawn: (cmd: string, args: string[]) => ReturnType; + spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string }; + env?: Record; +}) { + return await runNodeMain({ + cwd: params.tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + ...params.env, + }, + spawn: params.spawn, + ...(params.spawnSync ? { spawnSync: params.spawnSync } : {}), + execPath: process.execPath, + platform: process.platform, + }); +} + +async function expectManifestId(tmp: string, relativePath: string, id: string) { + await expect( + fs.readFile(resolvePath(tmp, relativePath), "utf-8").then((raw) => JSON.parse(raw)), + ).resolves.toMatchObject({ id }); +} + describe("run-node script", () => { it.runIf(process.platform !== "win32")( "preserves control-ui assets by building with tsdown --no-clean", async () => { await withTempDir(async (tmp) => { - const argsPath = path.join(tmp, ".build-args.txt"); - const indexPath = path.join(tmp, "dist", "control-ui", "index.html"); + const argsPath = resolvePath(tmp, ".build-args.txt"); + const indexPath = resolvePath(tmp, "dist/control-ui/index.html"); await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(indexPath), { recursive: true }); @@ -55,20 +170,13 @@ describe("run-node script", () => { if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") { fsSync.writeFileSync(argsPath, args.join(" "), "utf-8"); if (!args.includes("--no-clean")) { - fsSync.rmSync(path.join(tmp, "dist", "control-ui"), { recursive: true, force: true }); + fsSync.rmSync(resolvePath(tmp, "dist/control-ui"), { recursive: true, force: true }); } } if (cmd === process.execPath) { nodeCalls.push([cmd, ...args]); } - return { - on: (event: string, cb: (code: number | null, signal: string | null) => void) => { - if (event === "exit") { - queueMicrotask(() => cb(0, null)); - } - return undefined; - }, - }; + return createExitedProcess(0); }; const exitCode = await runNodeMain({ @@ -99,30 +207,21 @@ describe("run-node script", () => { it("copies bundled plugin metadata after rebuilding from a clean dist", async () => { await withTempDir(async (tmp) => { - const extensionManifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); - const extensionPackagePath = path.join(tmp, "extensions", "demo", "package.json"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(extensionManifestPath), { recursive: true }); - await fs.writeFile( - extensionManifestPath, - '{"id":"demo","configSchema":{"type":"object"}}\n', - "utf-8", - ); - await fs.writeFile( - extensionPackagePath, - JSON.stringify( - { - name: "demo", - openclaw: { - extensions: ["./src/index.ts", "./nested/entry.mts"], + await writeProjectFiles(tmp, { + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: + JSON.stringify( + { + name: "demo", + openclaw: { + extensions: ["./src/index.ts", "./nested/entry.mts"], + }, }, - }, - null, - 2, - ) + "\n", - "utf-8", - ); + null, + 2, + ) + "\n", + }); const spawnCalls: string[][] = []; const spawn = (cmd: string, args: string[]) => { @@ -130,35 +229,25 @@ describe("run-node script", () => { return createExitedProcess(0); }; - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_FORCE_BUILD: "1", - OPENCLAW_RUNNER_LOG: "0", - }, + const exitCode = await runStatusCommand({ + tmp, spawn, - execPath: process.execPath, - platform: process.platform, + env: { OPENCLAW_FORCE_BUILD: "1" }, }); expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBuildSpawn(), - [process.execPath, "openclaw.mjs", "status"], - ]); + expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]); await expect( - fs.readFile(path.join(tmp, "dist", "plugin-sdk", "root-alias.cjs"), "utf-8"), + fs.readFile(resolvePath(tmp, "dist/plugin-sdk/root-alias.cjs"), "utf-8"), ).resolves.toContain("module.exports = {};"); await expect( fs - .readFile(path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"), "utf-8") + .readFile(resolvePath(tmp, DIST_EXTENSION_MANIFEST), "utf-8") .then((raw) => JSON.parse(raw)), ).resolves.toMatchObject({ id: "demo" }); await expect( - fs.readFile(path.join(tmp, "dist", "extensions", "demo", "package.json"), "utf-8"), + fs.readFile(resolvePath(tmp, DIST_EXTENSION_PACKAGE), "utf-8"), ).resolves.toContain( '"extensions": [\n "./src/index.js",\n "./nested/entry.js"\n ]', ); @@ -167,58 +256,22 @@ describe("run-node script", () => { it("skips rebuilding when dist is current and the source tree is clean", async () => { await withTempDir(async (tmp) => { - const srcPath = path.join(tmp, "src", "index.ts"); - const distEntryPath = path.join(tmp, "dist", "entry.js"); - const buildStampPath = path.join(tmp, "dist", ".buildstamp"); - const tsconfigPath = path.join(tmp, "tsconfig.json"); - const packageJsonPath = path.join(tmp, "package.json"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(srcPath), { recursive: true }); - await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); - await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); - await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); - await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); - await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); - await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); - - const oldTime = new Date("2026-03-13T10:00:00.000Z"); - const stampTime = new Date("2026-03-13T12:00:00.000Z"); - await fs.utimes(srcPath, oldTime, oldTime); - await fs.utimes(tsconfigPath, oldTime, oldTime); - await fs.utimes(packageJsonPath, oldTime, oldTime); - await fs.utimes(distEntryPath, stampTime, stampTime); - await fs.utimes(buildStampPath, stampTime, stampTime); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - const spawnSync = (cmd: string, args: string[]) => { - if (cmd === "git" && args[0] === "rev-parse") { - return { status: 0, stdout: "abc123\n" }; - } - if (cmd === "git" && args[0] === "status") { - return { status: 0, stdout: "" }; - } - return { status: 1, stdout: "" }; - }; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", }, - spawn, - spawnSync, - execPath: process.execPath, - platform: process.platform, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: "", }); + const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + expect(spawnCalls).toEqual([statusCommandSpawn()]); }); }); @@ -250,522 +303,218 @@ describe("run-node script", () => { it("rebuilds when extension sources are newer than the build stamp", async () => { await withTempDir(async (tmp) => { - const extensionPath = path.join(tmp, "extensions", "demo", "src", "index.ts"); - const distEntryPath = path.join(tmp, "dist", "entry.js"); - const buildStampPath = path.join(tmp, "dist", ".buildstamp"); - const tsconfigPath = path.join(tmp, "tsconfig.json"); - const packageJsonPath = path.join(tmp, "package.json"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(extensionPath), { recursive: true }); - await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); - await fs.writeFile(extensionPath, "export const extensionValue = 1;\n", "utf-8"); - await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); - await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); - await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); - await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); - - const stampTime = new Date("2026-03-13T12:00:00.000Z"); - const newTime = new Date("2026-03-13T12:00:01.000Z"); - await fs.utimes(tsconfigPath, stampTime, stampTime); - await fs.utimes(packageJsonPath, stampTime, stampTime); - await fs.utimes(distEntryPath, stampTime, stampTime); - await fs.utimes(buildStampPath, stampTime, stampTime); - await fs.utimes(extensionPath, newTime, newTime); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - const spawnSync = () => ({ status: 1, stdout: "" }); - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", + await setupTrackedProject(tmp, { + files: { + [EXTENSION_SRC]: "export const extensionValue = 1;\n", }, - spawn, - spawnSync, - execPath: process.execPath, - platform: process.platform, + buildPaths: [ROOT_TSCONFIG, ROOT_PACKAGE, DIST_ENTRY, BUILD_STAMP], + newPaths: [EXTENSION_SRC], }); + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder(); + const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); + expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBuildSpawn(), - [process.execPath, "openclaw.mjs", "status"], - ]); + expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]); }); }); it("skips rebuilding when extension package metadata is newer than the build stamp", async () => { await withTempDir(async (tmp) => { - const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); - const packagePath = path.join(tmp, "extensions", "demo", "package.json"); - const distPackagePath = path.join(tmp, "dist", "extensions", "demo", "package.json"); - const distEntryPath = path.join(tmp, "dist", "entry.js"); - const buildStampPath = path.join(tmp, "dist", ".buildstamp"); - const tsconfigPath = path.join(tmp, "tsconfig.json"); - const packageJsonPath = path.join(tmp, "package.json"); - const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(manifestPath), { recursive: true }); - await fs.mkdir(path.dirname(packagePath), { recursive: true }); - await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); - await fs.mkdir(path.dirname(distPackagePath), { recursive: true }); - await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8"); - await fs.writeFile( - packagePath, - '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', - "utf-8", - ); - await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); - await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); - await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); - await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); - await fs.writeFile( - distPackagePath, - '{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\n', - "utf-8", - ); - await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); - - const oldTime = new Date("2026-03-13T10:00:00.000Z"); - const stampTime = new Date("2026-03-13T12:00:00.000Z"); - const newTime = new Date("2026-03-13T12:00:01.000Z"); - await fs.utimes(manifestPath, oldTime, oldTime); - await fs.utimes(tsconfigPath, oldTime, oldTime); - await fs.utimes(packageJsonPath, oldTime, oldTime); - await fs.utimes(tsdownConfigPath, oldTime, oldTime); - await fs.utimes(distEntryPath, stampTime, stampTime); - await fs.utimes(buildStampPath, stampTime, stampTime); - await fs.utimes(packagePath, newTime, newTime); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - const spawnSync = () => ({ status: 1, stdout: "" }); - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", + await setupTrackedProject(tmp, { + files: { + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', + [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\n', }, - spawn, - spawnSync, - execPath: process.execPath, - platform: process.platform, + oldPaths: [EXTENSION_MANIFEST, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN], + buildPaths: [DIST_ENTRY, BUILD_STAMP, DIST_EXTENSION_PACKAGE], + newPaths: [EXTENSION_PACKAGE], }); + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder(); + const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); + expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); - await expect(fs.readFile(distPackagePath, "utf-8")).resolves.toContain('"./index.js"'); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + await expect( + fs.readFile(resolvePath(tmp, DIST_EXTENSION_PACKAGE), "utf-8"), + ).resolves.toContain('"./index.js"'); }); }); it("skips rebuilding for dirty non-source files under extensions", async () => { await withTempDir(async (tmp) => { - const srcPath = path.join(tmp, "src", "index.ts"); - const readmePath = path.join(tmp, "extensions", "demo", "README.md"); - const distEntryPath = path.join(tmp, "dist", "entry.js"); - const buildStampPath = path.join(tmp, "dist", ".buildstamp"); - const tsconfigPath = path.join(tmp, "tsconfig.json"); - const packageJsonPath = path.join(tmp, "package.json"); - const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(srcPath), { recursive: true }); - await fs.mkdir(path.dirname(readmePath), { recursive: true }); - await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); - await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); - await fs.writeFile(readmePath, "# demo\n", "utf-8"); - await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); - await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); - await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); - await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); - await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); - - const stampTime = new Date("2026-03-13T12:00:00.000Z"); - await fs.utimes(srcPath, stampTime, stampTime); - await fs.utimes(readmePath, stampTime, stampTime); - await fs.utimes(tsconfigPath, stampTime, stampTime); - await fs.utimes(packageJsonPath, stampTime, stampTime); - await fs.utimes(tsdownConfigPath, stampTime, stampTime); - await fs.utimes(distEntryPath, stampTime, stampTime); - await fs.utimes(buildStampPath, stampTime, stampTime); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - const spawnSync = (cmd: string, args: string[]) => { - if (cmd === "git" && args[0] === "rev-parse") { - return { status: 0, stdout: "abc123\n" }; - } - if (cmd === "git" && args[0] === "status") { - return { status: 0, stdout: " M extensions/demo/README.md\n" }; - } - return { status: 1, stdout: "" }; - }; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_README]: "# demo\n", + [ROOT_TSDOWN]: "export default {};\n", }, - spawn, - spawnSync, - execPath: process.execPath, - platform: process.platform, + buildPaths: [ + ROOT_SRC, + EXTENSION_README, + ROOT_TSCONFIG, + ROOT_PACKAGE, + ROOT_TSDOWN, + DIST_ENTRY, + BUILD_STAMP, + ], + }); + + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: " M extensions/demo/README.md\n", }); + const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + expect(spawnCalls).toEqual([statusCommandSpawn()]); }); }); it("skips rebuilding for dirty extension manifests that only affect runtime reload", async () => { await withTempDir(async (tmp) => { - const srcPath = path.join(tmp, "src", "index.ts"); - const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); - const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"); - const distEntryPath = path.join(tmp, "dist", "entry.js"); - const buildStampPath = path.join(tmp, "dist", ".buildstamp"); - const tsconfigPath = path.join(tmp, "tsconfig.json"); - const packageJsonPath = path.join(tmp, "package.json"); - const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(srcPath), { recursive: true }); - await fs.mkdir(path.dirname(manifestPath), { recursive: true }); - await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); - await fs.mkdir(path.dirname(distManifestPath), { recursive: true }); - await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); - await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8"); - await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); - await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); - await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); - await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); - await fs.writeFile( - distManifestPath, - '{"id":"stale","configSchema":{"type":"object"}}\n', - "utf-8", - ); - await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); - - const stampTime = new Date("2026-03-13T12:00:00.000Z"); - await fs.utimes(srcPath, stampTime, stampTime); - await fs.utimes(manifestPath, stampTime, stampTime); - await fs.utimes(tsconfigPath, stampTime, stampTime); - await fs.utimes(packageJsonPath, stampTime, stampTime); - await fs.utimes(tsdownConfigPath, stampTime, stampTime); - await fs.utimes(distEntryPath, stampTime, stampTime); - await fs.utimes(buildStampPath, stampTime, stampTime); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - const spawnSync = (cmd: string, args: string[]) => { - if (cmd === "git" && args[0] === "rev-parse") { - return { status: 0, stdout: "abc123\n" }; - } - if (cmd === "git" && args[0] === "status") { - return { status: 0, stdout: " M extensions/demo/openclaw.plugin.json\n" }; - } - return { status: 1, stdout: "" }; - }; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_MANIFEST]: '{"id":"stale","configSchema":{"type":"object"}}\n', }, - spawn, - spawnSync, - execPath: process.execPath, - platform: process.platform, + buildPaths: [ + ROOT_SRC, + EXTENSION_MANIFEST, + ROOT_TSCONFIG, + ROOT_PACKAGE, + ROOT_TSDOWN, + DIST_ENTRY, + BUILD_STAMP, + DIST_EXTENSION_MANIFEST, + ], }); - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); - await expect( - fs.readFile(distManifestPath, "utf-8").then((raw) => JSON.parse(raw)), - ).resolves.toMatchObject({ - id: "demo", + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: " M extensions/demo/openclaw.plugin.json\n", }); + const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo"); }); }); it("repairs missing bundled plugin metadata without rerunning tsdown", async () => { await withTempDir(async (tmp) => { - const srcPath = path.join(tmp, "src", "index.ts"); - const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); - const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"); - const distEntryPath = path.join(tmp, "dist", "entry.js"); - const buildStampPath = path.join(tmp, "dist", ".buildstamp"); - const tsconfigPath = path.join(tmp, "tsconfig.json"); - const packageJsonPath = path.join(tmp, "package.json"); - const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(srcPath), { recursive: true }); - await fs.mkdir(path.dirname(manifestPath), { recursive: true }); - await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); - await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); - await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8"); - await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); - await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); - await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); - await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); - await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); - - const stampTime = new Date("2026-03-13T12:00:00.000Z"); - await fs.utimes(srcPath, stampTime, stampTime); - await fs.utimes(manifestPath, stampTime, stampTime); - await fs.utimes(tsconfigPath, stampTime, stampTime); - await fs.utimes(packageJsonPath, stampTime, stampTime); - await fs.utimes(tsdownConfigPath, stampTime, stampTime); - await fs.utimes(distEntryPath, stampTime, stampTime); - await fs.utimes(buildStampPath, stampTime, stampTime); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - const spawnSync = (cmd: string, args: string[]) => { - if (cmd === "git" && args[0] === "rev-parse") { - return { status: 0, stdout: "abc123\n" }; - } - if (cmd === "git" && args[0] === "status") { - return { status: 0, stdout: "" }; - } - return { status: 1, stdout: "" }; - }; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [ROOT_TSDOWN]: "export default {};\n", }, - spawn, - spawnSync, - execPath: process.execPath, - platform: process.platform, + buildPaths: [ + ROOT_SRC, + EXTENSION_MANIFEST, + ROOT_TSCONFIG, + ROOT_PACKAGE, + ROOT_TSDOWN, + DIST_ENTRY, + BUILD_STAMP, + ], }); - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); - await expect( - fs.readFile(distManifestPath, "utf-8").then((raw) => JSON.parse(raw)), - ).resolves.toMatchObject({ - id: "demo", + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: "", }); + const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo"); }); }); it("removes stale bundled plugin metadata when the source manifest is gone", async () => { await withTempDir(async (tmp) => { - const srcPath = path.join(tmp, "src", "index.ts"); - const extensionDir = path.join(tmp, "extensions", "demo"); - const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"); - const distPackagePath = path.join(tmp, "dist", "extensions", "demo", "package.json"); - const distEntryPath = path.join(tmp, "dist", "entry.js"); - const buildStampPath = path.join(tmp, "dist", ".buildstamp"); - const tsconfigPath = path.join(tmp, "tsconfig.json"); - const packageJsonPath = path.join(tmp, "package.json"); - const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(srcPath), { recursive: true }); - await fs.mkdir(extensionDir, { recursive: true }); - await fs.mkdir(path.dirname(distManifestPath), { recursive: true }); - await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); - await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); - await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); - await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); - await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); - await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); - await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); - await fs.writeFile( - distManifestPath, - '{"id":"stale","configSchema":{"type":"object"}}\n', - "utf-8", - ); - await fs.writeFile(distPackagePath, '{"name":"stale"}\n', "utf-8"); - - const stampTime = new Date("2026-03-13T12:00:00.000Z"); - await fs.utimes(srcPath, stampTime, stampTime); - await fs.utimes(tsconfigPath, stampTime, stampTime); - await fs.utimes(packageJsonPath, stampTime, stampTime); - await fs.utimes(tsdownConfigPath, stampTime, stampTime); - await fs.utimes(distEntryPath, stampTime, stampTime); - await fs.utimes(buildStampPath, stampTime, stampTime); + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_MANIFEST]: '{"id":"stale","configSchema":{"type":"object"}}\n', + [DIST_EXTENSION_PACKAGE]: '{"name":"stale"}\n', + }, + buildPaths: [ + ROOT_SRC, + ROOT_TSCONFIG, + ROOT_PACKAGE, + ROOT_TSDOWN, + DIST_ENTRY, + BUILD_STAMP, + DIST_EXTENSION_MANIFEST, + DIST_EXTENSION_PACKAGE, + ], + }); - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - const spawnSync = (cmd: string, args: string[]) => { - if (cmd === "git" && args[0] === "rev-parse") { - return { status: 0, stdout: "abc123\n" }; - } - if (cmd === "git" && args[0] === "status") { - return { status: 0, stdout: "" }; - } - return { status: 1, stdout: "" }; - }; + await fs.mkdir(resolvePath(tmp, "extensions/demo"), { recursive: true }); - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - }, - spawn, - spawnSync, - execPath: process.execPath, - platform: process.platform, + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: "", }); + const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); - await expect(fs.access(distManifestPath)).rejects.toThrow(); - await expect(fs.access(distPackagePath)).rejects.toThrow(); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + await expect(fs.access(resolvePath(tmp, DIST_EXTENSION_MANIFEST))).rejects.toThrow(); + await expect(fs.access(resolvePath(tmp, DIST_EXTENSION_PACKAGE))).rejects.toThrow(); }); }); it("skips rebuilding when only non-source extension files are newer than the build stamp", async () => { await withTempDir(async (tmp) => { - const srcPath = path.join(tmp, "src", "index.ts"); - const readmePath = path.join(tmp, "extensions", "demo", "README.md"); - const distEntryPath = path.join(tmp, "dist", "entry.js"); - const buildStampPath = path.join(tmp, "dist", ".buildstamp"); - const tsconfigPath = path.join(tmp, "tsconfig.json"); - const packageJsonPath = path.join(tmp, "package.json"); - const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(srcPath), { recursive: true }); - await fs.mkdir(path.dirname(readmePath), { recursive: true }); - await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); - await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); - await fs.writeFile(readmePath, "# demo\n", "utf-8"); - await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); - await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); - await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); - await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); - await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); - - const oldTime = new Date("2026-03-13T10:00:00.000Z"); - const stampTime = new Date("2026-03-13T12:00:00.000Z"); - const newTime = new Date("2026-03-13T12:00:01.000Z"); - await fs.utimes(srcPath, oldTime, oldTime); - await fs.utimes(tsconfigPath, oldTime, oldTime); - await fs.utimes(packageJsonPath, oldTime, oldTime); - await fs.utimes(tsdownConfigPath, oldTime, oldTime); - await fs.utimes(distEntryPath, stampTime, stampTime); - await fs.utimes(buildStampPath, stampTime, stampTime); - await fs.utimes(readmePath, newTime, newTime); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - const spawnSync = () => ({ status: 1, stdout: "" }); - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_README]: "# demo\n", + [ROOT_TSDOWN]: "export default {};\n", }, - spawn, - spawnSync, - execPath: process.execPath, - platform: process.platform, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + newPaths: [EXTENSION_README], }); + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder(); + const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); + expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + expect(spawnCalls).toEqual([statusCommandSpawn()]); }); }); it("rebuilds when tsdown config is newer than the build stamp", async () => { await withTempDir(async (tmp) => { - const srcPath = path.join(tmp, "src", "index.ts"); - const distEntryPath = path.join(tmp, "dist", "entry.js"); - const buildStampPath = path.join(tmp, "dist", ".buildstamp"); - const tsconfigPath = path.join(tmp, "tsconfig.json"); - const packageJsonPath = path.join(tmp, "package.json"); - const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(srcPath), { recursive: true }); - await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); - await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); - await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); - await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); - await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); - await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); - await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); - - const oldTime = new Date("2026-03-13T10:00:00.000Z"); - const stampTime = new Date("2026-03-13T12:00:00.000Z"); - const newTime = new Date("2026-03-13T12:00:01.000Z"); - await fs.utimes(srcPath, oldTime, oldTime); - await fs.utimes(tsconfigPath, oldTime, oldTime); - await fs.utimes(packageJsonPath, oldTime, oldTime); - await fs.utimes(distEntryPath, stampTime, stampTime); - await fs.utimes(buildStampPath, stampTime, stampTime); - await fs.utimes(tsdownConfigPath, newTime, newTime); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - const spawnSync = (cmd: string, args: string[]) => { - if (cmd === "git" && args[0] === "rev-parse") { - return { status: 0, stdout: "abc123\n" }; - } - if (cmd === "git" && args[0] === "status") { - return { status: 0, stdout: "" }; - } - return { status: 1, stdout: "" }; - }; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [ROOT_TSDOWN]: "export default {};\n", }, - spawn, - spawnSync, - execPath: process.execPath, - platform: process.platform, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + newPaths: [ROOT_TSDOWN], + }); + + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: "", }); + const exitCode = await runStatusCommand({ tmp, spawn, spawnSync }); expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBuildSpawn(), - [process.execPath, "openclaw.mjs", "status"], - ]); + expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]); }); }); }); diff --git a/src/infra/semver-compare.ts b/src/infra/semver-compare.ts new file mode 100644 index 0000000000000..ab8f44c400c9d --- /dev/null +++ b/src/infra/semver-compare.ts @@ -0,0 +1,111 @@ +export type ComparableSemver = { + major: number; + minor: number; + patch: number; + prerelease: string[] | null; +}; + +export function normalizeLegacyDotBetaVersion(version: string): string { + const trimmed = version.trim(); + const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed); + if (!dotBetaMatch) { + return trimmed; + } + const base = dotBetaMatch[1]; + const suffix = dotBetaMatch[2]; + return suffix ? `${base}-beta.${suffix}` : `${base}-beta`; +} + +export function parseComparableSemver( + version: string | null | undefined, + options?: { normalizeLegacyDotBeta?: boolean }, +): ComparableSemver | null { + if (!version) { + return null; + } + const normalized = options?.normalizeLegacyDotBeta + ? normalizeLegacyDotBetaVersion(version) + : version.trim(); + const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + normalized, + ); + if (!match) { + return null; + } + const [, major, minor, patch, prereleaseRaw] = match; + if (!major || !minor || !patch) { + return null; + } + return { + major: Number.parseInt(major, 10), + minor: Number.parseInt(minor, 10), + patch: Number.parseInt(patch, 10), + prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null, + }; +} + +export function comparePrereleaseIdentifiers(a: string[] | null, b: string[] | null): number { + if (!a?.length && !b?.length) { + return 0; + } + if (!a?.length) { + return 1; + } + if (!b?.length) { + return -1; + } + + const max = Math.max(a.length, b.length); + for (let i = 0; i < max; i += 1) { + const ai = a[i]; + const bi = b[i]; + if (ai == null && bi == null) { + return 0; + } + if (ai == null) { + return -1; + } + if (bi == null) { + return 1; + } + if (ai === bi) { + continue; + } + + const aiNumeric = /^[0-9]+$/.test(ai); + const biNumeric = /^[0-9]+$/.test(bi); + if (aiNumeric && biNumeric) { + const aiNum = Number.parseInt(ai, 10); + const biNum = Number.parseInt(bi, 10); + return aiNum < biNum ? -1 : 1; + } + if (aiNumeric && !biNumeric) { + return -1; + } + if (!aiNumeric && biNumeric) { + return 1; + } + return ai < bi ? -1 : 1; + } + + return 0; +} + +export function compareComparableSemver( + a: ComparableSemver | null, + b: ComparableSemver | null, +): number | null { + if (!a || !b) { + return null; + } + if (a.major !== b.major) { + return a.major < b.major ? -1 : 1; + } + if (a.minor !== b.minor) { + return a.minor < b.minor ? -1 : 1; + } + if (a.patch !== b.patch) { + return a.patch < b.patch ? -1 : 1; + } + return comparePrereleaseIdentifiers(a.prerelease, b.prerelease); +} diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 24e212e4f8358..9b47b13bae596 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -32,7 +32,7 @@ describe("session cost usage", () => { message: { role: "assistant", provider: "openai", - model: "gpt-5.2", + model: "gpt-5.4", usage: { input: 10, output: 20, @@ -49,7 +49,7 @@ describe("session cost usage", () => { message: { role: "assistant", provider: "openai", - model: "gpt-5.2", + model: "gpt-5.4", usage: { input: 10, output: 10, @@ -65,7 +65,7 @@ describe("session cost usage", () => { message: { role: "assistant", provider: "openai", - model: "gpt-5.2", + model: "gpt-5.4", usage: { input: 5, output: 5, @@ -88,7 +88,7 @@ describe("session cost usage", () => { openai: { models: [ { - id: "gpt-5.2", + id: "gpt-5.4", cost: { input: 1, output: 2, @@ -123,7 +123,7 @@ describe("session cost usage", () => { message: { role: "assistant", provider: "openai", - model: "gpt-5.2", + model: "gpt-5.4", usage: { input: 10, output: 20, @@ -164,7 +164,7 @@ describe("session cost usage", () => { message: { role: "assistant", provider: "openai", - model: "gpt-5.2", + model: "gpt-5.4", stopReason: "error", content: [ { type: "text", text: "Checking" }, @@ -200,7 +200,7 @@ describe("session cost usage", () => { expect(summary?.toolUsage?.uniqueTools).toBe(1); expect(summary?.toolUsage?.tools[0]?.name).toBe("weather"); expect(summary?.modelUsage?.[0]?.provider).toBe("openai"); - expect(summary?.modelUsage?.[0]?.model).toBe("gpt-5.2"); + expect(summary?.modelUsage?.[0]?.model).toBe("gpt-5.4"); expect(summary?.durationMs).toBe(5 * 60 * 1000); expect(summary?.latency?.count).toBe(1); expect(summary?.latency?.avgMs).toBe(5 * 60 * 1000); @@ -208,7 +208,7 @@ describe("session cost usage", () => { expect(summary?.dailyLatency?.[0]?.date).toBe("2026-02-01"); expect(summary?.dailyLatency?.[0]?.count).toBe(1); expect(summary?.dailyModelUsage?.[0]?.date).toBe("2026-02-01"); - expect(summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.2"); + expect(summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.4"); }); it("does not exclude sessions with mtime after endMs during discovery", async () => { @@ -544,7 +544,7 @@ describe("session cost usage", () => { message: { role: "assistant", provider: "openai", - model: "gpt-5.2", + model: "gpt-5.4", usage: { input: 7, output: 11, @@ -586,7 +586,7 @@ describe("session cost usage", () => { message: { role: "assistant", provider: "openai", - model: "gpt-5.2", + model: "gpt-5.4", usage: { input: 5, output: 3, totalTokens: 8, cost: { total: 0.001 } }, }, }), @@ -702,7 +702,7 @@ example message: { role: "assistant", provider: "openai", - model: "gpt-5.2", + model: "gpt-5.4", usage: { input: idx, output: idx * 2, diff --git a/src/infra/session-maintenance-warning.test.ts b/src/infra/session-maintenance-warning.test.ts index 25adad09fb8c7..12940213650f5 100644 --- a/src/infra/session-maintenance-warning.test.ts +++ b/src/infra/session-maintenance-warning.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ resolveSessionAgentId: vi.fn(() => "agent-from-key"), @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ type SessionMaintenanceWarningModule = typeof import("./session-maintenance-warning.js"); let deliverSessionMaintenanceWarning: SessionMaintenanceWarningModule["deliverSessionMaintenanceWarning"]; +let resetSessionMaintenanceWarningForTests: SessionMaintenanceWarningModule["__testing"]["resetSessionMaintenanceWarningForTests"]; function createParams( overrides: Partial[0]> = {}, @@ -43,18 +44,8 @@ describe("deliverSessionMaintenanceWarning", () => { let prevVitest: string | undefined; let prevNodeEnv: string | undefined; - beforeEach(async () => { - prevVitest = process.env.VITEST; - prevNodeEnv = process.env.NODE_ENV; - delete process.env.VITEST; - process.env.NODE_ENV = "development"; + beforeAll(async () => { vi.resetModules(); - mocks.resolveSessionAgentId.mockClear(); - mocks.deliveryContextFromSession.mockClear(); - mocks.normalizeMessageChannel.mockClear(); - mocks.isDeliverableMessageChannel.mockClear(); - mocks.deliverOutboundPayloads.mockClear(); - mocks.enqueueSystemEvent.mockClear(); vi.doMock("../agents/agent-scope.js", () => ({ resolveSessionAgentId: mocks.resolveSessionAgentId, })); @@ -71,7 +62,24 @@ describe("deliverSessionMaintenanceWarning", () => { vi.doMock("./system-events.js", () => ({ enqueueSystemEvent: mocks.enqueueSystemEvent, })); - ({ deliverSessionMaintenanceWarning } = await import("./session-maintenance-warning.js")); + ({ + deliverSessionMaintenanceWarning, + __testing: { resetSessionMaintenanceWarningForTests }, + } = await import("./session-maintenance-warning.js")); + }); + + beforeEach(() => { + prevVitest = process.env.VITEST; + prevNodeEnv = process.env.NODE_ENV; + delete process.env.VITEST; + process.env.NODE_ENV = "development"; + resetSessionMaintenanceWarningForTests(); + mocks.resolveSessionAgentId.mockClear(); + mocks.deliveryContextFromSession.mockClear(); + mocks.normalizeMessageChannel.mockClear(); + mocks.isDeliverableMessageChannel.mockClear(); + mocks.deliverOutboundPayloads.mockClear(); + mocks.enqueueSystemEvent.mockClear(); }); afterEach(() => { diff --git a/src/infra/session-maintenance-warning.ts b/src/infra/session-maintenance-warning.ts index 048dfcd213bcf..84dbd8b94f237 100644 --- a/src/infra/session-maintenance-warning.ts +++ b/src/infra/session-maintenance-warning.ts @@ -18,6 +18,15 @@ const warnedContexts = new Map(); const log = createSubsystemLogger("session-maintenance-warning"); let deliverRuntimePromise: Promise | null = null; +function resetSessionMaintenanceWarningForTests() { + warnedContexts.clear(); + deliverRuntimePromise = null; +} + +export const __testing = { + resetSessionMaintenanceWarningForTests, +} as const; + function loadDeliverRuntime() { deliverRuntimePromise ??= import("./outbound/deliver-runtime.js"); return deliverRuntimePromise; diff --git a/src/infra/shell-wrapper-resolution.ts b/src/infra/shell-wrapper-resolution.ts new file mode 100644 index 0000000000000..ea06a55672a92 --- /dev/null +++ b/src/infra/shell-wrapper-resolution.ts @@ -0,0 +1,264 @@ +import { + MAX_DISPATCH_WRAPPER_DEPTH, + hasDispatchEnvManipulation, + unwrapKnownDispatchWrapperInvocation, +} from "./dispatch-wrapper-resolution.js"; +import { normalizeExecutableToken } from "./exec-wrapper-tokens.js"; +import { + POSIX_INLINE_COMMAND_FLAGS, + POWERSHELL_INLINE_COMMAND_FLAGS, + resolveInlineCommandMatch, +} from "./shell-inline-command.js"; + +const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const; +const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const; +const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const; +const SHELL_MULTIPLEXER_WRAPPER_NAMES = ["busybox", "toybox"] as const; + +function withWindowsExeAliases(names: readonly string[]): string[] { + const expanded = new Set(); + for (const name of names) { + expanded.add(name); + expanded.add(`${name}.exe`); + } + return Array.from(expanded); +} + +export const POSIX_SHELL_WRAPPERS = new Set(POSIX_SHELL_WRAPPER_NAMES); +export const WINDOWS_CMD_WRAPPERS = new Set(withWindowsExeAliases(WINDOWS_CMD_WRAPPER_NAMES)); +export const POWERSHELL_WRAPPERS = new Set(withWindowsExeAliases(POWERSHELL_WRAPPER_NAMES)); + +const POSIX_SHELL_WRAPPER_CANONICAL = new Set(POSIX_SHELL_WRAPPER_NAMES); +const WINDOWS_CMD_WRAPPER_CANONICAL = new Set(WINDOWS_CMD_WRAPPER_NAMES); +const POWERSHELL_WRAPPER_CANONICAL = new Set(POWERSHELL_WRAPPER_NAMES); +const SHELL_MULTIPLEXER_WRAPPER_CANONICAL = new Set(SHELL_MULTIPLEXER_WRAPPER_NAMES); +const SHELL_WRAPPER_CANONICAL = new Set([ + ...POSIX_SHELL_WRAPPER_NAMES, + ...WINDOWS_CMD_WRAPPER_NAMES, + ...POWERSHELL_WRAPPER_NAMES, +]); + +type ShellWrapperKind = "posix" | "cmd" | "powershell"; + +type ShellWrapperSpec = { + kind: ShellWrapperKind; + names: ReadonlySet; +}; + +const SHELL_WRAPPER_SPECS: ReadonlyArray = [ + { kind: "posix", names: POSIX_SHELL_WRAPPER_CANONICAL }, + { kind: "cmd", names: WINDOWS_CMD_WRAPPER_CANONICAL }, + { kind: "powershell", names: POWERSHELL_WRAPPER_CANONICAL }, +]; + +export type ShellWrapperCommand = { + isWrapper: boolean; + command: string | null; +}; + +function isWithinDispatchClassificationDepth(depth: number): boolean { + return depth <= MAX_DISPATCH_WRAPPER_DEPTH; +} + +export function isShellWrapperExecutable(token: string): boolean { + return SHELL_WRAPPER_CANONICAL.has(normalizeExecutableToken(token)); +} + +function normalizeRawCommand(rawCommand?: string | null): string | null { + const trimmed = rawCommand?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null { + for (const spec of SHELL_WRAPPER_SPECS) { + if (spec.names.has(baseExecutable)) { + return spec; + } + } + return null; +} + +export type ShellMultiplexerUnwrapResult = + | { kind: "not-wrapper" } + | { kind: "blocked"; wrapper: string } + | { kind: "unwrapped"; wrapper: string; argv: string[] }; + +export function unwrapKnownShellMultiplexerInvocation( + argv: string[], +): ShellMultiplexerUnwrapResult { + const token0 = argv[0]?.trim(); + if (!token0) { + return { kind: "not-wrapper" }; + } + const wrapper = normalizeExecutableToken(token0); + if (!SHELL_MULTIPLEXER_WRAPPER_CANONICAL.has(wrapper)) { + return { kind: "not-wrapper" }; + } + + let appletIndex = 1; + if (argv[appletIndex]?.trim() === "--") { + appletIndex += 1; + } + const applet = argv[appletIndex]?.trim(); + if (!applet || !isShellWrapperExecutable(applet)) { + return { kind: "blocked", wrapper }; + } + + const unwrapped = argv.slice(appletIndex); + if (unwrapped.length === 0) { + return { kind: "blocked", wrapper }; + } + return { kind: "unwrapped", wrapper, argv: unwrapped }; +} + +function extractPosixShellInlineCommand(argv: string[]): string | null { + return extractInlineCommandByFlags(argv, POSIX_INLINE_COMMAND_FLAGS, { allowCombinedC: true }); +} + +function extractCmdInlineCommand(argv: string[]): string | null { + const idx = argv.findIndex((item) => { + const token = item.trim().toLowerCase(); + return token === "/c" || token === "/k"; + }); + if (idx === -1) { + return null; + } + const tail = argv.slice(idx + 1); + if (tail.length === 0) { + return null; + } + const cmd = tail.join(" ").trim(); + return cmd.length > 0 ? cmd : null; +} + +function extractPowerShellInlineCommand(argv: string[]): string | null { + return extractInlineCommandByFlags(argv, POWERSHELL_INLINE_COMMAND_FLAGS); +} + +function extractInlineCommandByFlags( + argv: string[], + flags: ReadonlySet, + options: { allowCombinedC?: boolean } = {}, +): string | null { + return resolveInlineCommandMatch(argv, flags, options).command; +} + +function extractShellWrapperPayload(argv: string[], spec: ShellWrapperSpec): string | null { + switch (spec.kind) { + case "posix": + return extractPosixShellInlineCommand(argv); + case "cmd": + return extractCmdInlineCommand(argv); + case "powershell": + return extractPowerShellInlineCommand(argv); + } +} + +function hasEnvManipulationBeforeShellWrapperInternal( + argv: string[], + depth: number, + envManipulationSeen: boolean, +): boolean { + if (!isWithinDispatchClassificationDepth(depth)) { + return false; + } + + const token0 = argv[0]?.trim(); + if (!token0) { + return false; + } + + const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(argv); + if (dispatchUnwrap.kind === "blocked") { + return false; + } + if (dispatchUnwrap.kind === "unwrapped") { + const nextEnvManipulationSeen = envManipulationSeen || hasDispatchEnvManipulation(argv); + return hasEnvManipulationBeforeShellWrapperInternal( + dispatchUnwrap.argv, + depth + 1, + nextEnvManipulationSeen, + ); + } + + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return false; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + return hasEnvManipulationBeforeShellWrapperInternal( + shellMultiplexerUnwrap.argv, + depth + 1, + envManipulationSeen, + ); + } + + const wrapper = findShellWrapperSpec(normalizeExecutableToken(token0)); + if (!wrapper) { + return false; + } + const payload = extractShellWrapperPayload(argv, wrapper); + if (!payload) { + return false; + } + return envManipulationSeen; +} + +export function hasEnvManipulationBeforeShellWrapper(argv: string[]): boolean { + return hasEnvManipulationBeforeShellWrapperInternal(argv, 0, false); +} + +function extractShellWrapperCommandInternal( + argv: string[], + rawCommand: string | null, + depth: number, +): ShellWrapperCommand { + if (!isWithinDispatchClassificationDepth(depth)) { + return { isWrapper: false, command: null }; + } + + const token0 = argv[0]?.trim(); + if (!token0) { + return { isWrapper: false, command: null }; + } + + const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(argv); + if (dispatchUnwrap.kind === "blocked") { + return { isWrapper: false, command: null }; + } + if (dispatchUnwrap.kind === "unwrapped") { + return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1); + } + + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return { isWrapper: false, command: null }; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + return extractShellWrapperCommandInternal(shellMultiplexerUnwrap.argv, rawCommand, depth + 1); + } + + const wrapper = findShellWrapperSpec(normalizeExecutableToken(token0)); + if (!wrapper) { + return { isWrapper: false, command: null }; + } + + const payload = extractShellWrapperPayload(argv, wrapper); + if (!payload) { + return { isWrapper: false, command: null }; + } + + return { isWrapper: true, command: rawCommand ?? payload }; +} + +export function extractShellWrapperInlineCommand(argv: string[]): string | null { + const extracted = extractShellWrapperCommandInternal(argv, null, 0); + return extracted.isWrapper ? extracted.command : null; +} + +export function extractShellWrapperCommand( + argv: string[], + rawCommand?: string | null, +): ShellWrapperCommand { + return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0); +} diff --git a/src/infra/state-migrations.state-dir.test.ts b/src/infra/state-migrations.state-dir.test.ts index c270e30475fff..c6bf9085424b6 100644 --- a/src/infra/state-migrations.state-dir.test.ts +++ b/src/infra/state-migrations.state-dir.test.ts @@ -25,10 +25,10 @@ afterEach(async () => { }); describe("legacy state dir auto-migration", () => { - it("follows legacy symlink when it points at another legacy dir (clawdbot -> moltbot)", async () => { + it("follows legacy symlink when it points at another legacy dir (clawdbot -> moldbot)", async () => { const root = await makeTempRoot(); const legacySymlink = path.join(root, ".clawdbot"); - const legacyDir = path.join(root, ".moltbot"); + const legacyDir = path.join(root, ".moldbot"); fs.mkdirSync(legacyDir, { recursive: true }); fs.writeFileSync(path.join(legacyDir, "marker.txt"), "ok", "utf-8"); @@ -46,7 +46,7 @@ describe("legacy state dir auto-migration", () => { const targetMarker = path.join(root, ".openclaw", "marker.txt"); expect(fs.readFileSync(targetMarker, "utf-8")).toBe("ok"); - expect(fs.readFileSync(path.join(root, ".moltbot", "marker.txt"), "utf-8")).toBe("ok"); + expect(fs.readFileSync(path.join(root, ".moldbot", "marker.txt"), "utf-8")).toBe("ok"); expect(fs.readFileSync(path.join(root, ".clawdbot", "marker.txt"), "utf-8")).toBe("ok"); }); diff --git a/src/infra/system-presence.version.test.ts b/src/infra/system-presence.version.test.ts index dd33916711094..a83a3d86a68a1 100644 --- a/src/infra/system-presence.version.test.ts +++ b/src/infra/system-presence.version.test.ts @@ -3,15 +3,25 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { resolveUsableRuntimeVersion } from "../version.js"; +vi.unmock("../version.js"); + async function withPresenceModule( env: Record, run: (module: typeof import("./system-presence.js")) => Promise | T, ): Promise { - return withEnvAsync(env, async () => { - vi.resetModules(); - const module = await import("./system-presence.js"); - return await run(module); - }); + return withEnvAsync( + { + OPENCLAW_VERSION: undefined, + OPENCLAW_SERVICE_VERSION: undefined, + npm_package_version: undefined, + ...env, + }, + async () => { + vi.resetModules(); + const module = await import("./system-presence.js"); + return await run(module); + }, + ); } describe("system-presence version fallback", () => { diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 8abe152d4adc5..ad2d08f77fb8c 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -34,12 +34,19 @@ describe("tsdown config", () => { expect(distGraphs).toHaveLength(1); expect(entryKeys(distGraphs[0])).toEqual( expect.arrayContaining([ + "agents/auth-profiles.runtime", + "agents/pi-model-discovery-runtime", "index", "cli/memory-cli", + "commands/status.summary.runtime", + "plugins/provider-runtime.runtime", "plugins/runtime/index", "plugin-sdk/compat", "plugin-sdk/index", "extensions/openai/index", + "extensions/matrix/index", + "extensions/msteams/index", + "extensions/whatsapp/index", "bundled/boot-md/handler", ]), ); diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 33ff9076947f9..1ea07fd4c47b8 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; +import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js"; import { channelToNpmTag, type UpdateChannel } from "./update-channels.js"; export type PackageManager = "pnpm" | "bun" | "npm" | "unknown"; @@ -342,109 +343,10 @@ export async function resolveNpmChannelTag(params: { } export function compareSemverStrings(a: string | null, b: string | null): number | null { - const pa = parseComparableSemver(a); - const pb = parseComparableSemver(b); - if (!pa || !pb) { - return null; - } - if (pa.major !== pb.major) { - return pa.major < pb.major ? -1 : 1; - } - if (pa.minor !== pb.minor) { - return pa.minor < pb.minor ? -1 : 1; - } - if (pa.patch !== pb.patch) { - return pa.patch < pb.patch ? -1 : 1; - } - return comparePrerelease(pa.prerelease, pb.prerelease); -} - -type ComparableSemver = { - major: number; - minor: number; - patch: number; - prerelease: string[] | null; -}; - -function parseComparableSemver(version: string | null): ComparableSemver | null { - if (!version) { - return null; - } - const normalized = normalizeLegacyDotBetaVersion(version.trim()); - const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( - normalized, + return compareComparableSemver( + parseComparableSemver(a, { normalizeLegacyDotBeta: true }), + parseComparableSemver(b, { normalizeLegacyDotBeta: true }), ); - if (!match) { - return null; - } - const [, major, minor, patch, prereleaseRaw] = match; - if (!major || !minor || !patch) { - return null; - } - return { - major: Number.parseInt(major, 10), - minor: Number.parseInt(minor, 10), - patch: Number.parseInt(patch, 10), - prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null, - }; -} - -function normalizeLegacyDotBetaVersion(version: string): string { - const trimmed = version.trim(); - const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed); - if (!dotBetaMatch) { - return trimmed; - } - const base = dotBetaMatch[1]; - const suffix = dotBetaMatch[2]; - return suffix ? `${base}-beta.${suffix}` : `${base}-beta`; -} - -function comparePrerelease(a: string[] | null, b: string[] | null): number { - if (!a?.length && !b?.length) { - return 0; - } - if (!a?.length) { - return 1; - } - if (!b?.length) { - return -1; - } - - const max = Math.max(a.length, b.length); - for (let i = 0; i < max; i += 1) { - const ai = a[i]; - const bi = b[i]; - if (ai == null && bi == null) { - return 0; - } - if (ai == null) { - return -1; - } - if (bi == null) { - return 1; - } - if (ai === bi) { - continue; - } - - const aiNumeric = /^[0-9]+$/.test(ai); - const biNumeric = /^[0-9]+$/.test(bi); - if (aiNumeric && biNumeric) { - const aiNum = Number.parseInt(ai, 10); - const biNum = Number.parseInt(bi, 10); - return aiNum < biNum ? -1 : 1; - } - if (aiNumeric && !biNumeric) { - return -1; - } - if (!aiNumeric && biNumeric) { - return 1; - } - return ai < bi ? -1 : 1; - } - - return 0; } export async function checkUpdateStatus(params: { diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index db57a26a11980..5264af27d3349 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -2,9 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../extensions/public-artifacts.js"; import { captureEnv } from "../test-utils/env.js"; import { canResolveRegistryVersionForPackageTarget, + collectInstalledGlobalPackageErrors, cleanupGlobalRenameDirs, detectGlobalInstallManagerByPresence, detectGlobalInstallManagerForRoot, @@ -185,4 +187,25 @@ describe("update global helpers", () => { await expect(fs.stat(path.join(root, "openclaw"))).resolves.toBeDefined(); await expect(fs.stat(path.join(root, ".openclaw-file"))).resolves.toBeDefined(); }); + + it("checks bundled runtime sidecars, including Matrix helper-api", async () => { + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-global-pkg-")); + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0" }), + "utf-8", + ); + for (const relativePath of BUNDLED_RUNTIME_SIDECAR_PATHS) { + const absolutePath = path.join(packageRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf-8"); + } + + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toEqual([]); + + await fs.rm(path.join(packageRoot, "dist/extensions/matrix/helper-api.js")); + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( + "missing bundled runtime sidecar dist/extensions/matrix/helper-api.js", + ); + }); }); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index d7d2ecd4533af..089d780f673b1 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -1,7 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../extensions/public-artifacts.js"; import { pathExists } from "../utils.js"; +import { readPackageVersion } from "./package-json.js"; import { applyPathPrepend } from "./path-prepend.js"; export type GlobalInstallManager = "npm" | "pnpm" | "bun"; @@ -47,6 +49,47 @@ export function isExplicitPackageInstallSpec(value: string): boolean { ); } +export function resolveExpectedInstalledVersionFromSpec( + packageName: string, + spec: string, +): string | null { + const normalizedPackageName = packageName.trim(); + const normalizedSpec = normalizePackageTarget(spec); + if (!normalizedPackageName || !normalizedSpec.startsWith(`${normalizedPackageName}@`)) { + return null; + } + const rawVersion = normalizedSpec.slice(normalizedPackageName.length + 1).trim(); + if ( + !rawVersion || + rawVersion.includes("/") || + rawVersion.includes(":") || + rawVersion.includes("#") || + /^(latest|beta|next|main)$/i.test(rawVersion) + ) { + return null; + } + return rawVersion; +} + +export async function collectInstalledGlobalPackageErrors(params: { + packageRoot: string; + expectedVersion?: string | null; +}): Promise { + const errors: string[] = []; + const installedVersion = await readPackageVersion(params.packageRoot); + if (params.expectedVersion && installedVersion !== params.expectedVersion) { + errors.push( + `expected installed version ${params.expectedVersion}, found ${installedVersion ?? ""}`, + ); + } + for (const relativePath of BUNDLED_RUNTIME_SIDECAR_PATHS) { + if (!(await pathExists(path.join(params.packageRoot, relativePath)))) { + errors.push(`missing bundled runtime sidecar ${relativePath}`); + } + } + return errors; +} + export function canResolveRegistryVersionForPackageTarget(value: string): boolean { const trimmed = normalizePackageTarget(value); if (!trimmed) { @@ -164,12 +207,22 @@ export async function resolveGlobalPackageRoot( manager: GlobalInstallManager, runCommand: CommandRunner, timeoutMs: number, + preferredPackageName?: string, ): Promise { const root = await resolveGlobalRoot(manager, runCommand, timeoutMs); if (!root) { return null; } - return path.join(root, PRIMARY_PACKAGE_NAME); + const candidates = [preferredPackageName?.trim(), ...ALL_PACKAGE_NAMES].filter( + (name): name is string => Boolean(name), + ); + for (const packageName of new Set(candidates)) { + const candidateRoot = path.join(root, packageName); + if (await pathExists(candidateRoot)) { + return candidateRoot; + } + } + return path.join(root, preferredPackageName?.trim() || PRIMARY_PACKAGE_NAME); } export async function detectGlobalInstallManagerForRoot( diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 4d0436eb5baea..1a9709551ea59 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../extensions/public-artifacts.js"; import { withEnvAsync } from "../test-utils/env.js"; import { pathExists } from "../utils.js"; import { resolveStableNodePath } from "./stable-node-path.js"; @@ -10,17 +11,20 @@ import { runGatewayUpdate } from "./update-runner.js"; type CommandResponse = { stdout?: string; stderr?: string; code?: number | null }; type CommandResult = { stdout: string; stderr: string; code: number | null }; +function toCommandResult(response?: CommandResponse): CommandResult { + return { + stdout: response?.stdout ?? "", + stderr: response?.stderr ?? "", + code: response?.code ?? 0, + }; +} + function createRunner(responses: Record) { const calls: string[] = []; const runner = async (argv: string[]) => { const key = argv.join(" "); calls.push(key); - const res = responses[key] ?? {}; - return { - stdout: res.stdout ?? "", - stderr: res.stderr ?? "", - code: res.code ?? 0, - }; + return toCommandResult(responses[key]); }; return { runner, calls }; } @@ -125,6 +129,11 @@ describe("runGatewayUpdate", () => { return uiIndexPath; } + async function setupGitPackageManagerFixture(packageManager = "pnpm@8.0.0") { + await setupGitCheckout({ packageManager }); + return await setupUiIndex(); + } + function buildStableTagResponses( stableTag: string, options?: { additionalTags?: string[] }, @@ -151,6 +160,36 @@ describe("runGatewayUpdate", () => { } satisfies Record; } + function createGitInstallRunner(params: { + stableTag: string; + installCommand: string; + buildCommand: string; + uiBuildCommand: string; + doctorCommand: string; + onCommand?: (key: string) => Promise | CommandResponse | undefined; + }) { + const calls: string[] = []; + const responses = { + ...buildStableTagResponses(params.stableTag), + [params.installCommand]: { stdout: "" }, + [params.buildCommand]: { stdout: "" }, + [params.uiBuildCommand]: { stdout: "" }, + [params.doctorCommand]: { stdout: "" }, + } satisfies Record; + + const runCommand = async (argv: string[]) => { + const key = argv.join(" "); + calls.push(key); + const override = await params.onCommand?.(key); + if (override) { + return toCommandResult(override); + } + return toCommandResult(responses[key]); + }; + + return { calls, runCommand }; + } + async function removeControlUiAssets() { await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true }); } @@ -185,6 +224,7 @@ describe("runGatewayUpdate", () => { JSON.stringify({ name: "openclaw", version }), "utf-8", ); + await writeBundledRuntimeSidecars(pkgRoot); } async function writeGlobalPackageVersion(pkgRoot: string, version = "2.0.0") { @@ -193,6 +233,15 @@ describe("runGatewayUpdate", () => { JSON.stringify({ name: "openclaw", version }), "utf-8", ); + await writeBundledRuntimeSidecars(pkgRoot); + } + + async function writeBundledRuntimeSidecars(pkgRoot: string) { + for (const relativePath of BUNDLED_RUNTIME_SIDECAR_PATHS) { + const absolutePath = path.join(pkgRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf-8"); + } } async function createGlobalPackageFixture(rootDir: string) { @@ -326,61 +375,24 @@ describe("runGatewayUpdate", () => { }); it("falls back to npm when pnpm is unavailable for git installs", async () => { - await fs.mkdir(path.join(tempDir, ".git")); - await fs.writeFile( - path.join(tempDir, "package.json"), - JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }), - "utf-8", - ); - const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html"); - await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); - await fs.writeFile(uiIndexPath, "", "utf-8"); - + await setupGitPackageManagerFixture(); const stableTag = "v1.0.1-1"; - const calls: string[] = []; - const runCommand = async (argv: string[]) => { - const key = argv.join(" "); - calls.push(key); - if (key === "pnpm --version") { - throw new Error("spawn pnpm ENOENT"); - } - if (key === "npm --version") { - return { stdout: "10.0.0", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { - return { stdout: tempDir, stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} rev-parse HEAD`) { - return { stdout: "abc123", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} fetch --all --prune --tags`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} tag --list v* --sort=-v:refname`) { - return { stdout: `${stableTag}\n`, stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} checkout --detach ${stableTag}`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "npm install --no-package-lock --legacy-peer-deps") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "npm run build") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "npm run ui:build") { - return { stdout: "", stderr: "", code: 0 }; - } - if ( - key === `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive` - ) { - return { stdout: "", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "", code: 0 }; - }; + const { calls, runCommand } = createGitInstallRunner({ + stableTag, + installCommand: "npm install --no-package-lock --legacy-peer-deps", + buildCommand: "npm run build", + uiBuildCommand: "npm run ui:build", + doctorCommand: `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`, + onCommand: (key) => { + if (key === "pnpm --version") { + throw new Error("spawn pnpm ENOENT"); + } + if (key === "npm --version") { + return { stdout: "10.0.0" }; + } + return undefined; + }, + }); const result = await runGatewayUpdate({ cwd: tempDir, @@ -398,69 +410,32 @@ describe("runGatewayUpdate", () => { }); it("bootstraps pnpm via corepack when pnpm is missing", async () => { - await fs.mkdir(path.join(tempDir, ".git")); - await fs.writeFile( - path.join(tempDir, "package.json"), - JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }), - "utf-8", - ); - const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html"); - await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); - await fs.writeFile(uiIndexPath, "", "utf-8"); - + await setupGitPackageManagerFixture(); const stableTag = "v1.0.1-1"; - const calls: string[] = []; let pnpmVersionChecks = 0; - const runCommand = async (argv: string[]) => { - const key = argv.join(" "); - calls.push(key); - if (key === "pnpm --version") { - pnpmVersionChecks += 1; - if (pnpmVersionChecks === 1) { - throw new Error("spawn pnpm ENOENT"); + const { calls, runCommand } = createGitInstallRunner({ + stableTag, + installCommand: "pnpm install", + buildCommand: "pnpm build", + uiBuildCommand: "pnpm ui:build", + doctorCommand: `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`, + onCommand: (key) => { + if (key === "pnpm --version") { + pnpmVersionChecks += 1; + if (pnpmVersionChecks === 1) { + throw new Error("spawn pnpm ENOENT"); + } + return { stdout: "10.0.0" }; } - return { stdout: "10.0.0", stderr: "", code: 0 }; - } - if (key === "corepack --version") { - return { stdout: "0.30.0", stderr: "", code: 0 }; - } - if (key === "corepack enable") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { - return { stdout: tempDir, stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} rev-parse HEAD`) { - return { stdout: "abc123", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} fetch --all --prune --tags`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} tag --list v* --sort=-v:refname`) { - return { stdout: `${stableTag}\n`, stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} checkout --detach ${stableTag}`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "pnpm install") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "pnpm build") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "pnpm ui:build") { - return { stdout: "", stderr: "", code: 0 }; - } - if ( - key === `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive` - ) { - return { stdout: "", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "", code: 0 }; - }; + if (key === "corepack --version") { + return { stdout: "0.30.0" }; + } + if (key === "corepack enable") { + return { stdout: "" }; + } + return undefined; + }, + }); const result = await runGatewayUpdate({ cwd: tempDir, @@ -660,11 +635,7 @@ describe("runGatewayUpdate", () => { return { stdout: "", stderr: "node-gyp failed", code: 1 }; }, onOmitOptionalInstall: async () => { - await fs.writeFile( - path.join(pkgRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2.0.0" }), - "utf-8", - ); + await writeGlobalPackageVersion(pkgRoot); return { stdout: "ok", stderr: "", code: 0 }; }, }); @@ -680,6 +651,47 @@ describe("runGatewayUpdate", () => { ]); }); + it("fails global npm update when the installed version misses the requested correction", async () => { + const { calls, result } = await runNpmGlobalUpdateCase({ + expectedInstallCommand: "npm i -g openclaw@2026.3.23-2 --no-fund --no-audit --loglevel=error", + tag: "2026.3.23-2", + }); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("global install verify"); + expect(result.after?.version).toBe("2.0.0"); + expect(result.steps.at(-1)?.stderrTail).toContain( + "expected installed version 2026.3.23-2, found 2.0.0", + ); + expect(calls).toContain("npm i -g openclaw@2026.3.23-2 --no-fund --no-audit --loglevel=error"); + }); + + it("fails global npm update when bundled runtime sidecars are missing after install", async () => { + const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); + const expectedInstallCommand = "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error"; + const { runCommand } = createGlobalInstallHarness({ + pkgRoot, + npmRootOutput: nodeModules, + installCommand: expectedInstallCommand, + onInstall: async () => { + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2.0.0" }), + "utf-8", + ); + await fs.rm(path.join(pkgRoot, "dist"), { recursive: true, force: true }); + }, + }); + + const result = await runWithCommand(runCommand, { cwd: pkgRoot }); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("global install verify"); + expect(result.steps.at(-1)?.stderrTail).toContain( + "missing bundled runtime sidecar dist/extensions/whatsapp/light-runtime-api.js", + ); + }); + it("prepends portable Git PATH for global Windows npm updates", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const localAppData = path.join(tempDir, "local-app-data"); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index a6f9bccd01a04..5d685ab76275a 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -21,12 +21,15 @@ import { } from "./update-channels.js"; import { compareSemverStrings } from "./update-check.js"; import { + collectInstalledGlobalPackageErrors, cleanupGlobalRenameDirs, createGlobalInstallEnv, detectGlobalInstallManagerForRoot, globalInstallArgs, globalInstallFallbackArgs, + resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallSpec, + resolveGlobalPackageRoot, } from "./update-global.js"; export type UpdateStepResult = { @@ -422,12 +425,32 @@ function normalizeTag(tag?: string) { ); } +function mergeCommandEnvironments( + baseEnv: NodeJS.ProcessEnv | undefined, + overrideEnv: NodeJS.ProcessEnv | undefined, +): NodeJS.ProcessEnv | undefined { + if (!baseEnv) { + return overrideEnv; + } + if (!overrideEnv) { + return baseEnv; + } + return { + ...baseEnv, + ...overrideEnv, + }; +} + export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise { const startedAt = Date.now(); + const defaultCommandEnv = await createGlobalInstallEnv(); const runCommand = opts.runCommand ?? (async (argv, options) => { - const res = await runCommandWithTimeout(argv, options); + const res = await runCommandWithTimeout(argv, { + ...options, + env: mergeCommandEnvironments(defaultCommandEnv, options.env), + }); return { stdout: res.stdout, stderr: res.stderr, code: res.code }; }); const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -1037,12 +1060,35 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< } } - const afterVersion = await readPackageVersion(pkgRoot); + const verifiedPackageRoot = + (await resolveGlobalPackageRoot(globalManager, runCommand, timeoutMs, packageName)) ?? + pkgRoot; + const expectedVersion = resolveExpectedInstalledVersionFromSpec(packageName, spec); + const verificationErrors = await collectInstalledGlobalPackageErrors({ + packageRoot: verifiedPackageRoot, + expectedVersion, + }); + if (verificationErrors.length > 0) { + steps.push({ + name: "global install verify", + command: `verify ${verifiedPackageRoot}`, + cwd: verifiedPackageRoot, + durationMs: 0, + exitCode: 1, + stderrTail: verificationErrors.join("\n"), + }); + } + const afterVersion = await readPackageVersion(verifiedPackageRoot); + const failedStep = + finalStep.exitCode !== 0 + ? finalStep + : (steps.find((step) => step.name === "global install verify" && step.exitCode !== 0) ?? + null); return { - status: finalStep.exitCode === 0 ? "ok" : "error", + status: failedStep ? "error" : "ok", mode: globalManager, - root: pkgRoot, - reason: finalStep.exitCode === 0 ? undefined : finalStep.name, + root: verifiedPackageRoot, + reason: failedStep ? failedStep.name : undefined, before: { version: beforeVersion }, after: { version: afterVersion }, steps, diff --git a/src/logging/config.ts b/src/logging/config.ts index bb17b94bff184..04dee9c0912fb 100644 --- a/src/logging/config.ts +++ b/src/logging/config.ts @@ -1,18 +1,10 @@ -import fs from "node:fs"; -import json5 from "json5"; -import { resolveConfigPath } from "../config/paths.js"; -import type { OpenClawConfig } from "../config/types.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; type LoggingConfig = OpenClawConfig["logging"]; export function readLoggingConfig(): LoggingConfig | undefined { - const configPath = resolveConfigPath(); try { - if (!fs.existsSync(configPath)) { - return undefined; - } - const raw = fs.readFileSync(configPath, "utf-8"); - const parsed = json5.parse(raw); + const parsed = loadConfig(); const logging = parsed?.logging; if (!logging || typeof logging !== "object" || Array.isArray(logging)) { return undefined; diff --git a/src/logging/console.ts b/src/logging/console.ts index 8f5ba3b75c6e2..dbf8899ae8ab2 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -1,6 +1,6 @@ import util from "node:util"; import type { OpenClawConfig } from "../config/types.js"; -import { isVerbose } from "../globals.js"; +import { isVerbose } from "../global-state.js"; import { stripAnsi } from "../terminal/ansi.js"; import { readLoggingConfig } from "./config.js"; import { resolveEnvLogLevelOverride } from "./env-log-level.js"; diff --git a/src/logging/subsystem.ts b/src/logging/subsystem.ts index fec90dc1f9fe2..6438cbba51699 100644 --- a/src/logging/subsystem.ts +++ b/src/logging/subsystem.ts @@ -1,6 +1,6 @@ import { Chalk } from "chalk"; import type { Logger as TsLogger } from "tslog"; -import { isVerbose } from "../globals.js"; +import { isVerbose } from "../global-state.js"; import { defaultRuntime, type OutputRuntimeEnv, type RuntimeEnv } from "../runtime.js"; import { clearActiveProgressLine } from "../terminal/progress-line.js"; import { @@ -307,13 +307,13 @@ function logToFile( export function createSubsystemLogger(subsystem: string): SubsystemLogger { let fileLogger: TsLogger | null = null; - const getFileLogger = () => { + const getFileLogger = (): TsLogger => { if (!fileLogger) { fileLogger = getChildLogger({ subsystem }); } return fileLogger; }; - const emit = (level: LogLevel, message: string, meta?: Record) => { + const emit = (level: LogLevel, message: string, meta?: Record): void => { const consoleSettings = getConsoleSettings(); const consoleEnabled = shouldLogToConsole(level, { level: consoleSettings.level }) && @@ -366,11 +366,13 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { shouldLogSubsystemToConsole(subsystem) ); }; - const isFileEnabled = (level: LogLevel): boolean => isFileLogLevelEnabled(level); + const isFileEnabled = (level: LogLevel): boolean => { + return isFileLogLevelEnabled(level); + }; const logger: SubsystemLogger = { subsystem, - isEnabled: (level, target = "any") => { + isEnabled(level, target = "any") { if (target === "console") { return isConsoleEnabled(level); } @@ -379,13 +381,25 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { } return isConsoleEnabled(level) || isFileEnabled(level); }, - trace: (message, meta) => emit("trace", message, meta), - debug: (message, meta) => emit("debug", message, meta), - info: (message, meta) => emit("info", message, meta), - warn: (message, meta) => emit("warn", message, meta), - error: (message, meta) => emit("error", message, meta), - fatal: (message, meta) => emit("fatal", message, meta), - raw: (message) => { + trace(message, meta) { + emit("trace", message, meta); + }, + debug(message, meta) { + emit("debug", message, meta); + }, + info(message, meta) { + emit("info", message, meta); + }, + warn(message, meta) { + emit("warn", message, meta); + }, + error(message, meta) { + emit("error", message, meta); + }, + fatal(message, meta) { + emit("fatal", message, meta); + }, + raw(message) { if (isFileEnabled("info")) { logToFile(getFileLogger(), "info", message, { raw: true }); } @@ -396,7 +410,9 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { writeConsoleLine("info", message); } }, - child: (name) => createSubsystemLogger(`${subsystem}/${name}`), + child(name) { + return createSubsystemLogger(`${subsystem}/${name}`); + }, }; return logger; } diff --git a/src/media-understanding/attachments.normalize.test.ts b/src/media-understanding/attachments.normalize.test.ts new file mode 100644 index 0000000000000..dacebccc46fc8 --- /dev/null +++ b/src/media-understanding/attachments.normalize.test.ts @@ -0,0 +1,29 @@ +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { normalizeAttachmentPath } from "./attachments.normalize.js"; + +describe("normalizeAttachmentPath", () => { + it("allows localhost file URLs", () => { + const localPath = path.join(os.tmpdir(), "photo.png"); + const fileUrl = pathToFileURL(localPath); + fileUrl.hostname = "localhost"; + + expect(normalizeAttachmentPath(fileUrl.href)).toBe(localPath); + }); + + it("rejects remote-host file URLs", () => { + expect(normalizeAttachmentPath("file://attacker/share/photo.png")).toBeUndefined(); + }); + + it("rejects Windows network paths", () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + + try { + expect(normalizeAttachmentPath("\\\\attacker\\share\\photo.png")).toBeUndefined(); + } finally { + platformSpy.mockRestore(); + } + }); +}); diff --git a/src/media-understanding/attachments.normalize.ts b/src/media-understanding/attachments.normalize.ts index 4c248c538f905..eea1467fa7ef6 100644 --- a/src/media-understanding/attachments.normalize.ts +++ b/src/media-understanding/attachments.normalize.ts @@ -1,5 +1,5 @@ -import { fileURLToPath } from "node:url"; import type { MsgContext } from "../auto-reply/templating.js"; +import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js"; import { getFileExtension, isAudioFileName, kindFromMime } from "../media/mime.js"; import type { MediaAttachment } from "./types.js"; @@ -10,11 +10,16 @@ export function normalizeAttachmentPath(raw?: string | null): string | undefined } if (value.startsWith("file://")) { try { - return fileURLToPath(value); + return safeFileURLToPath(value); } catch { return undefined; } } + try { + assertNoWindowsNetworkPath(value, "Attachment path"); + } catch { + return undefined; + } return value; } diff --git a/src/media-understanding/deepgram.audio.live.test.ts b/src/media-understanding/deepgram.audio.live.test.ts index 06fdf39cbc999..c451672392d46 100644 --- a/src/media-understanding/deepgram.audio.live.test.ts +++ b/src/media-understanding/deepgram.audio.live.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { transcribeDeepgramAudio } from "../../extensions/deepgram/audio.js"; -import { isTruthyEnvValue } from "../infra/env.js"; +import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; const DEEPGRAM_KEY = process.env.DEEPGRAM_API_KEY ?? ""; const DEEPGRAM_MODEL = process.env.DEEPGRAM_MODEL?.trim() || "nova-3"; @@ -8,10 +8,7 @@ const DEEPGRAM_BASE_URL = process.env.DEEPGRAM_BASE_URL?.trim(); const SAMPLE_URL = process.env.DEEPGRAM_SAMPLE_URL?.trim() || "https://static.deepgram.com/examples/Bueller-Life-moves-pretty-fast.wav"; -const LIVE = - isTruthyEnvValue(process.env.DEEPGRAM_LIVE_TEST) || - isTruthyEnvValue(process.env.LIVE) || - isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); +const LIVE = isLiveTestEnabled(["DEEPGRAM_LIVE_TEST"]); const describeLive = LIVE && DEEPGRAM_KEY ? describe : describe.skip; diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index 6be3bd45d6720..5a93e7b59cf3e 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -62,10 +62,40 @@ vi.mock("../agents/pi-model-discovery-runtime.js", () => ({ discoverModels: discoverModelsMock, })); -const { describeImageWithModel } = await import("./image.js"); +let describeImageWithModel: typeof import("./image.js").describeImageWithModel; describe("describeImageWithModel", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("@mariozechner/pi-ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + complete: completeMock, + }; + }); + vi.doMock("../agents/minimax-vlm.js", () => ({ + isMinimaxVlmProvider: (provider: string) => + provider === "minimax" || provider === "minimax-portal", + isMinimaxVlmModel: (provider: string, modelId: string) => + (provider === "minimax" || provider === "minimax-portal") && modelId === "MiniMax-VL-01", + minimaxUnderstandImage: minimaxUnderstandImageMock, + })); + vi.doMock("../agents/models-config.js", () => ({ + ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock, + })); + vi.doMock("../agents/model-auth.js", () => ({ + getApiKeyForModel: getApiKeyForModelMock, + resolveApiKeyForProvider: resolveApiKeyForProviderMock, + requireApiKey: requireApiKeyMock, + })); + vi.doMock("../agents/pi-model-discovery-runtime.js", () => ({ + discoverAuthStorage: () => ({ + setRuntimeApiKey: setRuntimeApiKeyMock, + }), + discoverModels: discoverModelsMock, + })); + ({ describeImageWithModel } = await import("./image.js")); vi.clearAllMocks(); minimaxUnderstandImageMock.mockResolvedValue("portal ok"); discoverModelsMock.mockReturnValue({ diff --git a/src/media-understanding/provider-id.ts b/src/media-understanding/provider-id.ts new file mode 100644 index 0000000000000..89667e7641ad3 --- /dev/null +++ b/src/media-understanding/provider-id.ts @@ -0,0 +1,9 @@ +import { normalizeProviderId } from "../agents/model-selection.js"; + +export function normalizeMediaProviderId(id: string): string { + const normalized = normalizeProviderId(id); + if (normalized === "gemini") { + return "google"; + } + return normalized; +} diff --git a/src/media-understanding/provider-registry.ts b/src/media-understanding/provider-registry.ts index f1488f0c25541..9441ccf5a7c4a 100644 --- a/src/media-understanding/provider-registry.ts +++ b/src/media-understanding/provider-registry.ts @@ -1,4 +1,3 @@ -import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { deepgramMediaUnderstandingProvider, @@ -6,6 +5,7 @@ import { } from "../plugin-sdk/media-understanding.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { normalizeMediaProviderId } from "./provider-id.js"; import type { MediaUnderstandingProvider } from "./types.js"; const PROVIDERS: MediaUnderstandingProvider[] = [ @@ -29,13 +29,7 @@ function mergeProviderIntoRegistry( registry.set(normalizedKey, merged); } -export function normalizeMediaProviderId(id: string): string { - const normalized = normalizeProviderId(id); - if (normalized === "gemini") { - return "google"; - } - return normalized; -} +export { normalizeMediaProviderId } from "./provider-id.js"; export function buildMediaUnderstandingRegistry( overrides?: Record, diff --git a/src/media-understanding/resolve.ts b/src/media-understanding/resolve.ts index 69a61661aa504..e7357e59bbb2f 100644 --- a/src/media-understanding/resolve.ts +++ b/src/media-understanding/resolve.ts @@ -12,7 +12,7 @@ import { DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, } from "./defaults.js"; -import { normalizeMediaProviderId } from "./provider-registry.js"; +import { normalizeMediaProviderId } from "./provider-id.js"; import { normalizeMediaUnderstandingChatType, resolveMediaUnderstandingScope } from "./scope.js"; import type { MediaUnderstandingCapability } from "./types.js"; diff --git a/src/media-understanding/runner.vision-skip.test.ts b/src/media-understanding/runner.vision-skip.test.ts index 1b00d285c6196..df38057113984 100644 --- a/src/media-understanding/runner.vision-skip.test.ts +++ b/src/media-understanding/runner.vision-skip.test.ts @@ -1,12 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/config.js"; -import { - buildProviderRegistry, - createMediaAttachmentCache, - normalizeMediaAttachments, - runCapability, -} from "./runner.js"; const catalog = [ { @@ -29,8 +23,29 @@ vi.mock("../agents/model-catalog.js", async () => { }; }); +let buildProviderRegistry: typeof import("./runner.js").buildProviderRegistry; +let createMediaAttachmentCache: typeof import("./runner.js").createMediaAttachmentCache; +let normalizeMediaAttachments: typeof import("./runner.js").normalizeMediaAttachments; +let runCapability: typeof import("./runner.js").runCapability; + describe("runCapability image skip", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("../agents/model-catalog.js", async () => { + const actual = await vi.importActual( + "../agents/model-catalog.js", + ); + return { + ...actual, + loadModelCatalog, + }; + }); + ({ + buildProviderRegistry, + createMediaAttachmentCache, + normalizeMediaAttachments, + runCapability, + } = await import("./runner.js")); loadModelCatalog.mockClear(); }); diff --git a/src/media/fetch.telegram-network.test.ts b/src/media/fetch.telegram-network.test.ts index 54c02069ea05d..2929b22b7bbc5 100644 --- a/src/media/fetch.telegram-network.test.ts +++ b/src/media/fetch.telegram-network.test.ts @@ -1,5 +1,4 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "../infra/net/undici-runtime.js"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const undiciMocks = vi.hoisted(() => { const createDispatcherCtor = | string>() => @@ -15,14 +14,6 @@ const undiciMocks = vi.hoisted(() => { }; }); -const mockedModuleIds = [ - "openclaw/plugin-sdk/infra-runtime", - "../../extensions/telegram/src/fetch.js", - "../../extensions/telegram/src/proxy.js", - "./fetch.js", - "undici", -] as const; - vi.mock("undici", () => ({ Agent: undiciMocks.agentCtor, EnvHttpProxyAgent: undiciMocks.envHttpProxyAgentCtor, @@ -31,27 +22,24 @@ vi.mock("undici", () => ({ })); let fetchRemoteMedia: typeof import("./fetch.js").fetchRemoteMedia; -let makeProxyFetch: typeof import("../../extensions/telegram/src/proxy.js").makeProxyFetch; let resolveTelegramTransport: typeof import("../../extensions/telegram/src/fetch.js").resolveTelegramTransport; let shouldRetryTelegramTransportFallback: typeof import("../../extensions/telegram/src/fetch.js").shouldRetryTelegramTransportFallback; - -async function loadTelegramNetworkModules(): Promise { - vi.resetModules(); - for (const id of mockedModuleIds) { - if (id !== "undici") { - vi.doUnmock(id); - } - } - ({ fetchRemoteMedia } = await import("./fetch.js")); - ({ makeProxyFetch } = await import("../../extensions/telegram/src/proxy.js")); - ({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } = - await import("../../extensions/telegram/src/fetch.js")); -} +let makeProxyFetch: typeof import("../../extensions/telegram/src/proxy.js").makeProxyFetch; +let TEST_UNDICI_RUNTIME_DEPS_KEY: typeof import("../infra/net/undici-runtime.js").TEST_UNDICI_RUNTIME_DEPS_KEY; describe("fetchRemoteMedia telegram network policy", () => { type LookupFn = NonNullable[0]["lookupFn"]>; - beforeEach(async () => { + beforeAll(async () => { + vi.resetModules(); + ({ TEST_UNDICI_RUNTIME_DEPS_KEY } = await import("../infra/net/undici-runtime.js")); + ({ fetchRemoteMedia } = await import("./fetch.js")); + ({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } = + await import("../../extensions/telegram/src/fetch.js")); + ({ makeProxyFetch } = await import("../../extensions/telegram/src/proxy.js")); + }); + + beforeEach(() => { undiciMocks.fetch.mockReset(); undiciMocks.agentCtor.mockClear(); undiciMocks.envHttpProxyAgentCtor.mockClear(); @@ -68,7 +56,6 @@ describe("fetchRemoteMedia telegram network policy", () => { EnvHttpProxyAgent: undiciMocks.envHttpProxyAgentCtor, ProxyAgent: undiciMocks.proxyAgentCtor, }; - await loadTelegramNetworkModules(); }); function createTelegramFetchFailedError(code: string): Error { @@ -84,10 +71,6 @@ describe("fetchRemoteMedia telegram network policy", () => { afterAll(() => { Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); - for (const id of mockedModuleIds) { - vi.doUnmock(id); - } - vi.resetModules(); }); it("preserves Telegram resolver transport policy for file downloads", async () => { @@ -174,12 +157,19 @@ describe("fetchRemoteMedia telegram network policy", () => { dispatcher?: { options?: { uri?: string; + requestTls?: Record; }; }; }) | undefined; expect(init?.dispatcher?.options?.uri).toBe("http://127.0.0.1:7890"); + expect(init?.dispatcher?.options?.requestTls).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + lookup: expect.any(Function), + }), + ); expect(undiciMocks.proxyAgentCtor).toHaveBeenCalled(); }); diff --git a/src/media/fetch.test.ts b/src/media/fetch.test.ts index 46ecc8cbeafec..622177a3faff9 100644 --- a/src/media/fetch.test.ts +++ b/src/media/fetch.test.ts @@ -68,6 +68,7 @@ describe("fetchRemoteMedia", () => { const telegramFileUrl = `https://api.telegram.org/file/bot${telegramToken}/photos/1.jpg`; beforeAll(async () => { + vi.resetModules(); ({ fetchRemoteMedia } = await import("./fetch.js")); }); @@ -186,6 +187,30 @@ describe("fetchRemoteMedia", () => { }); }); + it("bounds error-body snippets instead of reading the full response", async () => { + const hiddenTail = `${" ".repeat(9_000)}BAD`; + const fetchImpl = vi.fn( + async () => + new Response(makeStream([new TextEncoder().encode(hiddenTail)]), { + status: 400, + statusText: "Bad Request", + }), + ); + + const result = await fetchRemoteMedia({ + url: "https://example.com/file.bin", + fetchImpl, + maxBytes: 1024, + }).catch((err: unknown) => err); + + expect(result).toBeInstanceOf(Error); + if (!(result instanceof Error)) { + expect.unreachable("expected fetchRemoteMedia to reject"); + } + expect(result.message).not.toContain("BAD"); + expect(result.message).not.toContain("body:"); + }); + it("blocks private IP literals before fetching", async () => { const fetchImpl = vi.fn(); await expect( diff --git a/src/media/fetch.ts b/src/media/fetch.ts index b0bc6bf07431c..c51529bfdea4d 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -4,7 +4,7 @@ import { fetchWithSsrFGuard, withStrictGuardedFetchMode } from "../infra/net/fet import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js"; import { redactSensitiveText } from "../logging/redact.js"; import { detectMime, extensionForMime } from "./mime.js"; -import { readResponseWithLimit } from "./read-response-with-limit.js"; +import { readResponseTextSnippet, readResponseWithLimit } from "./read-response-with-limit.js"; type FetchMediaResult = { buffer: Buffer; @@ -74,20 +74,19 @@ function parseContentDispositionFileName(header?: string | null): string | undef return undefined; } -async function readErrorBodySnippet(res: Response, maxChars = 200): Promise { +async function readErrorBodySnippet( + res: Response, + opts?: { + maxChars?: number; + chunkTimeoutMs?: number; + }, +): Promise { try { - const text = await res.text(); - if (!text) { - return undefined; - } - const collapsed = text.replace(/\s+/g, " ").trim(); - if (!collapsed) { - return undefined; - } - if (collapsed.length <= maxChars) { - return collapsed; - } - return `${collapsed.slice(0, maxChars)}…`; + return await readResponseTextSnippet(res, { + maxBytes: 8 * 1024, + maxChars: opts?.maxChars, + chunkTimeoutMs: opts?.chunkTimeoutMs, + }); } catch { return undefined; } @@ -188,7 +187,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise { - vi.resetModules(); - vi.clearAllMocks(); +beforeAll(async () => { ({ fetchWithGuard, extractImageContentFromSource, extractFileContentFromSource } = await import("./input-files.js")); }); +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("HEIC input image normalization", () => { it("converts base64 HEIC images to JPEG before returning them", async () => { const normalized = Buffer.from("jpeg-normalized"); diff --git a/src/media/local-media-access.ts b/src/media/local-media-access.ts new file mode 100644 index 0000000000000..2a1653e4b375e --- /dev/null +++ b/src/media/local-media-access.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { assertNoWindowsNetworkPath } from "../infra/local-file-access.js"; +import { getDefaultMediaLocalRoots } from "./local-roots.js"; + +export type LocalMediaAccessErrorCode = + | "path-not-allowed" + | "invalid-root" + | "invalid-file-url" + | "network-path-not-allowed" + | "unsafe-bypass" + | "not-found" + | "invalid-path" + | "not-file"; + +export class LocalMediaAccessError extends Error { + code: LocalMediaAccessErrorCode; + + constructor(code: LocalMediaAccessErrorCode, message: string, options?: ErrorOptions) { + super(message, options); + this.code = code; + this.name = "LocalMediaAccessError"; + } +} + +export function getDefaultLocalRoots(): readonly string[] { + return getDefaultMediaLocalRoots(); +} + +export async function assertLocalMediaAllowed( + mediaPath: string, + localRoots: readonly string[] | "any" | undefined, +): Promise { + if (localRoots === "any") { + return; + } + try { + assertNoWindowsNetworkPath(mediaPath, "Local media path"); + } catch (err) { + throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, { + cause: err, + }); + } + const roots = localRoots ?? getDefaultLocalRoots(); + let resolved: string; + try { + resolved = await fs.realpath(mediaPath); + } catch { + resolved = path.resolve(mediaPath); + } + + if (localRoots === undefined) { + const workspaceRoot = roots.find((root) => path.basename(root) === "workspace"); + if (workspaceRoot) { + const stateDir = path.dirname(workspaceRoot); + const rel = path.relative(stateDir, resolved); + if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) { + const firstSegment = rel.split(path.sep)[0] ?? ""; + if (firstSegment.startsWith("workspace-")) { + throw new LocalMediaAccessError( + "path-not-allowed", + `Local media path is not under an allowed directory: ${mediaPath}`, + ); + } + } + } + } + + for (const root of roots) { + let resolvedRoot: string; + try { + resolvedRoot = await fs.realpath(root); + } catch { + resolvedRoot = path.resolve(root); + } + if (resolvedRoot === path.parse(resolvedRoot).root) { + throw new LocalMediaAccessError( + "invalid-root", + `Invalid localRoots entry (refuses filesystem root): ${root}. Pass a narrower directory.`, + ); + } + if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) { + return; + } + } + + throw new LocalMediaAccessError( + "path-not-allowed", + `Local media path is not under an allowed directory: ${mediaPath}`, + ); +} diff --git a/src/media/qr-image.ts b/src/media/qr-image.ts new file mode 100644 index 0000000000000..e0a0a276acc86 --- /dev/null +++ b/src/media/qr-image.ts @@ -0,0 +1,54 @@ +import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js"; +import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"; +import { encodePngRgba, fillPixel } from "./png-encode.ts"; + +type QRCodeConstructor = new ( + typeNumber: number, + errorCorrectLevel: unknown, +) => { + addData: (data: string) => void; + make: () => void; + getModuleCount: () => number; + isDark: (row: number, col: number) => boolean; +}; + +const QRCode = QRCodeModule as QRCodeConstructor; +const QRErrorCorrectLevel = QRErrorCorrectLevelModule; + +function createQrMatrix(input: string) { + const qr = new QRCode(-1, QRErrorCorrectLevel.L); + qr.addData(input); + qr.make(); + return qr; +} + +export async function renderQrPngBase64( + input: string, + opts: { scale?: number; marginModules?: number } = {}, +): Promise { + const { scale = 6, marginModules = 4 } = opts; + const qr = createQrMatrix(input); + const modules = qr.getModuleCount(); + const size = (modules + marginModules * 2) * scale; + + const buf = Buffer.alloc(size * size * 4, 255); + for (let row = 0; row < modules; row += 1) { + for (let col = 0; col < modules; col += 1) { + if (!qr.isDark(row, col)) { + continue; + } + const startX = (col + marginModules) * scale; + const startY = (row + marginModules) * scale; + for (let y = 0; y < scale; y += 1) { + const pixelY = startY + y; + for (let x = 0; x < scale; x += 1) { + const pixelX = startX + x; + fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255); + } + } + } + } + + const png = encodePngRgba(buf, size, size); + return png.toString("base64"); +} diff --git a/src/media/read-response-with-limit.test.ts b/src/media/read-response-with-limit.test.ts index cba6f05fec47e..bab822420c3e3 100644 --- a/src/media/read-response-with-limit.test.ts +++ b/src/media/read-response-with-limit.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { readResponseWithLimit } from "./read-response-with-limit.js"; +import { readResponseTextSnippet, readResponseWithLimit } from "./read-response-with-limit.js"; function makeStream(chunks: Uint8Array[], delayMs?: number) { return new ReadableStream({ @@ -81,3 +81,38 @@ describe("readResponseWithLimit", () => { } }); }); + +describe("readResponseTextSnippet", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("returns collapsed text within the limit", async () => { + const res = new Response(makeStream([new TextEncoder().encode("hello \n world")])); + await expect(readResponseTextSnippet(res, { maxBytes: 64, maxChars: 50 })).resolves.toBe( + "hello world", + ); + }); + + it("truncates to the byte limit without reading the full body", async () => { + const res = new Response( + makeStream([new TextEncoder().encode("12345"), new TextEncoder().encode("67890")]), + ); + await expect(readResponseTextSnippet(res, { maxBytes: 7, maxChars: 50 })).resolves.toBe( + "1234567…", + ); + }); + + it("applies the idle timeout while reading snippets", async () => { + vi.useFakeTimers(); + try { + const res = new Response(makeStallingStream([new Uint8Array([65, 66])])); + const readPromise = readResponseTextSnippet(res, { maxBytes: 64, chunkTimeoutMs: 50 }); + const rejection = expect(readPromise).rejects.toThrow(/stalled/i); + await vi.advanceTimersByTimeAsync(60); + await rejection; + } finally { + vi.useRealTimers(); + } + }, 5_000); +}); diff --git a/src/media/read-response-with-limit.ts b/src/media/read-response-with-limit.ts index 1c1a680e965df..8edbdabb73add 100644 --- a/src/media/read-response-with-limit.ts +++ b/src/media/read-response-with-limit.ts @@ -37,50 +37,67 @@ async function readChunkWithIdleTimeout( }); } -export async function readResponseWithLimit( +type ReadResponsePrefixResult = { + buffer: Buffer; + size: number; + truncated: boolean; +}; + +async function readResponsePrefix( res: Response, maxBytes: number, opts?: { - onOverflow?: (params: { size: number; maxBytes: number; res: Response }) => Error; chunkTimeoutMs?: number; }, -): Promise { - const onOverflow = - opts?.onOverflow ?? - ((params: { size: number; maxBytes: number }) => - new Error(`Content too large: ${params.size} bytes (limit: ${params.maxBytes} bytes)`)); +): Promise { const chunkTimeoutMs = opts?.chunkTimeoutMs; - const body = res.body; if (!body || typeof body.getReader !== "function") { const fallback = Buffer.from(await res.arrayBuffer()); if (fallback.length > maxBytes) { - throw onOverflow({ size: fallback.length, maxBytes, res }); + return { + buffer: fallback.subarray(0, maxBytes), + size: fallback.length, + truncated: true, + }; } - return fallback; + return { buffer: fallback, size: fallback.length, truncated: false }; } const reader = body.getReader(); const chunks: Uint8Array[] = []; let total = 0; + let size = 0; + let truncated = false; try { while (true) { const { done, value } = chunkTimeoutMs ? await readChunkWithIdleTimeout(reader, chunkTimeoutMs) : await reader.read(); if (done) { + size = total; break; } - if (value?.length) { - total += value.length; - if (total > maxBytes) { - try { - await reader.cancel(); - } catch {} - throw onOverflow({ size: total, maxBytes, res }); + if (!value?.length) { + continue; + } + const nextTotal = total + value.length; + if (nextTotal > maxBytes) { + const remaining = maxBytes - total; + if (remaining > 0) { + chunks.push(value.subarray(0, remaining)); + total += remaining; } - chunks.push(value); + size = nextTotal; + truncated = true; + try { + await reader.cancel(); + } catch {} + break; } + chunks.push(value); + total = nextTotal; + size = total; } } finally { try { @@ -88,8 +105,61 @@ export async function readResponseWithLimit( } catch {} } - return Buffer.concat( - chunks.map((chunk) => Buffer.from(chunk)), - total, - ); + return { + buffer: Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk)), + total, + ), + size, + truncated, + }; +} + +export async function readResponseWithLimit( + res: Response, + maxBytes: number, + opts?: { + onOverflow?: (params: { size: number; maxBytes: number; res: Response }) => Error; + chunkTimeoutMs?: number; + }, +): Promise { + const onOverflow = + opts?.onOverflow ?? + ((params: { size: number; maxBytes: number }) => + new Error(`Content too large: ${params.size} bytes (limit: ${params.maxBytes} bytes)`)); + const prefix = await readResponsePrefix(res, maxBytes, { chunkTimeoutMs: opts?.chunkTimeoutMs }); + if (prefix.truncated) { + throw onOverflow({ size: prefix.size, maxBytes, res }); + } + return prefix.buffer; +} + +export async function readResponseTextSnippet( + res: Response, + opts?: { + maxBytes?: number; + maxChars?: number; + chunkTimeoutMs?: number; + }, +): Promise { + const maxBytes = opts?.maxBytes ?? 8 * 1024; + const maxChars = opts?.maxChars ?? 200; + const prefix = await readResponsePrefix(res, maxBytes, { chunkTimeoutMs: opts?.chunkTimeoutMs }); + if (prefix.buffer.length === 0) { + return undefined; + } + + const text = new TextDecoder().decode(prefix.buffer); + if (!text) { + return undefined; + } + + const collapsed = text.replace(/\s+/g, " ").trim(); + if (!collapsed) { + return undefined; + } + if (collapsed.length > maxChars) { + return `${collapsed.slice(0, maxChars)}…`; + } + return prefix.truncated ? `${collapsed}…` : collapsed; } diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts new file mode 100644 index 0000000000000..275a65a3331d1 --- /dev/null +++ b/src/media/web-media.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { loadWebMedia } from "./web-media.js"; + +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + +let fixtureRoot = ""; +let tinyPngFile = ""; + +beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "web-media-core-")); + tinyPngFile = path.join(fixtureRoot, "tiny.png"); + await fs.writeFile(tinyPngFile, Buffer.from(TINY_PNG_BASE64, "base64")); +}); + +afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +describe("loadWebMedia", () => { + it("allows localhost file URLs for local files", async () => { + const fileUrl = pathToFileURL(tinyPngFile); + fileUrl.hostname = "localhost"; + + const result = await loadWebMedia(fileUrl.href, { + maxBytes: 1024 * 1024, + localRoots: [fixtureRoot], + }); + + expect(result.kind).toBe("image"); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it("rejects remote-host file URLs before filesystem checks", async () => { + const realpathSpy = vi.spyOn(fs, "realpath"); + + try { + await expect( + loadWebMedia("file://attacker/share/evil.png", { + maxBytes: 1024 * 1024, + localRoots: [fixtureRoot], + }), + ).rejects.toMatchObject({ code: "invalid-file-url" }); + await expect( + loadWebMedia("file://attacker/share/evil.png", { + maxBytes: 1024 * 1024, + localRoots: [fixtureRoot], + }), + ).rejects.toThrow(/remote hosts are not allowed/i); + expect(realpathSpy).not.toHaveBeenCalled(); + } finally { + realpathSpy.mockRestore(); + } + }); + + it("rejects Windows network paths before filesystem checks", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const realpathSpy = vi.spyOn(fs, "realpath"); + + try { + await expect( + loadWebMedia("\\\\attacker\\share\\evil.png", { + maxBytes: 1024 * 1024, + localRoots: [fixtureRoot], + }), + ).rejects.toMatchObject({ code: "network-path-not-allowed" }); + expect(realpathSpy).not.toHaveBeenCalled(); + } finally { + realpathSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); +}); diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 63a36586fa817..91dc474ef2854 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -1,8 +1,7 @@ -import fs from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { SafeOpenError, readLocalFileSafely } from "../infra/fs-safe.js"; +import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { resolveUserPath } from "../utils.js"; import { maxBytesForKind, type MediaKind } from "./constants.js"; @@ -13,9 +12,17 @@ import { optimizeImageToPng, resizeToJpeg, } from "./image-ops.js"; -import { getDefaultMediaLocalRoots } from "./local-roots.js"; +import { + assertLocalMediaAllowed, + getDefaultLocalRoots, + LocalMediaAccessError, + type LocalMediaAccessErrorCode, +} from "./local-media-access.js"; import { detectMime, extensionForMime, kindFromMime } from "./mime.js"; +export { getDefaultLocalRoots, LocalMediaAccessError }; +export type { LocalMediaAccessErrorCode }; + export type WebMediaResult = { buffer: Buffer; contentType?: string; @@ -55,88 +62,6 @@ function resolveWebMediaOptions(params: { }; } -export type LocalMediaAccessErrorCode = - | "path-not-allowed" - | "invalid-root" - | "invalid-file-url" - | "unsafe-bypass" - | "not-found" - | "invalid-path" - | "not-file"; - -export class LocalMediaAccessError extends Error { - code: LocalMediaAccessErrorCode; - - constructor(code: LocalMediaAccessErrorCode, message: string, options?: ErrorOptions) { - super(message, options); - this.code = code; - this.name = "LocalMediaAccessError"; - } -} - -export function getDefaultLocalRoots(): readonly string[] { - return getDefaultMediaLocalRoots(); -} - -async function assertLocalMediaAllowed( - mediaPath: string, - localRoots: readonly string[] | "any" | undefined, -): Promise { - if (localRoots === "any") { - return; - } - const roots = localRoots ?? getDefaultLocalRoots(); - // Resolve symlinks so a symlink under /tmp pointing to /etc/passwd is caught. - let resolved: string; - try { - resolved = await fs.realpath(mediaPath); - } catch { - resolved = path.resolve(mediaPath); - } - - // Hardening: the default allowlist includes the OpenClaw temp dir, and tests/CI may - // override the state dir into tmp. Avoid accidentally allowing per-agent - // `workspace-*` state roots via the temp-root prefix match; require explicit - // localRoots for those. - if (localRoots === undefined) { - const workspaceRoot = roots.find((root) => path.basename(root) === "workspace"); - if (workspaceRoot) { - const stateDir = path.dirname(workspaceRoot); - const rel = path.relative(stateDir, resolved); - if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) { - const firstSegment = rel.split(path.sep)[0] ?? ""; - if (firstSegment.startsWith("workspace-")) { - throw new LocalMediaAccessError( - "path-not-allowed", - `Local media path is not under an allowed directory: ${mediaPath}`, - ); - } - } - } - } - for (const root of roots) { - let resolvedRoot: string; - try { - resolvedRoot = await fs.realpath(root); - } catch { - resolvedRoot = path.resolve(root); - } - if (resolvedRoot === path.parse(resolvedRoot).root) { - throw new LocalMediaAccessError( - "invalid-root", - `Invalid localRoots entry (refuses filesystem root): ${root}. Pass a narrower directory.`, - ); - } - if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) { - return; - } - } - throw new LocalMediaAccessError( - "path-not-allowed", - `Local media path is not under an allowed directory: ${mediaPath}`, - ); -} - const HEIC_MIME_RE = /^image\/hei[cf]$/i; const HEIC_EXT_RE = /\.(heic|heif)$/i; const MB = 1024 * 1024; @@ -248,9 +173,9 @@ async function loadWebMediaInternal( // Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.) if (mediaUrl.startsWith("file://")) { try { - mediaUrl = fileURLToPath(mediaUrl); - } catch { - throw new LocalMediaAccessError("invalid-file-url", `Invalid file:// URL: ${mediaUrl}`); + mediaUrl = safeFileURLToPath(mediaUrl); + } catch (err) { + throw new LocalMediaAccessError("invalid-file-url", (err as Error).message, { cause: err }); } } @@ -341,6 +266,13 @@ async function loadWebMediaInternal( if (mediaUrl.startsWith("~")) { mediaUrl = resolveUserPath(mediaUrl); } + try { + assertNoWindowsNetworkPath(mediaUrl, "Local media path"); + } catch (err) { + throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, { + cause: err, + }); + } if ((sandboxValidated || localRoots === "any") && !readFileOverride) { throw new LocalMediaAccessError( diff --git a/src/memory/batch-http.test.ts b/src/memory/batch-http.test.ts index 7f5bf135e17b9..144c8582e22fb 100644 --- a/src/memory/batch-http.test.ts +++ b/src/memory/batch-http.test.ts @@ -14,7 +14,9 @@ describe("postJsonWithRetry", () => { let postJsonWithRetry: typeof import("./batch-http.js").postJsonWithRetry; beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); + vi.resetModules(); ({ postJsonWithRetry } = await import("./batch-http.js")); const retryModule = await import("../infra/retry.js"); const postJsonModule = await import("./post-json.js"); diff --git a/src/memory/embeddings-gemini.test.ts b/src/memory/embeddings-gemini.test.ts index 0173ec9b208e6..617b4d6ed9506 100644 --- a/src/memory/embeddings-gemini.test.ts +++ b/src/memory/embeddings-gemini.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as authModule from "../agents/model-auth.js"; import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; @@ -45,8 +45,7 @@ let GEMINI_EMBEDDING_2_MODELS: typeof import("./embeddings-gemini.js").GEMINI_EM let isGeminiEmbedding2Model: typeof import("./embeddings-gemini.js").isGeminiEmbedding2Model; let resolveGeminiOutputDimensionality: typeof import("./embeddings-gemini.js").resolveGeminiOutputDimensionality; -beforeEach(async () => { - vi.useRealTimers(); +beforeAll(async () => { vi.doUnmock("undici"); vi.resetModules(); ({ @@ -60,6 +59,11 @@ beforeEach(async () => { } = await import("./embeddings-gemini.js")); }); +beforeEach(() => { + vi.useRealTimers(); + vi.doUnmock("undici"); +}); + afterEach(() => { vi.doUnmock("undici"); vi.resetAllMocks(); diff --git a/src/memory/embeddings-gemini.ts b/src/memory/embeddings-gemini.ts index ab028241ed8ff..b1df835fdacbe 100644 --- a/src/memory/embeddings-gemini.ts +++ b/src/memory/embeddings-gemini.ts @@ -4,6 +4,10 @@ import { } from "../agents/api-key-rotation.js"; import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js"; import { parseGeminiAuth } from "../infra/gemini-auth.js"; +import { + DEFAULT_GOOGLE_API_BASE_URL, + normalizeGoogleApiBaseUrl, +} from "../infra/google-api-base-url.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { EmbeddingInput } from "./embedding-inputs.js"; import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js"; @@ -22,7 +26,6 @@ export type GeminiEmbeddingClient = { outputDimensionality?: number; }; -const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; export const DEFAULT_GEMINI_EMBEDDING_MODEL = "gemini-embedding-001"; const GEMINI_MAX_INPUT_TOKENS: Record = { "text-embedding-004": 2048, @@ -205,9 +208,9 @@ function normalizeGeminiBaseUrl(raw: string): string { const trimmed = raw.replace(/\/+$/, ""); const openAiIndex = trimmed.indexOf("/openai"); if (openAiIndex > -1) { - return trimmed.slice(0, openAiIndex); + return normalizeGoogleApiBaseUrl(trimmed.slice(0, openAiIndex)); } - return trimmed; + return normalizeGoogleApiBaseUrl(trimmed); } function buildGeminiModelPath(model: string): string { @@ -302,7 +305,8 @@ export async function resolveGeminiEmbeddingClient( ); const providerConfig = options.config.models?.providers?.google; - const rawBaseUrl = remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_GEMINI_BASE_URL; + const rawBaseUrl = + remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_GOOGLE_API_BASE_URL; const baseUrl = normalizeGeminiBaseUrl(rawBaseUrl); const ssrfPolicy = buildRemoteBaseUrlPolicy(baseUrl); const headerOverrides = Object.assign({}, providerConfig?.headers, remote?.headers); diff --git a/src/memory/embeddings-ollama.test.ts b/src/memory/embeddings-ollama.test.ts index fc6cd12e3b789..66e02e3c36b61 100644 --- a/src/memory/embeddings-ollama.test.ts +++ b/src/memory/embeddings-ollama.test.ts @@ -1,13 +1,15 @@ -import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; let createOllamaEmbeddingProvider: typeof import("./embeddings-ollama.js").createOllamaEmbeddingProvider; -beforeEach(async () => { +beforeAll(async () => { + ({ createOllamaEmbeddingProvider } = await import("./embeddings-ollama.js")); +}); + +beforeEach(() => { vi.useRealTimers(); vi.doUnmock("undici"); - vi.resetModules(); - ({ createOllamaEmbeddingProvider } = await import("./embeddings-ollama.js")); }); afterEach(() => { diff --git a/src/memory/embeddings-remote-fetch.test.ts b/src/memory/embeddings-remote-fetch.test.ts index f1b9cf7b19acf..78fa48b23117e 100644 --- a/src/memory/embeddings-remote-fetch.test.ts +++ b/src/memory/embeddings-remote-fetch.test.ts @@ -1,20 +1,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { postJson } from "./post-json.js"; -vi.mock("./post-json.js", () => ({ - postJson: vi.fn(), -})); +const postJsonMock = vi.hoisted(() => vi.fn()); type EmbeddingsRemoteFetchModule = typeof import("./embeddings-remote-fetch.js"); let fetchRemoteEmbeddingVectors: EmbeddingsRemoteFetchModule["fetchRemoteEmbeddingVectors"]; describe("fetchRemoteEmbeddingVectors", () => { - const postJsonMock = vi.mocked(postJson); - beforeEach(async () => { + vi.resetModules(); + vi.doMock("./post-json.js", () => ({ + postJson: postJsonMock, + })); ({ fetchRemoteEmbeddingVectors } = await import("./embeddings-remote-fetch.js")); - vi.clearAllMocks(); + postJsonMock.mockReset(); }); it("maps remote embedding response data to vectors", async () => { diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index 0520d9a960cc0..7404ea154e3fc 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -1,18 +1,8 @@ import { setTimeout as sleep } from "node:timers/promises"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js"; import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; -vi.mock("../agents/model-auth.js", async () => { - const { createModelAuthMockModule } = await import("../test-utils/model-auth-mock.js"); - return createModelAuthMockModule(); -}); - -const importNodeLlamaCppMock = vi.fn(); -vi.mock("./node-llama.js", () => ({ - importNodeLlamaCpp: (...args: unknown[]) => importNodeLlamaCppMock(...args), -})); - const createFetchMock = () => vi.fn(async (_input?: unknown, _init?: unknown) => ({ ok: true, @@ -37,11 +27,16 @@ type AuthModule = typeof import("../agents/model-auth.js"); type ResolvedProviderAuth = Awaited>; let authModule: AuthModule; +let nodeLlamaModule: typeof import("./node-llama.js"); let createEmbeddingProvider: EmbeddingsModule["createEmbeddingProvider"]; let DEFAULT_LOCAL_MODEL: EmbeddingsModule["DEFAULT_LOCAL_MODEL"]; -beforeAll(async () => { +beforeEach(async () => { + vi.resetModules(); authModule = await import("../agents/model-auth.js"); + nodeLlamaModule = await import("./node-llama.js"); + vi.spyOn(authModule, "resolveApiKeyForProvider"); + vi.spyOn(nodeLlamaModule, "importNodeLlamaCpp"); ({ createEmbeddingProvider, DEFAULT_LOCAL_MODEL } = await import("./embeddings.js")); }); @@ -66,7 +61,7 @@ function mockResolvedProviderKey(apiKey = "provider-key") { } function mockMissingLocalEmbeddingDependency() { - importNodeLlamaCppMock.mockRejectedValue( + vi.mocked(nodeLlamaModule.importNodeLlamaCpp).mockRejectedValue( Object.assign(new Error("Cannot find package 'node-llama-cpp'"), { code: "ERR_MODULE_NOT_FOUND", }), @@ -456,7 +451,7 @@ describe("local embedding normalization", () => { resolveModelFile: (modelPath: string, modelDirectory?: string) => Promise = async () => "/fake/model.gguf", ): void { - importNodeLlamaCppMock.mockResolvedValue({ + vi.mocked(nodeLlamaModule.importNodeLlamaCpp).mockResolvedValue({ getLlama: async () => ({ loadModel: vi.fn().mockResolvedValue({ createEmbeddingContext: vi.fn().mockResolvedValue({ @@ -468,7 +463,7 @@ describe("local embedding normalization", () => { }), resolveModelFile, LlamaLogLevel: { error: 0 }, - }); + } as never); } it("normalizes local embeddings to magnitude ~1.0", async () => { @@ -523,7 +518,7 @@ describe("local embedding normalization", () => { [1.0, 1.0, 1.0, 1.0], ]; - importNodeLlamaCppMock.mockResolvedValue({ + vi.mocked(nodeLlamaModule.importNodeLlamaCpp).mockResolvedValue({ getLlama: async () => ({ loadModel: vi.fn().mockResolvedValue({ createEmbeddingContext: vi.fn().mockResolvedValue({ @@ -537,7 +532,7 @@ describe("local embedding normalization", () => { }), resolveModelFile: async () => "/fake/model.gguf", LlamaLogLevel: { error: 0 }, - }); + } as never); const result = await createLocalProviderForTest(); diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts index 44fcea5995416..ec9c17b63d094 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -216,6 +216,7 @@ describe("memory index", () => { vectorEnabled?: boolean; cacheEnabled?: boolean; minScore?: number; + onSearch?: boolean; hybrid?: { enabled: boolean; vectorWeight?: number; textWeight?: number }; }): TestCfg { return { @@ -229,7 +230,7 @@ describe("memory index", () => { store: { path: params.storePath, vector: { enabled: params.vectorEnabled ?? false } }, // Perf: keep test indexes to a single chunk to reduce sqlite work. chunking: { tokens: 4000, overlap: 0 }, - sync: { watch: false, onSessionStart: false, onSearch: true }, + sync: { watch: false, onSessionStart: false, onSearch: params.onSearch ?? true }, query: { minScore: params.minScore ?? 0, hybrid: params.hybrid ?? { enabled: false }, @@ -432,6 +433,30 @@ describe("memory index", () => { await statusManager.close?.(); }); + it("does not cache builtin status-only managers across repeated requests", async () => { + const cfg = createCfg({ + storePath: path.join(workspaceDir, `index-status-${randomUUID()}.sqlite`), + }); + + const first = await getMemorySearchManager({ + cfg, + agentId: "main", + purpose: "status", + }); + const second = await getMemorySearchManager({ + cfg, + agentId: "main", + purpose: "status", + }); + + const firstManager = requireManager(first, "first status manager missing"); + const secondManager = requireManager(second, "second status manager missing"); + expect(secondManager).not.toBe(firstManager); + + await firstManager.close?.(); + await secondManager.close?.(); + }); + it("reindexes sessions when source config adds sessions to an existing index", async () => { const stateDir = sourceChangeStateDir; const sessionDir = path.join(stateDir, "agents", "main", "sessions"); @@ -529,6 +554,7 @@ describe("memory index", () => { db: { prepare: (sql: string) => { get: (path: string, source: string) => { hash: string } | undefined; + all?: (...args: unknown[]) => unknown; }; }; } @@ -562,13 +588,41 @@ describe("memory index", () => { })}\n`, ); + const originalPrepare = db.prepare.bind(db); + let bulkSessionStateAllCalls = 0; + let perFileSessionHashPrepareCalls = 0; + db.prepare = ((sql: string) => { + const statement = originalPrepare(sql); + if (sql === `SELECT path, hash FROM files WHERE source = ?`) { + if (!statement.all) { + throw new Error("expected sqlite statement.all for bulk session state query"); + } + const bulkAll = statement.all.bind(statement); + return { + ...statement, + all: (...args: unknown[]) => { + bulkSessionStateAllCalls += 1; + return bulkAll(...args); + }, + }; + } + if (sql === `SELECT hash FROM files WHERE path = ? AND source = ?`) { + perFileSessionHashPrepareCalls += 1; + } + return statement; + }) as typeof db.prepare; + await manager.sync?.({ reason: "post-compaction", sessionFiles: [firstSessionPath], }); + db.prepare = originalPrepare; + expect(getSessionHash("sessions/targeted-first.jsonl")).not.toBe(firstOriginalHash); expect(getSessionHash("sessions/targeted-second.jsonl")).toBe(secondOriginalHash); + expect(bulkSessionStateAllCalls).toBe(0); + expect(perFileSessionHashPrepareCalls).toBeGreaterThan(0); await manager.close?.(); } finally { if (previousStateDir === undefined) { @@ -933,6 +987,7 @@ describe("memory index", () => { const result = await getMemorySearchManager({ cfg, agentId: "main" }); const manager = requireManager(result); + await manager.probeEmbeddingAvailability(); expect( providerCalls.some( @@ -945,6 +1000,140 @@ describe("memory index", () => { await manager.close?.(); }); + it("does not initialize the provider when searching an empty index", async () => { + const cfg = createCfg({ + storePath: path.join(workspaceDir, `index-empty-${randomUUID()}.sqlite`), + provider: "gemini", + model: "gemini-embedding-2-preview", + outputDimensionality: 1536, + onSearch: false, + }); + + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + const manager = requireManager(result); + + const results = await manager.search("hello"); + + expect(results).toEqual([]); + expect(providerCalls).toEqual([]); + await manager.close?.(); + }); + + it("snapshots builtin file hashes with a single sqlite query per sync", async () => { + await fs.writeFile(path.join(memoryDir, "2026-01-13.md"), "beta line\n"); + const cfg = createCfg({ + storePath: path.join(workspaceDir, `index-prepare-reuse-${randomUUID()}.sqlite`), + onSearch: false, + }); + + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + const manager = requireManager(result); + managersForCleanup.add(manager); + + const db = ( + manager as unknown as { + db: { + prepare: (sql: string) => { get: (...args: unknown[]) => unknown }; + }; + } + ).db; + const originalPrepare = db.prepare.bind(db); + let selectSourceFileStatePrepareCalls = 0; + let perFileHashPrepareCalls = 0; + db.prepare = ((sql: string) => { + if (sql === `SELECT path, hash FROM files WHERE source = ?`) { + selectSourceFileStatePrepareCalls += 1; + } + if (sql === `SELECT hash FROM files WHERE path = ? AND source = ?`) { + perFileHashPrepareCalls += 1; + } + return originalPrepare(sql); + }) as typeof db.prepare; + + try { + await manager.sync({ reason: "test" }); + } finally { + db.prepare = originalPrepare; + } + + expect(selectSourceFileStatePrepareCalls).toBe(1); + expect(perFileHashPrepareCalls).toBe(0); + }); + + it("uses a single sqlite aggregation query for status counts", async () => { + const cfg = createCfg({ + storePath: path.join(workspaceDir, `index-status-aggregate-${randomUUID()}.sqlite`), + sources: ["memory", "sessions"], + sessionMemory: true, + onSearch: false, + }); + + await fs.writeFile(path.join(memoryDir, "2026-01-13.md"), "beta line\n"); + + const stateDir = path.join(fixtureRoot, `state-status-${randomUUID()}`); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const sessionDir = path.join(stateDir, "agents", "main", "sessions"); + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile( + path.join(sessionDir, "status.jsonl"), + JSON.stringify({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "session status line" }] }, + }) + "\n", + ); + + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + const manager = requireManager(result); + managersForCleanup.add(manager); + await manager.sync({ reason: "test" }); + + const db = ( + manager as unknown as { + db: { + prepare: (sql: string) => { all: (...args: unknown[]) => unknown }; + }; + } + ).db; + const originalPrepare = db.prepare.bind(db); + let aggregatePrepareCalls = 0; + let legacyCountPrepareCalls = 0; + db.prepare = ((sql: string) => { + if ( + sql.includes(`SELECT 'files' AS kind, source, COUNT(*) as c FROM files`) && + sql.includes(`UNION ALL`) + ) { + aggregatePrepareCalls += 1; + } + if ( + sql === `SELECT COUNT(*) as c FROM files WHERE 1=1` || + sql === `SELECT COUNT(*) as c FROM chunks WHERE 1=1` || + sql === `SELECT source, COUNT(*) as c FROM files WHERE 1=1 GROUP BY source` || + sql === `SELECT source, COUNT(*) as c FROM chunks WHERE 1=1 GROUP BY source` + ) { + legacyCountPrepareCalls += 1; + } + return originalPrepare(sql); + }) as typeof db.prepare; + + try { + const status = manager.status(); + expect(status.files).toBeGreaterThan(0); + expect(status.chunks).toBeGreaterThan(0); + expect( + status.sourceCounts?.find((entry) => entry.source === "memory")?.files, + ).toBeGreaterThan(0); + expect( + status.sourceCounts?.find((entry) => entry.source === "sessions")?.files, + ).toBeGreaterThan(0); + } finally { + db.prepare = originalPrepare; + vi.unstubAllEnvs(); + } + + expect(aggregatePrepareCalls).toBe(1); + expect(legacyCountPrepareCalls).toBe(0); + }); + it("reindexes when Gemini outputDimensionality changes", async () => { const base = createCfg({ storePath: indexModelPath, diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index 0822dd4197863..47495bcf9abdf 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -705,6 +705,23 @@ export abstract class MemoryManagerSyncOps { log.debug("Skipping memory file sync in FTS-only mode (no embedding provider)"); return; } + const selectSourceFileState = this.db.prepare(`SELECT path, hash FROM files WHERE source = ?`); + const deleteFileByPathAndSource = this.db.prepare( + `DELETE FROM files WHERE path = ? AND source = ?`, + ); + const deleteChunksByPathAndSource = this.db.prepare( + `DELETE FROM chunks WHERE path = ? AND source = ?`, + ); + const deleteVectorRowsByPathAndSource = + this.vector.enabled && this.vector.available + ? this.db.prepare( + `DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`, + ) + : null; + const deleteFtsRowsByPathSourceAndModel = + this.fts.enabled && this.fts.available + ? this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`) + : null; const files = await listMemoryFiles( this.workspaceDir, @@ -726,6 +743,11 @@ export abstract class MemoryManagerSyncOps { batch: this.batch.enabled, concurrency: this.getIndexConcurrency(), }); + const existingRows = selectSourceFileState.all("memory") as Array<{ + path: string; + hash: string; + }>; + const existingHashes = new Map(existingRows.map((row) => [row.path, row.hash])); const activePaths = new Set(fileEntries.map((entry) => entry.path)); if (params.progress) { params.progress.total += fileEntries.length; @@ -737,10 +759,7 @@ export abstract class MemoryManagerSyncOps { } const tasks = fileEntries.map((entry) => async () => { - const record = this.db - .prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`) - .get(entry.path, "memory") as { hash: string } | undefined; - if (!params.needsFullReindex && record?.hash === entry.hash) { + if (!params.needsFullReindex && existingHashes.get(entry.path) === entry.hash) { if (params.progress) { params.progress.completed += 1; params.progress.report({ @@ -761,27 +780,20 @@ export abstract class MemoryManagerSyncOps { }); await runWithConcurrency(tasks, this.getIndexConcurrency()); - const staleRows = this.db - .prepare(`SELECT path FROM files WHERE source = ?`) - .all("memory") as Array<{ path: string }>; - for (const stale of staleRows) { + for (const stale of existingRows) { if (activePaths.has(stale.path)) { continue; } - this.db.prepare(`DELETE FROM files WHERE path = ? AND source = ?`).run(stale.path, "memory"); - try { - this.db - .prepare( - `DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`, - ) - .run(stale.path, "memory"); - } catch {} - this.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`).run(stale.path, "memory"); - if (this.fts.enabled && this.fts.available) { + deleteFileByPathAndSource.run(stale.path, "memory"); + if (deleteVectorRowsByPathAndSource) { + try { + deleteVectorRowsByPathAndSource.run(stale.path, "memory"); + } catch {} + } + deleteChunksByPathAndSource.run(stale.path, "memory"); + if (deleteFtsRowsByPathSourceAndModel) { try { - this.db - .prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`) - .run(stale.path, "memory", this.provider.model); + deleteFtsRowsByPathSourceAndModel.run(stale.path, "memory", this.provider.model); } catch {} } } @@ -797,6 +809,24 @@ export abstract class MemoryManagerSyncOps { log.debug("Skipping session file sync in FTS-only mode (no embedding provider)"); return; } + const selectFileHash = this.db.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`); + const selectSourceFileState = this.db.prepare(`SELECT path, hash FROM files WHERE source = ?`); + const deleteFileByPathAndSource = this.db.prepare( + `DELETE FROM files WHERE path = ? AND source = ?`, + ); + const deleteChunksByPathAndSource = this.db.prepare( + `DELETE FROM chunks WHERE path = ? AND source = ?`, + ); + const deleteVectorRowsByPathAndSource = + this.vector.enabled && this.vector.available + ? this.db.prepare( + `DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`, + ) + : null; + const deleteFtsRowsByPathSourceAndModel = + this.fts.enabled && this.fts.available + ? this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`) + : null; const targetSessionFiles = params.needsFullReindex ? null @@ -807,6 +837,15 @@ export abstract class MemoryManagerSyncOps { const activePaths = targetSessionFiles ? null : new Set(files.map((file) => sessionPathForFile(file))); + const existingRows = + activePaths === null + ? null + : (selectSourceFileState.all("sessions") as Array<{ + path: string; + hash: string; + }>); + const existingHashes = + existingRows === null ? null : new Map(existingRows.map((row) => [row.path, row.hash])); const indexAll = params.needsFullReindex || Boolean(targetSessionFiles) || this.sessionsDirtyFiles.size === 0; log.debug("memory sync: indexing session files", { @@ -848,10 +887,16 @@ export abstract class MemoryManagerSyncOps { } return; } - const record = this.db - .prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`) - .get(entry.path, "sessions") as { hash: string } | undefined; - if (!params.needsFullReindex && record?.hash === entry.hash) { + const existingHash = + existingHashes?.get(entry.path) ?? + ( + selectFileHash.get(entry.path, "sessions") as + | { + hash: string; + } + | undefined + )?.hash; + if (!params.needsFullReindex && existingHash === entry.hash) { if (params.progress) { params.progress.completed += 1; params.progress.report({ @@ -880,31 +925,20 @@ export abstract class MemoryManagerSyncOps { return; } - const staleRows = this.db - .prepare(`SELECT path FROM files WHERE source = ?`) - .all("sessions") as Array<{ path: string }>; - for (const stale of staleRows) { + for (const stale of existingRows ?? []) { if (activePaths.has(stale.path)) { continue; } - this.db - .prepare(`DELETE FROM files WHERE path = ? AND source = ?`) - .run(stale.path, "sessions"); - try { - this.db - .prepare( - `DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`, - ) - .run(stale.path, "sessions"); - } catch {} - this.db - .prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`) - .run(stale.path, "sessions"); - if (this.fts.enabled && this.fts.available) { + deleteFileByPathAndSource.run(stale.path, "sessions"); + if (deleteVectorRowsByPathAndSource) { + try { + deleteVectorRowsByPathAndSource.run(stale.path, "sessions"); + } catch {} + } + deleteChunksByPathAndSource.run(stale.path, "sessions"); + if (deleteFtsRowsByPathSourceAndModel) { try { - this.db - .prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`) - .run(stale.path, "sessions", this.provider.model); + deleteFtsRowsByPathSourceAndModel.run(stale.path, "sessions", this.provider.model); } catch {} } } diff --git a/src/memory/manager.async-search.test.ts b/src/memory/manager.async-search.test.ts index 7b4855a3d6a7b..7968c4e129090 100644 --- a/src/memory/manager.async-search.test.ts +++ b/src/memory/manager.async-search.test.ts @@ -66,8 +66,16 @@ describe("memory search async sync", () => { const cfg = buildConfig(); manager = await createMemoryManagerOrThrow(cfg); - const pending = new Promise(() => {}); - const syncMock = vi.fn(async () => pending); + let releaseSync = () => {}; + const pending = new Promise((resolve) => { + releaseSync = () => resolve(); + }).finally(() => { + (manager as unknown as { syncing: Promise | null }).syncing = null; + }); + const syncMock = vi.fn(async () => { + (manager as unknown as { syncing: Promise | null }).syncing = pending; + return pending; + }); (manager as unknown as { sync: () => Promise }).sync = syncMock; const activeManager = manager; @@ -76,7 +84,11 @@ describe("memory search async sync", () => { } await activeManager.search("hello"); expect(syncMock).toHaveBeenCalledTimes(1); - }); + releaseSync(); + await vi.waitFor(() => { + expect((manager as unknown as { syncing: Promise | null }).syncing).toBeNull(); + }); + }, 300_000); it("waits for in-flight search sync during close", async () => { const cfg = buildConfig(); diff --git a/src/memory/manager.atomic-reindex.test.ts b/src/memory/manager.atomic-reindex.test.ts index ae92c4a4d2d70..3954df1ce90ee 100644 --- a/src/memory/manager.atomic-reindex.test.ts +++ b/src/memory/manager.atomic-reindex.test.ts @@ -24,14 +24,15 @@ describe("memory manager atomic reindex", () => { beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-atomic-")); + }); + + beforeEach(async () => { + vi.resetModules(); const embeddingMocks = await import("./embedding.test-mocks.js"); embedBatch = embeddingMocks.getEmbedBatchMock(); resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks; ({ getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js")); ({ closeAllMemorySearchManagers } = await import("./index.js")); - }); - - beforeEach(async () => { vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0"); resetEmbeddingMocks(); shouldFail = false; diff --git a/src/memory/manager.get-concurrency.test.ts b/src/memory/manager.get-concurrency.test.ts index 87ab3394a5853..d2b0021b4dae2 100644 --- a/src/memory/manager.get-concurrency.test.ts +++ b/src/memory/manager.get-concurrency.test.ts @@ -49,6 +49,7 @@ describe("memory manager cache hydration", () => { }); beforeEach(async () => { + await closeAllMemoryIndexManagers(); vi.clearAllMocks(); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-concurrent-")); await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); @@ -95,17 +96,15 @@ describe("memory manager cache hydration", () => { expect(managers).toHaveLength(12); expect(new Set(managers).size).toBe(1); - expect(hoisted.providerCreateCalls).toBe(1); + expect(hoisted.providerCreateCalls).toBe(0); await managers[0].close(); }); - it("drains in-flight manager creation during global teardown", async () => { + it("evicts cached managers during global teardown", async () => { const indexPath = path.join(workspaceDir, "index.sqlite"); const cfg = createMemoryConcurrencyConfig(indexPath); - hoisted.providerDelayMs = 100; - const pendingResult = RawMemoryIndexManager.get({ cfg, agentId: "main" }); await closeAllMemoryIndexManagers(); const firstManager = await pendingResult; @@ -115,12 +114,12 @@ describe("memory manager cache hydration", () => { expect(firstManager).toBeTruthy(); expect(secondManager).toBeTruthy(); expect(Object.is(secondManager, firstManager)).toBe(false); - expect(hoisted.providerCreateCalls).toBe(2); + expect(hoisted.providerCreateCalls).toBe(0); await secondManager?.close?.(); }); - it("caches status-only managers separately from full managers", async () => { + it("does not identity-cache status-only managers", async () => { const indexPath = path.join(workspaceDir, "index.sqlite"); const cfg = createMemoryConcurrencyConfig(indexPath); @@ -129,9 +128,10 @@ describe("memory manager cache hydration", () => { expect(first).toBeTruthy(); expect(second).toBeTruthy(); - expect(Object.is(second, first)).toBe(true); + expect(Object.is(second, first)).toBe(false); expect(hoisted.providerCreateCalls).toBe(0); await first?.close?.(); + await second?.close?.(); }); }); diff --git a/src/memory/manager.mistral-provider.test.ts b/src/memory/manager.mistral-provider.test.ts index ceb369330be24..4265fd8b2cf6e 100644 --- a/src/memory/manager.mistral-provider.test.ts +++ b/src/memory/manager.mistral-provider.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js"; import type { @@ -68,11 +68,9 @@ describe("memory manager mistral provider wiring", () => { let indexPath = ""; let manager: MemoryIndexManager | null = null; - beforeAll(async () => { - ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); - }); - beforeEach(async () => { + vi.resetModules(); + ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); vi.clearAllMocks(); createEmbeddingProviderMock.mockReset(); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-mistral-")); @@ -113,6 +111,7 @@ describe("memory manager mistral provider wiring", () => { throw new Error(`manager missing: ${result.error ?? "no error provided"}`); } manager = result.manager as unknown as MemoryIndexManager; + await manager.probeEmbeddingAvailability(); const internal = manager as unknown as { mistral?: MistralEmbeddingClient }; expect(internal.mistral).toBe(mistralClient); @@ -146,6 +145,7 @@ describe("memory manager mistral provider wiring", () => { throw new Error(`manager missing: ${result.error ?? "no error provided"}`); } manager = result.manager as unknown as MemoryIndexManager; + await manager.probeEmbeddingAvailability(); const internal = manager as unknown as { activateFallbackProvider: (reason: string) => Promise; openAi?: OpenAiEmbeddingClient; @@ -187,6 +187,7 @@ describe("memory manager mistral provider wiring", () => { throw new Error(`manager missing: ${result.error ?? "no error provided"}`); } manager = result.manager as unknown as MemoryIndexManager; + await manager.probeEmbeddingAvailability(); const internal = manager as unknown as { activateFallbackProvider: (reason: string) => Promise; openAi?: OpenAiEmbeddingClient; diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 26bdb64eaebe5..5f8fba75a0651 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -174,16 +174,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem const purpose = params.purpose === "status" ? "status" : "default"; const key = `${agentId}:${workspaceDir}:${JSON.stringify(settings)}:${purpose}`; const statusOnly = params.purpose === "status"; - const existing = INDEX_CACHE.get(key); - if (existing) { - return existing; - } - const pending = INDEX_CACHE_PENDING.get(key); - if (pending) { - return pending; - } if (statusOnly) { - const manager = new MemoryIndexManager({ + return new MemoryIndexManager({ cacheKey: key, cfg, agentId, @@ -191,15 +183,16 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem settings, purpose: params.purpose, }); - INDEX_CACHE.set(key, manager); - return manager; + } + const existing = INDEX_CACHE.get(key); + if (existing) { + return existing; + } + const pending = INDEX_CACHE_PENDING.get(key); + if (pending) { + return pending; } const createPromise = (async () => { - const providerResult = await MemoryIndexManager.loadProviderResult({ - cfg, - agentId, - settings, - }); const refreshed = INDEX_CACHE.get(key); if (refreshed) { return refreshed; @@ -210,7 +203,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem agentId, workspaceDir, settings, - providerResult, purpose: params.purpose, }); INDEX_CACHE.set(key, manager); @@ -336,17 +328,21 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem sessionKey?: string; }, ): Promise { - await this.ensureProviderInitialized(); + const cleaned = query.trim(); + if (!cleaned) { + return []; + } void this.warmSession(opts?.sessionKey); if (this.settings.sync.onSearch && (this.dirty || this.sessionsDirty)) { void this.sync({ reason: "search" }).catch((err) => { log.warn(`memory sync failed (search): ${String(err)}`); }); } - const cleaned = query.trim(); - if (!cleaned) { + const hasIndexedContent = this.hasIndexedContent(); + if (!hasIndexedContent) { return []; } + await this.ensureProviderInitialized(); const minScore = opts?.minScore ?? this.settings.query.minScore; const maxResults = opts?.maxResults ?? this.settings.query.maxResults; const hybrid = this.settings.query.hybrid; @@ -439,6 +435,26 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem .slice(0, maxResults); } + private hasIndexedContent(): boolean { + const chunkRow = this.db.prepare(`SELECT 1 as found FROM chunks LIMIT 1`).get() as + | { + found?: number; + } + | undefined; + if (chunkRow?.found === 1) { + return true; + } + if (!this.fts.enabled || !this.fts.available) { + return false; + } + const ftsRow = this.db.prepare(`SELECT 1 as found FROM ${FTS_TABLE} LIMIT 1`).get() as + | { + found?: number; + } + | undefined; + return ftsRow?.found === 1; + } + private async searchVector( queryVec: number[], limit: number, @@ -678,46 +694,42 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem status(): MemoryProviderStatus { const sourceFilter = this.buildSourceFilter(); - const files = this.db - .prepare(`SELECT COUNT(*) as c FROM files WHERE 1=1${sourceFilter.sql}`) - .get(...sourceFilter.params) as { - c: number; - }; - const chunks = this.db - .prepare(`SELECT COUNT(*) as c FROM chunks WHERE 1=1${sourceFilter.sql}`) - .get(...sourceFilter.params) as { + const aggregateRows = this.db + .prepare( + `SELECT 'files' AS kind, source, COUNT(*) as c FROM files WHERE 1=1${sourceFilter.sql} GROUP BY source\n` + + `UNION ALL\n` + + `SELECT 'chunks' AS kind, source, COUNT(*) as c FROM chunks WHERE 1=1${sourceFilter.sql} GROUP BY source`, + ) + .all(...sourceFilter.params, ...sourceFilter.params) as Array<{ + kind: "files" | "chunks"; + source: MemorySource; c: number; - }; - const sourceCounts = (() => { + }>; + const aggregateState = (() => { const sources = Array.from(this.sources); - if (sources.length === 0) { - return []; - } const bySource = new Map(); for (const source of sources) { bySource.set(source, { files: 0, chunks: 0 }); } - const fileRows = this.db - .prepare( - `SELECT source, COUNT(*) as c FROM files WHERE 1=1${sourceFilter.sql} GROUP BY source`, - ) - .all(...sourceFilter.params) as Array<{ source: MemorySource; c: number }>; - for (const row of fileRows) { + let files = 0; + let chunks = 0; + for (const row of aggregateRows) { + const count = row.c ?? 0; const entry = bySource.get(row.source) ?? { files: 0, chunks: 0 }; - entry.files = row.c ?? 0; - bySource.set(row.source, entry); - } - const chunkRows = this.db - .prepare( - `SELECT source, COUNT(*) as c FROM chunks WHERE 1=1${sourceFilter.sql} GROUP BY source`, - ) - .all(...sourceFilter.params) as Array<{ source: MemorySource; c: number }>; - for (const row of chunkRows) { - const entry = bySource.get(row.source) ?? { files: 0, chunks: 0 }; - entry.chunks = row.c ?? 0; + if (row.kind === "files") { + entry.files = count; + files += count; + } else { + entry.chunks = count; + chunks += count; + } bySource.set(row.source, entry); } - return sources.map((source) => Object.assign({ source }, bySource.get(source)!)); + return { + files, + chunks, + sourceCounts: sources.map((source) => Object.assign({ source }, bySource.get(source)!)), + }; })(); const searchMode = this.provider || !this.providerInitialized ? "hybrid" : "fts-only"; @@ -729,8 +741,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem return { backend: "builtin", - files: files?.c ?? 0, - chunks: chunks?.c ?? 0, + files: aggregateState.files, + chunks: aggregateState.chunks, dirty: this.dirty || this.sessionsDirty, workspaceDir: this.workspaceDir, dbPath: this.settings.store.path, @@ -739,7 +751,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem requestedProvider: this.requestedProvider, sources: Array.from(this.sources), extraPaths: this.settings.extraPaths, - sourceCounts, + sourceCounts: aggregateState.sourceCounts, cache: this.cache.enabled ? { enabled: true, diff --git a/src/memory/manager.vector-dedupe.test.ts b/src/memory/manager.vector-dedupe.test.ts index d41dda1d75028..9ad889c1b5a68 100644 --- a/src/memory/manager.vector-dedupe.test.ts +++ b/src/memory/manager.vector-dedupe.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { MemoryIndexManager } from "./index.js"; @@ -45,13 +45,11 @@ describe("memory vector dedupe", () => { manager = null; } - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ buildFileEntry } = await import("./internal.js")); ({ createMemoryManagerOrThrow } = await import("./test-manager.js")); ({ closeAllMemorySearchManagers } = await import("./index.js")); - }); - - beforeEach(async () => { workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-")); indexPath = path.join(workspaceDir, "index.sqlite"); await seedMemoryWorkspace(workspaceDir); @@ -81,6 +79,7 @@ describe("memory vector dedupe", () => { } as OpenClawConfig; manager = await createMemoryManagerOrThrow(cfg); + await manager.probeEmbeddingAvailability(); const db = ( manager as unknown as { @@ -89,15 +88,6 @@ describe("memory vector dedupe", () => { ).db; db.exec("CREATE TABLE IF NOT EXISTS chunks_vec (id TEXT PRIMARY KEY, embedding BLOB)"); - const sqlSeen: string[] = []; - const originalPrepare = db.prepare.bind(db); - db.prepare = (sql: string) => { - if (sql.includes("chunks_vec")) { - sqlSeen.push(sql); - } - return originalPrepare(sql); - }; - ( manager as unknown as { ensureVectorReady: (dims?: number) => Promise } ).ensureVectorReady = async () => true; @@ -111,13 +101,27 @@ describe("memory vector dedupe", () => { indexFile: (entry: unknown, options: { source: "memory" }) => Promise; } ).indexFile(entry, { source: "memory" }); + await ( + manager as unknown as { + indexFile: (entry: unknown, options: { source: "memory" }) => Promise; + } + ).indexFile(entry, { source: "memory" }); - const deleteIndex = sqlSeen.findIndex((sql) => - sql.includes("DELETE FROM chunks_vec WHERE id = ?"), - ); - const insertIndex = sqlSeen.findIndex((sql) => sql.includes("INSERT INTO chunks_vec")); - expect(deleteIndex).toBeGreaterThan(-1); - expect(insertIndex).toBeGreaterThan(-1); - expect(deleteIndex).toBeLessThan(insertIndex); + db.exec(` + CREATE TRIGGER IF NOT EXISTS fail_if_vector_row_not_deleted + BEFORE INSERT ON chunks_vec + WHEN EXISTS (SELECT 1 FROM chunks_vec WHERE id = NEW.id) + BEGIN + SELECT RAISE(FAIL, 'vector row not deleted before insert'); + END; + `); + + await expect( + ( + manager as unknown as { + indexFile: (entry: unknown, options: { source: "memory" }) => Promise; + } + ).indexFile(entry, { source: "memory" }), + ).resolves.toBeUndefined(); }); }); diff --git a/src/memory/manager.watcher-config.test.ts b/src/memory/manager.watcher-config.test.ts index 4dd26d431021c..531ef4d6e2e99 100644 --- a/src/memory/manager.watcher-config.test.ts +++ b/src/memory/manager.watcher-config.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { MemorySearchConfig } from "../config/types.tools.js"; import type { MemoryIndexManager } from "./index.js"; @@ -44,11 +44,9 @@ describe("memory watcher config", () => { let workspaceDir = ""; let extraDir = ""; - beforeAll(async () => { - ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); - }); - beforeEach(async () => { + vi.resetModules(); + ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); vi.clearAllMocks(); }); diff --git a/src/memory/post-json.test.ts b/src/memory/post-json.test.ts index cb5e2bc32bfad..ebfe858fa4df7 100644 --- a/src/memory/post-json.test.ts +++ b/src/memory/post-json.test.ts @@ -11,7 +11,9 @@ describe("postJson", () => { let remoteHttpMock: ReturnType>; beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); + vi.resetModules(); ({ postJson } = await import("./post-json.js")); ({ withRemoteHttpResponse } = await import("./remote-http.js")); remoteHttpMock = vi.mocked(withRemoteHttpResponse); diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index 938fd0bc8b538..5395f7fa7ccee 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -195,7 +195,7 @@ describe("getMemorySearchManager caching", () => { expect(createQmdManagerMock).toHaveBeenCalledTimes(2); }); - it("uses lightweight cached managers for status-only qmd requests", async () => { + it("does not cache qmd managers for status-only requests", async () => { const agentId = "status-agent"; const cfg = createQmdCfg(agentId); @@ -209,16 +209,80 @@ describe("getMemorySearchManager caching", () => { provider: "qmd", model: "qmd", requestedProvider: "qmd", - custom: { - qmd: { - lightweightStatus: true, - }, - }, }); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(createQmdManagerMock).not.toHaveBeenCalled(); + expect(createQmdManagerMock).toHaveBeenCalledTimes(2); expect(mockMemoryIndexGet).not.toHaveBeenCalled(); - expect(second.manager).toBe(first.manager); + + await first.manager?.close?.(); + await second.manager?.close?.(); + expect(mockPrimary.close).toHaveBeenCalledTimes(2); + }); + + it("reports real qmd index counts for status-only requests", async () => { + const agentId = "status-counts-agent"; + const cfg = createQmdCfg(agentId); + mockPrimary.status.mockReturnValueOnce({ + ...createManagerStatus({ + backend: "qmd", + provider: "qmd", + model: "qmd", + requestedProvider: "qmd", + withMemorySourceCounts: true, + }), + files: 10, + chunks: 42, + sourceCounts: [{ source: "memory" as const, files: 10, chunks: 42 }], + }); + + const result = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + const manager = requireManager(result); + + expect(manager.status()).toMatchObject({ + backend: "qmd", + files: 10, + chunks: 42, + sourceCounts: [{ source: "memory", files: 10, chunks: 42 }], + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(createQmdManagerMock).toHaveBeenCalledWith( + expect.objectContaining({ agentId, mode: "status" }), + ); + }); + + it("reuses cached full qmd manager for status-only requests", async () => { + const agentId = "status-reuses-full-agent"; + const cfg = createQmdCfg(agentId); + + const full = await getMemorySearchManager({ cfg, agentId }); + const status = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + + requireManager(full); + requireManager(status); + expect(status.manager).not.toBe(full.manager); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(createQmdManagerMock).toHaveBeenCalledTimes(1); + await status.manager?.close?.(); + expect(mockPrimary.close).not.toHaveBeenCalled(); + + const fullAgain = await getMemorySearchManager({ cfg, agentId }); + expect(fullAgain.manager).toBe(full.manager); + }); + + it("gets a fresh qmd manager for later status requests after close", async () => { + const agentId = "status-eviction-agent"; + const cfg = createQmdCfg(agentId); + + const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + const firstManager = requireManager(first); + await firstManager.close?.(); + + const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + requireManager(second); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(createQmdManagerMock).toHaveBeenCalledTimes(2); + expect(mockPrimary.close).toHaveBeenCalledTimes(1); }); it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => { diff --git a/src/memory/search-manager.ts b/src/memory/search-manager.ts index 9d1b7edf9e1c2..9d28251f3841a 100644 --- a/src/memory/search-manager.ts +++ b/src/memory/search-manager.ts @@ -1,8 +1,4 @@ -import os from "node:os"; -import path from "node:path"; -import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveStateDir } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import type { ResolvedQmdConfig } from "./backend-config.js"; @@ -57,13 +53,13 @@ export async function getMemorySearchManager(params: { return { manager: cached }; } if (statusOnly) { - const manager = new QmdStatusOnlyManager({ - cfg: params.cfg, - agentId: params.agentId, - resolved: resolved.qmd, - }); - QMD_MANAGER_CACHE.set(cacheKey, manager); - return { manager }; + const fullCached = QMD_MANAGER_CACHE.get(`${baseCacheKey}:full`); + if (fullCached) { + // Status callers often close the manager they receive. Wrap the live + // full manager with a no-op close so health/status probes do not tear + // down the active QMD manager for the process. + return { manager: new BorrowedMemoryManager(fullCached) }; + } } try { const { QmdMemoryManager } = await import("./qmd-manager.js"); @@ -75,7 +71,6 @@ export async function getMemorySearchManager(params: { }); if (primary) { if (statusOnly) { - QMD_MANAGER_CACHE.set(cacheKey, primary); return { manager: primary }; } const wrapper = new FallbackMemoryManager( @@ -109,87 +104,42 @@ export async function getMemorySearchManager(params: { } } -class QmdStatusOnlyManager implements MemorySearchManager { - private readonly workspaceDir: string; - private readonly indexPath: string; - private readonly sourceSet: Set<"memory" | "sessions">; +class BorrowedMemoryManager implements MemorySearchManager { + constructor(private readonly inner: MemorySearchManager) {} - constructor( - private readonly params: { - cfg: OpenClawConfig; - agentId: string; - resolved: ResolvedQmdConfig; - }, + async search( + query: string, + opts?: { maxResults?: number; minScore?: number; sessionKey?: string }, ) { - this.workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); - const stateDir = resolveStateDir(process.env, os.homedir); - this.indexPath = path.join( - stateDir, - "agents", - params.agentId, - "qmd", - "xdg-cache", - "qmd", - "index.sqlite", - ); - this.sourceSet = new Set( - params.resolved.collections.map((collection) => - collection.kind === "sessions" ? "sessions" : "memory", - ), - ); - } - - async search(): Promise { - throw new Error("memory search unavailable in status-only mode"); + return await this.inner.search(query, opts); } - async readFile(): Promise { - throw new Error("memory read unavailable in status-only mode"); + async readFile(params: { relPath: string; from?: number; lines?: number }) { + return await this.inner.readFile(params); } status() { - return { - backend: "qmd" as const, - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - files: 0, - chunks: 0, - dirty: false, - workspaceDir: this.workspaceDir, - dbPath: this.indexPath, - sources: Array.from(this.sourceSet), - vector: { enabled: true, available: true }, - batch: { - enabled: false, - failures: 0, - limit: 0, - wait: false, - concurrency: 0, - pollIntervalMs: 0, - timeoutMs: 0, - }, - custom: { - qmd: { - collections: this.params.resolved.collections.length, - lastUpdateAt: null, - lightweightStatus: true, - }, - }, - }; + return this.inner.status(); } - async sync(): Promise {} + async sync(params?: { + reason?: string; + force?: boolean; + sessionFiles?: string[]; + progress?: (update: MemorySyncProgressUpdate) => void; + }) { + await this.inner.sync?.(params); + } async probeEmbeddingAvailability(): Promise { - return { ok: true }; + return await this.inner.probeEmbeddingAvailability(); } - async probeVectorAvailability(): Promise { - return true; + async probeVectorAvailability() { + return await this.inner.probeVectorAvailability(); } - async close(): Promise {} + async close() {} } export async function closeAllMemorySearchManagers(): Promise { diff --git a/src/node-host/invoke-browser.test.ts b/src/node-host/invoke-browser.test.ts index a5341841f03f6..8dcd2ac817def 100644 --- a/src/node-host/invoke-browser.test.ts +++ b/src/node-host/invoke-browser.test.ts @@ -15,7 +15,7 @@ const dispatcherMocks = vi.hoisted(() => ({ const configMocks = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({ browser: {}, - nodeHost: { browserProxy: { enabled: true } }, + nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } }, })), })); @@ -50,7 +50,7 @@ describe("runBrowserProxyCommand", () => { controlServiceMocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue(true); configMocks.loadConfig.mockReset().mockReturnValue({ browser: {}, - nodeHost: { browserProxy: { enabled: true } }, + nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } }, }); browserConfigMocks.resolveBrowserConfig.mockReset().mockReturnValue({ enabled: true, @@ -59,7 +59,7 @@ describe("runBrowserProxyCommand", () => { ({ runBrowserProxyCommand } = await import("./invoke-browser.js")); configMocks.loadConfig.mockReturnValue({ browser: {}, - nodeHost: { browserProxy: { enabled: true } }, + nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } }, }); browserConfigMocks.resolveBrowserConfig.mockReturnValue({ enabled: true, @@ -183,4 +183,134 @@ describe("runBrowserProxyCommand", () => { ), ).rejects.toThrow("tab not found"); }); + + it("rejects unauthorized query.profile when allowProfiles is configured", async () => { + configMocks.loadConfig.mockReturnValue({ + browser: {}, + nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, + }); + + await expect( + runBrowserProxyCommand( + JSON.stringify({ + method: "GET", + path: "/snapshot", + query: { profile: "user" }, + timeoutMs: 50, + }), + ), + ).rejects.toThrow("INVALID_REQUEST: browser profile not allowed"); + expect(dispatcherMocks.dispatch).not.toHaveBeenCalled(); + }); + + it("rejects unauthorized body.profile when allowProfiles is configured", async () => { + configMocks.loadConfig.mockReturnValue({ + browser: {}, + nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, + }); + + await expect( + runBrowserProxyCommand( + JSON.stringify({ + method: "POST", + path: "/stop", + body: { profile: "user" }, + timeoutMs: 50, + }), + ), + ).rejects.toThrow("INVALID_REQUEST: browser profile not allowed"); + expect(dispatcherMocks.dispatch).not.toHaveBeenCalled(); + }); + + it("rejects persistent profile creation when allowProfiles is configured", async () => { + configMocks.loadConfig.mockReturnValue({ + browser: {}, + nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, + }); + + await expect( + runBrowserProxyCommand( + JSON.stringify({ + method: "POST", + path: "/profiles/create", + body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" }, + timeoutMs: 50, + }), + ), + ).rejects.toThrow( + "INVALID_REQUEST: browser.proxy cannot create or delete persistent browser profiles when allowProfiles is configured", + ); + expect(dispatcherMocks.dispatch).not.toHaveBeenCalled(); + }); + + it("rejects persistent profile deletion when allowProfiles is configured", async () => { + configMocks.loadConfig.mockReturnValue({ + browser: {}, + nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, + }); + + await expect( + runBrowserProxyCommand( + JSON.stringify({ + method: "DELETE", + path: "/profiles/poc", + timeoutMs: 50, + }), + ), + ).rejects.toThrow( + "INVALID_REQUEST: browser.proxy cannot create or delete persistent browser profiles when allowProfiles is configured", + ); + expect(dispatcherMocks.dispatch).not.toHaveBeenCalled(); + }); + + it("canonicalizes an allowlisted body profile into the dispatched query", async () => { + configMocks.loadConfig.mockReturnValue({ + browser: {}, + nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, + }); + dispatcherMocks.dispatch.mockResolvedValue({ + status: 200, + body: { ok: true }, + }); + + await runBrowserProxyCommand( + JSON.stringify({ + method: "POST", + path: "/stop", + body: { profile: "openclaw" }, + timeoutMs: 50, + }), + ); + + expect(dispatcherMocks.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/stop", + query: { profile: "openclaw" }, + }), + ); + }); + + it("preserves legacy proxy behavior when allowProfiles is empty", async () => { + dispatcherMocks.dispatch.mockResolvedValue({ + status: 200, + body: { ok: true }, + }); + + await runBrowserProxyCommand( + JSON.stringify({ + method: "POST", + path: "/profiles/create", + body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" }, + timeoutMs: 50, + }), + ); + + expect(dispatcherMocks.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + path: "/profiles/create", + body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" }, + }), + ); + }); }); diff --git a/src/node-host/invoke-browser.ts b/src/node-host/invoke-browser.ts index 8a440dc905ab9..d352d2d8ea1d5 100644 --- a/src/node-host/invoke-browser.ts +++ b/src/node-host/invoke-browser.ts @@ -5,6 +5,11 @@ import { createBrowserControlContext, startBrowserControlServiceFromConfig, } from "../browser/control-service.js"; +import { + isPersistentBrowserProfileMutation, + normalizeBrowserRequestPath, + resolveRequestedBrowserProfile, +} from "../browser/request-policy.js"; import { createBrowserRouteDispatcher } from "../browser/routes/dispatcher.js"; import { loadConfig } from "../config/config.js"; import { detectMime } from "../media/mime.js"; @@ -221,10 +226,23 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis await ensureBrowserControlService(); const cfg = loadConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); - const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : ""; + const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET"; + const path = normalizeBrowserRequestPath(pathValue); + const body = params.body; + const requestedProfile = + resolveRequestedBrowserProfile({ + query: params.query, + body, + profile: params.profile, + }) ?? ""; const allowedProfiles = proxyConfig.allowProfiles; if (allowedProfiles.length > 0) { - if (pathValue !== "/profiles") { + if (isPersistentBrowserProfileMutation(method, path)) { + throw new Error( + "INVALID_REQUEST: browser.proxy cannot create or delete persistent browser profiles when allowProfiles is configured", + ); + } + if (path !== "/profiles") { const profileToCheck = requestedProfile || resolved.defaultProfile; if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: profileToCheck })) { throw new Error("INVALID_REQUEST: browser profile not allowed"); @@ -236,14 +254,8 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis } } - const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET"; - const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`; - const body = params.body; const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs); const query: Record = {}; - if (requestedProfile) { - query.profile = requestedProfile; - } const rawQuery = params.query ?? {}; for (const [key, value] of Object.entries(rawQuery)) { if (value === undefined || value === null) { @@ -251,6 +263,9 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis } query[key] = typeof value === "string" ? value : String(value); } + if (requestedProfile) { + query.profile = requestedProfile; + } const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext()); let response; diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 6d90c8a7eb680..bf22b9e0c10e3 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -1004,7 +1004,8 @@ export function hardenApprovedExecutionPaths(params: { }; } - const pinnedExecutable = resolution?.resolvedRealPath ?? resolution?.resolvedPath; + const pinnedExecutable = + resolution?.execution.resolvedRealPath ?? resolution?.execution.resolvedPath; if (!pinnedExecutable) { return { ok: false, diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index bb08e9f36551c..facd3215bc4c4 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -5,6 +5,7 @@ import type { GatewayClient } from "../gateway/client.js"; import { addAllowlistEntry, recordAllowlistUse, + resolveApprovalAuditCandidatePath, resolveAllowAlwaysPatterns, resolveExecApprovals, type ExecAllowlistEntry, @@ -575,7 +576,7 @@ async function executeSystemRunPhase( phase.agentId, match, phase.commandText, - phase.segments[0]?.resolution?.resolvedPath, + resolveApprovalAuditCandidatePath(phase.segments[0]?.resolution ?? null, phase.cwd), ); } } diff --git a/src/pairing/pairing-messages.test.ts b/src/pairing/pairing-messages.test.ts index 5480d333c51d3..27c0bb1932dde 100644 --- a/src/pairing/pairing-messages.test.ts +++ b/src/pairing/pairing-messages.test.ts @@ -6,7 +6,8 @@ describe("buildPairingReply", () => { let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_PROFILE"]); + envSnapshot = captureEnv(["OPENCLAW_CONTAINER_HINT", "OPENCLAW_PROFILE"]); + delete process.env.OPENCLAW_CONTAINER_HINT; process.env.OPENCLAW_PROFILE = "isolated"; }); diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index a35e9c3786713..bebbf63bee41a 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -10,6 +10,7 @@ vi.mock("../infra/device-bootstrap.js", () => ({ let encodePairingSetupCode: typeof import("./setup-code.js").encodePairingSetupCode; let resolvePairingSetupFromConfig: typeof import("./setup-code.js").resolvePairingSetupFromConfig; +let issueDeviceBootstrapTokenMock: typeof import("../infra/device-bootstrap.js").issueDeviceBootstrapToken; describe("pairing setup code", () => { type ResolvedSetup = Awaited>; @@ -53,6 +54,14 @@ describe("pairing setup code", () => { } expect(resolved.authLabel).toBe(params.authLabel); expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); + expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith( + expect.objectContaining({ + profile: { + roles: ["node"], + scopes: [], + }, + }), + ); if (params.url) { expect(resolved.payload.url).toBe(params.url); } @@ -78,6 +87,9 @@ describe("pairing setup code", () => { beforeEach(async () => { ({ encodePairingSetupCode, resolvePairingSetupFromConfig } = await import("./setup-code.js")); + ({ issueDeviceBootstrapToken: issueDeviceBootstrapTokenMock } = + await import("../infra/device-bootstrap.js")); + vi.mocked(issueDeviceBootstrapTokenMock).mockClear(); }); afterEach(() => { diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index e3847211fa703..fb22d3bcd0f23 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -13,6 +13,7 @@ import { pickMatchingExternalInterfaceAddress, safeNetworkInterfaces, } from "../infra/network-interfaces.js"; +import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js"; import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; @@ -384,6 +385,7 @@ export async function resolvePairingSetupFromConfig( bootstrapToken: ( await issueDeviceBootstrapToken({ baseDir: options.pairingBaseDir, + profile: PAIRING_SETUP_BOOTSTRAP_PROFILE, }) ).token, }, diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index a7191fd5a01f4..93b20704d517c 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -10,6 +10,7 @@ export * from "../agents/model-auth-markers.js"; export * from "../agents/model-auth.js"; export * from "../agents/model-catalog.js"; export * from "../agents/model-selection.js"; +export * from "../agents/simple-completion-runtime.js"; export * from "../agents/pi-embedded-block-chunker.js"; export * from "../agents/pi-embedded-utils.js"; export * from "../agents/provider-id.js"; diff --git a/src/plugin-sdk/channel-actions.ts b/src/plugin-sdk/channel-actions.ts index 03e34b815c61e..068df1389de46 100644 --- a/src/plugin-sdk/channel-actions.ts +++ b/src/plugin-sdk/channel-actions.ts @@ -25,11 +25,13 @@ export function createMessageToolButtonsSchema(): TSchema { /** Schema helper for channels that accept provider-native card payloads. */ export function createMessageToolCardSchema(): TSchema { - return Type.Object( - {}, - { - additionalProperties: true, - description: "Structured card payload for channels that support card-style messages.", - }, + return Type.Optional( + Type.Object( + {}, + { + additionalProperties: true, + description: "Structured card payload for channels that support card-style messages.", + }, + ), ); } diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index 1fba887407d88..ce12611ed773d 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { formatPairingApproveHint } from "../channels/plugins/helpers.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { adaptScopedAccountAccessor, @@ -266,7 +267,7 @@ describe("createScopedDmSecurityResolver", () => { allowFrom: ["Owner"], policyPath: "channels.demo.accounts.alt.dmPolicy", allowFromPath: "channels.demo.accounts.alt.", - approveHint: "Approve via: openclaw pairing list demo / openclaw pairing approve demo ", + approveHint: formatPairingApproveHint("demo"), normalizeEntry: expect.any(Function), }); }); diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 9571bd15ee626..ccec4815f39b1 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -34,6 +34,51 @@ export { }; export type { ConfigWriteAuthorizationResult, ConfigWriteScope, ConfigWriteTarget }; +type ChannelCrudConfigAdapter = Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" +>; + +type ChannelConfigAdapterWithAccessors = Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" + | "resolveAllowFrom" + | "formatAllowFrom" + | "resolveDefaultTo" +>; + +type ChannelConfigAccessorParams = { + cfg: Config; + accountId?: string | null; +}; + +type MultiAccountChannelConfigAdapterParams< + ResolvedAccount, + AccessorAccount = ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +> = { + sectionKey: string; + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + resolveAccessorAccount?: (params: ChannelConfigAccessorParams) => AccessorAccount; + defaultAccountId: (cfg: Config) => string; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + clearBaseFields: string[]; + resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; + formatAllowFrom: (allowFrom: Array) => string[]; + resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; +}; + /** Coerce mixed allowlist config values into plain strings without trimming or deduping. */ export function mapAllowFromEntries( allowFrom: Array | null | undefined, @@ -99,6 +144,102 @@ export function createScopedAccountConfigAccessors< }; } +function createNamedAccountConfigBase< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + defaultAccountId: (cfg: Config) => string; + setAccountEnabled: (params: { + cfg: OpenClawConfig; + accountId: string; + enabled: boolean; + }) => OpenClawConfig; + deleteAccount: (params: { cfg: OpenClawConfig; accountId: string }) => OpenClawConfig; +}): ChannelCrudConfigAdapter { + return { + listAccountIds: (cfg) => params.listAccountIds(cfg as Config), + resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), + inspectAccount: params.inspectAccount + ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) + : undefined, + defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + setAccountEnabled: ({ cfg, accountId, enabled }) => + params.setAccountEnabled({ + cfg, + accountId: normalizeAccountId(accountId), + enabled, + }) as Config, + deleteAccount: ({ cfg, accountId }) => + params.deleteAccount({ + cfg, + accountId: normalizeAccountId(accountId), + }) as Config, + }; +} + +function resolveAccessorAccountWithFallback< + AccessorAccount, + Config extends OpenClawConfig = OpenClawConfig, +>( + resolveAccessorAccount: + | ((params: ChannelConfigAccessorParams) => AccessorAccount) + | undefined, + fallbackResolveAccessorAccount: (params: ChannelConfigAccessorParams) => AccessorAccount, +): (params: ChannelConfigAccessorParams) => AccessorAccount { + return resolveAccessorAccount ?? fallbackResolveAccessorAccount; +} + +function createChannelConfigAdapterWithAccessors< + ResolvedAccount, + AccessorAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + base: ChannelCrudConfigAdapter; + resolveAccessorAccount?: (params: ChannelConfigAccessorParams) => AccessorAccount; + fallbackResolveAccessorAccount: (params: ChannelConfigAccessorParams) => AccessorAccount; + resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; + formatAllowFrom: (allowFrom: Array) => string[]; + resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; +}): ChannelConfigAdapterWithAccessors { + return { + ...params.base, + ...createScopedAccountConfigAccessors({ + resolveAccount: resolveAccessorAccountWithFallback( + params.resolveAccessorAccount, + params.fallbackResolveAccessorAccount, + ), + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }), + }; +} + +function createChannelConfigAdapterFromBase< + ResolvedAccount, + AccessorAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + base: ChannelCrudConfigAdapter; + resolveAccessorAccount?: (params: ChannelConfigAccessorParams) => AccessorAccount; + resolveAccountForAccessors: (params: ChannelConfigAccessorParams) => AccessorAccount; + resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; + formatAllowFrom: (allowFrom: Array) => string[]; + resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; +}): ChannelConfigAdapterWithAccessors { + return createChannelConfigAdapterWithAccessors({ + base: params.base, + resolveAccessorAccount: params.resolveAccessorAccount, + fallbackResolveAccessorAccount: params.resolveAccountForAccessors, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }); +} + /** Build the common CRUD/config helpers for channels that store multiple named accounts. */ export function createScopedChannelConfigBase< ResolvedAccount, @@ -120,16 +261,14 @@ export function createScopedChannelConfigBase< | "setAccountEnabled" | "deleteAccount" > { - return { - listAccountIds: (cfg) => params.listAccountIds(cfg as Config), - resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), - inspectAccount: params.inspectAccount - ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) - : undefined, - defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + return createNamedAccountConfigBase({ + listAccountIds: params.listAccountIds, + resolveAccount: params.resolveAccount, + inspectAccount: params.inspectAccount, + defaultAccountId: params.defaultAccountId, setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, enabled, @@ -137,12 +276,12 @@ export function createScopedChannelConfigBase< }), deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, clearBaseFields: params.clearBaseFields, }), - }; + }); } /** Build the full shared config adapter for account-scoped channels with allowlist/default target accessors. */ @@ -150,37 +289,13 @@ export function createScopedChannelConfigAdapter< ResolvedAccount, AccessorAccount = ResolvedAccount, Config extends OpenClawConfig = OpenClawConfig, ->(params: { - sectionKey: string; - listAccountIds: (cfg: Config) => string[]; - resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; - resolveAccessorAccount?: (params: { cfg: Config; accountId?: string | null }) => AccessorAccount; - defaultAccountId: (cfg: Config) => string; - inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; - clearBaseFields: string[]; - allowTopLevel?: boolean; - resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; - formatAllowFrom: (allowFrom: Array) => string[]; - resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; -}): Pick< - ChannelConfigAdapter, - | "listAccountIds" - | "resolveAccount" - | "inspectAccount" - | "defaultAccountId" - | "setAccountEnabled" - | "deleteAccount" - | "resolveAllowFrom" - | "formatAllowFrom" - | "resolveDefaultTo" -> { - const resolveAccessorAccount = - params.resolveAccessorAccount ?? - (({ cfg, accountId }: { cfg: Config; accountId?: string | null }) => - params.resolveAccount(cfg, accountId) as unknown as AccessorAccount); - - return { - ...createScopedChannelConfigBase({ +>( + params: MultiAccountChannelConfigAdapterParams & { + allowTopLevel?: boolean; + }, +): ChannelConfigAdapterWithAccessors { + return createChannelConfigAdapterFromBase({ + base: createScopedChannelConfigBase({ sectionKey: params.sectionKey, listAccountIds: params.listAccountIds, resolveAccount: params.resolveAccount, @@ -189,13 +304,13 @@ export function createScopedChannelConfigAdapter< clearBaseFields: params.clearBaseFields, allowTopLevel: params.allowTopLevel, }), - ...createScopedAccountConfigAccessors({ - resolveAccount: resolveAccessorAccount, - resolveAllowFrom: params.resolveAllowFrom, - formatAllowFrom: params.formatAllowFrom, - resolveDefaultTo: params.resolveDefaultTo, - }), - }; + resolveAccessorAccount: params.resolveAccessorAccount, + resolveAccountForAccessors: ({ cfg, accountId }) => + params.resolveAccount(cfg, accountId) as unknown as AccessorAccount, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }); } function setTopLevelChannelEnabledInConfigSection(params: { @@ -318,25 +433,9 @@ export function createTopLevelChannelConfigAdapter< resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; formatAllowFrom: (allowFrom: Array) => string[]; resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; -}): Pick< - ChannelConfigAdapter, - | "listAccountIds" - | "resolveAccount" - | "inspectAccount" - | "defaultAccountId" - | "setAccountEnabled" - | "deleteAccount" - | "resolveAllowFrom" - | "formatAllowFrom" - | "resolveDefaultTo" -> { - const resolveAccessorAccount = - params.resolveAccessorAccount ?? - (({ cfg }: { cfg: Config; accountId?: string | null }) => - params.resolveAccount(cfg) as unknown as AccessorAccount); - - return { - ...createTopLevelChannelConfigBase({ +}): ChannelConfigAdapterWithAccessors { + return createChannelConfigAdapterFromBase({ + base: createTopLevelChannelConfigBase({ sectionKey: params.sectionKey, resolveAccount: params.resolveAccount, listAccountIds: params.listAccountIds, @@ -345,13 +444,13 @@ export function createTopLevelChannelConfigAdapter< deleteMode: params.deleteMode, clearBaseFields: params.clearBaseFields, }), - ...createScopedAccountConfigAccessors({ - resolveAccount: resolveAccessorAccount, - resolveAllowFrom: params.resolveAllowFrom, - formatAllowFrom: params.formatAllowFrom, - resolveDefaultTo: params.resolveDefaultTo, - }), - }; + resolveAccessorAccount: params.resolveAccessorAccount, + resolveAccountForAccessors: ({ cfg }) => + params.resolveAccount(cfg) as unknown as AccessorAccount, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }); } /** Build CRUD/config helpers for channels where the default account lives at channel root and named accounts live under `accounts`. */ @@ -375,23 +474,21 @@ export function createHybridChannelConfigBase< | "setAccountEnabled" | "deleteAccount" > { - return { - listAccountIds: (cfg) => params.listAccountIds(cfg as Config), - resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), - inspectAccount: params.inspectAccount - ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) - : undefined, - defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + return createNamedAccountConfigBase({ + listAccountIds: params.listAccountIds, + resolveAccount: params.resolveAccount, + inspectAccount: params.inspectAccount, + defaultAccountId: params.defaultAccountId, setAccountEnabled: ({ cfg, accountId, enabled }) => { if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { return setTopLevelChannelEnabledInConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, enabled, }); } return setAccountEnabledInConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, enabled, @@ -401,26 +498,26 @@ export function createHybridChannelConfigBase< if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { if (params.preserveSectionOnDefaultDelete) { return clearTopLevelChannelConfigFields({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, clearBaseFields: params.clearBaseFields, }); } return deleteAccountFromConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, clearBaseFields: params.clearBaseFields, }); } return deleteAccountFromConfigSection({ - cfg: cfg as Config, + cfg, sectionKey: params.sectionKey, accountId, clearBaseFields: params.clearBaseFields, }); }, - }; + }); } /** Build the full shared config adapter for hybrid channels with allowlist/default target accessors. */ @@ -428,37 +525,13 @@ export function createHybridChannelConfigAdapter< ResolvedAccount, AccessorAccount = ResolvedAccount, Config extends OpenClawConfig = OpenClawConfig, ->(params: { - sectionKey: string; - listAccountIds: (cfg: Config) => string[]; - resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; - resolveAccessorAccount?: (params: { cfg: Config; accountId?: string | null }) => AccessorAccount; - defaultAccountId: (cfg: Config) => string; - inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; - clearBaseFields: string[]; - preserveSectionOnDefaultDelete?: boolean; - resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; - formatAllowFrom: (allowFrom: Array) => string[]; - resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; -}): Pick< - ChannelConfigAdapter, - | "listAccountIds" - | "resolveAccount" - | "inspectAccount" - | "defaultAccountId" - | "setAccountEnabled" - | "deleteAccount" - | "resolveAllowFrom" - | "formatAllowFrom" - | "resolveDefaultTo" -> { - const resolveAccessorAccount = - params.resolveAccessorAccount ?? - (({ cfg, accountId }: { cfg: Config; accountId?: string | null }) => - params.resolveAccount(cfg, accountId) as unknown as AccessorAccount); - - return { - ...createHybridChannelConfigBase({ +>( + params: MultiAccountChannelConfigAdapterParams & { + preserveSectionOnDefaultDelete?: boolean; + }, +): ChannelConfigAdapterWithAccessors { + return createChannelConfigAdapterFromBase({ + base: createHybridChannelConfigBase({ sectionKey: params.sectionKey, listAccountIds: params.listAccountIds, resolveAccount: params.resolveAccount, @@ -467,13 +540,13 @@ export function createHybridChannelConfigAdapter< clearBaseFields: params.clearBaseFields, preserveSectionOnDefaultDelete: params.preserveSectionOnDefaultDelete, }), - ...createScopedAccountConfigAccessors({ - resolveAccount: resolveAccessorAccount, - resolveAllowFrom: params.resolveAllowFrom, - formatAllowFrom: params.formatAllowFrom, - resolveDefaultTo: params.resolveDefaultTo, - }), - }; + resolveAccessorAccount: params.resolveAccessorAccount, + resolveAccountForAccessors: ({ cfg, accountId }) => + params.resolveAccount(cfg, accountId) as unknown as AccessorAccount, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }); } /** Convert account-specific DM security fields into the shared runtime policy resolver shape. */ diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 3e3a9ad8e51e8..46c8c0ed96b79 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -2,27 +2,11 @@ import { readdirSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES } from "../extensions/public-artifacts.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const REPO_ROOT = resolve(ROOT_DIR, ".."); -const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set([ - "action-runtime.runtime.js", - "action-runtime-api.js", - "allow-from.js", - "api.js", - "auth-presence.js", - "index.js", - "light-runtime-api.js", - "login-qr-api.js", - "onboard.js", - "openai-codex-catalog.js", - "provider-catalog.js", - "runtime-api.js", - "session-key-api.js", - "setup-api.js", - "setup-entry.js", - "timeouts.js", -]); +const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set(GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES); const GUARDED_CHANNEL_EXTENSIONS = new Set([ "bluebubbles", "discord", @@ -171,6 +155,12 @@ const LOCAL_EXTENSION_API_BARREL_EXCEPTIONS = [ ] as const; const sourceTextCache = new Map(); +type SourceAnalysis = { + text: string; + importSpecifiers: string[]; + extensionImports: string[]; +}; +const sourceAnalysisCache = new Map(); let extensionSourceFilesCache: string[] | null = null; let coreSourceFilesCache: string[] | null = null; const extensionFilesCache = new Map(); @@ -349,14 +339,44 @@ function collectExtensionFiles(extensionId: string): string[] { return files; } -function collectExtensionImports(text: string): string[] { - return [...text.matchAll(/["']([^"']*extensions\/[^"']+\.(?:[cm]?[jt]sx?))["']/g)].map( - (match) => match[1] ?? "", - ); +function collectModuleSpecifiers(text: string): string[] { + const patterns = [ + /\bimport\s*\(\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']\s*\)/g, + /\brequire\s*\(\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']\s*\)/g, + /\b(?:import|export)\b[\s\S]*?\bfrom\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']/g, + /\bimport\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']/g, + ] as const; + const specifiers = new Set(); + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const specifier = match[1]?.trim(); + if (specifier) { + specifiers.add(specifier); + } + } + } + return [...specifiers]; } function collectImportSpecifiers(text: string): string[] { - return [...text.matchAll(/["']([^"']+\.(?:[cm]?[jt]sx?))["']/g)].map((match) => match[1] ?? ""); + return collectModuleSpecifiers(text); +} + +function getSourceAnalysis(path: string): SourceAnalysis { + const fullPath = resolve(REPO_ROOT, path); + const cached = sourceAnalysisCache.get(fullPath); + if (cached) { + return cached; + } + const text = readSource(path); + const importSpecifiers = collectImportSpecifiers(text); + const analysis = { + text, + importSpecifiers, + extensionImports: importSpecifiers.filter((specifier) => specifier.includes("extensions/")), + } satisfies SourceAnalysis; + sourceAnalysisCache.set(fullPath, analysis); + return analysis; } function expectOnlyApprovedExtensionSeams(file: string, imports: string[]): void { @@ -419,11 +439,11 @@ describe("channel import guardrails", () => { it("keeps bundled extension source files off root and compat plugin-sdk imports", () => { for (const file of collectExtensionSourceFiles()) { - const text = readSource(file); - expect(text, `${file} should not import openclaw/plugin-sdk root`).not.toMatch( + const analysis = getSourceAnalysis(file); + expect(analysis.text, `${file} should not import openclaw/plugin-sdk root`).not.toMatch( /["']openclaw\/plugin-sdk["']/, ); - expect(text, `${file} should not import openclaw/plugin-sdk/compat`).not.toMatch( + expect(analysis.text, `${file} should not import openclaw/plugin-sdk/compat`).not.toMatch( /["']openclaw\/plugin-sdk\/compat["']/, ); } @@ -432,8 +452,8 @@ describe("channel import guardrails", () => { it("keeps bundled extension source files off legacy core send-deps src imports", () => { const legacyCoreSendDepsImport = /["'][^"']*src\/infra\/outbound\/send-deps\.[cm]?[jt]s["']/; for (const file of collectExtensionSourceFiles()) { - const text = readSource(file); - expect(text, `${file} should not import src/infra/outbound/send-deps.*`).not.toMatch( + const analysis = getSourceAnalysis(file); + expect(analysis.text, `${file} should not import src/infra/outbound/send-deps.*`).not.toMatch( legacyCoreSendDepsImport, ); } @@ -441,8 +461,8 @@ describe("channel import guardrails", () => { it("keeps core production files off extension private src imports", () => { for (const file of collectCoreSourceFiles()) { - const text = readSource(file); - expect(text, `${file} should not import extensions/*/src`).not.toMatch( + const analysis = getSourceAnalysis(file); + expect(analysis.text, `${file} should not import extensions/*/src`).not.toMatch( /["'][^"']*extensions\/[^/"']+\/src\//, ); } @@ -450,20 +470,19 @@ describe("channel import guardrails", () => { it("keeps extension production files off other extensions' private src imports", () => { for (const file of collectExtensionSourceFiles()) { - const text = readSource(file); - expectNoSiblingExtensionPrivateSrcImports(file, collectImportSpecifiers(text)); + expectNoSiblingExtensionPrivateSrcImports(file, getSourceAnalysis(file).importSpecifiers); } }); it("keeps core extension imports limited to approved public surfaces", () => { for (const file of collectCoreSourceFiles()) { - expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readSource(file))); + expectOnlyApprovedExtensionSeams(file, getSourceAnalysis(file).extensionImports); } }); it("keeps extension-to-extension imports limited to approved public surfaces", () => { for (const file of collectExtensionSourceFiles()) { - expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readSource(file))); + expectOnlyApprovedExtensionSeams(file, getSourceAnalysis(file).extensionImports); } }); @@ -482,7 +501,7 @@ describe("channel import guardrails", () => { ) { continue; } - const text = readSource(file); + const { text } = getSourceAnalysis(file); expect( text, `${normalized} should import ${extensionId} helpers via the local api barrel`, diff --git a/src/plugin-sdk/channel-policy.test.ts b/src/plugin-sdk/channel-policy.test.ts index 7287545182819..18339afb92e60 100644 --- a/src/plugin-sdk/channel-policy.test.ts +++ b/src/plugin-sdk/channel-policy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { formatPairingApproveHint } from "../channels/plugins/helpers.js"; import type { GroupPolicy } from "../config/types.base.js"; import { createRestrictSendersChannelSecurity } from "./channel-policy.js"; @@ -37,7 +38,7 @@ describe("createRestrictSendersChannelSecurity", () => { allowFrom: ["line:user:abc"], policyPath: "channels.line.dmPolicy", allowFromPath: "channels.line.", - approveHint: "Approve via: openclaw pairing list line / openclaw pairing approve line ", + approveHint: formatPairingApproveHint("line"), normalizeEntry: undefined, }); diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index fa35796edec14..bf45adc0f63f5 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -9,6 +9,7 @@ export { } from "../config/io.js"; export { logConfigUpdated } from "../config/logging.js"; export { updateConfig } from "../commands/models/shared.js"; +export { resolveChannelModelOverride } from "../channels/model-overrides.js"; export { resolveMarkdownTableMode } from "../config/markdown-tables.js"; export { resolveChannelGroupPolicy, @@ -95,4 +96,7 @@ export { resolveThreadFlag, } from "../config/sessions/reset.js"; export { resolveSessionStoreEntry } from "../config/sessions/store.js"; -export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; +export { + isDangerousNameMatchingEnabled, + resolveDangerousNameMatchingEnabled, +} from "../config/dangerous-name-matching.js"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index edda71db1a3b3..055afbfa0b7c4 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -120,6 +120,7 @@ export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secre export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; +export { resolveGatewayPort } from "../config/paths.js"; export { normalizeAtHashSlug, normalizeHyphenSlug } from "../shared/string-normalization.js"; export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; diff --git a/src/plugin-sdk/device-bootstrap.ts b/src/plugin-sdk/device-bootstrap.ts index 6b2c933fc2729..850a3fe93e7d1 100644 --- a/src/plugin-sdk/device-bootstrap.ts +++ b/src/plugin-sdk/device-bootstrap.ts @@ -6,3 +6,10 @@ export { issueDeviceBootstrapToken, revokeDeviceBootstrapToken, } from "../infra/device-bootstrap.js"; +export { + normalizeDeviceBootstrapProfile, + PAIRING_SETUP_BOOTSTRAP_PROFILE, + sameDeviceBootstrapProfile, + type DeviceBootstrapProfile, + type DeviceBootstrapProfileInput, +} from "../shared/device-bootstrap-profile.js"; diff --git a/src/plugin-sdk/file-lock.ts b/src/plugin-sdk/file-lock.ts index 3870c38fc35a1..cd91e06ea93c4 100644 --- a/src/plugin-sdk/file-lock.ts +++ b/src/plugin-sdk/file-lock.ts @@ -1,3 +1,4 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { isPidAlive } from "../shared/pid-alive.js"; @@ -27,6 +28,42 @@ type HeldLock = { const HELD_LOCKS_KEY = Symbol.for("openclaw.fileLockHeldLocks"); const HELD_LOCKS = resolveProcessScopedMap(HELD_LOCKS_KEY); +const CLEANUP_REGISTERED_KEY = Symbol.for("openclaw.fileLockCleanupRegistered"); + +function releaseAllLocksSync(): void { + for (const [normalizedFile, held] of HELD_LOCKS) { + // Kick off best-effort async closes before dropping references so tests + // don't leave FileHandle objects for GC to close later. + void held.handle.close().catch(() => undefined); + rmLockPathSync(held.lockPath); + HELD_LOCKS.delete(normalizedFile); + } +} + +async function drainAllLocks(): Promise { + for (const [normalizedFile, held] of Array.from(HELD_LOCKS.entries())) { + HELD_LOCKS.delete(normalizedFile); + await held.handle.close().catch(() => undefined); + await fs.rm(held.lockPath, { force: true }).catch(() => undefined); + } +} + +function rmLockPathSync(lockPath: string): void { + try { + fsSync.rmSync(lockPath, { force: true }); + } catch { + // Best-effort exit cleanup only. + } +} + +function ensureExitCleanupRegistered(): void { + const proc = process as NodeJS.Process & { [CLEANUP_REGISTERED_KEY]?: boolean }; + if (proc[CLEANUP_REGISTERED_KEY]) { + return; + } + proc[CLEANUP_REGISTERED_KEY] = true; + process.on("exit", releaseAllLocksSync); +} function computeDelayMs(retries: FileLockOptions["retries"], attempt: number): number { const base = Math.min( @@ -100,11 +137,20 @@ async function releaseHeldLock(normalizedFile: string): Promise { await fs.rm(current.lockPath, { force: true }).catch(() => undefined); } +export function resetFileLockStateForTest(): void { + releaseAllLocksSync(); +} + +export async function drainFileLockStateForTest(): Promise { + await drainAllLocks(); +} + /** Acquire a re-entrant process-local file lock backed by a `.lock` sidecar file. */ export async function acquireFileLock( filePath: string, options: FileLockOptions, ): Promise { + ensureExitCleanupRegistered(); const normalizedFile = await resolveNormalizedFilePath(filePath); const lockPath = `${normalizedFile}.lock`; const held = HELD_LOCKS.get(normalizedFile); diff --git a/src/plugin-sdk/github-copilot-token.ts b/src/plugin-sdk/github-copilot-token.ts new file mode 100644 index 0000000000000..743daf1842e37 --- /dev/null +++ b/src/plugin-sdk/github-copilot-token.ts @@ -0,0 +1 @@ +export * from "../agents/github-copilot-token.js"; diff --git a/src/plugin-sdk/google.ts b/src/plugin-sdk/google.ts index 79ca16d674d58..aac4a88c0c49d 100644 --- a/src/plugin-sdk/google.ts +++ b/src/plugin-sdk/google.ts @@ -1,4 +1,6 @@ // Private Google-specific helpers used by bundled Google plugins. export { normalizeGoogleModelId } from "../agents/model-id-normalization.js"; +export { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js"; +export { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js"; export { parseGeminiAuth } from "../infra/gemini-auth.js"; diff --git a/src/plugin-sdk/inbound-reply-dispatch.ts b/src/plugin-sdk/inbound-reply-dispatch.ts index d7d6b0e41e94e..baeb37b30c8bc 100644 --- a/src/plugin-sdk/inbound-reply-dispatch.ts +++ b/src/plugin-sdk/inbound-reply-dispatch.ts @@ -27,6 +27,7 @@ export async function dispatchReplyFromConfigWithSettledDispatcher(params: { dispatcher: ReplyDispatcher; onSettled: () => void | Promise; replyOptions?: ReplyDispatchFromConfigOptions; + configOverride?: OpenClawConfig; }): Promise { return await withReplyDispatcher({ dispatcher: params.dispatcher, @@ -37,6 +38,7 @@ export async function dispatchReplyFromConfigWithSettledDispatcher(params: { cfg: params.cfg, dispatcher: params.dispatcher, replyOptions: params.replyOptions, + configOverride: params.configOverride, }), }); } diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts index dfc21eb753b13..060e5ff830724 100644 --- a/src/plugin-sdk/infra-runtime.ts +++ b/src/plugin-sdk/infra-runtime.ts @@ -20,11 +20,13 @@ export * from "../infra/heartbeat-visibility.ts"; export * from "../infra/home-dir.js"; export * from "../infra/http-body.js"; export * from "../infra/json-files.js"; +export * from "../infra/local-file-access.js"; export * from "../infra/map-size.js"; export * from "../infra/net/hostname.ts"; export * from "../infra/net/fetch-guard.js"; export * from "../infra/net/proxy-env.js"; export * from "../infra/net/proxy-fetch.js"; +export * from "../infra/net/undici-global-dispatcher.js"; export * from "../infra/net/ssrf.js"; export * from "../infra/outbound/identity.js"; export * from "../infra/outbound/send-deps.js"; @@ -38,5 +40,6 @@ export * from "../infra/system-message.ts"; export * from "../infra/tmp-openclaw-dir.js"; export * from "../infra/transport-ready.js"; export * from "../infra/wsl.ts"; +export * from "../utils/fetch-timeout.js"; export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forwarders.js"; export * from "./ssrf-policy.js"; diff --git a/src/plugin-sdk/media-runtime.ts b/src/plugin-sdk/media-runtime.ts index d77f42cd3143e..07d954a468145 100644 --- a/src/plugin-sdk/media-runtime.ts +++ b/src/plugin-sdk/media-runtime.ts @@ -8,10 +8,12 @@ export * from "../media/ffmpeg-limits.js"; export * from "../media/image-ops.js"; export * from "../media/inbound-path-policy.js"; export * from "../media/load-options.js"; +export * from "../media/local-media-access.js"; export * from "../media/local-roots.js"; export * from "../media/mime.js"; export * from "../media/outbound-attachment.js"; export * from "../media/png-encode.ts"; +export * from "../media/qr-image.ts"; export * from "../media/store.js"; export * from "../media/temp-files.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; diff --git a/src/plugin-sdk/memory-lancedb.ts b/src/plugin-sdk/memory-lancedb.ts index b987b56d51c1d..2f49fb9371f10 100644 --- a/src/plugin-sdk/memory-lancedb.ts +++ b/src/plugin-sdk/memory-lancedb.ts @@ -2,4 +2,5 @@ // Keep this list additive and scoped to symbols used under extensions/memory-lancedb. export { definePluginEntry } from "./plugin-entry.js"; +export { resolveStateDir } from "./state-paths.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/provider-entry.ts b/src/plugin-sdk/provider-entry.ts index 273b71e8a7ee9..94ed9d6381854 100644 --- a/src/plugin-sdk/provider-entry.ts +++ b/src/plugin-sdk/provider-entry.ts @@ -1,3 +1,5 @@ +import { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; +import { buildSingleProviderApiKeyCatalog } from "../plugins/provider-catalog.js"; import type { ProviderPlugin, ProviderPluginWizardSetup } from "../plugins/types.js"; import { definePluginEntry } from "./plugin-entry.js"; import type { @@ -5,8 +7,6 @@ import type { OpenClawPluginConfigSchema, OpenClawPluginDefinition, } from "./plugin-entry.js"; -import { createProviderApiKeyAuthMethod } from "./provider-auth.js"; -import { buildSingleProviderApiKeyCatalog } from "./provider-catalog.js"; type ApiKeyAuthMethodOptions = Parameters[0]; diff --git a/src/plugin-sdk/provider-google.ts b/src/plugin-sdk/provider-google.ts index 43130b853cac8..47b755ffb1511 100644 --- a/src/plugin-sdk/provider-google.ts +++ b/src/plugin-sdk/provider-google.ts @@ -1,4 +1,6 @@ // Public Google provider helpers shared by bundled Google extensions. export { normalizeGoogleModelId } from "../agents/model-id-normalization.js"; +export { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js"; +export { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js"; export { parseGeminiAuth } from "../infra/gemini-auth.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index 95de0adc38482..5b13bcc02e8e2 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -26,7 +26,18 @@ export { } from "../agents/model-compat.js"; export { normalizeProviderId } from "../agents/provider-id.js"; export { normalizeXaiModelId } from "../agents/model-id-normalization.js"; -export { cloneFirstTemplateModel } from "../plugins/provider-model-helpers.js"; +export { + cloneFirstTemplateModel, + matchesExactOrPrefix, +} from "../plugins/provider-model-helpers.js"; +export { + MINIMAX_DEFAULT_MODEL_ID, + MINIMAX_DEFAULT_MODEL_REF, + MINIMAX_TEXT_MODEL_CATALOG, + MINIMAX_TEXT_MODEL_ORDER, + MINIMAX_TEXT_MODEL_REFS, + isMiniMaxModernModelId, +} from "../plugins/provider-model-minimax.js"; export { applyGoogleGeminiModelDefault, @@ -74,6 +85,11 @@ export { SYNTHETIC_DEFAULT_MODEL_REF, SYNTHETIC_MODEL_CATALOG, } from "../agents/synthetic-models.js"; +export { + buildDeepSeekModelDefinition, + DEEPSEEK_BASE_URL, + DEEPSEEK_MODEL_CATALOG, +} from "../agents/deepseek-models.js"; export { buildTogetherModelDefinition, TOGETHER_BASE_URL, @@ -117,6 +133,15 @@ export { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL, } from "../agents/vercel-ai-gateway.js"; +export { + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, + MODELSTUDIO_DEFAULT_MODEL_ID, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, +} from "../plugins/provider-model-definitions.js"; export function buildKilocodeModelDefinition(): ModelDefinitionConfig { return { diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts index 9ed067cbf23ac..b03aebff79b0b 100644 --- a/src/plugin-sdk/provider-web-search.ts +++ b/src/plugin-sdk/provider-web-search.ts @@ -16,6 +16,7 @@ export { MAX_SEARCH_COUNT, normalizeFreshness, normalizeToIsoDate, + parseIsoDateRange, readCachedSearchPayload, readConfiguredSecretString, readProviderEnvValue, diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index c4e890eaae1a3..4879db2eb2462 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -76,6 +76,20 @@ function getPackageRoot() { return path.resolve(__dirname, "..", ".."); } +function findDistChunkByPrefix(prefix) { + const distRoot = path.join(getPackageRoot(), "dist"); + try { + const entries = fs.readdirSync(distRoot, { withFileTypes: true }); + const match = entries.find( + (entry) => + entry.isFile() && entry.name.startsWith(`${prefix}-`) && entry.name.endsWith(".js"), + ); + return match ? path.join(distRoot, match.name) : null; + } catch { + return null; + } +} + function listPluginSdkExportedSubpaths() { const packageRoot = getPackageRoot(); if (pluginSdkSubpathsCache.has(packageRoot)) { @@ -148,7 +162,7 @@ function loadMonolithicSdk() { } } - monolithicSdk = getJiti(false)(path.join(__dirname, "compat.ts")); + monolithicSdk = getJiti(false)(path.join(getPackageRoot(), "src", "plugin-sdk", "compat.ts")); return monolithicSdk; } @@ -157,7 +171,7 @@ function loadDiagnosticEventsModule() { return diagnosticEventsModule; } - const distCandidate = path.resolve( + const directDistCandidate = path.resolve( __dirname, "..", "..", @@ -165,21 +179,42 @@ function loadDiagnosticEventsModule() { "infra", "diagnostic-events.js", ); - if (!shouldPreferSourceInTests && fs.existsSync(distCandidate)) { - try { - diagnosticEventsModule = getJiti(true)(distCandidate); - return diagnosticEventsModule; - } catch { - // Fall through to source path if dist is unavailable or stale. + if (!shouldPreferSourceInTests) { + const distCandidate = + (fs.existsSync(directDistCandidate) && directDistCandidate) || + findDistChunkByPrefix("diagnostic-events"); + if (distCandidate) { + try { + diagnosticEventsModule = normalizeDiagnosticEventsModule(getJiti(true)(distCandidate)); + return diagnosticEventsModule; + } catch { + // Fall through to source path if dist is unavailable or stale. + } } } - diagnosticEventsModule = getJiti(false)( - path.join(__dirname, "..", "infra", "diagnostic-events.ts"), + diagnosticEventsModule = normalizeDiagnosticEventsModule( + getJiti(false)(path.join(getPackageRoot(), "src", "infra", "diagnostic-events.ts")), ); return diagnosticEventsModule; } +function normalizeDiagnosticEventsModule(mod) { + if (!mod || typeof mod !== "object") { + return mod; + } + if (typeof mod.onDiagnosticEvent === "function") { + return mod; + } + if (typeof mod.r === "function") { + return { + ...mod, + onDiagnosticEvent: mod.r, + }; + } + return mod; +} + function tryLoadMonolithicSdk() { try { return loadMonolithicSdk(); diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 07c08b0e55a62..c784411b28480 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -22,8 +22,10 @@ type EmptySchema = { function loadRootAliasWithStubs(options?: { distExists?: boolean; + distEntries?: string[]; env?: Record; monolithicExports?: Record; + aliasPath?: string; }) { let createJitiCalls = 0; let jitiLoadCalls = 0; @@ -48,6 +50,7 @@ function loadRootAliasWithStubs(options?: { __dirname: string, ) => void; const module = { exports: {} as Record }; + const aliasPath = options?.aliasPath ?? rootAliasPath; const localRequire = ((id: string) => { if (id === "node:path") { return path; @@ -60,7 +63,18 @@ function loadRootAliasWithStubs(options?: { "./plugin-sdk/group-access": { default: "./dist/plugin-sdk/group-access.js" }, }, }), - existsSync: () => options?.distExists ?? false, + existsSync: (targetPath: string) => { + if (targetPath.endsWith(path.join("dist", "infra", "diagnostic-events.js"))) { + return options?.distExists ?? false; + } + return options?.distExists ?? false; + }, + readdirSync: () => + (options?.distEntries ?? []).map((name) => ({ + name, + isFile: () => true, + isDirectory: () => false, + })), }; } if (id === "jiti") { @@ -78,7 +92,7 @@ function loadRootAliasWithStubs(options?: { } throw new Error(`unexpected require: ${id}`); }) as NodeJS.Require; - wrapper(module.exports, localRequire, module, rootAliasPath, path.dirname(rootAliasPath)); + wrapper(module.exports, localRequire, module, aliasPath, path.dirname(aliasPath)); return { moduleExports: module.exports, get createJitiCalls() { @@ -175,6 +189,58 @@ describe("plugin-sdk root alias", () => { expect(lazyModule.createJitiOptions.at(-1)?.tryNative).toBe(false); }); + it("falls back to src files even when the alias itself is loaded from dist", () => { + const packageRoot = path.dirname(path.dirname(rootAliasPath)); + const distAliasPath = path.join(packageRoot, "dist", "plugin-sdk", "root-alias.cjs"); + const lazyModule = loadRootAliasWithStubs({ + aliasPath: distAliasPath, + distExists: false, + monolithicExports: { + onDiagnosticEvent: () => () => undefined, + slowHelper: () => "loaded", + }, + }); + + expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded"); + expect(lazyModule.loadedSpecifiers).toContain( + path.join(packageRoot, "src", "plugin-sdk", "compat.ts"), + ); + expect( + typeof (lazyModule.moduleExports.onDiagnosticEvent as (listener: () => void) => () => void)( + () => undefined, + ), + ).toBe("function"); + expect(lazyModule.loadedSpecifiers).toContain( + path.join(packageRoot, "src", "infra", "diagnostic-events.ts"), + ); + }); + + it("prefers hashed dist diagnostic events chunks before falling back to src", () => { + const packageRoot = path.dirname(path.dirname(rootAliasPath)); + const distAliasPath = path.join(packageRoot, "dist", "plugin-sdk", "root-alias.cjs"); + const lazyModule = loadRootAliasWithStubs({ + aliasPath: distAliasPath, + distExists: false, + distEntries: ["diagnostic-events-W3Hz61fI.js"], + monolithicExports: { + r: () => () => undefined, + slowHelper: () => "loaded", + }, + }); + + expect( + typeof (lazyModule.moduleExports.onDiagnosticEvent as (listener: () => void) => () => void)( + () => undefined, + ), + ).toBe("function"); + expect(lazyModule.loadedSpecifiers).toContain( + path.join(packageRoot, "dist", "diagnostic-events-W3Hz61fI.js"), + ); + expect(lazyModule.loadedSpecifiers).not.toContain( + path.join(packageRoot, "src", "infra", "diagnostic-events.ts"), + ); + }); + it("forwards delegateCompactionToRuntime through the compat-backed root alias", () => { const delegateCompactionToRuntime = () => "delegated"; const lazyModule = loadRootAliasWithStubs({ diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index 2ba4dd590a39c..99e73dd7a647b 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -57,6 +57,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { ], "extensions/telegram/runtime-api.ts": [ 'export type { ChannelMessageActionAdapter, ChannelPlugin, OpenClawConfig, OpenClawPluginApi, PluginRuntime, TelegramAccountConfig, TelegramActionConfig, TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram";', + 'export type { TelegramApiOverride } from "./src/send.js";', 'export type { OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "openclaw/plugin-sdk/core";', 'export type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeDoctorReport, AcpRuntimeEnsureInput, AcpRuntimeEvent, AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, AcpRuntimeErrorCode, AcpSessionUpdateTag } from "openclaw/plugin-sdk/acp-runtime";', 'export { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime";', diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts index ce349fb9de53c..8cc447e870a78 100644 --- a/src/plugin-sdk/sandbox.ts +++ b/src/plugin-sdk/sandbox.ts @@ -33,6 +33,8 @@ export { getSandboxBackendManager, registerSandboxBackend, requireSandboxBackendFactory, + resolveWritableRenameTargets, + resolveWritableRenameTargetsForBridge, runSshSandboxCommand, shellEscape, uploadDirectoryToSshTarget, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index e310f184cc10c..d8183d0eaf353 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -20,6 +20,7 @@ import type { OpenClawPluginApi as CoreOpenClawPluginApi, PluginRuntime as CorePluginRuntime, } from "openclaw/plugin-sdk/core"; +import * as providerEntrySdk from "openclaw/plugin-sdk/provider-entry"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; import type { @@ -574,6 +575,7 @@ describe("plugin-sdk subpath exports", () => { it("keeps runtime entry subpaths importable", async () => { const [ coreSdk, + textRuntimeSdk, pluginEntrySdk, channelLifecycleSdk, channelPairingSdk, @@ -581,6 +583,7 @@ describe("plugin-sdk subpath exports", () => { ...representativeModules ] = await Promise.all([ importResolvedPluginSdkSubpath("openclaw/plugin-sdk/core"), + importResolvedPluginSdkSubpath("openclaw/plugin-sdk/text-runtime"), importResolvedPluginSdkSubpath("openclaw/plugin-sdk/plugin-entry"), importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-lifecycle"), importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-pairing"), @@ -591,6 +594,10 @@ describe("plugin-sdk subpath exports", () => { ]); expect(coreSdk.definePluginEntry).toBe(pluginEntrySdk.definePluginEntry); + expect(typeof coreSdk.optionalStringEnum).toBe("function"); + expect(typeof textRuntimeSdk.createScopedExpiringIdCache).toBe("function"); + expect(typeof textRuntimeSdk.resolveGlobalMap).toBe("function"); + expect(typeof textRuntimeSdk.resolveGlobalSingleton).toBe("function"); expectSourceMentions("infra-runtime", ["createRuntimeOutboundDelegates"]); expectSourceContains("infra-runtime", "../infra/outbound/send-deps.js"); @@ -622,4 +629,8 @@ describe("plugin-sdk subpath exports", () => { expect(mod, `subpath ${id} should resolve`).toBeTruthy(); } }); + + it("exports single-provider plugin entry helpers from the dedicated subpath", () => { + expect(typeof providerEntrySdk.defineSingleProviderPluginEntry).toBe("function"); + }); }); diff --git a/src/plugins/bundle-config-shared.ts b/src/plugins/bundle-config-shared.ts new file mode 100644 index 0000000000000..7980f72a6fad4 --- /dev/null +++ b/src/plugins/bundle-config-shared.ts @@ -0,0 +1,139 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { applyMergePatch } from "../config/merge-patch.js"; +import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { isRecord } from "../utils.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginBundleFormat } from "./types.js"; + +type ReadBundleJsonResult = + | { ok: true; raw: Record } + | { ok: false; error: string }; + +export type BundleServerRuntimeSupport = { + hasSupportedServer: boolean; + supportedServerNames: string[]; + unsupportedServerNames: string[]; + diagnostics: string[]; +}; + +export function readBundleJsonObject(params: { + rootDir: string; + relativePath: string; + onOpenFailure?: ( + failure: Extract, { ok: false }>, + ) => ReadBundleJsonResult; +}): ReadBundleJsonResult { + const absolutePath = path.join(params.rootDir, params.relativePath); + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: true, + }); + if (!opened.ok) { + return params.onOpenFailure?.(opened) ?? { ok: true, raw: {} }; + } + try { + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + if (!isRecord(raw)) { + return { ok: false, error: `${params.relativePath} must contain a JSON object` }; + } + return { ok: true, raw }; + } catch (error) { + return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` }; + } finally { + fs.closeSync(opened.fd); + } +} + +export function resolveBundleJsonOpenFailure(params: { + failure: Extract, { ok: false }>; + relativePath: string; + allowMissing?: boolean; +}): ReadBundleJsonResult { + return matchBoundaryFileOpenFailure(params.failure, { + path: () => { + if (params.allowMissing) { + return { ok: true, raw: {} }; + } + return { ok: false, error: `unable to read ${params.relativePath}: path` }; + }, + fallback: (failure) => ({ + ok: false, + error: `unable to read ${params.relativePath}: ${failure.reason}`, + }), + }); +} + +export function inspectBundleServerRuntimeSupport(params: { + loaded: { config: TConfig; diagnostics: string[] }; + resolveServers: (config: TConfig) => Record>; +}): BundleServerRuntimeSupport { + const supportedServerNames: string[] = []; + const unsupportedServerNames: string[] = []; + let hasSupportedServer = false; + for (const [serverName, server] of Object.entries(params.resolveServers(params.loaded.config))) { + if (typeof server.command === "string" && server.command.trim().length > 0) { + hasSupportedServer = true; + supportedServerNames.push(serverName); + continue; + } + unsupportedServerNames.push(serverName); + } + return { + hasSupportedServer, + supportedServerNames, + unsupportedServerNames, + diagnostics: params.loaded.diagnostics, + }; +} + +export function loadEnabledBundleConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; + createEmptyConfig: () => TConfig; + loadBundleConfig: (params: { + pluginId: string; + rootDir: string; + bundleFormat: PluginBundleFormat; + }) => { config: TConfig; diagnostics: string[] }; + createDiagnostic: (pluginId: string, message: string) => TDiagnostic; +}): { config: TConfig; diagnostics: TDiagnostic[] } { + const registry = loadPluginManifestRegistry({ + workspaceDir: params.workspaceDir, + config: params.cfg, + }); + const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); + const diagnostics: TDiagnostic[] = []; + let merged = params.createEmptyConfig(); + + for (const record of registry.plugins) { + if (record.format !== "bundle" || !record.bundleFormat) { + continue; + } + const enableState = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedPlugins, + rootConfig: params.cfg, + }); + if (!enableState.enabled) { + continue; + } + + const loaded = params.loadBundleConfig({ + pluginId: record.id, + rootDir: record.rootDir, + bundleFormat: record.bundleFormat, + }); + merged = applyMergePatch(merged, loaded.config) as TConfig; + for (const message of loaded.diagnostics) { + diagnostics.push(params.createDiagnostic(record.id, message)); + } + } + + return { config: merged, diagnostics }; +} diff --git a/src/plugins/bundle-lsp.ts b/src/plugins/bundle-lsp.ts index 0151d5d1df2e4..7c8e29d7e2cf6 100644 --- a/src/plugins/bundle-lsp.ts +++ b/src/plugins/bundle-lsp.ts @@ -4,13 +4,16 @@ import type { OpenClawConfig } from "../config/config.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; +import { + inspectBundleServerRuntimeSupport, + loadEnabledBundleConfig, + readBundleJsonObject, +} from "./bundle-config-shared.js"; import { CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, mergeBundlePathLists, normalizeBundlePathList, } from "./bundle-manifest.js"; -import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginBundleFormat } from "./types.js"; export type BundleLspServerConfig = Record; @@ -30,33 +33,6 @@ const MANIFEST_PATH_BY_FORMAT: Partial> = { claude: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, }; -function readPluginJsonObject(params: { - rootDir: string; - relativePath: string; -}): { ok: true; raw: Record } | { ok: false; error: string } { - const absolutePath = path.join(params.rootDir, params.relativePath); - const opened = openBoundaryFileSync({ - absolutePath, - rootPath: params.rootDir, - boundaryLabel: "plugin root", - rejectHardlinks: true, - }); - if (!opened.ok) { - return { ok: true, raw: {} }; - } - try { - const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; - if (!isRecord(raw)) { - return { ok: false, error: `${params.relativePath} must contain a JSON object` }; - } - return { ok: true, raw }; - } catch (error) { - return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` }; - } finally { - fs.closeSync(opened.fd); - } -} - function extractLspServerMap(raw: unknown): Record { if (!isRecord(raw)) { return {}; @@ -120,7 +96,7 @@ function loadBundleLspConfig(params: { return { config: { lspServers: {} }, diagnostics: [] }; } - const manifestLoaded = readPluginJsonObject({ + const manifestLoaded = readBundleJsonObject({ rootDir: params.rootDir, relativePath: manifestRelativePath, }); @@ -151,23 +127,15 @@ export function inspectBundleLspRuntimeSupport(params: { rootDir: string; bundleFormat: PluginBundleFormat; }): BundleLspRuntimeSupport { - const loaded = loadBundleLspConfig(params); - const supportedServerNames: string[] = []; - const unsupportedServerNames: string[] = []; - let hasStdioServer = false; - for (const [serverName, server] of Object.entries(loaded.config.lspServers)) { - if (typeof server.command === "string" && server.command.trim().length > 0) { - hasStdioServer = true; - supportedServerNames.push(serverName); - continue; - } - unsupportedServerNames.push(serverName); - } + const support = inspectBundleServerRuntimeSupport({ + loaded: loadBundleLspConfig(params), + resolveServers: (config) => config.lspServers, + }); return { - hasStdioServer, - supportedServerNames, - unsupportedServerNames, - diagnostics: loaded.diagnostics, + hasStdioServer: support.hasSupportedServer, + supportedServerNames: support.supportedServerNames, + unsupportedServerNames: support.unsupportedServerNames, + diagnostics: support.diagnostics, }; } @@ -175,38 +143,11 @@ export function loadEnabledBundleLspConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; }): { config: BundleLspConfig; diagnostics: Array<{ pluginId: string; message: string }> } { - const registry = loadPluginManifestRegistry({ + return loadEnabledBundleConfig({ workspaceDir: params.workspaceDir, - config: params.cfg, + cfg: params.cfg, + createEmptyConfig: () => ({ lspServers: {} }), + loadBundleConfig: loadBundleLspConfig, + createDiagnostic: (pluginId, message) => ({ pluginId, message }), }); - const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); - const diagnostics: Array<{ pluginId: string; message: string }> = []; - let merged: BundleLspConfig = { lspServers: {} }; - - for (const record of registry.plugins) { - if (record.format !== "bundle" || !record.bundleFormat) { - continue; - } - const enableState = resolveEffectiveEnableState({ - id: record.id, - origin: record.origin, - config: normalizedPlugins, - rootConfig: params.cfg, - }); - if (!enableState.enabled) { - continue; - } - - const loaded = loadBundleLspConfig({ - pluginId: record.id, - rootDir: record.rootDir, - bundleFormat: record.bundleFormat, - }); - merged = applyMergePatch(merged, loaded.config) as BundleLspConfig; - for (const message of loaded.diagnostics) { - diagnostics.push({ pluginId: record.id, message }); - } - } - - return { config: merged, diagnostics }; } diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 357961caf7224..f117c6d39f874 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -2,8 +2,14 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { applyMergePatch } from "../config/merge-patch.js"; -import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; +import { + inspectBundleServerRuntimeSupport, + loadEnabledBundleConfig, + readBundleJsonObject, + resolveBundleJsonOpenFailure, +} from "./bundle-config-shared.js"; import { CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, @@ -11,8 +17,6 @@ import { mergeBundlePathLists, normalizeBundlePathList, } from "./bundle-manifest.js"; -import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginBundleFormat } from "./types.js"; export type BundleMcpServerConfig = Record; @@ -44,45 +48,6 @@ const MANIFEST_PATH_BY_FORMAT: Record = { }; const CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"; -function readPluginJsonObject(params: { - rootDir: string; - relativePath: string; - allowMissing?: boolean; -}): { ok: true; raw: Record } | { ok: false; error: string } { - const absolutePath = path.join(params.rootDir, params.relativePath); - const opened = openBoundaryFileSync({ - absolutePath, - rootPath: params.rootDir, - boundaryLabel: "plugin root", - rejectHardlinks: true, - }); - if (!opened.ok) { - return matchBoundaryFileOpenFailure(opened, { - path: () => { - if (params.allowMissing) { - return { ok: true, raw: {} }; - } - return { ok: false, error: `unable to read ${params.relativePath}: path` }; - }, - fallback: (failure) => ({ - ok: false, - error: `unable to read ${params.relativePath}: ${failure.reason}`, - }), - }); - } - try { - const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; - if (!isRecord(raw)) { - return { ok: false, error: `${params.relativePath} must contain a JSON object` }; - } - return { ok: true, raw }; - } catch (error) { - return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` }; - } finally { - fs.closeSync(opened.fd); - } -} - function resolveBundleMcpConfigPaths(params: { raw: Record; rootDir: string; @@ -258,10 +223,15 @@ function loadBundleMcpConfig(params: { bundleFormat: PluginBundleFormat; }): { config: BundleMcpConfig; diagnostics: string[] } { const manifestRelativePath = MANIFEST_PATH_BY_FORMAT[params.bundleFormat]; - const manifestLoaded = readPluginJsonObject({ + const manifestLoaded = readBundleJsonObject({ rootDir: params.rootDir, relativePath: manifestRelativePath, - allowMissing: params.bundleFormat === "claude", + onOpenFailure: (failure) => + resolveBundleJsonOpenFailure({ + failure, + relativePath: manifestRelativePath, + allowMissing: params.bundleFormat === "claude", + }), }); if (!manifestLoaded.ok) { return { config: { mcpServers: {} }, diagnostics: [manifestLoaded.error] }; @@ -299,23 +269,15 @@ export function inspectBundleMcpRuntimeSupport(params: { rootDir: string; bundleFormat: PluginBundleFormat; }): BundleMcpRuntimeSupport { - const loaded = loadBundleMcpConfig(params); - const supportedServerNames: string[] = []; - const unsupportedServerNames: string[] = []; - let hasSupportedStdioServer = false; - for (const [serverName, server] of Object.entries(loaded.config.mcpServers)) { - if (typeof server.command === "string" && server.command.trim().length > 0) { - hasSupportedStdioServer = true; - supportedServerNames.push(serverName); - continue; - } - unsupportedServerNames.push(serverName); - } + const support = inspectBundleServerRuntimeSupport({ + loaded: loadBundleMcpConfig(params), + resolveServers: (config) => config.mcpServers, + }); return { - hasSupportedStdioServer, - supportedServerNames, - unsupportedServerNames, - diagnostics: loaded.diagnostics, + hasSupportedStdioServer: support.hasSupportedServer, + supportedServerNames: support.supportedServerNames, + unsupportedServerNames: support.unsupportedServerNames, + diagnostics: support.diagnostics, }; } @@ -323,38 +285,11 @@ export function loadEnabledBundleMcpConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; }): EnabledBundleMcpConfigResult { - const registry = loadPluginManifestRegistry({ + return loadEnabledBundleConfig({ workspaceDir: params.workspaceDir, - config: params.cfg, + cfg: params.cfg, + createEmptyConfig: () => ({ mcpServers: {} }), + loadBundleConfig: loadBundleMcpConfig, + createDiagnostic: (pluginId, message) => ({ pluginId, message }), }); - const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); - const diagnostics: BundleMcpDiagnostic[] = []; - let merged: BundleMcpConfig = { mcpServers: {} }; - - for (const record of registry.plugins) { - if (record.format !== "bundle" || !record.bundleFormat) { - continue; - } - const enableState = resolveEffectiveEnableState({ - id: record.id, - origin: record.origin, - config: normalizedPlugins, - rootConfig: params.cfg, - }); - if (!enableState.enabled) { - continue; - } - - const loaded = loadBundleMcpConfig({ - pluginId: record.id, - rootDir: record.rootDir, - bundleFormat: record.bundleFormat, - }); - merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig; - for (const message of loaded.diagnostics) { - diagnostics.push({ pluginId: record.id, message }); - } - } - - return { config: merged, diagnostics }; } diff --git a/src/plugins/bundled-plugin-metadata.generated.ts b/src/plugins/bundled-plugin-metadata.generated.ts index b257d49d2087f..676d5fe1aaf02 100644 --- a/src/plugins/bundled-plugin-metadata.generated.ts +++ b/src/plugins/bundled-plugin-metadata.generated.ts @@ -88,7 +88,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ }, expectedVersion: { label: "Expected acpx Version", - help: 'Exact version to enforce (for example 0.1.16) or "any" to skip strict version matching.', + help: 'Exact version to enforce or "any" to skip strict version matching.', }, cwd: { label: "Default Working Directory", @@ -490,6 +490,47 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ }, }, }, + { + dirName: "deepseek", + idHint: "deepseek", + source: { + source: "./index.ts", + built: "index.js", + }, + packageName: "@openclaw/deepseek-provider", + packageVersion: "2026.3.14", + packageDescription: "OpenClaw DeepSeek provider plugin", + packageManifest: { + extensions: ["./index.ts"], + }, + manifest: { + id: "deepseek", + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + providers: ["deepseek"], + providerAuthEnvVars: { + deepseek: ["DEEPSEEK_API_KEY"], + }, + providerAuthChoices: [ + { + provider: "deepseek", + method: "api-key", + choiceId: "deepseek-api-key", + choiceLabel: "DeepSeek API key", + groupId: "deepseek", + groupLabel: "DeepSeek", + groupHint: "API key", + optionKey: "deepseekApiKey", + cliFlag: "--deepseek-api-key", + cliOption: "--deepseek-api-key ", + cliDescription: "DeepSeek API key", + }, + ], + }, + }, { dirName: "diagnostics-otel", idHint: "diagnostics-otel", @@ -1886,6 +1927,34 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ modelstudio: ["MODELSTUDIO_API_KEY"], }, providerAuthChoices: [ + { + provider: "modelstudio", + method: "standard-api-key-cn", + choiceId: "modelstudio-standard-api-key-cn", + choiceLabel: "Standard API Key for China (pay-as-you-go)", + choiceHint: "Endpoint: dashscope.aliyuncs.com", + groupId: "modelstudio", + groupLabel: "Qwen (Alibaba Cloud Model Studio)", + groupHint: "Standard / Coding Plan (CN / Global)", + optionKey: "modelstudioStandardApiKeyCn", + cliFlag: "--modelstudio-standard-api-key-cn", + cliOption: "--modelstudio-standard-api-key-cn ", + cliDescription: "Alibaba Cloud Model Studio Standard API key (China)", + }, + { + provider: "modelstudio", + method: "standard-api-key", + choiceId: "modelstudio-standard-api-key", + choiceLabel: "Standard API Key for Global/Intl (pay-as-you-go)", + choiceHint: "Endpoint: dashscope-intl.aliyuncs.com", + groupId: "modelstudio", + groupLabel: "Qwen (Alibaba Cloud Model Studio)", + groupHint: "Standard / Coding Plan (CN / Global)", + optionKey: "modelstudioStandardApiKey", + cliFlag: "--modelstudio-standard-api-key", + cliOption: "--modelstudio-standard-api-key ", + cliDescription: "Alibaba Cloud Model Studio Standard API key (Global/Intl)", + }, { provider: "modelstudio", method: "api-key-cn", @@ -1893,8 +1962,8 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ choiceLabel: "Coding Plan API Key for China (subscription)", choiceHint: "Endpoint: coding.dashscope.aliyuncs.com", groupId: "modelstudio", - groupLabel: "Alibaba Cloud Model Studio", - groupHint: "Coding Plan API key (CN / Global)", + groupLabel: "Qwen (Alibaba Cloud Model Studio)", + groupHint: "Standard / Coding Plan (CN / Global)", optionKey: "modelstudioApiKeyCn", cliFlag: "--modelstudio-api-key-cn", cliOption: "--modelstudio-api-key-cn ", @@ -1907,8 +1976,8 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ choiceLabel: "Coding Plan API Key for Global/Intl (subscription)", choiceHint: "Endpoint: coding-intl.dashscope.aliyuncs.com", groupId: "modelstudio", - groupLabel: "Alibaba Cloud Model Studio", - groupHint: "Coding Plan API key (CN / Global)", + groupLabel: "Qwen (Alibaba Cloud Model Studio)", + groupHint: "Standard / Coding Plan (CN / Global)", optionKey: "modelstudioApiKey", cliFlag: "--modelstudio-api-key", cliOption: "--modelstudio-api-key ", @@ -2022,10 +2091,10 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ channel: { id: "msteams", label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", + selectionLabel: "Microsoft Teams (Teams SDK)", docsPath: "/channels/msteams", docsLabel: "msteams", - blurb: "Bot Framework; enterprise support.", + blurb: "Teams SDK; enterprise support.", aliases: ["teams"], order: 60, }, diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index bde1abc0856d0..cb8d4f67d7534 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { collectBundledPluginMetadata, writeBundledPluginMetadataModule, @@ -10,20 +9,14 @@ import { BUNDLED_PLUGIN_METADATA, resolveBundledPluginGeneratedPath, } from "./bundled-plugin-metadata.js"; +import { + createGeneratedPluginTempRoot, + installGeneratedPluginTempRootCleanup, + pluginTestRepoRoot as repoRoot, + writeJson, +} from "./generated-plugin-test-helpers.js"; -const repoRoot = path.resolve(import.meta.dirname, "../.."); -const tempDirs: string[] = []; - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); +installGeneratedPluginTempRootCleanup(); describe("bundled plugin metadata", () => { it("matches the generated metadata snapshot", () => { @@ -38,8 +31,7 @@ describe("bundled plugin metadata", () => { }); it("prefers built generated paths when present and falls back to source paths", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-plugin-metadata-")); - tempDirs.push(tempRoot); + const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-metadata-"); fs.mkdirSync(path.join(tempRoot, "plugin"), { recursive: true }); fs.writeFileSync(path.join(tempRoot, "plugin", "index.ts"), "export {};\n", "utf8"); @@ -60,8 +52,7 @@ describe("bundled plugin metadata", () => { }); it("supports check mode for stale generated artifacts", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-plugin-generated-")); - tempDirs.push(tempRoot); + const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-generated-"); writeJson(path.join(tempRoot, "extensions", "alpha", "package.json"), { name: "@openclaw/alpha", diff --git a/src/plugins/bundled-provider-auth-env-vars.generated.ts b/src/plugins/bundled-provider-auth-env-vars.generated.ts index 5a45d13293eff..fff97d51e8a02 100644 --- a/src/plugins/bundled-provider-auth-env-vars.generated.ts +++ b/src/plugins/bundled-provider-auth-env-vars.generated.ts @@ -6,6 +6,7 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { byteplus: ["BYTEPLUS_API_KEY"], chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + deepseek: ["DEEPSEEK_API_KEY"], exa: ["EXA_API_KEY"], fal: ["FAL_KEY"], firecrawl: ["FIRECRAWL_API_KEY"], diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts index bf0d481834be6..a6a28155a7530 100644 --- a/src/plugins/bundled-provider-auth-env-vars.test.ts +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -1,27 +1,19 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { afterEach } from "vitest"; import { collectBundledProviderAuthEnvVars, writeBundledProviderAuthEnvVarModule, } from "../../scripts/generate-bundled-provider-auth-env-vars.mjs"; import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js"; +import { + createGeneratedPluginTempRoot, + installGeneratedPluginTempRootCleanup, + pluginTestRepoRoot as repoRoot, + writeJson, +} from "./generated-plugin-test-helpers.js"; -const repoRoot = path.resolve(import.meta.dirname, "../.."); -const tempDirs: string[] = []; - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); +installGeneratedPluginTempRootCleanup(); describe("bundled provider auth env vars", () => { it("matches the generated manifest snapshot", () => { @@ -57,8 +49,7 @@ describe("bundled provider auth env vars", () => { }); it("supports check mode for stale generated artifacts", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-auth-env-vars-")); - tempDirs.push(tempRoot); + const tempRoot = createGeneratedPluginTempRoot("openclaw-provider-auth-env-vars-"); writeJson(path.join(tempRoot, "extensions", "alpha", "openclaw.plugin.json"), { id: "alpha", diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index aed26eb6e01c0..83ee008bc7c6b 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -46,6 +46,10 @@ describe("bundled plugin runtime dependencies", () => { expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "@whiskeysockets/baileys"); }); + it("keeps WhatsApp image helper deps plugin-local so bundled builds resolve Baileys peers", () => { + expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "jimp"); + }); + it("keeps bundled proxy-agent deps plugin-local instead of mirroring them into the root package", () => { expectPluginOwnsRuntimeDep("extensions/discord/package.json", "https-proxy-agent"); }); diff --git a/src/plugins/bundled-web-search-provider-ids.ts b/src/plugins/bundled-web-search-provider-ids.ts new file mode 100644 index 0000000000000..198d7d4758711 --- /dev/null +++ b/src/plugins/bundled-web-search-provider-ids.ts @@ -0,0 +1,25 @@ +const BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS = { + brave: "brave", + exa: "exa", + firecrawl: "firecrawl", + gemini: "google", + grok: "xai", + kimi: "moonshot", + perplexity: "perplexity", + tavily: "tavily", +} as const satisfies Record; + +export function resolveBundledWebSearchPluginId( + providerId: string | undefined, +): string | undefined { + if (!providerId) { + return undefined; + } + const normalizedProviderId = providerId.trim().toLowerCase(); + if (!(normalizedProviderId in BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS)) { + return undefined; + } + return BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS[ + normalizedProviderId as keyof typeof BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS + ]; +} diff --git a/src/plugins/bundled-web-search.test.ts b/src/plugins/bundled-web-search.test.ts index c3f2a0c187e7d..8000c27f3e364 100644 --- a/src/plugins/bundled-web-search.test.ts +++ b/src/plugins/bundled-web-search.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js"; import type { OpenClawConfig } from "../config/config.js"; import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-web-search-ids.js"; +import { resolveBundledWebSearchPluginId } from "./bundled-web-search-provider-ids.js"; import { listBundledWebSearchProviders, resolveBundledWebSearchPluginIds, @@ -91,6 +92,17 @@ describe("bundled web search metadata", () => { ); }); + it("keeps bundled web search provider-to-plugin ids aligned with bundled contracts", () => { + expect(resolveBundledWebSearchPluginId("brave")).toBe("brave"); + expect(resolveBundledWebSearchPluginId("exa")).toBe("exa"); + expect(resolveBundledWebSearchPluginId("firecrawl")).toBe("firecrawl"); + expect(resolveBundledWebSearchPluginId("gemini")).toBe("google"); + expect(resolveBundledWebSearchPluginId("kimi")).toBe("moonshot"); + expect(resolveBundledWebSearchPluginId("perplexity")).toBe("perplexity"); + expect(resolveBundledWebSearchPluginId("tavily")).toBe("tavily"); + expect(resolveBundledWebSearchPluginId("grok")).toBe("xai"); + }); + it("keeps fast-path bundled provider metadata aligned with bundled plugin contracts", async () => { const fastPathProviders = listBundledWebSearchProviders(); diff --git a/src/plugins/bundled-web-search.ts b/src/plugins/bundled-web-search.ts index 767b7babada6f..ca97551e349ef 100644 --- a/src/plugins/bundled-web-search.ts +++ b/src/plugins/bundled-web-search.ts @@ -1,5 +1,6 @@ import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js"; import { listBundledWebSearchPluginIds as listBundledWebSearchPluginIdsFromIds } from "./bundled-web-search-ids.js"; +import { resolveBundledWebSearchPluginId as resolveBundledWebSearchPluginIdFromMap } from "./bundled-web-search-provider-ids.js"; import { capturePluginRegistration } from "./captured-registration.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -76,8 +77,5 @@ export function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] export function resolveBundledWebSearchPluginId( providerId: string | undefined, ): string | undefined { - if (!providerId) { - return undefined; - } - return loadBundledWebSearchProviders().find((provider) => provider.id === providerId)?.pluginId; + return resolveBundledWebSearchPluginIdFromMap(providerId); } diff --git a/src/plugins/cache-controls.ts b/src/plugins/cache-controls.ts new file mode 100644 index 0000000000000..3b11d63247928 --- /dev/null +++ b/src/plugins/cache-controls.ts @@ -0,0 +1,68 @@ +export const DEFAULT_PLUGIN_DISCOVERY_CACHE_MS = 1000; +export const DEFAULT_PLUGIN_MANIFEST_CACHE_MS = 1000; + +export function shouldUsePluginSnapshotCache(env: NodeJS.ProcessEnv): boolean { + if (env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim()) { + return false; + } + if (env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim()) { + return false; + } + const discoveryCacheMs = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); + if (discoveryCacheMs === "0") { + return false; + } + const manifestCacheMs = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); + if (manifestCacheMs === "0") { + return false; + } + return true; +} + +export function resolvePluginCacheMs(rawValue: string | undefined, defaultMs: number): number { + const raw = rawValue?.trim(); + if (raw === "" || raw === "0") { + return 0; + } + if (!raw) { + return defaultMs; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) { + return defaultMs; + } + return Math.max(0, parsed); +} + +export function resolvePluginSnapshotCacheTtlMs(env: NodeJS.ProcessEnv): number { + const discoveryCacheMs = resolvePluginCacheMs( + env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, + DEFAULT_PLUGIN_DISCOVERY_CACHE_MS, + ); + const manifestCacheMs = resolvePluginCacheMs( + env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, + DEFAULT_PLUGIN_MANIFEST_CACHE_MS, + ); + return Math.min(discoveryCacheMs, manifestCacheMs); +} + +export function buildPluginSnapshotCacheEnvKey( + env: NodeJS.ProcessEnv, + options: { includeProcessVitestFallback?: boolean } = {}, +) { + return { + OPENCLAW_BUNDLED_PLUGINS_DIR: env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE ?? "", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS ?? "", + OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ?? "", + OPENCLAW_HOME: env.OPENCLAW_HOME ?? "", + OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR ?? "", + OPENCLAW_CONFIG_PATH: env.OPENCLAW_CONFIG_PATH ?? "", + HOME: env.HOME ?? "", + USERPROFILE: env.USERPROFILE ?? "", + VITEST: options.includeProcessVitestFallback + ? (env.VITEST ?? process.env.VITEST ?? "") + : (env.VITEST ?? ""), + }; +} diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 78ca25349bc19..379e8711eb349 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -5,8 +5,6 @@ const fetchClawHubPackageDetailMock = vi.fn(); const fetchClawHubPackageVersionMock = vi.fn(); const downloadClawHubPackageArchiveMock = vi.fn(); const resolveLatestVersionFromPackageMock = vi.fn(); -const satisfiesPluginApiRangeMock = vi.fn(); -const satisfiesGatewayMinimumMock = vi.fn(); const resolveRuntimeServiceVersionMock = vi.fn(); const installPluginFromArchiveMock = vi.fn(); @@ -21,8 +19,6 @@ vi.mock("../infra/clawhub.js", async () => { downloadClawHubPackageArchiveMock(...args), resolveLatestVersionFromPackage: (...args: unknown[]) => resolveLatestVersionFromPackageMock(...args), - satisfiesPluginApiRange: (...args: unknown[]) => satisfiesPluginApiRangeMock(...args), - satisfiesGatewayMinimum: (...args: unknown[]) => satisfiesGatewayMinimumMock(...args), }; }); @@ -45,8 +41,6 @@ describe("installPluginFromClawHub", () => { fetchClawHubPackageVersionMock.mockReset(); downloadClawHubPackageArchiveMock.mockReset(); resolveLatestVersionFromPackageMock.mockReset(); - satisfiesPluginApiRangeMock.mockReset(); - satisfiesGatewayMinimumMock.mockReset(); resolveRuntimeServiceVersionMock.mockReset(); installPluginFromArchiveMock.mockReset(); @@ -61,19 +55,19 @@ describe("installPluginFromClawHub", () => { createdAt: 0, updatedAt: 0, compatibility: { - pluginApiRange: "^1.2.0", + pluginApiRange: ">=2026.3.22", minGatewayVersion: "2026.3.0", }, }, }); - resolveLatestVersionFromPackageMock.mockReturnValue("1.2.3"); + resolveLatestVersionFromPackageMock.mockReturnValue("2026.3.22"); fetchClawHubPackageVersionMock.mockResolvedValue({ version: { - version: "1.2.3", + version: "2026.3.22", createdAt: 0, changelog: "", compatibility: { - pluginApiRange: "^1.2.0", + pluginApiRange: ">=2026.3.22", minGatewayVersion: "2026.3.0", }, }, @@ -82,14 +76,12 @@ describe("installPluginFromClawHub", () => { archivePath: "/tmp/clawhub-demo/archive.zip", integrity: "sha256-demo", }); - satisfiesPluginApiRangeMock.mockReturnValue(true); resolveRuntimeServiceVersionMock.mockReturnValue("2026.3.22"); - satisfiesGatewayMinimumMock.mockReturnValue(true); installPluginFromArchiveMock.mockResolvedValue({ ok: true, pluginId: "demo", targetDir: "/tmp/openclaw/plugins/demo", - version: "1.2.3", + version: "2026.3.22", }); }); @@ -116,7 +108,7 @@ describe("installPluginFromClawHub", () => { expect(fetchClawHubPackageVersionMock).toHaveBeenCalledWith( expect.objectContaining({ name: "demo", - version: "1.2.3", + version: "2026.3.22", }), ); expect(installPluginFromArchiveMock).toHaveBeenCalledWith( @@ -127,7 +119,7 @@ describe("installPluginFromClawHub", () => { expect(result).toMatchObject({ ok: true, pluginId: "demo", - version: "1.2.3", + version: "2026.3.22", clawhub: { source: "clawhub", clawhubPackage: "demo", @@ -136,11 +128,22 @@ describe("installPluginFromClawHub", () => { integrity: "sha256-demo", }, }); - expect(info).toHaveBeenCalledWith("ClawHub code-plugin demo@1.2.3 channel=official"); - expect(info).toHaveBeenCalledWith("Compatibility: pluginApi=^1.2.0 minGateway=2026.3.0"); + expect(info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official"); + expect(info).toHaveBeenCalledWith("Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0"); expect(warn).not.toHaveBeenCalled(); }); + it("rejects packages whose plugin API range exceeds the runtime version", async () => { + resolveRuntimeServiceVersionMock.mockReturnValueOnce("2026.3.21"); + + await expect(installPluginFromClawHub({ spec: "clawhub:demo" })).resolves.toMatchObject({ + ok: false, + code: CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API, + error: + 'Plugin "demo" requires plugin API >=2026.3.22, but this OpenClaw runtime exposes 2026.3.21.', + }); + }); + it("rejects skill families and redirects to skills install", async () => { fetchClawHubPackageDetailMock.mockResolvedValueOnce({ package: { diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 7e0f89db7ab85..9eaf8ba468e8a 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -10,13 +10,13 @@ import { satisfiesGatewayMinimum, satisfiesPluginApiRange, type ClawHubPackageChannel, + type ClawHubPackageCompatibility, type ClawHubPackageDetail, type ClawHubPackageFamily, } from "../infra/clawhub.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import { installPluginFromArchive, type InstallPluginResult } from "./install.js"; -export const OPENCLAW_PLUGIN_API_VERSION = "1.2.0"; export const CLAWHUB_INSTALL_ERROR_CODE = { INVALID_SPEC: "invalid_spec", PACKAGE_NOT_FOUND: "package_not_found", @@ -104,10 +104,7 @@ async function resolveCompatiblePackageVersion(params: { | { ok: true; version: string; - compatibility?: { - pluginApiRange?: string; - minGatewayVersion?: string; - } | null; + compatibility?: ClawHubPackageCompatibility | null; } | ClawHubInstallFailure > { @@ -143,10 +140,8 @@ async function resolveCompatiblePackageVersion(params: { function validateClawHubPluginPackage(params: { detail: ClawHubPackageDetail; - compatibility?: { - pluginApiRange?: string; - minGatewayVersion?: string; - } | null; + compatibility?: ClawHubPackageCompatibility | null; + runtimeVersion: string; }): ClawHubInstallFailure | null { const pkg = params.detail.package; if (!pkg) { @@ -175,17 +170,17 @@ function validateClawHubPluginPackage(params: { } const compatibility = params.compatibility; + const runtimeVersion = params.runtimeVersion; if ( compatibility?.pluginApiRange && - !satisfiesPluginApiRange(OPENCLAW_PLUGIN_API_VERSION, compatibility.pluginApiRange) + !satisfiesPluginApiRange(runtimeVersion, compatibility.pluginApiRange) ) { return buildClawHubInstallFailure( - `Plugin "${pkg.name}" requires plugin API ${compatibility.pluginApiRange}, but this OpenClaw runtime exposes ${OPENCLAW_PLUGIN_API_VERSION}.`, + `Plugin "${pkg.name}" requires plugin API ${compatibility.pluginApiRange}, but this OpenClaw runtime exposes ${runtimeVersion}.`, CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API, ); } - const runtimeVersion = resolveRuntimeServiceVersion(); if ( compatibility?.minGatewayVersion && !satisfiesGatewayMinimum(runtimeVersion, compatibility.minGatewayVersion) @@ -201,6 +196,7 @@ function validateClawHubPluginPackage(params: { function logClawHubPackageSummary(params: { detail: ClawHubPackageDetail; version: string; + compatibility?: ClawHubPackageCompatibility | null; logger?: PluginInstallLogger; }) { const pkg = params.detail.package; @@ -212,9 +208,11 @@ function logClawHubPackageSummary(params: { `ClawHub ${pkg.family} ${pkg.name}@${params.version} channel=${pkg.channel}${verification}`, ); const compatibilityParts = [ - pkg.compatibility?.pluginApiRange ? `pluginApi=${pkg.compatibility.pluginApiRange}` : null, - pkg.compatibility?.minGatewayVersion - ? `minGateway=${pkg.compatibility.minGatewayVersion}` + params.compatibility?.pluginApiRange + ? `pluginApi=${params.compatibility.pluginApiRange}` + : null, + params.compatibility?.minGatewayVersion + ? `minGateway=${params.compatibility.minGatewayVersion}` : null, ].filter(Boolean); if (compatibilityParts.length > 0) { @@ -276,9 +274,11 @@ export async function installPluginFromClawHub(params: { if (!versionState.ok) { return versionState; } + const runtimeVersion = resolveRuntimeServiceVersion(); const validationFailure = validateClawHubPluginPackage({ detail, compatibility: versionState.compatibility, + runtimeVersion, }); if (validationFailure) { return validationFailure; @@ -286,6 +286,7 @@ export async function installPluginFromClawHub(params: { logClawHubPackageSummary({ detail, version: versionState.version, + compatibility: versionState.compatibility, logger: params.logger, }); diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index 386b23cd37e07..290e49b72d21c 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -14,19 +14,27 @@ type PluginCommandState = { const PLUGIN_COMMAND_STATE_KEY = Symbol.for("openclaw.pluginCommandsState"); -const state = resolveGlobalSingleton(PLUGIN_COMMAND_STATE_KEY, () => ({ - pluginCommands: new Map(), - registryLocked: false, -})); +const getState = () => + resolveGlobalSingleton(PLUGIN_COMMAND_STATE_KEY, () => ({ + pluginCommands: new Map(), + registryLocked: false, + })); + +const getPluginCommandMap = () => getState().pluginCommands; -export const pluginCommands = state.pluginCommands; +export const pluginCommands = new Proxy(new Map(), { + get(_target, property) { + const value = Reflect.get(getPluginCommandMap(), property, getPluginCommandMap()); + return typeof value === "function" ? value.bind(getPluginCommandMap()) : value; + }, +}); export function isPluginCommandRegistryLocked(): boolean { - return state.registryLocked; + return getState().registryLocked; } export function setPluginCommandRegistryLocked(locked: boolean): void { - state.registryLocked = locked; + getState().registryLocked = locked; } export function clearPluginCommands(): void { diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 01f2b14cfd768..0b40e4418ad05 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -84,7 +84,7 @@ describe("normalizePluginsConfig", () => { "voice-call": { subagent: { allowModelOverride: true, - allowedModels: [" anthropic/claude-haiku-4-5 ", "", "openai/gpt-4.1-mini"], + allowedModels: [" anthropic/claude-sonnet-4-6 ", "", "openai/gpt-5.4"], }, }, }, @@ -92,7 +92,7 @@ describe("normalizePluginsConfig", () => { expect(result.entries["voice-call"]?.subagent).toEqual({ allowModelOverride: true, hasAllowedModelsConfig: true, - allowedModels: ["anthropic/claude-haiku-4-5", "openai/gpt-4.1-mini"], + allowedModels: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"], }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index cc2492887419d..7c9015721f643 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -34,6 +34,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "anthropic", "byteplus", "cloudflare-ai-gateway", + "deepseek", "device-pair", "github-copilot", "google", @@ -72,7 +73,7 @@ const PLUGIN_ID_ALIASES: Readonly> = { "minimax-portal-auth": "minimax", }; -function normalizePluginId(id: string): string { +export function normalizePluginId(id: string): string { const trimmed = id.trim(); return PLUGIN_ID_ALIASES[trimmed] ?? trimmed; } diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts index e0f19e7bac5c3..1d12d71165acf 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -2,14 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { createNonExitingRuntime } from "../../runtime.js"; -import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import type { WizardMultiSelectParams, WizardPrompter, WizardProgress, WizardSelectParams, } from "../../wizard/prompts.js"; -import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; +import { registerProviders, requireProvider } from "./testkit.js"; type LoginOpenAICodexOAuth = (typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"]; @@ -56,22 +55,6 @@ import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; import openAIPlugin from "../../../extensions/openai/index.js"; import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js"; -function registerProviders(...plugins: Array<{ register(api: OpenClawPluginApi): void }>) { - const captured = createCapturedPluginRegistration(); - for (const plugin of plugins) { - plugin.register(captured.api); - } - return captured.providers; -} - -function requireProvider(providers: ProviderPlugin[], providerId: string) { - const provider = providers.find((entry) => entry.id === providerId); - if (!provider) { - throw new Error(`provider ${providerId} missing`); - } - return provider; -} - function buildPrompter(): WizardPrompter { const progress: WizardProgress = { update() {}, diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 77606c8dcf925..546bcbe25ff18 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { QWEN_OAUTH_MARKER } from "../../agents/model-auth-markers.js"; import type { ModelDefinitionConfig } from "../../config/types.models.js"; @@ -113,7 +113,8 @@ function runCatalog(params: { } describe("provider discovery contract", () => { - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); vi.doMock("openclaw/plugin-sdk/agent-runtime", async () => { // Import the direct source module, not the mocked subpath, so bundled // provider helpers still see the full agent-runtime surface. @@ -201,9 +202,6 @@ describe("provider discovery contract", () => { registerProviders(cloudflareAiGatewayPlugin), "cloudflare-ai-gateway", ); - }); - - beforeEach(() => { setRuntimeAuthStore(); }); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 6ad03b97e3d01..17bb9d5a91c0a 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -5,6 +5,7 @@ import chutesPlugin from "../../../extensions/chutes/index.js"; import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js"; import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js"; import deepgramPlugin from "../../../extensions/deepgram/index.js"; +import deepseekPlugin from "../../../extensions/deepseek/index.js"; import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js"; import falPlugin from "../../../extensions/fal/index.js"; import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; @@ -357,6 +358,7 @@ const bundledProviderPlugins = dedupePlugins([ chutesPlugin, cloudflareAiGatewayPlugin, copilotProxyPlugin, + deepseekPlugin, githubCopilotPlugin, falPlugin, googlePlugin, diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index c0eb30b1b747f..68c5249eccfe2 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -4,11 +4,10 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import openAIPlugin from "../../../extensions/openai/index.js"; import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js"; -import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; -import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; -import type { ProviderRuntimeModel } from "../types.js"; +import type { ProviderPlugin, ProviderRuntimeModel } from "../types.js"; import { requireProviderContractProvider as requireBundledProviderContractProvider } from "./registry.js"; +import { registerProviders, requireProvider } from "./testkit.js"; const CONTRACT_SETUP_TIMEOUT_MS = 300_000; @@ -61,22 +60,6 @@ function createModel(overrides: Partial & Pick) { - const captured = createCapturedPluginRegistration(); - for (const plugin of plugins) { - plugin.register(captured.api); - } - return captured.providers; -} - -function requireProvider(providers: ProviderPlugin[], providerId: string) { - const provider = providers.find((entry) => entry.id === providerId); - if (!provider) { - throw new Error(`provider ${providerId} missing`); - } - return provider; -} - function requireProviderContractProvider(providerId: string): ProviderPlugin { if (providerId === "openai-codex") { return requireProvider(registerProviders(openAIPlugin), providerId); @@ -208,7 +191,7 @@ describe("provider runtime contract", () => { const provider = requireProviderContractProvider("github-copilot"); const model = provider.resolveDynamicModel?.({ provider: "github-copilot", - modelId: "gpt-5.3-codex", + modelId: "gpt-5.4", modelRegistry: { find: (_provider: string, id: string) => id === "gpt-5.2-codex" @@ -223,7 +206,7 @@ describe("provider runtime contract", () => { }); expect(model).toMatchObject({ - id: "gpt-5.3-codex", + id: "gpt-5.4", provider: "github-copilot", api: "openai-codex-responses", }); diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index b02d6ec2fc3f3..13a4983905061 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -111,20 +111,22 @@ vi.mock("./runtime.js", () => ({ }, })); -const { - __testing, - buildPluginBindingApprovalCustomId, - detachPluginConversationBinding, - getCurrentPluginConversationBinding, - parsePluginBindingApprovalCustomId, - requestPluginConversationBinding, - resolvePluginConversationBindingApproval, -} = await import("./conversation-binding.js"); -const { registerSessionBindingAdapter, unregisterSessionBindingAdapter } = - await import("../infra/outbound/session-binding-service.js"); -const { setActivePluginRegistry } = await import("./runtime.js"); +let __testing: typeof import("./conversation-binding.js").__testing; +let buildPluginBindingApprovalCustomId: typeof import("./conversation-binding.js").buildPluginBindingApprovalCustomId; +let detachPluginConversationBinding: typeof import("./conversation-binding.js").detachPluginConversationBinding; +let getCurrentPluginConversationBinding: typeof import("./conversation-binding.js").getCurrentPluginConversationBinding; +let parsePluginBindingApprovalCustomId: typeof import("./conversation-binding.js").parsePluginBindingApprovalCustomId; +let requestPluginConversationBinding: typeof import("./conversation-binding.js").requestPluginConversationBinding; +let resolvePluginConversationBindingApproval: typeof import("./conversation-binding.js").resolvePluginConversationBindingApproval; +let registerSessionBindingAdapter: typeof import("../infra/outbound/session-binding-service.js").registerSessionBindingAdapter; +let unregisterSessionBindingAdapter: typeof import("../infra/outbound/session-binding-service.js").unregisterSessionBindingAdapter; +let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry; type PluginBindingRequest = Awaited>; +type PluginBindingRequestInput = Parameters[0]; +type PluginBindingDecision = Parameters< + typeof resolvePluginConversationBindingApproval +>[0]["decision"]; type ConversationBindingModule = typeof import("./conversation-binding.js"); const conversationBindingModuleUrl = new URL("./conversation-binding.ts", import.meta.url).href; @@ -154,14 +156,82 @@ function createAdapter(channel: string, accountId: string): SessionBindingAdapte }; } +function createDiscordCodexBindRequest( + conversationId: string, + summary: string, + accountId = "isolated", +): PluginBindingRequestInput { + return { + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId, + conversationId, + }, + binding: { summary }, + }; +} + +function createTelegramCodexBindRequest( + conversationId: string, + threadId: string, + summary: string, + pluginRoot = "/plugins/codex-a", +): PluginBindingRequestInput { + return { + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot, + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId, + parentConversationId: "-10099", + threadId, + }, + binding: { summary }, + }; +} + +async function requestPendingBinding( + input: PluginBindingRequestInput, + requestBinding = requestPluginConversationBinding, +) { + const request = await requestBinding(input); + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + return request; +} + +async function approveBindingRequest( + approvalId: string, + decision: PluginBindingDecision, + resolveApproval = resolvePluginConversationBindingApproval, +) { + return await resolveApproval({ + approvalId, + decision, + senderId: "user-1", + }); +} + +async function importDuplicateConversationBindingModules() { + const first = await importConversationBindingModule(`first-${Date.now()}`); + const second = await importConversationBindingModule(`second-${Date.now()}`); + first.__testing.reset(); + return { first, second }; +} + async function resolveRequestedBinding(request: PluginBindingRequest) { expect(["pending", "bound"]).toContain(request.status); if (request.status === "pending") { - const approved = await resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-once", - senderId: "user-1", - }); + const approved = await approveBindingRequest(request.approvalId, "allow-once"); expect(approved.status).toBe("approved"); if (approved.status !== "approved") { throw new Error("expected approved bind result"); @@ -187,7 +257,38 @@ function createDeferredVoid(): { promise: Promise; resolve: () => void } { } describe("plugin conversation binding approvals", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("../infra/home-dir.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + expandHomePrefix: (value: string) => { + if (value === "~/.openclaw/plugin-binding-approvals.json") { + return approvalsPath; + } + return actual.expandHomePrefix(value); + }, + }; + }); + vi.doMock("./runtime.js", () => ({ + getActivePluginRegistry: () => pluginRuntimeState.registry, + setActivePluginRegistry: (registry: PluginRegistry) => { + pluginRuntimeState.registry = registry; + }, + })); + ({ + __testing, + buildPluginBindingApprovalCustomId, + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + parsePluginBindingApprovalCustomId, + requestPluginConversationBinding, + resolvePluginConversationBindingApproval, + } = await import("./conversation-binding.js")); + ({ registerSessionBindingAdapter, unregisterSessionBindingAdapter } = + await import("../infra/outbound/session-binding-service.js")); + ({ setActivePluginRegistry } = await import("./runtime.js")); sessionBindingState.reset(); __testing.reset(); setActivePluginRegistry(createEmptyPluginRegistry()); @@ -217,138 +318,62 @@ describe("plugin conversation binding approvals", () => { }); it("requires a fresh approval again after allow-once is consumed", async () => { - const firstRequest = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:1", - }, - binding: { summary: "Bind this conversation to Codex thread 123." }, - }); - - expect(firstRequest.status).toBe("pending"); - if (firstRequest.status !== "pending") { - throw new Error("expected pending bind request"); - } - - const approved = await resolvePluginConversationBindingApproval({ - approvalId: firstRequest.approvalId, - decision: "allow-once", - senderId: "user-1", - }); + const firstRequest = await requestPendingBinding( + createDiscordCodexBindRequest("channel:1", "Bind this conversation to Codex thread 123."), + ); + const approved = await approveBindingRequest(firstRequest.approvalId, "allow-once"); expect(approved.status).toBe("approved"); - const secondRequest = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:2", - }, - binding: { summary: "Bind this conversation to Codex thread 456." }, - }); + const secondRequest = await requestPluginConversationBinding( + createDiscordCodexBindRequest("channel:2", "Bind this conversation to Codex thread 456."), + ); expect(secondRequest.status).toBe("pending"); }); it("persists always-allow by plugin root plus channel/account only", async () => { - const firstRequest = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:1", - }, - binding: { summary: "Bind this conversation to Codex thread 123." }, - }); - - expect(firstRequest.status).toBe("pending"); - if (firstRequest.status !== "pending") { - throw new Error("expected pending bind request"); - } - - const approved = await resolvePluginConversationBindingApproval({ - approvalId: firstRequest.approvalId, - decision: "allow-always", - senderId: "user-1", - }); + const firstRequest = await requestPendingBinding( + createDiscordCodexBindRequest("channel:1", "Bind this conversation to Codex thread 123."), + ); + const approved = await approveBindingRequest(firstRequest.approvalId, "allow-always"); expect(approved.status).toBe("approved"); - const sameScope = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:2", - }, - binding: { summary: "Bind this conversation to Codex thread 456." }, - }); + const sameScope = await requestPluginConversationBinding( + createDiscordCodexBindRequest("channel:2", "Bind this conversation to Codex thread 456."), + ); expect(sameScope.status).toBe("bound"); - const differentAccount = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "work", - conversationId: "channel:3", - }, - binding: { summary: "Bind this conversation to Codex thread 789." }, - }); + const differentAccount = await requestPluginConversationBinding( + createDiscordCodexBindRequest( + "channel:3", + "Bind this conversation to Codex thread 789.", + "work", + ), + ); expect(differentAccount.status).toBe("pending"); }); it("shares pending bind approvals across duplicate module instances", async () => { - const first = await importConversationBindingModule(`first-${Date.now()}`); - const second = await importConversationBindingModule(`second-${Date.now()}`); - - first.__testing.reset(); - - const request = await first.requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - threadId: "77", - }, - binding: { summary: "Bind this conversation to Codex thread abc." }, - }); - - expect(request.status).toBe("pending"); - if (request.status !== "pending") { - throw new Error("expected pending bind request"); - } + const { first, second } = await importDuplicateConversationBindingModules(); + const request = await requestPendingBinding( + createTelegramCodexBindRequest( + "-10099:topic:77", + "77", + "Bind this conversation to Codex thread abc.", + ), + first.requestPluginConversationBinding, + ); await expect( - second.resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-once", - senderId: "user-1", - }), + approveBindingRequest( + request.approvalId, + "allow-once", + second.resolvePluginConversationBindingApproval, + ), ).resolves.toMatchObject({ status: "approved", binding: expect.objectContaining({ @@ -362,56 +387,34 @@ describe("plugin conversation binding approvals", () => { }); it("shares persistent approvals across duplicate module instances", async () => { - const first = await importConversationBindingModule(`first-${Date.now()}`); - const second = await importConversationBindingModule(`second-${Date.now()}`); - - first.__testing.reset(); - - const request = await first.requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - threadId: "77", - }, - binding: { summary: "Bind this conversation to Codex thread abc." }, - }); - - expect(request.status).toBe("pending"); - if (request.status !== "pending") { - throw new Error("expected pending bind request"); - } + const { first, second } = await importDuplicateConversationBindingModules(); + const request = await requestPendingBinding( + createTelegramCodexBindRequest( + "-10099:topic:77", + "77", + "Bind this conversation to Codex thread abc.", + ), + first.requestPluginConversationBinding, + ); await expect( - second.resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-always", - senderId: "user-1", - }), + approveBindingRequest( + request.approvalId, + "allow-always", + second.resolvePluginConversationBindingApproval, + ), ).resolves.toMatchObject({ status: "approved", decision: "allow-always", }); - const rebound = await first.requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:78", - parentConversationId: "-10099", - threadId: "78", - }, - binding: { summary: "Bind this conversation to Codex thread def." }, - }); + const rebound = await first.requestPluginConversationBinding( + createTelegramCodexBindRequest( + "-10099:topic:78", + "78", + "Bind this conversation to Codex thread def.", + ), + ); expect(rebound.status).toBe("bound"); diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 5c4163610a122..b08d0658360b8 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -8,7 +8,7 @@ import { } from "../../scripts/copy-bundled-plugin-metadata.mjs"; const tempDirs: string[] = []; -const includeOptionalEnv = { OPENCLAW_INCLUDE_OPTIONAL_BUNDLED: "1" } as const; +const excludeOptionalEnv = { OPENCLAW_INCLUDE_OPTIONAL_BUNDLED: "0" } as const; const copyBundledPluginMetadataWithEnv = copyBundledPluginMetadata as (params?: { repoRoot?: string; env?: NodeJS.ProcessEnv; @@ -60,7 +60,7 @@ describe("copyBundledPluginMetadata", () => { openclaw: { extensions: ["./index.ts"] }, }); - copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv }); + copyBundledPluginMetadata({ repoRoot }); expect( fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json")), @@ -131,7 +131,7 @@ describe("copyBundledPluginMetadata", () => { fs.mkdirSync(staleNodeModulesSkillDir, { recursive: true }); fs.writeFileSync(path.join(staleNodeModulesSkillDir, "stale.txt"), "stale\n", "utf8"); - copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv }); + copyBundledPluginMetadata({ repoRoot }); const copiedSkillDir = path.join( repoRoot, @@ -174,7 +174,7 @@ describe("copyBundledPluginMetadata", () => { openclaw: { extensions: ["./index.ts"] }, }); - copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv }); + copyBundledPluginMetadata({ repoRoot }); expect( fs.readFileSync( @@ -227,7 +227,7 @@ describe("copyBundledPluginMetadata", () => { const staleNodeModulesDir = path.join(repoRoot, "dist", "extensions", "tlon", "node_modules"); fs.mkdirSync(staleNodeModulesDir, { recursive: true }); - copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv }); + copyBundledPluginMetadata({ repoRoot }); const bundledManifest = JSON.parse( fs.readFileSync( @@ -269,7 +269,7 @@ describe("copyBundledPluginMetadata", () => { }); try { - copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv }); + copyBundledPluginMetadata({ repoRoot }); } finally { cpSyncSpy.mockRestore(); } @@ -319,7 +319,7 @@ describe("copyBundledPluginMetadata", () => { }); fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true }); - copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv }); + copyBundledPluginMetadata({ repoRoot }); expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin"))).toBe(false); }); @@ -339,12 +339,12 @@ describe("copyBundledPluginMetadata", () => { name: "@openclaw/google-gemini-cli-auth", }); - copyBundledPluginMetadataWithEnv({ repoRoot, env: includeOptionalEnv }); + copyBundledPluginMetadata({ repoRoot }); expect(fs.existsSync(staleDistDir)).toBe(false); }); - it("skips metadata for optional bundled clusters unless explicitly enabled", () => { + it("skips metadata for optional bundled clusters only when explicitly disabled", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-optional-skip-"); const pluginDir = path.join(repoRoot, "extensions", "acpx"); fs.mkdirSync(pluginDir, { recursive: true }); @@ -357,8 +357,29 @@ describe("copyBundledPluginMetadata", () => { openclaw: { extensions: ["./index.ts"] }, }); - copyBundledPluginMetadataWithEnv({ repoRoot, env: {} }); + copyBundledPluginMetadataWithEnv({ repoRoot, env: excludeOptionalEnv }); expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx"))).toBe(false); }); + + it("still bundles previously released optional plugins without the opt-in env", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-released-optional-"); + const pluginDir = path.join(repoRoot, "extensions", "whatsapp"); + fs.mkdirSync(pluginDir, { recursive: true }); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "whatsapp", + configSchema: { type: "object" }, + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/whatsapp", + openclaw: { + extensions: ["./index.ts"], + install: { npmSpec: "@openclaw/whatsapp" }, + }, + }); + + copyBundledPluginMetadataWithEnv({ repoRoot, env: {} }); + + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "whatsapp"))).toBe(true); + }); }); diff --git a/src/plugins/generated-plugin-test-helpers.ts b/src/plugins/generated-plugin-test-helpers.ts new file mode 100644 index 0000000000000..86c2ed18e5b78 --- /dev/null +++ b/src/plugins/generated-plugin-test-helpers.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach } from "vitest"; + +export const pluginTestRepoRoot = path.resolve(import.meta.dirname, "../.."); + +const tempDirs: string[] = []; + +export function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +export function createGeneratedPluginTempRoot(prefix: string): string { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(tempRoot); + return tempRoot; +} + +export function installGeneratedPluginTempRootCleanup() { + afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts index 43d212025e74f..cef104b34e5ff 100644 --- a/src/plugins/hook-runner-global.ts +++ b/src/plugins/hook-runner-global.ts @@ -11,24 +11,27 @@ import { createHookRunner, type HookRunner } from "./hooks.js"; import type { PluginRegistry } from "./registry.js"; import type { PluginHookGatewayContext, PluginHookGatewayStopEvent } from "./types.js"; -const log = createSubsystemLogger("plugins"); - type HookRunnerGlobalState = { hookRunner: HookRunner | null; registry: PluginRegistry | null; }; const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state"); -const state = resolveGlobalSingleton(hookRunnerGlobalStateKey, () => ({ - hookRunner: null, - registry: null, -})); +const getState = () => + resolveGlobalSingleton(hookRunnerGlobalStateKey, () => ({ + hookRunner: null, + registry: null, + })); + +const getLog = () => createSubsystemLogger("plugins"); /** * Initialize the global hook runner with a plugin registry. * Called once when plugins are loaded during gateway startup. */ export function initializeGlobalHookRunner(registry: PluginRegistry): void { + const state = getState(); + const log = getLog(); state.registry = registry; state.hookRunner = createHookRunner(registry, { logger: { @@ -50,7 +53,7 @@ export function initializeGlobalHookRunner(registry: PluginRegistry): void { * Returns null if plugins haven't been loaded yet. */ export function getGlobalHookRunner(): HookRunner | null { - return state.hookRunner; + return getState().hookRunner; } /** @@ -58,14 +61,14 @@ export function getGlobalHookRunner(): HookRunner | null { * Returns null if plugins haven't been loaded yet. */ export function getGlobalPluginRegistry(): PluginRegistry | null { - return state.registry; + return getState().registry; } /** * Check if any hooks are registered for a given hook name. */ export function hasGlobalHooks(hookName: Parameters[0]): boolean { - return state.hookRunner?.hasHooks(hookName) ?? false; + return getState().hookRunner?.hasHooks(hookName) ?? false; } export async function runGlobalGatewayStopSafely(params: { @@ -73,6 +76,7 @@ export async function runGlobalGatewayStopSafely(params: { ctx: PluginHookGatewayContext; onError?: (err: unknown) => void; }): Promise { + const log = getLog(); const hookRunner = getGlobalHookRunner(); if (!hookRunner?.hasHooks("gateway_stop")) { return; @@ -92,6 +96,7 @@ export async function runGlobalGatewayStopSafely(params: { * Reset the global hook runner (for testing). */ export function resetGlobalHookRunner(): void { + const state = getState(); state.hookRunner = null; state.registry = null; } diff --git a/src/plugins/hooks.before-agent-start.test.ts b/src/plugins/hooks.before-agent-start.test.ts index cf12e5af4f840..60af08742361c 100644 --- a/src/plugins/hooks.before-agent-start.test.ts +++ b/src/plugins/hooks.before-agent-start.test.ts @@ -73,7 +73,7 @@ describe("before_agent_start hook merger", () => { }); it("higher-priority plugin wins for modelOverride", async () => { - addBeforeAgentStartHook(registry, "low-priority", () => ({ modelOverride: "gpt-4o" }), 1); + addBeforeAgentStartHook(registry, "low-priority", () => ({ modelOverride: "gpt-5.4" }), 1); addBeforeAgentStartHook( registry, "high-priority", diff --git a/src/plugins/hooks.model-override-wiring.test.ts b/src/plugins/hooks.model-override-wiring.test.ts index 6caf40500890e..5c858c90a73d1 100644 --- a/src/plugins/hooks.model-override-wiring.test.ts +++ b/src/plugins/hooks.model-override-wiring.test.ts @@ -95,7 +95,7 @@ describe("model override pipeline wiring", () => { pluginId: "legacy-hook", hookName: "before_agent_start", handler: (() => ({ - modelOverride: "gpt-4o", + modelOverride: "gpt-5.4", providerOverride: "openai", })) as PluginHookRegistration["handler"], }); diff --git a/src/plugins/hooks.phase-hooks.test.ts b/src/plugins/hooks.phase-hooks.test.ts index 70a43645f5786..9f39521409de7 100644 --- a/src/plugins/hooks.phase-hooks.test.ts +++ b/src/plugins/hooks.phase-hooks.test.ts @@ -34,7 +34,7 @@ describe("phase hooks merger", () => { }); it("before_model_resolve keeps higher-priority override values", async () => { - addTypedHook(registry, "before_model_resolve", "low", () => ({ modelOverride: "gpt-4o" }), 1); + addTypedHook(registry, "before_model_resolve", "low", () => ({ modelOverride: "gpt-5.4" }), 1); addTypedHook( registry, "before_model_resolve", diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 7b68969b37a76..6a149c55729a5 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -57,6 +57,117 @@ async function importInteractiveModule(cacheBust: string): Promise { + return { + channel: "telegram", + data: params.data, + callbackId: params.callbackId, + ctx: { + accountId: "default", + callbackId: params.callbackId, + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + senderId: "user-1", + senderUsername: "ada", + threadId: 77, + isGroup: true, + isForum: true, + auth: { isAuthorizedSender: true }, + callbackMessage: { + messageId: 55, + chatId: "-10099", + messageText: "Pick a thread", + }, + }, + respond: { + reply: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + editButtons: vi.fn(async () => {}), + clearButtons: vi.fn(async () => {}), + deleteMessage: vi.fn(async () => {}), + }, + }; +} + +function createDiscordDispatchParams(params: { + data: string; + interactionId: string; + interaction?: Partial; +}): Extract { + return { + channel: "discord", + data: params.data, + interactionId: params.interactionId, + ctx: { + accountId: "default", + interactionId: params.interactionId, + conversationId: "channel-1", + parentConversationId: "parent-1", + guildId: "guild-1", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button", + messageId: "message-1", + values: ["allow"], + ...params.interaction, + }, + }, + respond: { + acknowledge: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + clearComponents: vi.fn(async () => {}), + }, + }; +} + +function createSlackDispatchParams(params: { + data: string; + interactionId: string; + interaction?: Partial; +}): Extract { + return { + channel: "slack", + data: params.data, + interactionId: params.interactionId, + ctx: { + accountId: "default", + interactionId: params.interactionId, + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button", + actionId: "codex", + blockId: "codex_actions", + messageTs: "1710000000.000200", + threadTs: "1710000000.000100", + value: "approve:thread-1", + selectedValues: ["approve:thread-1"], + selectedLabels: ["Approve"], + triggerId: "trigger-1", + responseUrl: "https://hooks.slack.test/response", + ...params.interaction, + }, + }, + respond: { + acknowledge: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + }, + }; +} + async function expectDedupedInteractiveDispatch(params: { baseParams: InteractiveDispatchParams; handler: ReturnType; @@ -134,35 +245,10 @@ describe("plugin interactive handlers", () => { }), ).toEqual({ ok: true }); - const baseParams = { - channel: "telegram" as const, + const baseParams = createTelegramDispatchParams({ data: "codex:resume:thread-1", callbackId: "cb-1", - ctx: { - accountId: "default", - callbackId: "cb-1", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - senderId: "user-1", - senderUsername: "ada", - threadId: 77, - isGroup: true, - isForum: true, - auth: { isAuthorizedSender: true }, - callbackMessage: { - messageId: 55, - chatId: "-10099", - messageText: "Pick a thread", - }, - }, - respond: { - reply: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - editButtons: vi.fn(async () => {}), - clearButtons: vi.fn(async () => {}), - deleteMessage: vi.fn(async () => {}), - }, - }; + }); await expectDedupedInteractiveDispatch({ baseParams, @@ -196,35 +282,12 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - second.dispatchPluginInteractiveHandler({ - channel: "telegram", - data: "codexapp:resume:thread-1", - callbackId: "cb-shared-1", - ctx: { - accountId: "default", + second.dispatchPluginInteractiveHandler( + createTelegramDispatchParams({ + data: "codexapp:resume:thread-1", callbackId: "cb-shared-1", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - senderId: "user-1", - senderUsername: "ada", - threadId: 77, - isGroup: true, - isForum: true, - auth: { isAuthorizedSender: true }, - callbackMessage: { - messageId: 55, - chatId: "-10099", - messageText: "Pick a thread", - }, - }, - respond: { - reply: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - editButtons: vi.fn(async () => {}), - clearButtons: vi.fn(async () => {}), - deleteMessage: vi.fn(async () => {}), - }, - }), + }), + ), ).resolves.toEqual({ matched: true, handled: true, duplicate: false }); expect(handler).toHaveBeenCalledWith( @@ -269,33 +332,11 @@ describe("plugin interactive handlers", () => { }), ).toEqual({ ok: true }); - const baseParams = { - channel: "discord" as const, + const baseParams = createDiscordDispatchParams({ data: "codex:approve:thread-1", interactionId: "ix-1", - ctx: { - accountId: "default", - interactionId: "ix-1", - conversationId: "channel-1", - parentConversationId: "parent-1", - guildId: "guild-1", - senderId: "user-1", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, - interaction: { - kind: "button" as const, - messageId: "message-1", - values: ["allow"], - }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - clearComponents: vi.fn(async () => {}), - }, - }; + interaction: { kind: "button", values: ["allow"] }, + }); await expectDedupedInteractiveDispatch({ baseParams, @@ -330,30 +371,11 @@ describe("plugin interactive handlers", () => { await expect( dispatchPluginInteractiveHandler({ - channel: "discord", - data: "codex:approve:thread-1", - interactionId: "ix-ack-1", - ctx: { - accountId: "default", + ...createDiscordDispatchParams({ + data: "codex:approve:thread-1", interactionId: "ix-ack-1", - conversationId: "channel-1", - parentConversationId: "parent-1", - guildId: "guild-1", - senderId: "user-1", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, - interaction: { - kind: "button", - messageId: "message-1", - }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - clearComponents: vi.fn(async () => {}), - }, + interaction: { kind: "button", values: undefined }, + }), onMatched: async () => { callOrder.push("ack"); }, @@ -375,40 +397,11 @@ describe("plugin interactive handlers", () => { }), ).toEqual({ ok: true }); - const baseParams = { - channel: "slack" as const, + const baseParams = createSlackDispatchParams({ data: "codex:approve:thread-1", interactionId: "slack-ix-1", - ctx: { - channel: "slack" as const, - accountId: "default", - interactionId: "slack-ix-1", - conversationId: "C123", - parentConversationId: "C123", - threadId: "1710000000.000100", - senderId: "U123", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, - interaction: { - kind: "button" as const, - actionId: "codex", - blockId: "codex_actions", - messageTs: "1710000000.000200", - threadTs: "1710000000.000100", - value: "approve:thread-1", - selectedValues: ["approve:thread-1"], - selectedLabels: ["Approve"], - triggerId: "trigger-1", - responseUrl: "https://hooks.slack.test/response", - }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - }, - }; + interaction: { kind: "button" }, + }); await expectDedupedInteractiveDispatch({ baseParams, @@ -474,35 +467,12 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - dispatchPluginInteractiveHandler({ - channel: "telegram", - data: "codex:bind", - callbackId: "cb-bind", - ctx: { - accountId: "default", + dispatchPluginInteractiveHandler( + createTelegramDispatchParams({ + data: "codex:bind", callbackId: "cb-bind", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - senderId: "user-1", - senderUsername: "ada", - threadId: 77, - isGroup: true, - isForum: true, - auth: { isAuthorizedSender: true }, - callbackMessage: { - messageId: 55, - chatId: "-10099", - messageText: "Pick a thread", - }, - }, - respond: { - reply: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - editButtons: vi.fn(async () => {}), - clearButtons: vi.fn(async () => {}), - deleteMessage: vi.fn(async () => {}), - }, - }), + }), + ), ).resolves.toEqual({ matched: true, handled: true, @@ -591,33 +561,13 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - dispatchPluginInteractiveHandler({ - channel: "discord", - data: "codex:bind", - interactionId: "ix-bind", - ctx: { - accountId: "default", + dispatchPluginInteractiveHandler( + createDiscordDispatchParams({ + data: "codex:bind", interactionId: "ix-bind", - conversationId: "channel-1", - parentConversationId: "parent-1", - guildId: "guild-1", - senderId: "user-1", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, - interaction: { - kind: "button", - messageId: "message-1", - values: ["allow"], - }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - clearComponents: vi.fn(async () => {}), - }, - }), + interaction: { kind: "button", values: ["allow"] }, + }), + ), ).resolves.toEqual({ matched: true, handled: true, @@ -703,39 +653,18 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - dispatchPluginInteractiveHandler({ - channel: "slack", - data: "codex:bind", - interactionId: "slack-bind", - ctx: { - accountId: "default", + dispatchPluginInteractiveHandler( + createSlackDispatchParams({ + data: "codex:bind", interactionId: "slack-bind", - conversationId: "C123", - parentConversationId: "C123", - threadId: "1710000000.000100", - senderId: "user-1", - senderUsername: "ada", - auth: { isAuthorizedSender: true }, interaction: { kind: "button", - actionId: "codex", - blockId: "codex_actions", - messageTs: "1710000000.000200", - threadTs: "1710000000.000100", value: "bind", selectedValues: ["bind"], selectedLabels: ["Bind"], - triggerId: "trigger-1", - responseUrl: "https://hooks.slack.test/response", }, - }, - respond: { - acknowledge: vi.fn(async () => {}), - reply: vi.fn(async () => {}), - followUp: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - }, - }), + }), + ), ).resolves.toEqual({ matched: true, handled: true, @@ -793,35 +722,10 @@ describe("plugin interactive handlers", () => { }), ).toEqual({ ok: true }); - const baseParams = { - channel: "telegram" as const, + const baseParams = createTelegramDispatchParams({ data: "codex:resume:thread-1", callbackId: "cb-throw", - ctx: { - accountId: "default", - callbackId: "cb-throw", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - senderId: "user-1", - senderUsername: "ada", - threadId: 77, - isGroup: true, - isForum: true, - auth: { isAuthorizedSender: true }, - callbackMessage: { - messageId: 55, - chatId: "-10099", - messageText: "Pick a thread", - }, - }, - respond: { - reply: vi.fn(async () => {}), - editMessage: vi.fn(async () => {}), - editButtons: vi.fn(async () => {}), - clearButtons: vi.fn(async () => {}), - deleteMessage: vi.fn(async () => {}), - }, - }; + }); await expect(dispatchPluginInteractiveHandler(baseParams)).rejects.toThrow("boom"); await expect(dispatchPluginInteractiveHandler(baseParams)).resolves.toEqual({ diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index d711751dc536b..7c0bb67003a55 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -41,16 +41,20 @@ type InteractiveState = { const PLUGIN_INTERACTIVE_STATE_KEY = Symbol.for("openclaw.pluginInteractiveState"); -const state = resolveGlobalSingleton(PLUGIN_INTERACTIVE_STATE_KEY, () => ({ - interactiveHandlers: new Map(), - callbackDedupe: resolveGlobalDedupeCache(Symbol.for("openclaw.pluginInteractiveCallbackDedupe"), { - ttlMs: 5 * 60_000, - maxSize: 4096, - }), -})); +const getState = () => + resolveGlobalSingleton(PLUGIN_INTERACTIVE_STATE_KEY, () => ({ + interactiveHandlers: new Map(), + callbackDedupe: resolveGlobalDedupeCache( + Symbol.for("openclaw.pluginInteractiveCallbackDedupe"), + { + ttlMs: 5 * 60_000, + maxSize: 4096, + }, + ), + })); -const interactiveHandlers = state.interactiveHandlers; -const callbackDedupe = state.callbackDedupe; +const getInteractiveHandlers = () => getState().interactiveHandlers; +const getCallbackDedupe = () => getState().callbackDedupe; function toRegistryKey(channel: string, namespace: string): string { return `${channel.trim().toLowerCase()}:${namespace.trim()}`; @@ -74,6 +78,7 @@ function resolveNamespaceMatch( channel: string, data: string, ): { registration: RegisteredInteractiveHandler; namespace: string; payload: string } | null { + const interactiveHandlers = getInteractiveHandlers(); const trimmedData = data.trim(); if (!trimmedData) { return null; @@ -99,6 +104,7 @@ export function registerPluginInteractiveHandler( registration: PluginInteractiveHandlerRegistration, opts?: { pluginName?: string; pluginRoot?: string }, ): InteractiveRegistrationResult { + const interactiveHandlers = getInteractiveHandlers(); const namespace = normalizeNamespace(registration.namespace); const validationError = validateNamespace(namespace); if (validationError) { @@ -144,11 +150,14 @@ export function registerPluginInteractiveHandler( } export function clearPluginInteractiveHandlers(): void { + const interactiveHandlers = getInteractiveHandlers(); + const callbackDedupe = getCallbackDedupe(); interactiveHandlers.clear(); callbackDedupe.clear(); } export function clearPluginInteractiveHandlersForPlugin(pluginId: string): void { + const interactiveHandlers = getInteractiveHandlers(); for (const [key, value] of interactiveHandlers.entries()) { if (value.pluginId === pluginId) { interactiveHandlers.delete(key); @@ -210,6 +219,7 @@ export async function dispatchPluginInteractiveHandler(params: { | PluginInteractiveSlackHandlerContext["respond"]; onMatched?: () => Promise | void; }): Promise { + const callbackDedupe = getCallbackDedupe(); const match = resolveNamespaceMatch(params.channel, params.data); if (!match) { return { matched: false, handled: false, duplicate: false }; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 90630da8c527a..9a7b4fc420c80 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -6,9 +6,11 @@ import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diag import { buildMemoryPromptSection, registerMemoryPromptSection } from "../memory/prompt-section.js"; import { withEnv } from "../test-utils/env.js"; import { clearPluginCommands, getPluginCommandSpecs } from "./command-registry-state.js"; +import { clearPluginDiscoveryCache } from "./discovery.js"; import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; import { createHookRunner } from "./hooks.js"; import { __testing, clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js"; +import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; import { createEmptyPluginRegistry } from "./registry.js"; import { getActivePluginRegistry, @@ -521,6 +523,8 @@ function expectEscapingEntryRejected(params: { afterEach(() => { clearPluginLoaderCache(); + clearPluginDiscoveryCache(); + clearPluginManifestRegistryCache(); resetDiagnosticEventsForTest(); if (prevBundledDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; @@ -1020,6 +1024,30 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip clearPluginCommands(); }); + it("can scope bundled provider loads to deepseek without hanging", () => { + if (prevBundledDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = prevBundledDir; + } + + const scoped = loadOpenClawPlugins({ + cache: false, + activate: false, + config: { + plugins: { + enabled: true, + allow: ["deepseek"], + }, + }, + onlyPluginIds: ["deepseek"], + }); + + expect(scoped.plugins.map((entry) => entry.id)).toEqual(["deepseek"]); + expect(scoped.plugins[0]?.status).toBe("loaded"); + expect(scoped.providers.map((entry) => entry.provider.id)).toEqual(["deepseek"]); + }); + it("does not replace the active memory prompt section during non-activating loads", () => { useNoBundledPlugins(); registerMemoryPromptSection(() => ["active memory section"]); @@ -2298,7 +2326,7 @@ module.exports = { api.on("before_prompt_build", () => ({ prependContext: "prepend" })); api.on("before_agent_start", () => ({ prependContext: "legacy", - modelOverride: "gpt-4o", + modelOverride: "gpt-5.4", providerOverride: "anthropic", })); api.on("before_model_resolve", () => ({ providerOverride: "openai" })); @@ -2327,7 +2355,7 @@ module.exports = { const runner = createHookRunner(registry); const legacyResult = await runner.runBeforeAgentStart({ prompt: "hello", messages: [] }, {}); expect(legacyResult).toEqual({ - modelOverride: "gpt-4o", + modelOverride: "gpt-5.4", providerOverride: "anthropic", }); const blockedDiagnostics = registry.diagnostics.filter((diag) => diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 2a5441916568f..388d6f0b708ab 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginCandidate } from "./discovery.js"; import { clearPluginManifestRegistryCache, @@ -9,6 +9,8 @@ import { import type { OpenClawPackageManifest } from "./manifest.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; +vi.unmock("../version.js"); + const tempDirs: string[] = []; function chmodSafeDir(dir: string) { @@ -60,6 +62,16 @@ function loadRegistry(candidates: PluginCandidate[]) { }); } +function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_VERSION: undefined, + VITEST: "true", + ...overrides, + }; +} + function countDuplicateWarnings(registry: ReturnType): number { return registry.diagnostics.filter( (diagnostic) => @@ -136,6 +148,7 @@ function expectUnsafeWorkspaceManifestRejected(params: { } afterEach(() => { + vi.restoreAllMocks(); clearPluginManifestRegistryCache(); cleanupTrackedTempDirs(tempDirs); }); @@ -762,17 +775,15 @@ describe("loadPluginManifestRegistry", () => { const first = loadPluginManifestRegistry({ cache: true, - env: { - ...process.env, + env: hermeticEnv({ OPENCLAW_BUNDLED_PLUGINS_DIR: bundledA, - }, + }), }); const second = loadPluginManifestRegistry({ cache: true, - env: { - ...process.env, + env: hermeticEnv({ OPENCLAW_BUNDLED_PLUGINS_DIR: bundledB, - }, + }), }); expect( @@ -814,22 +825,20 @@ describe("loadPluginManifestRegistry", () => { const first = loadPluginManifestRegistry({ cache: true, config, - env: { - ...process.env, + env: hermeticEnv({ HOME: homeA, OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: path.join(homeA, ".state"), - }, + }), }); const second = loadPluginManifestRegistry({ cache: true, config, - env: { - ...process.env, + env: hermeticEnv({ HOME: homeB, OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: path.join(homeB, ".state"), - }, + }), }); expect( @@ -862,18 +871,16 @@ describe("loadPluginManifestRegistry", () => { const olderHost = loadPluginManifestRegistry({ cache: true, candidates, - env: { - ...process.env, + env: hermeticEnv({ OPENCLAW_VERSION: "2026.3.21", - }, + }), }); const newerHost = loadPluginManifestRegistry({ cache: true, candidates, - env: { - ...process.env, + env: hermeticEnv({ OPENCLAW_VERSION: "2026.3.22", - }, + }), }); expect(olderHost.plugins).toEqual([]); diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index 6ae2b010556b7..63777eae834e5 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -5,11 +5,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; const installPluginFromPathMock = vi.fn(); +const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); vi.mock("./install.js", () => ({ installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args), })); +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + async function withTempDir(fn: (dir: string) => Promise): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-")); try { @@ -19,9 +24,23 @@ async function withTempDir(fn: (dir: string) => Promise): Promise { } } +function mockRemoteMarketplaceClone(manifest: unknown) { + runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { + const repoDir = argv.at(-1); + expect(typeof repoDir).toBe("string"); + await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(repoDir as string, ".claude-plugin", "marketplace.json"), + JSON.stringify(manifest), + ); + return { code: 0, stdout: "", stderr: "", killed: false }; + }); +} + describe("marketplace plugins", () => { afterEach(() => { installPluginFromPathMock.mockReset(); + runCommandWithTimeoutMock.mockReset(); }); it("lists plugins from a local marketplace root", async () => { @@ -141,4 +160,132 @@ describe("marketplace plugins", () => { }); }); }); + + it("installs remote marketplace plugins from relative paths inside the cloned repo", async () => { + runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { + const repoDir = argv.at(-1); + expect(typeof repoDir).toBe("string"); + await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true }); + await fs.mkdir(path.join(repoDir as string, "plugins", "frontend-design"), { + recursive: true, + }); + await fs.writeFile( + path.join(repoDir as string, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + plugins: [ + { + name: "frontend-design", + source: "./plugins/frontend-design", + }, + ], + }), + ); + return { code: 0, stdout: "", stderr: "", killed: false }; + }); + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "frontend-design", + targetDir: "/tmp/frontend-design", + version: "0.1.0", + extensions: ["index.ts"], + }); + + const { installPluginFromMarketplace } = await import("./marketplace.js"); + const result = await installPluginFromMarketplace({ + marketplace: "owner/repo", + plugin: "frontend-design", + }); + + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + expect(runCommandWithTimeoutMock).toHaveBeenCalledWith( + ["git", "clone", "--depth", "1", "https://github.com/owner/repo.git", expect.any(String)], + { timeoutMs: 120_000 }, + ); + expect(installPluginFromPathMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringMatching(/[\\/]repo[\\/]plugins[\\/]frontend-design$/), + }), + ); + expect(result).toMatchObject({ + ok: true, + pluginId: "frontend-design", + marketplacePlugin: "frontend-design", + marketplaceSource: "owner/repo", + }); + }); + + it("rejects remote marketplace git plugin sources before cloning nested remotes", async () => { + mockRemoteMarketplaceClone({ + plugins: [ + { + name: "frontend-design", + source: { + type: "git", + url: "https://evil.example/repo.git", + }, + }, + ], + }); + + const { listMarketplacePlugins } = await import("./marketplace.js"); + const result = await listMarketplacePlugins({ marketplace: "owner/repo" }); + + expect(result).toEqual({ + ok: false, + error: + 'invalid marketplace entry "frontend-design" in owner/repo: ' + + "remote marketplaces may not use git plugin sources", + }); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + }); + + it("rejects remote marketplace absolute plugin paths", async () => { + mockRemoteMarketplaceClone({ + plugins: [ + { + name: "frontend-design", + source: { + type: "path", + path: "/tmp/frontend-design", + }, + }, + ], + }); + + const { listMarketplacePlugins } = await import("./marketplace.js"); + const result = await listMarketplacePlugins({ marketplace: "owner/repo" }); + + expect(result).toEqual({ + ok: false, + error: + 'invalid marketplace entry "frontend-design" in owner/repo: ' + + "remote marketplaces may only use relative plugin paths", + }); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + }); + + it("rejects remote marketplace HTTP plugin paths", async () => { + mockRemoteMarketplaceClone({ + plugins: [ + { + name: "frontend-design", + source: { + type: "path", + path: "https://evil.example/plugin.tgz", + }, + }, + ], + }); + + const { listMarketplacePlugins } = await import("./marketplace.js"); + const result = await listMarketplacePlugins({ marketplace: "owner/repo" }); + + expect(result).toEqual({ + ok: false, + error: + 'invalid marketplace entry "frontend-design" in owner/repo: ' + + "remote marketplaces may not use HTTP(S) plugin paths", + }); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index 24d2fae8ba1b2..855ebdcce5b53 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -51,6 +51,13 @@ type LoadedMarketplace = { cleanup?: () => Promise; }; +type MarketplaceManifestOrigin = "local" | "remote"; + +type ResolvedLocalMarketplaceSource = { + manifestPath: string; + rootDir: string; +}; + type KnownMarketplaceRecord = { installLocation?: string; source?: unknown; @@ -462,25 +469,41 @@ async function loadMarketplace(params: { logger?: MarketplaceLogger; timeoutMs?: number; }): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> { + const loadResolvedLocalMarketplace = async ( + local: ResolvedLocalMarketplaceSource, + sourceLabel: string, + ): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> => { + const raw = await fs.readFile(local.manifestPath, "utf-8"); + const parsed = parseMarketplaceManifest(raw, local.manifestPath); + if (!parsed.ok) { + return parsed; + } + const validated = validateMarketplaceManifest({ + manifest: parsed.manifest, + sourceLabel: local.manifestPath, + rootDir: local.rootDir, + origin: "local", + }); + if (!validated.ok) { + return validated; + } + return { + ok: true, + marketplace: { + manifest: validated.manifest, + rootDir: local.rootDir, + sourceLabel, + }, + }; + }; + const knownMarketplaces = await readClaudeKnownMarketplaces(); const known = knownMarketplaces[params.source]; if (known) { if (known.installLocation) { const local = await resolveLocalMarketplaceSource(known.installLocation); if (local?.ok) { - const raw = await fs.readFile(local.manifestPath, "utf-8"); - const parsed = parseMarketplaceManifest(raw, local.manifestPath); - if (!parsed.ok) { - return parsed; - } - return { - ok: true, - marketplace: { - manifest: parsed.manifest, - rootDir: local.rootDir, - sourceLabel: params.source, - }, - }; + return await loadResolvedLocalMarketplace(local, params.source); } } @@ -500,19 +523,7 @@ async function loadMarketplace(params: { } if (local?.ok) { - const raw = await fs.readFile(local.manifestPath, "utf-8"); - const parsed = parseMarketplaceManifest(raw, local.manifestPath); - if (!parsed.ok) { - return parsed; - } - return { - ok: true, - marketplace: { - manifest: parsed.manifest, - rootDir: local.rootDir, - sourceLabel: local.manifestPath, - }, - }; + return await loadResolvedLocalMarketplace(local, local.manifestPath); } const cloned = await cloneMarketplaceRepo({ @@ -543,11 +554,21 @@ async function loadMarketplace(params: { await cloned.cleanup(); return parsed; } + const validated = validateMarketplaceManifest({ + manifest: parsed.manifest, + sourceLabel: cloned.label, + rootDir: cloned.rootDir, + origin: "remote", + }); + if (!validated.ok) { + await cloned.cleanup(); + return validated; + } return { ok: true, marketplace: { - manifest: parsed.manifest, + manifest: validated.manifest, rootDir: cloned.rootDir, sourceLabel: cloned.label, cleanup: cloned.cleanup, @@ -600,6 +621,56 @@ function ensureInsideMarketplaceRoot( return { ok: true, path: resolved }; } +function validateMarketplaceManifest(params: { + manifest: MarketplaceManifest; + sourceLabel: string; + rootDir: string; + origin: MarketplaceManifestOrigin; +}): { ok: true; manifest: MarketplaceManifest } | { ok: false; error: string } { + if (params.origin === "local") { + return { ok: true, manifest: params.manifest }; + } + + for (const plugin of params.manifest.plugins) { + const source = plugin.source; + if (source.kind === "path") { + if (isHttpUrl(source.path)) { + return { + ok: false, + error: + `invalid marketplace entry "${plugin.name}" in ${params.sourceLabel}: ` + + "remote marketplaces may not use HTTP(S) plugin paths", + }; + } + if (path.isAbsolute(source.path)) { + return { + ok: false, + error: + `invalid marketplace entry "${plugin.name}" in ${params.sourceLabel}: ` + + "remote marketplaces may only use relative plugin paths", + }; + } + const resolved = ensureInsideMarketplaceRoot(params.rootDir, source.path); + if (!resolved.ok) { + return { + ok: false, + error: `invalid marketplace entry "${plugin.name}" in ${params.sourceLabel}: ${resolved.error}`, + }; + } + continue; + } + + return { + ok: false, + error: + `invalid marketplace entry "${plugin.name}" in ${params.sourceLabel}: ` + + `remote marketplaces may not use ${source.kind} plugin sources`, + }; + } + + return { ok: true, manifest: params.manifest }; +} + async function resolveMarketplaceEntryInstallPath(params: { source: MarketplaceEntrySource; marketplaceRootDir: string; diff --git a/src/plugins/provider-catalog-metadata.ts b/src/plugins/provider-catalog-metadata.ts index 1347fe0062912..084284a01864f 100644 --- a/src/plugins/provider-catalog-metadata.ts +++ b/src/plugins/provider-catalog-metadata.ts @@ -51,12 +51,12 @@ export function augmentBundledProviderCatalog( const openAiCodexGpt54Template = findCatalogTemplate({ entries: context.entries, providerId: OPENAI_CODEX_PROVIDER_ID, - templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + templateIds: ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"], }); const openAiCodexSparkTemplate = findCatalogTemplate({ entries: context.entries, providerId: OPENAI_CODEX_PROVIDER_ID, - templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + templateIds: ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"], }); return [ diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 7dae52bf3a87c..a6f66abc2246c 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -14,6 +14,7 @@ export function resolvePluginDiscoveryProviders(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + onlyPluginIds?: string[]; }): ProviderPlugin[] { return resolvePluginProviders({ ...params, diff --git a/src/plugins/provider-model-definitions.ts b/src/plugins/provider-model-definitions.ts index 967aefaaf2704..d745226459681 100644 --- a/src/plugins/provider-model-definitions.ts +++ b/src/plugins/provider-model-definitions.ts @@ -6,6 +6,7 @@ import { KILOCODE_DEFAULT_MODEL_ID, KILOCODE_DEFAULT_MODEL_NAME, } from "./provider-model-kilocode.js"; +import { MINIMAX_DEFAULT_MODEL_ID, MINIMAX_TEXT_MODEL_CATALOG } from "./provider-model-minimax.js"; const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"; const KIMI_CODING_MODEL_ID = "kimi-code"; @@ -14,25 +15,19 @@ const KIMI_CODING_MODEL_REF = `kimi/${KIMI_CODING_MODEL_ID}`; const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic"; -const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.7"; +const MINIMAX_HOSTED_MODEL_ID = MINIMAX_DEFAULT_MODEL_ID; const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; const DEFAULT_MINIMAX_MAX_TOKENS = 8192; const MINIMAX_API_COST = { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 }; const MINIMAX_HOSTED_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; const MINIMAX_LM_STUDIO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; -const MINIMAX_MODEL_CATALOG = { - "MiniMax-M2.7": { name: "MiniMax M2.7", reasoning: true }, - "MiniMax-M2.7-highspeed": { name: "MiniMax M2.7 Highspeed", reasoning: true }, - "MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true }, - "MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true }, -} as const; const MISTRAL_BASE_URL = "https://api.mistral.ai/v1"; const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest"; const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`; const MISTRAL_DEFAULT_CONTEXT_WINDOW = 262144; -const MISTRAL_DEFAULT_MAX_TOKENS = 262144; +const MISTRAL_DEFAULT_MAX_TOKENS = 16384; const MISTRAL_DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; @@ -140,7 +135,7 @@ function buildMinimaxModelDefinition(params: { contextWindow: number; maxTokens: number; }): ModelDefinitionConfig { - const catalog = MINIMAX_MODEL_CATALOG[params.id as keyof typeof MINIMAX_MODEL_CATALOG]; + const catalog = MINIMAX_TEXT_MODEL_CATALOG[params.id as keyof typeof MINIMAX_TEXT_MODEL_CATALOG]; return { id: params.id, name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`, diff --git a/src/plugins/provider-model-helpers.test.ts b/src/plugins/provider-model-helpers.test.ts index 905195775fe25..4adf16d589d48 100644 --- a/src/plugins/provider-model-helpers.test.ts +++ b/src/plugins/provider-model-helpers.test.ts @@ -1,6 +1,6 @@ import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; import { describe, expect, it } from "vitest"; -import { cloneFirstTemplateModel } from "./provider-model-helpers.js"; +import { cloneFirstTemplateModel, matchesExactOrPrefix } from "./provider-model-helpers.js"; import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel } from "./types.js"; function createContext(models: ProviderRuntimeModel[]): ProviderResolveDynamicModelContext { @@ -54,3 +54,11 @@ describe("cloneFirstTemplateModel", () => { expect(model).toBeUndefined(); }); }); + +describe("matchesExactOrPrefix", () => { + it("matches exact ids and prefixed variants case-insensitively", () => { + expect(matchesExactOrPrefix("MiniMax-M2.7", ["minimax-m2.7"])).toBe(true); + expect(matchesExactOrPrefix("minimax-m2.7-highspeed", ["MiniMax-M2.7"])).toBe(true); + expect(matchesExactOrPrefix("glm-5", ["minimax-m2.7"])).toBe(false); + }); +}); diff --git a/src/plugins/provider-model-helpers.ts b/src/plugins/provider-model-helpers.ts index 8ffd8d18be777..08e2651b6cb49 100644 --- a/src/plugins/provider-model-helpers.ts +++ b/src/plugins/provider-model-helpers.ts @@ -1,6 +1,14 @@ import { normalizeModelCompat } from "../agents/model-compat.js"; import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel } from "./types.js"; +export function matchesExactOrPrefix(id: string, values: readonly string[]): boolean { + const normalizedId = id.trim().toLowerCase(); + return values.some((value) => { + const normalizedValue = value.trim().toLowerCase(); + return normalizedId === normalizedValue || normalizedId.startsWith(normalizedValue); + }); +} + export function cloneFirstTemplateModel(params: { providerId: string; modelId: string; diff --git a/src/plugins/provider-model-minimax.ts b/src/plugins/provider-model-minimax.ts new file mode 100644 index 0000000000000..6ff7a4f1f56d3 --- /dev/null +++ b/src/plugins/provider-model-minimax.ts @@ -0,0 +1,39 @@ +import { matchesExactOrPrefix } from "./provider-model-helpers.js"; + +export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.7"; +export const MINIMAX_DEFAULT_MODEL_REF = `minimax/${MINIMAX_DEFAULT_MODEL_ID}`; + +export const MINIMAX_TEXT_MODEL_ORDER = [ + "MiniMax-M2", + "MiniMax-M2.1", + "MiniMax-M2.1-highspeed", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", +] as const; + +export const MINIMAX_TEXT_MODEL_CATALOG = { + "MiniMax-M2": { name: "MiniMax M2", reasoning: true }, + "MiniMax-M2.1": { name: "MiniMax M2.1", reasoning: true }, + "MiniMax-M2.1-highspeed": { name: "MiniMax M2.1 Highspeed", reasoning: true }, + "MiniMax-M2.7": { name: "MiniMax M2.7", reasoning: true }, + "MiniMax-M2.7-highspeed": { name: "MiniMax M2.7 Highspeed", reasoning: true }, + "MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true }, + "MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true }, +} as const; + +export const MINIMAX_TEXT_MODEL_REFS = MINIMAX_TEXT_MODEL_ORDER.map( + (modelId) => `minimax/${modelId}`, +); + +export const MINIMAX_MODERN_MODEL_MATCHERS = [ + "minimax-m2", + "minimax-m2.1", + "minimax-m2.5", + "minimax-m2.7", +] as const; + +export function isMiniMaxModernModelId(modelId: string): boolean { + return matchesExactOrPrefix(modelId, MINIMAX_MODERN_MODEL_MATCHERS); +} diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts index f15f7999e2cb3..9716a4249b178 100644 --- a/src/plugins/provider-openai-codex-oauth.ts +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -1,4 +1,5 @@ import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth"; +import { ensureGlobalUndiciEnvProxyDispatcher } from "../infra/net/undici-global-dispatcher.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; @@ -17,6 +18,11 @@ export async function loginOpenAICodexOAuth(params: { localBrowserMessage?: string; }): Promise { const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params; + + // Ensure env-based proxy dispatcher is active before any outbound fetch calls, + // including the TLS preflight check. + ensureGlobalUndiciEnvProxyDispatcher(); + const preflight = await runOpenAIOAuthTlsPreflight(); if (!preflight.ok && preflight.kind === "tls-cert") { const hint = formatOpenAIOAuthTlsPreflightFix(preflight); diff --git a/src/plugins/provider-runtime.runtime.ts b/src/plugins/provider-runtime.runtime.ts index d4f036e1cf854..c8675f38ed191 100644 --- a/src/plugins/provider-runtime.runtime.ts +++ b/src/plugins/provider-runtime.runtime.ts @@ -1,7 +1,64 @@ -export { - augmentModelCatalogWithProviderPlugins, - buildProviderAuthDoctorHintWithPlugin, - buildProviderMissingAuthMessageWithPlugin, - formatProviderAuthProfileApiKeyWithPlugin, - refreshProviderOAuthCredentialWithPlugin, -} from "./provider-runtime.js"; +type ProviderRuntimeModule = typeof import("./provider-runtime.js"); + +type AugmentModelCatalogWithProviderPlugins = + ProviderRuntimeModule["augmentModelCatalogWithProviderPlugins"]; +type BuildProviderAuthDoctorHintWithPlugin = + ProviderRuntimeModule["buildProviderAuthDoctorHintWithPlugin"]; +type BuildProviderMissingAuthMessageWithPlugin = + ProviderRuntimeModule["buildProviderMissingAuthMessageWithPlugin"]; +type FormatProviderAuthProfileApiKeyWithPlugin = + ProviderRuntimeModule["formatProviderAuthProfileApiKeyWithPlugin"]; +type PrepareProviderRuntimeAuth = ProviderRuntimeModule["prepareProviderRuntimeAuth"]; +type RefreshProviderOAuthCredentialWithPlugin = + ProviderRuntimeModule["refreshProviderOAuthCredentialWithPlugin"]; + +let providerRuntimePromise: Promise | undefined; + +async function loadProviderRuntime(): Promise { + // Keep the heavy provider runtime behind an actual async boundary so callers + // can import this wrapper eagerly without collapsing the lazy chunk. + providerRuntimePromise ??= import("./provider-runtime.js"); + return providerRuntimePromise; +} + +export async function augmentModelCatalogWithProviderPlugins( + ...args: Parameters +): Promise>> { + const runtime = await loadProviderRuntime(); + return runtime.augmentModelCatalogWithProviderPlugins(...args); +} + +export async function buildProviderAuthDoctorHintWithPlugin( + ...args: Parameters +): Promise>> { + const runtime = await loadProviderRuntime(); + return runtime.buildProviderAuthDoctorHintWithPlugin(...args); +} + +export async function buildProviderMissingAuthMessageWithPlugin( + ...args: Parameters +): Promise>> { + const runtime = await loadProviderRuntime(); + return runtime.buildProviderMissingAuthMessageWithPlugin(...args); +} + +export async function formatProviderAuthProfileApiKeyWithPlugin( + ...args: Parameters +): Promise>> { + const runtime = await loadProviderRuntime(); + return runtime.formatProviderAuthProfileApiKeyWithPlugin(...args); +} + +export async function prepareProviderRuntimeAuth( + ...args: Parameters +): Promise>> { + const runtime = await loadProviderRuntime(); + return runtime.prepareProviderRuntimeAuth(...args); +} + +export async function refreshProviderOAuthCredentialWithPlugin( + ...args: Parameters +): Promise>> { + const runtime = await loadProviderRuntime(); + return runtime.refreshProviderOAuthCredentialWithPlugin(...args); +} diff --git a/src/plugins/provider-runtime.test-support.ts b/src/plugins/provider-runtime.test-support.ts index 9e9fb0bb87763..9aa8cbd8892ef 100644 --- a/src/plugins/provider-runtime.test-support.ts +++ b/src/plugins/provider-runtime.test-support.ts @@ -5,7 +5,7 @@ export const openaiCodexCatalogEntries = [ { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, { provider: "openai", id: "gpt-5-mini", name: "GPT-5 mini" }, { provider: "openai", id: "gpt-5-nano", name: "GPT-5 nano" }, - { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { provider: "openai-codex", id: "gpt-5.4", name: "GPT-5.4" }, ]; export const expectedAugmentedOpenaiCodexCatalogEntries = [ diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 84f6e9c935ce5..75f48bcf71084 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { expectAugmentedCodexCatalog, expectCodexBuiltInSuppression, @@ -20,17 +20,6 @@ const resolveOwningPluginIdsForProviderMock = vi.fn undefined as string[] | undefined, ); -vi.mock("./providers.js", () => ({ - resolveNonBundledProviderPluginIds: (params: unknown) => - resolveNonBundledProviderPluginIdsMock(params as never), - resolveOwningPluginIdsForProvider: (params: unknown) => - resolveOwningPluginIdsForProviderMock(params as never), -})); - -vi.mock("./providers.runtime.js", () => ({ - resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), -})); - let augmentModelCatalogWithProviderPlugins: typeof import("./provider-runtime.js").augmentModelCatalogWithProviderPlugins; let buildProviderAuthDoctorHintWithPlugin: typeof import("./provider-runtime.js").buildProviderAuthDoctorHintWithPlugin; let buildProviderMissingAuthMessageWithPlugin: typeof import("./provider-runtime.js").buildProviderMissingAuthMessageWithPlugin; @@ -68,7 +57,17 @@ const MODEL: ProviderRuntimeModel = { }; describe("provider-runtime", () => { - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("./providers.js", () => ({ + resolveNonBundledProviderPluginIds: (params: unknown) => + resolveNonBundledProviderPluginIdsMock(params as never), + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), + })); + vi.doMock("./providers.runtime.js", () => ({ + resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + })); ({ augmentModelCatalogWithProviderPlugins, buildProviderAuthDoctorHintWithPlugin, @@ -93,9 +92,6 @@ describe("provider-runtime", () => { runProviderDynamicModel, wrapProviderStreamFn, } = await import("./provider-runtime.js")); - }); - - beforeEach(() => { resetProviderRuntimeHookCacheForTest(); resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); @@ -478,7 +474,7 @@ describe("provider-runtime", () => { { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, { provider: "openai", id: "gpt-5-mini", name: "GPT-5 mini" }, { provider: "openai", id: "gpt-5-nano", name: "GPT-5 nano" }, - { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { provider: "openai-codex", id: "gpt-5.4", name: "GPT-5.4" }, ], }, }), diff --git a/src/plugins/provider-wizard.test.ts b/src/plugins/provider-wizard.test.ts index 317ab3024f054..385674da913eb 100644 --- a/src/plugins/provider-wizard.test.ts +++ b/src/plugins/provider-wizard.test.ts @@ -20,6 +20,46 @@ function makeProvider(overrides: Partial & Pick { beforeEach(() => { vi.clearAllMocks(); @@ -248,23 +288,8 @@ describe("provider wizard boundaries", () => { }); it("invalidates the wizard cache when config or env contents change in place", () => { - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); - const config = { - plugins: { - allow: ["sglang"], - }, - }; + const provider = createSglangSetupProvider(); + const config = createSglangConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-home-a" } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); @@ -291,95 +316,36 @@ describe("provider wizard boundaries", () => { }); it("skips provider-wizard memoization when plugin cache opt-outs are set", () => { - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); - const config = { - plugins: { - allow: ["sglang"], - }, - }; + const provider = createSglangSetupProvider(); + const config = createSglangConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-home", OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); - resolveProviderWizardOptions({ - config, - workspaceDir: "/tmp/workspace", - env, - }); - resolveProviderWizardOptions({ - config, - workspaceDir: "/tmp/workspace", - env, - }); + resolveWizardOptionsTwice({ config, env }); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); }); it("skips provider-wizard memoization when discovery cache ttl is zero", () => { - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); - const config = { - plugins: { - allow: ["sglang"], - }, - }; + const provider = createSglangSetupProvider(); + const config = createSglangConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-home", OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0", } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); - resolveProviderWizardOptions({ - config, - workspaceDir: "/tmp/workspace", - env, - }); - resolveProviderWizardOptions({ - config, - workspaceDir: "/tmp/workspace", - env, - }); + resolveWizardOptionsTwice({ config, env }); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); }); it("expires provider-wizard memoization after the shortest plugin cache ttl", () => { vi.useFakeTimers(); - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); + const provider = createSglangSetupProvider(); const config = {}; const env = { OPENCLAW_HOME: "/tmp/openclaw-home", @@ -410,18 +376,7 @@ describe("provider wizard boundaries", () => { }); it("invalidates provider-wizard snapshots when cache-control env values change in place", () => { - const provider = makeProvider({ - id: "sglang", - label: "SGLang", - auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], - wizard: { - setup: { - choiceLabel: "SGLang setup", - groupId: "sglang", - groupLabel: "SGLang", - }, - }, - }); + const provider = createSglangSetupProvider(); const config = {}; const env = { OPENCLAW_HOME: "/tmp/openclaw-home", diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index 665bfedd98f9c..f252cbd5ac1d9 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -3,6 +3,11 @@ import { parseModelRef } from "../agents/model-selection.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { + buildPluginSnapshotCacheEnvKey, + resolvePluginSnapshotCacheTtlMs, + shouldUsePluginSnapshotCache, +} from "./cache-controls.js"; import { resolvePluginProviders } from "./providers.runtime.js"; import type { ProviderAuthMethod, @@ -21,54 +26,6 @@ const providerWizardCache = new WeakMap< WeakMap> >(); -const DEFAULT_DISCOVERY_CACHE_MS = 1000; -const DEFAULT_MANIFEST_CACHE_MS = 1000; - -function shouldUseProviderWizardCache(env: NodeJS.ProcessEnv): boolean { - if (env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim()) { - return false; - } - if (env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim()) { - return false; - } - const discoveryCacheMs = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); - if (discoveryCacheMs === "0") { - return false; - } - const manifestCacheMs = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); - if (manifestCacheMs === "0") { - return false; - } - return true; -} - -function resolveProviderWizardCacheTtlMs(env: NodeJS.ProcessEnv): number { - const discoveryCacheMs = resolveCacheMs( - env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, - DEFAULT_DISCOVERY_CACHE_MS, - ); - const manifestCacheMs = resolveCacheMs( - env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, - DEFAULT_MANIFEST_CACHE_MS, - ); - return Math.min(discoveryCacheMs, manifestCacheMs); -} - -function resolveCacheMs(rawValue: string | undefined, defaultMs: number): number { - const raw = rawValue?.trim(); - if (raw === "" || raw === "0") { - return 0; - } - if (!raw) { - return defaultMs; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) { - return defaultMs; - } - return Math.max(0, parsed); -} - function buildProviderWizardCacheKey(params: { config: OpenClawConfig; workspaceDir?: string; @@ -77,21 +34,7 @@ function buildProviderWizardCacheKey(params: { return JSON.stringify({ workspaceDir: params.workspaceDir ?? "", config: params.config, - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: params.env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: - params.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: - params.env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE ?? "", - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: params.env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS ?? "", - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: params.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ?? "", - OPENCLAW_HOME: params.env.OPENCLAW_HOME ?? "", - OPENCLAW_STATE_DIR: params.env.OPENCLAW_STATE_DIR ?? "", - OPENCLAW_CONFIG_PATH: params.env.OPENCLAW_CONFIG_PATH ?? "", - HOME: params.env.HOME ?? "", - USERPROFILE: params.env.USERPROFILE ?? "", - VITEST: params.env.VITEST ?? "", - }, + env: buildPluginSnapshotCacheEnvKey(params.env), }); } @@ -188,7 +131,7 @@ function resolveProviderWizardProviders(params: { return resolvePluginProviders(params); } const env = params.env ?? process.env; - if (!shouldUseProviderWizardCache(env)) { + if (!shouldUsePluginSnapshotCache(env)) { return resolvePluginProviders({ config: params.config, workspaceDir: params.workspaceDir, @@ -211,7 +154,7 @@ function resolveProviderWizardProviders(params: { workspaceDir: params.workspaceDir, env, }); - const ttlMs = resolveProviderWizardCacheTtlMs(env); + const ttlMs = resolvePluginSnapshotCacheTtlMs(env); let nextConfigCache = configCache; if (!nextConfigCache) { nextConfigCache = new WeakMap>(); diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index e37b97f96bbcf..2fa9ef86b32a1 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "./registry.js"; import { + getActivePluginRegistry, pinActivePluginHttpRouteRegistry, releasePinnedPluginHttpRouteRegistry, resetPluginRuntimeStateForTest, @@ -14,6 +15,12 @@ describe("plugin runtime route registry", () => { resetPluginRuntimeStateForTest(); }); + it("stays empty until a caller explicitly installs or requires a registry", () => { + resetPluginRuntimeStateForTest(); + + expect(getActivePluginRegistry()).toBeNull(); + }); + it("keeps the pinned route registry when the active plugin registry changes", () => { const startupRegistry = createEmptyPluginRegistry(); const laterRegistry = createEmptyPluginRegistry(); diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 03c43ed4d1bbf..861fba6a7ce76 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -1,4 +1,3 @@ -import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import type { PluginRegistry } from "./registry.js"; @@ -12,13 +11,21 @@ type RegistryState = { version: number; }; -const state = resolveGlobalSingleton(REGISTRY_STATE, () => ({ - registry: createEmptyPluginRegistry(), - httpRouteRegistry: null, - httpRouteRegistryPinned: false, - key: null, - version: 0, -})); +const state: RegistryState = (() => { + const globalState = globalThis as typeof globalThis & { + [REGISTRY_STATE]?: RegistryState; + }; + if (!globalState[REGISTRY_STATE]) { + globalState[REGISTRY_STATE] = { + registry: null, + httpRouteRegistry: null, + httpRouteRegistryPinned: false, + key: null, + version: 0, + }; + } + return globalState[REGISTRY_STATE]; +})(); export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: string) { state.registry = registry; @@ -93,9 +100,8 @@ export function getActivePluginRegistryVersion(): number { } export function resetPluginRuntimeStateForTest(): void { - const emptyRegistry = createEmptyPluginRegistry(); - state.registry = emptyRegistry; - state.httpRouteRegistry = emptyRegistry; + state.registry = null; + state.httpRouteRegistry = null; state.httpRouteRegistryPinned = false; state.key = null; state.version += 1; diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 5ffbd60aa2e66..1c86b551969c2 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -4,6 +4,7 @@ import { onAgentEvent } from "../../infra/agent-events.js"; import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; import * as execModule from "../../process/exec.js"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import { VERSION } from "../../version.js"; import { clearGatewaySubagentRuntime, createPluginRuntime, @@ -141,4 +142,9 @@ describe("plugin runtime command execution", () => { }); expect(run).toHaveBeenCalledWith({ sessionKey: "s-2", message: "hello" }); }); + + it("exposes runtime.version from the shared VERSION constant", () => { + const runtime = createPluginRuntime(); + expect(runtime.version).toBe(VERSION); + }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 963aa24bf39de..e3a4e6e2c0cc7 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -1,4 +1,3 @@ -import { createRequire } from "node:module"; import { resolveStateDir } from "../../config/paths.js"; import { listRuntimeImageGenerationProviders, @@ -10,8 +9,10 @@ import { createLazyRuntimeMethodBinder, createLazyRuntimeModule, } from "../../shared/lazy-runtime.js"; +import { VERSION } from "../../version.js"; import { listWebSearchProviders, runWebSearch } from "../../web-search/runtime.js"; import { createRuntimeAgent } from "./runtime-agent.js"; +import { defineCachedValue } from "./runtime-cache.js"; import { createRuntimeChannel } from "./runtime-channel.js"; import { createRuntimeConfig } from "./runtime-config.js"; import { createRuntimeEvents } from "./runtime-events.js"; @@ -21,26 +22,6 @@ import { createRuntimeSystem } from "./runtime-system.js"; import { createRuntimeTools } from "./runtime-tools.js"; import type { PluginRuntime } from "./types.js"; -function defineCachedValue( - target: T, - key: K, - create: () => unknown, -): void { - let cached: unknown; - let ready = false; - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - get() { - if (!ready) { - cached = create(); - ready = true; - } - return cached; - }, - }); -} - const loadTtsRuntime = createLazyRuntimeModule(() => import("./runtime-tts.runtime.js")); const loadMediaUnderstandingRuntime = createLazyRuntimeModule( () => import("./runtime-media-understanding.runtime.js"), @@ -96,23 +77,6 @@ function createRuntimeModelAuth(): PluginRuntime["modelAuth"] { }; } -let cachedVersion: string | null = null; - -function resolveVersion(): string { - if (cachedVersion) { - return cachedVersion; - } - try { - const require = createRequire(import.meta.url); - const pkg = require("../../../package.json") as { version?: string }; - cachedVersion = pkg.version ?? "unknown"; - return cachedVersion; - } catch { - cachedVersion = "unknown"; - return cachedVersion; - } -} - function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] { const unavailable = () => { throw new Error("Plugin runtime subagent methods are only available during a gateway request."); @@ -201,7 +165,9 @@ export type CreatePluginRuntimeOptions = { export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): PluginRuntime { const mediaUnderstanding = createRuntimeMediaUnderstandingFacade(); const runtime = { - version: resolveVersion(), + // Sourced from the shared OpenClaw version resolver (#52899) so plugins + // always see the same version the CLI reports, avoiding API-version drift. + version: VERSION, config: createRuntimeConfig(), agent: createRuntimeAgent(), subagent: createLateBindingSubagent( diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index c8c3c0d97e1fd..cd5868d5de044 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -7,28 +7,9 @@ import { ensureAgentWorkspace } from "../../agents/workspace.js"; import { resolveSessionFilePath, resolveStorePath } from "../../config/sessions/paths.js"; import { loadSessionStore, saveSessionStore } from "../../config/sessions/store.js"; import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; +import { defineCachedValue } from "./runtime-cache.js"; import type { PluginRuntime } from "./types.js"; -function defineCachedValue( - target: T, - key: K, - create: () => unknown, -): void { - let cached: unknown; - let ready = false; - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - get() { - if (!ready) { - cached = create(); - ready = true; - } - return cached; - }, - }); -} - const loadEmbeddedPiRuntime = createLazyRuntimeModule( () => import("./runtime-embedded-pi.runtime.js"), ); diff --git a/src/plugins/runtime/runtime-cache.ts b/src/plugins/runtime/runtime-cache.ts new file mode 100644 index 0000000000000..20a4a016411a4 --- /dev/null +++ b/src/plugins/runtime/runtime-cache.ts @@ -0,0 +1,19 @@ +export function defineCachedValue( + target: T, + key: K, + create: () => unknown, +): void { + let cached: unknown; + let ready = false; + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + get() { + if (!ready) { + cached = create(); + ready = true; + } + return cached; + }, + }); +} diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index cff52a971c791..63157320c5534 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -76,6 +76,7 @@ import { resolveLineAccount, } from "../../plugin-sdk/line.js"; import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js"; +import { defineCachedValue } from "./runtime-cache.js"; import { createRuntimeDiscord } from "./runtime-discord.js"; import { createRuntimeIMessage } from "./runtime-imessage.js"; import { createRuntimeMatrix } from "./runtime-matrix.js"; @@ -86,26 +87,6 @@ import { createRuntimeWhatsApp } from "./runtime-whatsapp.js"; import { createRuntimeX } from "./runtime-x.js"; import type { PluginRuntime } from "./types.js"; -function defineCachedValue( - target: T, - key: K, - create: () => unknown, -): void { - let cached: unknown; - let ready = false; - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - get() { - if (!ready) { - cached = create(); - ready = true; - } - return cached; - }, - }); -} - export function createRuntimeChannel(): PluginRuntime["channel"] { const channelRuntime = { text: { diff --git a/src/plugins/runtime/runtime-matrix-boundary.ts b/src/plugins/runtime/runtime-matrix-boundary.ts index a122e613c1f0b..f608fc611bdcf 100644 --- a/src/plugins/runtime/runtime-matrix-boundary.ts +++ b/src/plugins/runtime/runtime-matrix-boundary.ts @@ -1,14 +1,9 @@ -import fs from "node:fs"; -import path from "node:path"; import { createJiti } from "jiti"; -import { loadConfig } from "../../config/config.js"; -import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { - buildPluginLoaderJitiOptions, - resolvePluginSdkAliasFile, - resolvePluginSdkScopedAliasMap, - shouldPreferNativeJiti, -} from "../sdk-alias.js"; + loadPluginBoundaryModuleWithJiti, + resolvePluginRuntimeModulePath, + resolvePluginRuntimeRecord, +} from "./runtime-plugin-boundary.js"; const MATRIX_PLUGIN_ID = "matrix"; @@ -24,70 +19,12 @@ let cachedModule: MatrixModule | null = null; const jitiLoaders = new Map>(); -function readConfigSafely() { - try { - return loadConfig(); - } catch { - return {}; - } -} - function resolveMatrixPluginRecord(): MatrixPluginRecord | null { - const manifestRegistry = loadPluginManifestRegistry({ - config: readConfigSafely(), - cache: true, - }); - const record = manifestRegistry.plugins.find((plugin) => plugin.id === MATRIX_PLUGIN_ID); - if (!record?.source) { - return null; - } - return { - rootDir: record.rootDir, - source: record.source, - }; + return resolvePluginRuntimeRecord(MATRIX_PLUGIN_ID) as MatrixPluginRecord | null; } function resolveMatrixRuntimeModulePath(record: MatrixPluginRecord): string | null { - const candidates = [ - path.join(path.dirname(record.source), "runtime-api.js"), - path.join(path.dirname(record.source), "runtime-api.ts"), - ...(record.rootDir - ? [path.join(record.rootDir, "runtime-api.js"), path.join(record.rootDir, "runtime-api.ts")] - : []), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - return null; -} - -function getJiti(modulePath: string) { - const tryNative = shouldPreferNativeJiti(modulePath); - const cached = jitiLoaders.get(tryNative); - if (cached) { - return cached; - } - const pluginSdkAlias = resolvePluginSdkAliasFile({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - modulePath, - }); - const aliasMap = { - ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap({ modulePath }), - }; - const loader = createJiti(import.meta.url, { - ...buildPluginLoaderJitiOptions(aliasMap), - tryNative, - }); - jitiLoaders.set(tryNative, loader); - return loader; -} - -function loadWithJiti(modulePath: string): TModule { - return getJiti(modulePath)(modulePath) as TModule; + return resolvePluginRuntimeModulePath(record, "runtime-api"); } function loadMatrixModule(): MatrixModule | null { @@ -102,7 +39,7 @@ function loadMatrixModule(): MatrixModule | null { if (cachedModule && cachedModulePath === modulePath) { return cachedModule; } - const loaded = loadWithJiti(modulePath); + const loaded = loadPluginBoundaryModuleWithJiti(modulePath, jitiLoaders); cachedModulePath = modulePath; cachedModule = loaded; return loaded; diff --git a/src/plugins/runtime/runtime-plugin-boundary.ts b/src/plugins/runtime/runtime-plugin-boundary.ts new file mode 100644 index 0000000000000..bdca0acd37e2a --- /dev/null +++ b/src/plugins/runtime/runtime-plugin-boundary.ts @@ -0,0 +1,106 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createJiti } from "jiti"; +import { loadConfig } from "../../config/config.js"; +import { loadPluginManifestRegistry } from "../manifest-registry.js"; +import { + buildPluginLoaderJitiOptions, + resolvePluginSdkAliasFile, + resolvePluginSdkScopedAliasMap, + shouldPreferNativeJiti, +} from "../sdk-alias.js"; + +type PluginRuntimeRecord = { + origin?: string; + rootDir?: string; + source: string; +}; + +export function readPluginBoundaryConfigSafely() { + try { + return loadConfig(); + } catch { + return {}; + } +} + +export function resolvePluginRuntimeRecord( + pluginId: string, + onMissing?: () => never, +): PluginRuntimeRecord | null { + const manifestRegistry = loadPluginManifestRegistry({ + config: readPluginBoundaryConfigSafely(), + cache: true, + }); + const record = manifestRegistry.plugins.find((plugin) => plugin.id === pluginId); + if (!record?.source) { + if (onMissing) { + onMissing(); + } + return null; + } + return { + ...(record.origin ? { origin: record.origin } : {}), + rootDir: record.rootDir, + source: record.source, + }; +} + +export function resolvePluginRuntimeModulePath( + record: Pick, + entryBaseName: string, + onMissing?: () => never, +): string | null { + const candidates = [ + path.join(path.dirname(record.source), `${entryBaseName}.js`), + path.join(path.dirname(record.source), `${entryBaseName}.ts`), + ...(record.rootDir + ? [ + path.join(record.rootDir, `${entryBaseName}.js`), + path.join(record.rootDir, `${entryBaseName}.ts`), + ] + : []), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + if (onMissing) { + onMissing(); + } + return null; +} + +export function getPluginBoundaryJiti( + modulePath: string, + loaders: Map>, +) { + const tryNative = shouldPreferNativeJiti(modulePath); + const cached = loaders.get(tryNative); + if (cached) { + return cached; + } + const pluginSdkAlias = resolvePluginSdkAliasFile({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + modulePath, + }); + const aliasMap = { + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...resolvePluginSdkScopedAliasMap({ modulePath }), + }; + const loader = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + tryNative, + }); + loaders.set(tryNative, loader); + return loader; +} + +export function loadPluginBoundaryModuleWithJiti( + modulePath: string, + loaders: Map>, +): TModule { + return getPluginBoundaryJiti(modulePath, loaders)(modulePath) as TModule; +} diff --git a/src/plugins/runtime/runtime-telegram-contract.ts b/src/plugins/runtime/runtime-telegram-contract.ts index 09e7f1ff1398f..42d1e95ced286 100644 --- a/src/plugins/runtime/runtime-telegram-contract.ts +++ b/src/plugins/runtime/runtime-telegram-contract.ts @@ -25,6 +25,7 @@ export type { TelegramInlineButtons, } from "../../../extensions/telegram/api.js"; export type { StickerMetadata } from "../../../extensions/telegram/api.js"; +export type { TelegramApiOverride } from "../../../extensions/telegram/runtime-api.js"; export { emptyPluginConfigSchema } from "../config-schema.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; diff --git a/src/plugins/runtime/runtime-whatsapp-boundary.ts b/src/plugins/runtime/runtime-whatsapp-boundary.ts index b44856b799af4..a1312677f4e48 100644 --- a/src/plugins/runtime/runtime-whatsapp-boundary.ts +++ b/src/plugins/runtime/runtime-whatsapp-boundary.ts @@ -1,21 +1,16 @@ -import fs from "node:fs"; -import path from "node:path"; import { createJiti } from "jiti"; import { resolveWhatsAppHeartbeatRecipients } from "../../channels/plugins/whatsapp-heartbeat.js"; -import { loadConfig } from "../../config/config.js"; import { getDefaultLocalRoots as getDefaultLocalRootsImpl, loadWebMedia as loadWebMediaImpl, loadWebMediaRaw as loadWebMediaRawImpl, optimizeImageToJpeg as optimizeImageToJpegImpl, } from "../../media/web-media.js"; -import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { - buildPluginLoaderJitiOptions, - resolvePluginSdkAliasFile, - resolvePluginSdkScopedAliasMap, - shouldPreferNativeJiti, -} from "../sdk-alias.js"; + loadPluginBoundaryModuleWithJiti, + resolvePluginRuntimeModulePath, + resolvePluginRuntimeRecord, +} from "./runtime-plugin-boundary.js"; const WHATSAPP_PLUGIN_ID = "whatsapp"; @@ -35,86 +30,34 @@ let cachedLightModule: WhatsAppLightModule | null = null; const jitiLoaders = new Map>(); -function readConfigSafely() { - try { - return loadConfig(); - } catch { - return {}; - } -} - function resolveWhatsAppPluginRecord(): WhatsAppPluginRecord { - const manifestRegistry = loadPluginManifestRegistry({ - config: readConfigSafely(), - cache: true, - }); - const record = manifestRegistry.plugins.find((plugin) => plugin.id === WHATSAPP_PLUGIN_ID); - if (!record?.source) { + return resolvePluginRuntimeRecord(WHATSAPP_PLUGIN_ID, () => { throw new Error( `WhatsApp plugin runtime is unavailable: missing plugin '${WHATSAPP_PLUGIN_ID}'`, ); - } - return { - origin: record.origin, - rootDir: record.rootDir, - source: record.source, - }; + }) as WhatsAppPluginRecord; } function resolveWhatsAppRuntimeModulePath( record: WhatsAppPluginRecord, entryBaseName: "light-runtime-api" | "runtime-api", ): string { - const candidates = [ - path.join(path.dirname(record.source), `${entryBaseName}.js`), - path.join(path.dirname(record.source), `${entryBaseName}.ts`), - ...(record.rootDir - ? [ - path.join(record.rootDir, `${entryBaseName}.js`), - path.join(record.rootDir, `${entryBaseName}.ts`), - ] - : []), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - throw new Error( - `WhatsApp plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WHATSAPP_PLUGIN_ID}'`, - ); -} - -function getJiti(modulePath: string) { - const tryNative = shouldPreferNativeJiti(modulePath); - const cached = jitiLoaders.get(tryNative); - if (cached) { - return cached; - } - const pluginSdkAlias = resolvePluginSdkAliasFile({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - modulePath: modulePath, - }); - const aliasMap = { - ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap({ modulePath }), - }; - const loader = createJiti(import.meta.url, { - ...buildPluginLoaderJitiOptions(aliasMap), - tryNative, + const modulePath = resolvePluginRuntimeModulePath(record, entryBaseName, () => { + throw new Error( + `WhatsApp plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WHATSAPP_PLUGIN_ID}'`, + ); }); - jitiLoaders.set(tryNative, loader); - return loader; -} - -function loadWithJiti(modulePath: string): TModule { - return getJiti(modulePath)(modulePath) as TModule; + if (!modulePath) { + throw new Error( + `WhatsApp plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WHATSAPP_PLUGIN_ID}'`, + ); + } + return modulePath; } function loadCurrentHeavyModuleSync(): WhatsAppHeavyModule { const modulePath = resolveWhatsAppRuntimeModulePath(resolveWhatsAppPluginRecord(), "runtime-api"); - return loadWithJiti(modulePath); + return loadPluginBoundaryModuleWithJiti(modulePath, jitiLoaders); } function loadWhatsAppLightModule(): WhatsAppLightModule { @@ -125,7 +68,7 @@ function loadWhatsAppLightModule(): WhatsAppLightModule { if (cachedLightModule && cachedLightModulePath === modulePath) { return cachedLightModule; } - const loaded = loadWithJiti(modulePath); + const loaded = loadPluginBoundaryModuleWithJiti(modulePath, jitiLoaders); cachedLightModulePath = modulePath; cachedLightModule = loaded; return loaded; @@ -137,7 +80,7 @@ async function loadWhatsAppHeavyModule(): Promise { if (cachedHeavyModule && cachedHeavyModulePath === modulePath) { return cachedHeavyModule; } - const loaded = loadWithJiti(modulePath); + const loaded = loadPluginBoundaryModuleWithJiti(modulePath, jitiLoaders); cachedHeavyModulePath = modulePath; cachedHeavyModule = loaded; return loaded; diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index adaff5e7c11fa..b6079013499f9 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -182,7 +182,7 @@ export type PluginRuntimeChannel = { token?: string; accountId?: string; verbose?: boolean; - api?: Partial; + api?: import("../../plugin-sdk/telegram.js").TelegramApiOverride; retry?: import("../../infra/retry.js").RetryConfig; cfg?: ReturnType; }, diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index b0e3681d0fb9a..8646e5842efd4 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -245,7 +245,12 @@ describe("plugin sdk alias helpers", () => { }, { name: "resolves plugin-sdk alias from package root when loader runs from transpiler cache path", - buildFixture: () => createPluginSdkAliasFixture(), + buildFixture: () => + createPluginSdkAliasFixture({ + packageExports: { + "./plugin-sdk/index": { default: "./dist/plugin-sdk/index.js" }, + }, + }), modulePath: () => "/tmp/tsx-cache/openclaw-loader.js", argv1: (root: string) => path.join(root, "openclaw.mjs"), srcFile: "index.ts", @@ -326,6 +331,9 @@ describe("plugin sdk alias helpers", () => { "./plugin-sdk/compat": { default: "./dist/plugin-sdk/compat.js" }, "./plugin-sdk/telegram": { default: "./dist/plugin-sdk/telegram.js" }, "./plugin-sdk/nested/value": { default: "./dist/plugin-sdk/nested/value.js" }, + "./plugin-sdk/..\\..\\evil": { default: "./dist/plugin-sdk/evil.js" }, + "./plugin-sdk/C:temp": { default: "./dist/plugin-sdk/drive.js" }, + "./plugin-sdk/.hidden": { default: "./dist/plugin-sdk/hidden.js" }, }, }); const subpaths = listPluginSdkExportedSubpaths({ @@ -461,6 +469,35 @@ describe("plugin sdk alias helpers", () => { ); }); + it("resolves plugin-sdk aliases for user-installed plugins via the running openclaw argv hint", () => { + const fixture = createPluginSdkAliasFixture({ + srcFile: "channel-runtime.ts", + distFile: "channel-runtime.js", + packageExports: { + "./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" }, + }, + }); + const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs"); + fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8"); + const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo"); + const externalPluginEntry = path.join(externalPluginRoot, "index.ts"); + mkdirSafe(externalPluginRoot); + fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8"); + + const aliases = withCwd(externalPluginRoot, () => + withEnv({ NODE_ENV: undefined }, () => + buildPluginLoaderAliasMap(externalPluginEntry, path.join(fixture.root, "openclaw.mjs")), + ), + ); + + expect(fs.realpathSync(aliases["openclaw/plugin-sdk"] ?? "")).toBe( + fs.realpathSync(sourceRootAlias), + ); + expect(fs.realpathSync(aliases["openclaw/plugin-sdk/channel-runtime"] ?? "")).toBe( + fs.realpathSync(path.join(fixture.root, "src", "plugin-sdk", "channel-runtime.ts")), + ); + }); + it("does not resolve plugin-sdk alias files from cwd fallback when package root is not an OpenClaw root", () => { const fixture = createPluginSdkAliasFixture({ srcFile: "channel-runtime.ts", diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index b8801dae6ed07..983eb94f3bf6f 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -17,6 +17,8 @@ type PluginSdkPackageJson = { bin?: string | Record; }; +const STARTUP_ARGV1 = process.argv[1]; + function resolveLoaderModulePath(params: LoaderModuleResolveParams = {}): string { return params.modulePath ?? fileURLToPath(params.moduleUrl ?? import.meta.url); } @@ -30,11 +32,15 @@ function readPluginSdkPackageJson(packageRoot: string): PluginSdkPackageJson | n } } +function isSafePluginSdkSubpathSegment(subpath: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath); +} + function listPluginSdkSubpathsFromPackageJson(pkg: PluginSdkPackageJson): string[] { return Object.keys(pkg.exports ?? {}) .filter((key) => key.startsWith("./plugin-sdk/")) .map((key) => key.slice("./plugin-sdk/".length)) - .filter((subpath) => Boolean(subpath) && !subpath.includes("/")) + .filter((subpath) => isSafePluginSdkSubpathSegment(subpath)) .toSorted(); } @@ -73,6 +79,27 @@ function readPluginSdkSubpathsFromPackageRoot(packageRoot: string): string[] | n return subpaths.length > 0 ? subpaths : null; } +function resolveTrustedOpenClawRootFromArgvHint(params: { + argv1?: string; + cwd: string; +}): string | null { + if (!params.argv1) { + return null; + } + const packageRoot = resolveOpenClawPackageRootSync({ + cwd: params.cwd, + argv1: params.argv1, + }); + if (!packageRoot) { + return null; + } + const packageJson = readPluginSdkPackageJson(packageRoot); + if (!packageJson) { + return null; + } + return hasTrustedOpenClawRootIndicator({ packageRoot, packageJson }) ? packageRoot : null; +} + function findNearestPluginSdkPackageRoot(startDir: string, maxDepth = 12): string | null { let cursor = path.resolve(startDir); for (let i = 0; i < maxDepth; i += 1) { @@ -112,13 +139,13 @@ function resolveLoaderPluginSdkPackageRoot( const cwd = params.cwd ?? path.dirname(params.modulePath); const fromCwd = resolveOpenClawPackageRootSync({ cwd }); const fromExplicitHints = - params.argv1 || params.moduleUrl + resolveTrustedOpenClawRootFromArgvHint({ cwd, argv1: params.argv1 }) ?? + (params.moduleUrl ? resolveOpenClawPackageRootSync({ cwd, - ...(params.argv1 ? { argv1: params.argv1 } : {}), - ...(params.moduleUrl ? { moduleUrl: params.moduleUrl } : {}), + moduleUrl: params.moduleUrl, }) - : null; + : null); return ( fromCwd ?? fromExplicitHints ?? @@ -207,9 +234,11 @@ export function resolvePluginSdkAliasFile(params: { const cachedPluginSdkExportedSubpaths = new Map(); const cachedPluginSdkScopedAliasMaps = new Map>(); -export function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { +export function listPluginSdkExportedSubpaths( + params: { modulePath?: string; argv1?: string } = {}, +): string[] { const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); - const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath }); + const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath, argv1: params.argv1 }); if (!packageRoot) { return []; } @@ -223,10 +252,10 @@ export function listPluginSdkExportedSubpaths(params: { modulePath?: string } = } export function resolvePluginSdkScopedAliasMap( - params: { modulePath?: string } = {}, + params: { modulePath?: string; argv1?: string } = {}, ): Record { const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); - const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath }); + const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath, argv1: params.argv1 }); if (!packageRoot) { return {}; } @@ -240,7 +269,7 @@ export function resolvePluginSdkScopedAliasMap( return cached; } const aliasMap: Record = {}; - for (const subpath of listPluginSdkExportedSubpaths({ modulePath })) { + for (const subpath of listPluginSdkExportedSubpaths({ modulePath, argv1: params.argv1 })) { const candidateMap = { src: path.join(packageRoot, "src", "plugin-sdk", `${subpath}.ts`), dist: path.join(packageRoot, "dist", "plugin-sdk", `${subpath}.js`), @@ -285,17 +314,21 @@ export function resolveExtensionApiAlias(params: LoaderModuleResolveParams = {}) return null; } -export function buildPluginLoaderAliasMap(modulePath: string): Record { +export function buildPluginLoaderAliasMap( + modulePath: string, + argv1: string | undefined = STARTUP_ARGV1, +): Record { const pluginSdkAlias = resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs", modulePath, + argv1, }); const extensionApiAlias = resolveExtensionApiAlias({ modulePath }); return { ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap({ modulePath }), + ...resolvePluginSdkScopedAliasMap({ modulePath, argv1 }), }; } diff --git a/src/plugins/services.ts b/src/plugins/services.ts index 470e9e886a601..f5dd6ace2cc15 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -2,9 +2,31 @@ import type { OpenClawConfig } from "../config/config.js"; import { STATE_DIR } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { PluginRegistry } from "./registry.js"; +import type { OpenClawPluginServiceContext, PluginLogger } from "./types.js"; const log = createSubsystemLogger("plugins"); +function createPluginLogger(): PluginLogger { + return { + info: (msg) => log.info(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + debug: (msg) => log.debug(msg), + }; +} + +function createServiceContext(params: { + config: OpenClawConfig; + workspaceDir?: string; +}): OpenClawPluginServiceContext { + return { + config: params.config, + workspaceDir: params.workspaceDir, + stateDir: STATE_DIR, + logger: createPluginLogger(), + }; +} + export type PluginServicesHandle = { stop: () => Promise; }; @@ -22,33 +44,14 @@ export async function startPluginServices(params: { for (const entry of params.registry.services) { const service = entry.service; try { - await service.start({ + const context = createServiceContext({ config: params.config, workspaceDir: params.workspaceDir, - stateDir: STATE_DIR, - logger: { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - error: (msg) => log.error(msg), - debug: (msg) => log.debug(msg), - }, }); + await service.start(context); running.push({ id: service.id, - stop: service.stop - ? () => - service.stop?.({ - config: params.config, - workspaceDir: params.workspaceDir, - stateDir: STATE_DIR, - logger: { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - error: (msg) => log.error(msg), - debug: (msg) => log.debug(msg), - }, - }) - : undefined, + stop: service.stop ? () => service.stop?.(context) : undefined, }); } catch (err) { const error = err as Error; diff --git a/src/plugins/setup-binary.ts b/src/plugins/setup-binary.ts index c1e534c2944e5..794d93f1448e1 100644 --- a/src/plugins/setup-binary.ts +++ b/src/plugins/setup-binary.ts @@ -1,36 +1 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { isSafeExecutableValue } from "../infra/exec-safety.js"; -import { runCommandWithTimeout } from "../process/exec.js"; -import { resolveUserPath } from "../utils.js"; - -export async function detectBinary(name: string): Promise { - if (!name?.trim()) { - return false; - } - if (!isSafeExecutableValue(name)) { - return false; - } - const resolved = name.startsWith("~") ? resolveUserPath(name) : name; - if ( - path.isAbsolute(resolved) || - resolved.startsWith(".") || - resolved.includes("/") || - resolved.includes("\\") - ) { - try { - await fs.access(resolved); - return true; - } catch { - return false; - } - } - - const command = process.platform === "win32" ? ["where", name] : ["/usr/bin/env", "which", name]; - try { - const result = await runCommandWithTimeout(command, { timeoutMs: 2000 }); - return result.code === 0 && result.stdout.trim().length > 0; - } catch { - return false; - } -} +export { detectBinary } from "../infra/detect-binary.js"; diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 7bdb986e03046..e3aecd629d4cb 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs"; import { discoverOpenClawPlugins } from "./discovery.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -77,6 +77,35 @@ describe("stageBundledPluginRuntime", () => { expect(runtimeModule.value).toBe(1); }); + it("stages root runtime sidecars that bundled plugin boundaries resolve directly", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-sidecars-"); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "whatsapp"); + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(distPluginDir, "light-runtime-api.js"), + "export const light = true;\n", + "utf8", + ); + fs.writeFileSync( + path.join(distPluginDir, "runtime-api.js"), + "export const heavy = true;\n", + "utf8", + ); + + stageBundledPluginRuntime({ repoRoot }); + + const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", "whatsapp"); + expect(fs.existsSync(path.join(runtimePluginDir, "light-runtime-api.js"))).toBe(true); + expect(fs.existsSync(path.join(runtimePluginDir, "runtime-api.js"))).toBe(true); + expect(fs.readFileSync(path.join(runtimePluginDir, "light-runtime-api.js"), "utf8")).toContain( + "../../../dist/extensions/whatsapp/light-runtime-api.js", + ); + expect(fs.readFileSync(path.join(runtimePluginDir, "runtime-api.js"), "utf8")).toContain( + "../../../dist/extensions/whatsapp/runtime-api.js", + ); + }); + it("keeps plugin command registration on the canonical dist graph when loaded from dist-runtime", async () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-commands-"); const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo"); @@ -329,4 +358,40 @@ describe("stageBundledPluginRuntime", () => { expect(fs.existsSync(path.join(repoRoot, "dist-runtime"))).toBe(false); }); + + it("tolerates EEXIST when an identical runtime symlink is materialized concurrently", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-eexist-"); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "feishu"); + const distSkillDir = path.join(distPluginDir, "skills", "feishu-doc"); + fs.mkdirSync(distSkillDir, { recursive: true }); + fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8"); + fs.writeFileSync(path.join(distSkillDir, "SKILL.md"), "# Feishu Doc\n", "utf8"); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = vi.spyOn(fs, "symlinkSync").mockImplementation(((target, link, type) => { + const linkPath = String(link); + if (linkPath.endsWith(path.join("skills", "feishu-doc", "SKILL.md"))) { + const err = Object.assign(new Error("file already exists"), { code: "EEXIST" }); + realSymlinkSync(String(target), linkPath, type); + throw err; + } + return realSymlinkSync(String(target), linkPath, type); + }) as typeof fs.symlinkSync); + + expect(() => stageBundledPluginRuntime({ repoRoot })).not.toThrow(); + + const runtimeSkillPath = path.join( + repoRoot, + "dist-runtime", + "extensions", + "feishu", + "skills", + "feishu-doc", + "SKILL.md", + ); + expect(fs.lstatSync(runtimeSkillPath).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(runtimeSkillPath, "utf8")).toBe("# Feishu Doc\n"); + + symlinkSpy.mockRestore(); + }); }); diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts new file mode 100644 index 0000000000000..0704e334fc54c --- /dev/null +++ b/src/plugins/status.test-helpers.ts @@ -0,0 +1,135 @@ +import type { PluginLoadResult } from "./loader.js"; +import type { PluginRecord } from "./registry.js"; +import type { PluginCompatibilityNotice, PluginStatusReport } from "./status.js"; +import type { PluginHookName } from "./types.js"; + +export const LEGACY_BEFORE_AGENT_START_MESSAGE = + "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work."; +export const HOOK_ONLY_MESSAGE = + "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet."; + +export function createCompatibilityNotice( + params: Pick, +): PluginCompatibilityNotice { + if (params.code === "legacy-before-agent-start") { + return { + pluginId: params.pluginId, + code: params.code, + severity: "warn", + message: LEGACY_BEFORE_AGENT_START_MESSAGE, + }; + } + + return { + pluginId: params.pluginId, + code: params.code, + severity: "info", + message: HOOK_ONLY_MESSAGE, + }; +} + +export function createPluginRecord( + overrides: Partial & Pick, +): PluginRecord { + const { id, ...rest } = overrides; + return { + id, + name: overrides.name ?? id, + description: overrides.description ?? "", + source: overrides.source ?? `/tmp/${id}/index.ts`, + origin: overrides.origin ?? "workspace", + enabled: overrides.enabled ?? true, + status: overrides.status ?? "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + ...rest, + }; +} + +export function createTypedHook(params: { + pluginId: string; + hookName: PluginHookName; + source?: string; +}): PluginLoadResult["typedHooks"][number] { + return { + pluginId: params.pluginId, + hookName: params.hookName, + handler: () => undefined, + source: params.source ?? `/tmp/${params.pluginId}/index.ts`, + }; +} + +export function createCustomHook(params: { + pluginId: string; + events: string[]; + name?: string; +}): PluginLoadResult["hooks"][number] { + const source = `/tmp/${params.pluginId}/handler.ts`; + return { + pluginId: params.pluginId, + events: params.events, + source, + entry: { + hook: { + name: params.name ?? "legacy", + description: "", + source: "openclaw-plugin", + pluginId: params.pluginId, + filePath: `/tmp/${params.pluginId}/HOOK.md`, + baseDir: `/tmp/${params.pluginId}`, + handlerPath: source, + }, + frontmatter: {}, + }, + }; +} + +export function createPluginLoadResult( + overrides: Partial & Pick = { plugins: [] }, +): PluginLoadResult { + const { plugins, ...rest } = overrides; + return { + plugins, + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + ...rest, + }; +} + +export function createPluginStatusReport( + overrides: Partial & Pick, +): PluginStatusReport { + const { workspaceDir, ...loadResultOverrides } = overrides; + return { + workspaceDir, + ...createPluginLoadResult(loadResultOverrides), + }; +} diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index cc1b35a13617b..0a9c599cc3e24 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -1,4 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createCompatibilityNotice, + createCustomHook, + createPluginLoadResult, + createPluginRecord, + createTypedHook, + HOOK_ONLY_MESSAGE, + LEGACY_BEFORE_AGENT_START_MESSAGE, +} from "./status.test-helpers.js"; const loadConfigMock = vi.fn(); const loadOpenClawPluginsMock = vi.fn(); @@ -27,31 +36,22 @@ vi.mock("../agents/workspace.js", () => ({ resolveDefaultAgentWorkspaceDir: () => "/default-workspace", })); +function setPluginLoadResult(overrides: Partial>) { + loadOpenClawPluginsMock.mockReturnValue( + createPluginLoadResult({ + plugins: [], + ...overrides, + }), + ); +} + describe("buildPluginStatusReport", () => { beforeEach(async () => { vi.resetModules(); loadConfigMock.mockReset(); loadOpenClawPluginsMock.mockReset(); loadConfigMock.mockReturnValue({}); - loadOpenClawPluginsMock.mockReturnValue({ - plugins: [], - diagnostics: [], - channels: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [], - channelSetups: [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], - }); + setPluginLoadResult({ plugins: [] }); ({ buildAllPluginInspectReports, buildPluginCompatibilityNotices, @@ -81,6 +81,30 @@ describe("buildPluginStatusReport", () => { ); }); + it("normalizes bundled plugin versions to the core base release", () => { + setPluginLoadResult({ + plugins: [ + createPluginRecord({ + id: "whatsapp", + name: "WhatsApp", + description: "Bundled channel plugin", + version: "2026.3.22", + origin: "bundled", + channelIds: ["whatsapp"], + }), + ], + }); + + const report = buildPluginStatusReport({ + config: {}, + env: { + OPENCLAW_VERSION: "2026.3.23-1", + } as NodeJS.ProcessEnv, + }); + + expect(report.plugins[0]?.version).toBe("2026.3.23"); + }); + it("builds an inspect report with capability shape and policy", () => { loadConfigMock.mockReturnValue({ plugins: { @@ -95,56 +119,21 @@ describe("buildPluginStatusReport", () => { }, }, }); - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "google", name: "Google", description: "Google provider plugin", - source: "/tmp/google/index.ts", origin: "bundled", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["google"], - speechProviderIds: [], mediaUnderstandingProviderIds: ["google"], imageGenerationProviderIds: ["google"], webSearchProviderIds: ["google"], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], diagnostics: [{ level: "warn", pluginId: "google", message: "watch this surface" }], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [ - { - pluginId: "google", - hookName: "before_agent_start", - handler: () => undefined, - source: "/tmp/google/index.ts", - }, - ], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], + typedHooks: [createTypedHook({ pluginId: "google", hookName: "before_agent_start" })], }); const inspect = buildPluginInspectReport({ id: "google" }); @@ -160,13 +149,7 @@ describe("buildPluginStatusReport", () => { ]); expect(inspect?.usesLegacyBeforeAgentStart).toBe(true); expect(inspect?.compatibility).toEqual([ - { - pluginId: "google", - code: "legacy-before-agent-start", - severity: "warn", - message: - "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - }, + createCompatibilityNotice({ pluginId: "google", code: "legacy-before-agent-start" }), ]); expect(inspect?.policy).toEqual({ allowPromptInjection: false, @@ -180,91 +163,25 @@ describe("buildPluginStatusReport", () => { }); it("builds inspect reports for every loaded plugin", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "lca", name: "LCA", description: "Legacy hook plugin", - source: "/tmp/lca/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, hookCount: 1, - configSchema: false, - }, - { + }), + createPluginRecord({ id: "microsoft", name: "Microsoft", description: "Hybrid capability plugin", - source: "/tmp/microsoft/index.ts", origin: "bundled", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["microsoft"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], webSearchProviderIds: ["microsoft"], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, - ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [ - { - pluginId: "lca", - events: ["message"], - entry: { - hook: { - name: "legacy", - handler: () => undefined, - }, - }, - }, - ], - typedHooks: [ - { - pluginId: "lca", - hookName: "before_agent_start", - handler: () => undefined, - source: "/tmp/lca/index.ts", - }, + }), ], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], + hooks: [createCustomHook({ pluginId: "lca", events: ["message"] })], + typedHooks: [createTypedHook({ pluginId: "lca", hookName: "before_agent_start" })], }); const inspect = buildAllPluginInspectReports(); @@ -279,214 +196,58 @@ describe("buildPluginStatusReport", () => { }); it("builds compatibility warnings for legacy compatibility paths", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "lca", name: "LCA", description: "Legacy hook plugin", - source: "/tmp/lca/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, hookCount: 1, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [ - { - pluginId: "lca", - hookName: "before_agent_start", - handler: () => undefined, - source: "/tmp/lca/index.ts", - }, - ], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], + typedHooks: [createTypedHook({ pluginId: "lca", hookName: "before_agent_start" })], }); expect(buildPluginCompatibilityWarnings()).toEqual([ - "lca still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - "lca is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", + `lca ${LEGACY_BEFORE_AGENT_START_MESSAGE}`, + `lca ${HOOK_ONLY_MESSAGE}`, ]); }); it("builds structured compatibility notices with deterministic ordering", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "hook-only", name: "Hook Only", - description: "", - source: "/tmp/hook-only/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, hookCount: 1, - configSchema: false, - }, - { + }), + createPluginRecord({ id: "legacy-only", name: "Legacy Only", - description: "", - source: "/tmp/legacy-only/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["legacy-only"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, hookCount: 1, - configSchema: false, - }, - ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [ - { - pluginId: "hook-only", - events: ["message"], - entry: { - hook: { - name: "legacy", - handler: () => undefined, - }, - }, - }, - ], - typedHooks: [ - { - pluginId: "legacy-only", - hookName: "before_agent_start", - handler: () => undefined, - source: "/tmp/legacy-only/index.ts", - }, + }), ], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], + hooks: [createCustomHook({ pluginId: "hook-only", events: ["message"] })], + typedHooks: [createTypedHook({ pluginId: "legacy-only", hookName: "before_agent_start" })], }); expect(buildPluginCompatibilityNotices()).toEqual([ - { - pluginId: "hook-only", - code: "hook-only", - severity: "info", - message: - "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", - }, - { - pluginId: "legacy-only", - code: "legacy-before-agent-start", - severity: "warn", - message: - "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - }, + createCompatibilityNotice({ pluginId: "hook-only", code: "hook-only" }), + createCompatibilityNotice({ pluginId: "legacy-only", code: "legacy-before-agent-start" }), ]); }); it("returns no compatibility warnings for modern capability plugins", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "modern", name: "Modern", - description: "", - source: "/tmp/modern/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["modern"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], }); expect(buildPluginCompatibilityNotices()).toEqual([]); @@ -494,53 +255,19 @@ describe("buildPluginStatusReport", () => { }); it("populates bundleCapabilities from plugin record", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "claude-bundle", name: "Claude Bundle", description: "A bundle plugin with skills and commands", source: "/tmp/claude-bundle/.claude-plugin/plugin.json", - origin: "workspace", - enabled: true, - status: "loaded", format: "bundle", bundleFormat: "claude", bundleCapabilities: ["skills", "commands", "agents", "settings"], rootDir: "/tmp/claude-bundle", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], }); const inspect = buildPluginInspectReport({ id: "claude-bundle" }); @@ -552,49 +279,15 @@ describe("buildPluginStatusReport", () => { }); it("returns empty bundleCapabilities and mcpServers for non-bundle plugins", () => { - loadOpenClawPluginsMock.mockReturnValue({ + setPluginLoadResult({ plugins: [ - { + createPluginRecord({ id: "plain-plugin", name: "Plain Plugin", description: "A regular plugin", - source: "/tmp/plain-plugin/index.ts", - origin: "workspace", - enabled: true, - status: "loaded", - toolNames: [], - hookNames: [], - channelIds: [], providerIds: ["plain"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }, + }), ], - diagnostics: [], - channels: [], - channelSetups: [], - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - tools: [], - hooks: [], - typedHooks: [], - httpRoutes: [], - gatewayHandlers: {}, - cliRegistrars: [], - services: [], - commands: [], }); const inspect = buildPluginInspectReport({ id: "plain-plugin" }); @@ -605,27 +298,18 @@ describe("buildPluginStatusReport", () => { }); it("formats and summarizes compatibility notices", () => { - const notice = { + const notice = createCompatibilityNotice({ pluginId: "legacy-plugin", - code: "legacy-before-agent-start" as const, - severity: "warn" as const, - message: - "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", - }; + code: "legacy-before-agent-start", + }); expect(formatPluginCompatibilityNotice(notice)).toBe( - "legacy-plugin still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", + `legacy-plugin ${LEGACY_BEFORE_AGENT_START_MESSAGE}`, ); expect( summarizePluginCompatibility([ notice, - { - pluginId: "legacy-plugin", - code: "hook-only", - severity: "info", - message: - "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", - }, + createCompatibilityNotice({ pluginId: "legacy-plugin", code: "hook-only" }), ]), ).toEqual({ noticeCount: 2, diff --git a/src/plugins/status.ts b/src/plugins/status.ts index a6b215415228d..7ad0bcd834707 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -1,7 +1,9 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { loadConfig } from "../config/config.js"; +import { normalizeOpenClawVersionBase } from "../config/version.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveRuntimeServiceVersion } from "../version.js"; import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { normalizePluginsConfig } from "./config-state.js"; @@ -114,6 +116,20 @@ function buildCompatibilityNoticesForInspect( const log = createSubsystemLogger("plugins"); +function resolveReportedPluginVersion( + plugin: PluginRegistry["plugins"][number], + env: NodeJS.ProcessEnv | undefined, +): string | undefined { + if (plugin.origin !== "bundled") { + return plugin.version; + } + return ( + normalizeOpenClawVersionBase(resolveRuntimeServiceVersion(env)) ?? + normalizeOpenClawVersionBase(plugin.version) ?? + plugin.version + ); +} + export function buildPluginStatusReport(params?: { config?: ReturnType; workspaceDir?: string; @@ -136,6 +152,10 @@ export function buildPluginStatusReport(params?: { return { workspaceDir, ...registry, + plugins: registry.plugins.map((plugin) => ({ + ...plugin, + version: resolveReportedPluginVersion(plugin, params?.env), + })), }; } diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 9a1142a830652..06a2b9446e2ce 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -4,6 +4,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js"; import { loadOpenClawPlugins } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; +import { getActivePluginRegistry, getActivePluginRegistryKey } from "./runtime.js"; import type { OpenClawPluginToolContext } from "./types.js"; const log = createSubsystemLogger("plugins"); @@ -19,6 +20,13 @@ export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefine return pluginToolMeta.get(tool); } +export function copyPluginToolMeta(source: AnyAgentTool, target: AnyAgentTool): void { + const meta = pluginToolMeta.get(source); + if (meta) { + pluginToolMeta.set(target, meta); + } +} + function normalizeAllowlist(list?: string[]) { return new Set((list ?? []).map(normalizeToolName).filter(Boolean)); } @@ -59,17 +67,21 @@ export function resolvePluginTools(params: { return []; } - const registry = loadOpenClawPlugins({ - config: effectiveConfig, - workspaceDir: params.context.workspaceDir, - runtimeOptions: params.allowGatewaySubagentBinding - ? { - allowGatewaySubagentBinding: true, - } - : undefined, - env, - logger: createPluginLoaderLogger(log), - }); + const activeRegistry = getActivePluginRegistry(); + const registry = + getActivePluginRegistryKey() && activeRegistry + ? activeRegistry + : loadOpenClawPlugins({ + config: effectiveConfig, + workspaceDir: params.context.workspaceDir, + runtimeOptions: params.allowGatewaySubagentBinding + ? { + allowGatewaySubagentBinding: true, + } + : undefined, + env, + logger: createPluginLoaderLogger(log), + }); const tools: AnyAgentTool[] = []; const existing = params.existingToolNames ?? new Set(); @@ -104,6 +116,11 @@ export function resolvePluginTools(params: { continue; } if (!resolved) { + if (entry.names.length > 0) { + log.debug( + `plugin tool factory returned null (${entry.pluginId}): [${entry.names.join(", ")}]`, + ); + } continue; } const listRaw = Array.isArray(resolved) ? resolved : [resolved]; diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index fc2c5fe045b71..46b5a0c9fedca 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -1,10 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createEmptyPluginRegistry } from "./registry.js"; -import { setActivePluginRegistry } from "./runtime.js"; -import { - resolvePluginWebSearchProviders, - resolveRuntimeWebSearchProviders, -} from "./web-search-providers.runtime.js"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +type RegistryModule = typeof import("./registry.js"); +type RuntimeModule = typeof import("./runtime.js"); +type WebSearchProvidersRuntimeModule = typeof import("./web-search-providers.runtime.js"); +type ManifestRegistryModule = typeof import("./manifest-registry.js"); const BUNDLED_WEB_SEARCH_PROVIDERS = [ { pluginId: "brave", id: "brave", order: 10 }, @@ -15,87 +14,161 @@ const BUNDLED_WEB_SEARCH_PROVIDERS = [ { pluginId: "firecrawl", id: "firecrawl", order: 60 }, { pluginId: "exa", id: "exa", order: 65 }, { pluginId: "tavily", id: "tavily", order: 70 }, + { pluginId: "duckduckgo", id: "duckduckgo", order: 100 }, ] as const; -const { loadOpenClawPluginsMock } = vi.hoisted(() => ({ - loadOpenClawPluginsMock: vi.fn((params?: { config?: { plugins?: Record } }) => { - const plugins = params?.config?.plugins as - | { - enabled?: boolean; - allow?: string[]; - entries?: Record; - } - | undefined; - if (plugins?.enabled === false) { - return { webSearchProviders: [] }; - } - const allow = Array.isArray(plugins?.allow) && plugins.allow.length > 0 ? plugins.allow : null; - const entries = plugins?.entries ?? {}; - const webSearchProviders = BUNDLED_WEB_SEARCH_PROVIDERS.filter((provider) => { - if (allow && !allow.includes(provider.pluginId)) { - return false; - } - if (entries[provider.pluginId]?.enabled === false) { - return false; +let createEmptyPluginRegistry: RegistryModule["createEmptyPluginRegistry"]; +let loadPluginManifestRegistryMock: ReturnType; +let setActivePluginRegistry: RuntimeModule["setActivePluginRegistry"]; +let resolvePluginWebSearchProviders: WebSearchProvidersRuntimeModule["resolvePluginWebSearchProviders"]; +let resolveRuntimeWebSearchProviders: WebSearchProvidersRuntimeModule["resolveRuntimeWebSearchProviders"]; +let resetWebSearchProviderSnapshotCacheForTests: WebSearchProvidersRuntimeModule["__testing"]["resetWebSearchProviderSnapshotCacheForTests"]; +let loadOpenClawPluginsMock: ReturnType; +let loaderModule: typeof import("./loader.js"); +let manifestRegistryModule: ManifestRegistryModule; + +function buildMockedWebSearchProviders(params?: { + config?: { plugins?: Record }; +}) { + const plugins = params?.config?.plugins as + | { + enabled?: boolean; + allow?: string[]; + entries?: Record; } - return true; - }).map((provider) => ({ - pluginId: provider.pluginId, - pluginName: provider.pluginId, - source: "test" as const, - provider: { - id: provider.id, - label: provider.id, - hint: `${provider.id} provider`, - envVars: [`${provider.id.toUpperCase()}_API_KEY`], - placeholder: `${provider.id}-...`, - signupUrl: `https://example.com/${provider.id}`, - autoDetectOrder: provider.order, - credentialPath: `plugins.entries.${provider.pluginId}.config.webSearch.apiKey`, - getCredentialValue: () => "configured", - setCredentialValue: () => {}, - createTool: () => ({ - description: provider.id, - parameters: {}, - execute: async () => ({}), - }), - }, - })); - return { webSearchProviders }; - }), -})); - -vi.mock("./loader.js", () => ({ - loadOpenClawPlugins: loadOpenClawPluginsMock, -})); + | undefined; + if (plugins?.enabled === false) { + return []; + } + const allow = Array.isArray(plugins?.allow) && plugins.allow.length > 0 ? plugins.allow : null; + const entries = plugins?.entries ?? {}; + const webSearchProviders = BUNDLED_WEB_SEARCH_PROVIDERS.filter((provider) => { + if (allow && !allow.includes(provider.pluginId)) { + return false; + } + if (entries[provider.pluginId]?.enabled === false) { + return false; + } + return true; + }).map((provider) => ({ + pluginId: provider.pluginId, + pluginName: provider.pluginId, + source: "test" as const, + provider: { + id: provider.id, + label: provider.id, + hint: `${provider.id} provider`, + envVars: [`${provider.id.toUpperCase()}_API_KEY`], + placeholder: `${provider.id}-...`, + signupUrl: `https://example.com/${provider.id}`, + autoDetectOrder: provider.order, + credentialPath: `plugins.entries.${provider.pluginId}.config.webSearch.apiKey`, + getCredentialValue: () => "configured", + setCredentialValue: () => {}, + createTool: () => ({ + description: provider.id, + parameters: {}, + execute: async () => ({}), + }), + }, + })); + return webSearchProviders; +} describe("resolvePluginWebSearchProviders", () => { + beforeAll(async () => { + ({ createEmptyPluginRegistry } = await import("./registry.js")); + manifestRegistryModule = await import("./manifest-registry.js"); + loaderModule = await import("./loader.js"); + ({ setActivePluginRegistry } = await import("./runtime.js")); + ({ + resolvePluginWebSearchProviders, + resolveRuntimeWebSearchProviders, + __testing: { resetWebSearchProviderSnapshotCacheForTests }, + } = await import("./web-search-providers.runtime.js")); + }); + beforeEach(() => { - loadOpenClawPluginsMock.mockClear(); + resetWebSearchProviderSnapshotCacheForTests(); + loadPluginManifestRegistryMock = vi + .spyOn(manifestRegistryModule, "loadPluginManifestRegistry") + .mockReturnValue({ + plugins: [ + { + id: "brave", + origin: "bundled", + rootDir: "/tmp/brave", + source: "/tmp/brave/index.js", + manifestPath: "/tmp/brave/openclaw.plugin.json", + channels: [], + providers: [], + skills: [], + hooks: [], + configUiHints: { "webSearch.apiKey": { label: "key" } }, + }, + { + id: "noise", + origin: "bundled", + rootDir: "/tmp/noise", + source: "/tmp/noise/index.js", + manifestPath: "/tmp/noise/openclaw.plugin.json", + channels: [], + providers: [], + skills: [], + hooks: [], + configUiHints: { unrelated: { label: "nope" } }, + }, + ], + diagnostics: [], + } as ManifestRegistryModule["loadPluginManifestRegistry"] extends ( + ...args: unknown[] + ) => infer R + ? R + : never); + loadOpenClawPluginsMock = vi + .spyOn(loaderModule, "loadOpenClawPlugins") + .mockImplementation((params) => { + const registry = createEmptyPluginRegistry(); + registry.webSearchProviders = buildMockedWebSearchProviders(params); + return registry; + }); setActivePluginRegistry(createEmptyPluginRegistry()); vi.useRealTimers(); }); afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); + vi.restoreAllMocks(); }); - it("loads bundled providers through the plugin loader in auto-detect order", () => { + it("loads bundled providers through the plugin loader in alphabetical order", () => { const providers = resolvePluginWebSearchProviders({}); expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ "brave:brave", + "duckduckgo:duckduckgo", + "exa:exa", + "firecrawl:firecrawl", "google:gemini", "xai:grok", "moonshot:kimi", "perplexity:perplexity", - "firecrawl:firecrawl", - "exa:exa", "tavily:tavily", ]); expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); }); + it("scopes plugin loading to manifest-declared web-search candidates", () => { + resolvePluginWebSearchProviders({}); + + expect(loadPluginManifestRegistryMock).toHaveBeenCalled(); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["brave"], + }), + ); + }); + it("memoizes snapshot provider resolution for the same config and env", () => { const config = { plugins: { diff --git a/src/plugins/web-search-providers.runtime.ts b/src/plugins/web-search-providers.runtime.ts index 8741299d2c2f2..631437f008d97 100644 --- a/src/plugins/web-search-providers.runtime.ts +++ b/src/plugins/web-search-providers.runtime.ts @@ -1,8 +1,15 @@ import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { isRecord } from "../utils.js"; +import { + buildPluginSnapshotCacheEnvKey, + resolvePluginSnapshotCacheTtlMs, + shouldUsePluginSnapshotCache, +} from "./cache-controls.js"; import { loadOpenClawPlugins } from "./loader.js"; import type { PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; +import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import { getActivePluginRegistry } from "./runtime.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; import { @@ -15,59 +22,21 @@ type WebSearchProviderSnapshotCacheEntry = { expiresAt: number; providers: PluginWebSearchProviderEntry[]; }; -const webSearchProviderSnapshotCache = new WeakMap< +let webSearchProviderSnapshotCache = new WeakMap< OpenClawConfig, WeakMap> >(); -const DEFAULT_DISCOVERY_CACHE_MS = 1000; -const DEFAULT_MANIFEST_CACHE_MS = 1000; - -function shouldUseWebSearchProviderSnapshotCache(env: NodeJS.ProcessEnv): boolean { - if (env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim()) { - return false; - } - if (env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim()) { - return false; - } - const discoveryCacheMs = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); - if (discoveryCacheMs === "0") { - return false; - } - const manifestCacheMs = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); - if (manifestCacheMs === "0") { - return false; - } - return true; -} - -function resolveWebSearchProviderSnapshotCacheTtlMs(env: NodeJS.ProcessEnv): number { - const discoveryCacheMs = resolveCacheMs( - env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, - DEFAULT_DISCOVERY_CACHE_MS, - ); - const manifestCacheMs = resolveCacheMs( - env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, - DEFAULT_MANIFEST_CACHE_MS, - ); - return Math.min(discoveryCacheMs, manifestCacheMs); -} - -function resolveCacheMs(rawValue: string | undefined, defaultMs: number): number { - const raw = rawValue?.trim(); - if (raw === "" || raw === "0") { - return 0; - } - if (!raw) { - return defaultMs; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) { - return defaultMs; - } - return Math.max(0, parsed); +function resetWebSearchProviderSnapshotCacheForTests() { + webSearchProviderSnapshotCache = new WeakMap< + OpenClawConfig, + WeakMap> + >(); } +export const __testing = { + resetWebSearchProviderSnapshotCacheForTests, +} as const; function buildWebSearchSnapshotCacheKey(params: { config?: OpenClawConfig; workspaceDir?: string; @@ -79,24 +48,41 @@ function buildWebSearchSnapshotCacheKey(params: { workspaceDir: params.workspaceDir ?? "", bundledAllowlistCompat: params.bundledAllowlistCompat === true, config: params.config ?? null, - env: { - OPENCLAW_BUNDLED_PLUGINS_DIR: params.env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: - params.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: - params.env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE ?? "", - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: params.env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS ?? "", - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: params.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ?? "", - OPENCLAW_HOME: params.env.OPENCLAW_HOME ?? "", - OPENCLAW_STATE_DIR: params.env.OPENCLAW_STATE_DIR ?? "", - OPENCLAW_CONFIG_PATH: params.env.OPENCLAW_CONFIG_PATH ?? "", - HOME: params.env.HOME ?? "", - USERPROFILE: params.env.USERPROFILE ?? "", - VITEST: effectiveVitest, - }, + env: buildPluginSnapshotCacheEnvKey(params.env, { + includeProcessVitestFallback: effectiveVitest !== (params.env.VITEST ?? ""), + }), }); } +function pluginManifestDeclaresWebSearch(record: PluginManifestRecord): boolean { + const configUiHintKeys = Object.keys(record.configUiHints ?? {}); + if (configUiHintKeys.some((key) => key === "webSearch" || key.startsWith("webSearch."))) { + return true; + } + if (!isRecord(record.configSchema)) { + return false; + } + const properties = record.configSchema.properties; + return isRecord(properties) && "webSearch" in properties; +} + +function resolveWebSearchCandidatePluginIds(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] | undefined { + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const ids = registry.plugins + .filter(pluginManifestDeclaresWebSearch) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); + return ids.length > 0 ? ids : undefined; +} + export function resolvePluginWebSearchProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -108,9 +94,7 @@ export function resolvePluginWebSearchProviders(params: { const env = params.env ?? process.env; const cacheOwnerConfig = params.config; const shouldMemoizeSnapshot = - params.activate !== true && - params.cache !== true && - shouldUseWebSearchProviderSnapshotCache(env); + params.activate !== true && params.cache !== true && shouldUsePluginSnapshotCache(env); const cacheKey = buildWebSearchSnapshotCacheKey({ config: cacheOwnerConfig, workspaceDir: params.workspaceDir, @@ -129,12 +113,18 @@ export function resolvePluginWebSearchProviders(params: { ...params, env, }); + const onlyPluginIds = resolveWebSearchCandidatePluginIds({ + config, + workspaceDir: params.workspaceDir, + env, + }); const registry = loadOpenClawPlugins({ config, workspaceDir: params.workspaceDir, env, cache: params.cache ?? false, activate: params.activate ?? false, + ...(onlyPluginIds ? { onlyPluginIds } : {}), logger: createPluginLoaderLogger(log), }); @@ -145,7 +135,7 @@ export function resolvePluginWebSearchProviders(params: { })), ); if (cacheOwnerConfig && shouldMemoizeSnapshot) { - const ttlMs = resolveWebSearchProviderSnapshotCacheTtlMs(env); + const ttlMs = resolvePluginSnapshotCacheTtlMs(env); let configCache = webSearchProviderSnapshotCache.get(cacheOwnerConfig); if (!configCache) { configCache = new WeakMap< diff --git a/src/plugins/web-search-providers.shared.ts b/src/plugins/web-search-providers.shared.ts index 31a90f509156c..9b4640022f4da 100644 --- a/src/plugins/web-search-providers.shared.ts +++ b/src/plugins/web-search-providers.shared.ts @@ -52,16 +52,29 @@ function withBundledWebSearchVitestCompat(params: { }; } +function compareWebSearchProvidersAlphabetically( + left: Pick, + right: Pick, +): number { + return left.id.localeCompare(right.id) || left.pluginId.localeCompare(right.pluginId); +} + export function sortWebSearchProviders( providers: PluginWebSearchProviderEntry[], ): PluginWebSearchProviderEntry[] { - return providers.toSorted((a, b) => { - const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - if (aOrder !== bOrder) { - return aOrder - bOrder; + return providers.toSorted(compareWebSearchProvidersAlphabetically); +} + +export function sortWebSearchProvidersForAutoDetect( + providers: PluginWebSearchProviderEntry[], +): PluginWebSearchProviderEntry[] { + return providers.toSorted((left, right) => { + const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; } - return a.id.localeCompare(b.id); + return compareWebSearchProvidersAlphabetically(left, right); }); } diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index aab2010010026..419da161305f2 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -2,32 +2,32 @@ import { describe, expect, it } from "vitest"; import { resolveBundledPluginWebSearchProviders } from "./web-search-providers.js"; describe("resolveBundledPluginWebSearchProviders", () => { - it("returns bundled providers in auto-detect order", () => { + it("returns bundled providers in alphabetical order", () => { const providers = resolveBundledPluginWebSearchProviders({}); expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ "brave:brave", + "duckduckgo:duckduckgo", + "exa:exa", + "firecrawl:firecrawl", "google:gemini", - "qveris:qveris", "xai:grok", "moonshot:kimi", "perplexity:perplexity", - "firecrawl:firecrawl", - "exa:exa", + "qveris:qveris", "tavily:tavily", - "duckduckgo:duckduckgo", ]); expect(providers.map((provider) => provider.credentialPath)).toEqual([ "plugins.entries.brave.config.webSearch.apiKey", + "", + "plugins.entries.exa.config.webSearch.apiKey", + "plugins.entries.firecrawl.config.webSearch.apiKey", "plugins.entries.google.config.webSearch.apiKey", - "plugins.entries.qveris.config.webSearch.apiKey", "plugins.entries.xai.config.webSearch.apiKey", "plugins.entries.moonshot.config.webSearch.apiKey", "plugins.entries.perplexity.config.webSearch.apiKey", - "plugins.entries.firecrawl.config.webSearch.apiKey", - "plugins.entries.exa.config.webSearch.apiKey", + "plugins.entries.qveris.config.webSearch.apiKey", "plugins.entries.tavily.config.webSearch.apiKey", - "", ]); expect(providers.find((provider) => provider.id === "firecrawl")?.applySelectionConfig).toEqual( expect.any(Function), @@ -49,15 +49,15 @@ describe("resolveBundledPluginWebSearchProviders", () => { expect(providers.map((provider) => provider.pluginId)).toEqual([ "brave", + "duckduckgo", + "exa", + "firecrawl", "google", - "qveris", "xai", "moonshot", "perplexity", - "firecrawl", - "exa", + "qveris", "tavily", - "duckduckgo", ]); }); @@ -106,15 +106,15 @@ describe("resolveBundledPluginWebSearchProviders", () => { expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ "brave:brave", + "duckduckgo:duckduckgo", + "exa:exa", + "firecrawl:firecrawl", "google:gemini", - "qveris:qveris", "xai:grok", "moonshot:kimi", "perplexity:perplexity", - "firecrawl:firecrawl", - "exa:exa", + "qveris:qveris", "tavily:tavily", - "duckduckgo:duckduckgo", ]); }); diff --git a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts index 147ca323a91e9..ceb61a94b21e4 100644 --- a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts +++ b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts @@ -37,6 +37,7 @@ function createToolHandlerCtx(params: { sessionId: params.sessionId, onBlockReplyFlush: params.onBlockReplyFlush, }, + hookRunner: hookMocks.runner, state: { toolMetaById: new Map(), ...createBaseToolHandlerState(), diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index 3421f7d9c86a3..42da633e846ab 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -53,11 +53,13 @@ type LaneState = { */ const COMMAND_QUEUE_STATE_KEY = Symbol.for("openclaw.commandQueueState"); -const queueState = resolveGlobalSingleton(COMMAND_QUEUE_STATE_KEY, () => ({ - gatewayDraining: false, - lanes: new Map(), - nextTaskId: 1, -})); +function getQueueState() { + return resolveGlobalSingleton(COMMAND_QUEUE_STATE_KEY, () => ({ + gatewayDraining: false, + lanes: new Map(), + nextTaskId: 1, + })); +} function normalizeLane(lane: string): string { return lane.trim() || CommandLane.Main; @@ -68,6 +70,7 @@ function getLaneDepth(state: LaneState): number { } function getLaneState(lane: string): LaneState { + const queueState = getQueueState(); const existing = queueState.lanes.get(lane); if (existing) { return existing; @@ -120,7 +123,7 @@ function drainLane(lane: string) { ); } logLaneDequeue(lane, waitedMs, state.queue.length); - const taskId = queueState.nextTaskId++; + const taskId = getQueueState().nextTaskId++; const taskGeneration = state.generation; state.activeTaskIds.add(taskId); void (async () => { @@ -163,7 +166,7 @@ function drainLane(lane: string) { * `GatewayDrainingError` instead of being silently killed on shutdown. */ export function markGatewayDraining(): void { - queueState.gatewayDraining = true; + getQueueState().gatewayDraining = true; } export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) { @@ -181,6 +184,7 @@ export function enqueueCommandInLane( onWait?: (waitMs: number, queuedAhead: number) => void; }, ): Promise { + const queueState = getQueueState(); if (queueState.gatewayDraining) { return Promise.reject(new GatewayDrainingError()); } @@ -213,7 +217,7 @@ export function enqueueCommand( export function getQueueSize(lane: string = CommandLane.Main) { const resolved = normalizeLane(lane); - const state = queueState.lanes.get(resolved); + const state = getQueueState().lanes.get(resolved); if (!state) { return 0; } @@ -222,7 +226,7 @@ export function getQueueSize(lane: string = CommandLane.Main) { export function getTotalQueueSize() { let total = 0; - for (const s of queueState.lanes.values()) { + for (const s of getQueueState().lanes.values()) { total += getLaneDepth(s); } return total; @@ -230,7 +234,7 @@ export function getTotalQueueSize() { export function clearCommandLane(lane: string = CommandLane.Main) { const cleaned = normalizeLane(lane); - const state = queueState.lanes.get(cleaned); + const state = getQueueState().lanes.get(cleaned); if (!state) { return 0; } @@ -257,6 +261,7 @@ export function clearCommandLane(lane: string = CommandLane.Main) { * `enqueueCommandInLane()` call (which may never come). */ export function resetAllLanes(): void { + const queueState = getQueueState(); queueState.gatewayDraining = false; const lanesToDrain: string[] = []; for (const state of queueState.lanes.values()) { @@ -278,6 +283,7 @@ export function resetAllLanes(): void { * (excludes queued-but-not-started entries). */ export function getActiveTaskCount(): number { + const queueState = getQueueState(); let total = 0; for (const s of queueState.lanes.values()) { total += s.activeTaskIds.size; @@ -297,6 +303,7 @@ export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolea // Keep shutdown/drain checks responsive without busy looping. const POLL_INTERVAL_MS = 50; const deadline = Date.now() + timeoutMs; + const queueState = getQueueState(); const activeAtStart = new Set(); for (const state of queueState.lanes.values()) { for (const taskId of state.activeTaskIds) { diff --git a/src/process/supervisor/adapters/child.test.ts b/src/process/supervisor/adapters/child.test.ts index 2d3040f8811dc..52112e90f3f48 100644 --- a/src/process/supervisor/adapters/child.test.ts +++ b/src/process/supervisor/adapters/child.test.ts @@ -87,7 +87,7 @@ describe("createChildAdapter", () => { adapter.kill(); expect(killProcessTreeMock).toHaveBeenCalledWith(4321); - expect(killMock).not.toHaveBeenCalled(); + expect(killMock).toHaveBeenCalledWith("SIGKILL"); }); it("uses direct child.kill for non-SIGKILL signals", async () => { diff --git a/src/process/supervisor/adapters/child.ts b/src/process/supervisor/adapters/child.ts index 04d7e1d7aa18a..ad1f71a91eddb 100644 --- a/src/process/supervisor/adapters/child.ts +++ b/src/process/supervisor/adapters/child.ts @@ -125,12 +125,11 @@ export async function createChildAdapter(params: { if (signal === undefined || signal === "SIGKILL") { if (pid) { killProcessTree(pid); - } else { - try { - child.kill("SIGKILL"); - } catch { - // ignore kill errors - } + } + try { + child.kill("SIGKILL"); + } catch { + // ignore kill errors } return; } diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index dec725e1501f2..fc30a596ea67a 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -12,6 +12,10 @@ function createWriteStdoutArgv(output: string): string[] { return ["/usr/bin/printf", "%s", output]; } +function createSilentIdleArgv(): string[] { + return [process.execPath, "-e", "setInterval(() => {}, 1_000)"]; +} + async function spawnChild(supervisor: ProcessSupervisor, options: ChildSpawnOptions) { return supervisor.spawn({ ...options, @@ -39,7 +43,7 @@ describe("process supervisor", () => { const supervisor = createProcessSupervisor(); const run = await spawnChild(supervisor, { sessionId: "s1", - argv: [process.execPath, "-e", "setTimeout(() => {}, 14)"], + argv: createSilentIdleArgv(), timeoutMs: 300, noOutputTimeoutMs: 5, stdinMode: "pipe-closed", @@ -80,7 +84,7 @@ describe("process supervisor", () => { const supervisor = createProcessSupervisor(); const run = await spawnChild(supervisor, { sessionId: "s-timeout", - argv: [process.execPath, "-e", "setTimeout(() => {}, 12)"], + argv: createSilentIdleArgv(), timeoutMs: 1, stdinMode: "pipe-closed", }); diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index d4e13194620bf..d740d4d7c44ca 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -1,11 +1,6 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; -import * as bundledWebSearchProviders from "../plugins/web-search-providers.js"; -import * as runtimeWebSearchProviders from "../plugins/web-search-providers.runtime.js"; -import * as secretResolve from "./resolve.js"; -import { createResolverContext } from "./runtime-shared.js"; -import { resolveRuntimeWebTools } from "./runtime-web-tools.js"; type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "duckduckgo"; @@ -22,13 +17,24 @@ const mockedModuleIds = [ "../plugins/web-search-providers.runtime.js", ] as const; +let bundledWebSearchProviders: typeof import("../plugins/web-search-providers.js"); +let runtimeWebSearchProviders: typeof import("../plugins/web-search-providers.runtime.js"); +let secretResolve: typeof import("./resolve.js"); +let createResolverContext: typeof import("./runtime-shared.js").createResolverContext; +let resolveRuntimeWebTools: typeof import("./runtime-web-tools.js").resolveRuntimeWebTools; + vi.mock("../plugins/web-search-providers.js", () => ({ resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock, })); -vi.mock("../plugins/web-search-providers.runtime.js", () => ({ - resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, -})); +vi.mock("../plugins/web-search-providers.runtime.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, + }; +}); function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; @@ -194,7 +200,17 @@ function expectInactiveFirecrawlSecretRef(params: { } describe("runtime web tools resolution", () => { + beforeAll(async () => { + vi.resetModules(); + bundledWebSearchProviders = await import("../plugins/web-search-providers.js"); + runtimeWebSearchProviders = await import("../plugins/web-search-providers.runtime.js"); + secretResolve = await import("./resolve.js"); + ({ createResolverContext } = await import("./runtime-shared.js")); + ({ resolveRuntimeWebTools } = await import("./runtime-web-tools.js")); + }); + beforeEach(() => { + runtimeWebSearchProviders.__testing.resetWebSearchProviderSnapshotCacheForTests(); vi.mocked(bundledWebSearchProviders.resolveBundledPluginWebSearchProviders).mockClear(); vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders).mockClear(); }); @@ -207,11 +223,13 @@ describe("runtime web tools resolution", () => { for (const id of mockedModuleIds) { vi.doUnmock(id); } - vi.resetModules(); }); - it("skips loading web search providers when search config is absent", async () => { - const providerSpy = vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders); + it("keeps web search disabled when search config is absent", async () => { + const bundledProviderSpy = vi.mocked( + bundledWebSearchProviders.resolveBundledPluginWebSearchProviders, + ); + const runtimeProviderSpy = vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders); const { metadata } = await runRuntimeWebTools({ config: asConfig({ @@ -230,7 +248,9 @@ describe("runtime web tools resolution", () => { }, }); - expect(providerSpy).not.toHaveBeenCalled(); + expect(bundledProviderSpy).not.toHaveBeenCalled(); + expect(runtimeProviderSpy).not.toHaveBeenCalled(); + expect(metadata.search.selectedProvider).toBeUndefined(); expect(metadata.search.providerSource).toBe("none"); expect(metadata.fetch.firecrawl.active).toBe(true); expect(metadata.fetch.firecrawl.apiKeySource).toBe("env"); @@ -666,6 +686,53 @@ describe("runtime web tools resolution", () => { expectInactiveFirecrawlSecretRef({ resolveSpy, metadata, context }); }); + it("keeps configured provider metadata and inactive warnings when search is disabled", async () => { + const { metadata, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + enabled: false, + provider: "gemini", + }, + }, + }, + plugins: { + entries: { + google: { + enabled: true, + config: { + webSearch: { + apiKey: { source: "env", provider: "default", id: "GEMINI_PROVIDER_REF" }, + }, + }, + }, + }, + }, + }), + }); + + expect(metadata.search.providerConfigured).toBe("gemini"); + expect(metadata.search.providerSource).toBe("configured"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "plugins.entries.google.config.webSearch.apiKey", + }), + ]), + ); + }); + + it("does not auto-enable search when tools.web.search is absent", async () => { + const { metadata } = await runRuntimeWebTools({ + config: asConfig({}), + }); + + expect(metadata.search.providerSource).toBe("none"); + expect(metadata.search.selectedProvider).toBeUndefined(); + }); + it("uses env fallback for unresolved Firecrawl SecretRef when active", async () => { const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ config: asConfig({ diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index c7341a10d318a..c3b177c19b108 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -1,15 +1,14 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; -import { - listBundledWebSearchPluginIds, - resolveBundledWebSearchPluginId, -} from "../plugins/bundled-web-search.js"; +import { listBundledWebSearchPluginIds } from "../plugins/bundled-web-search-ids.js"; +import { resolveBundledWebSearchPluginId } from "../plugins/bundled-web-search-provider-ids.js"; import type { PluginWebSearchProviderEntry, WebSearchCredentialResolutionSource, } from "../plugins/types.js"; import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js"; import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; +import { sortWebSearchProvidersForAutoDetect } from "../plugins/web-search-providers.shared.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { secretRefKey } from "./ref-contract.js"; import { resolveSecretRefValues } from "./resolve.js"; @@ -299,33 +298,36 @@ export async function resolveRuntimeWebTools(params: { const rawProvider = typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : ""; const configuredBundledPluginId = resolveBundledWebSearchPluginId(rawProvider); - const providers = search - ? configuredBundledPluginId - ? resolveBundledPluginWebSearchProviders({ - config: params.sourceConfig, - env: { ...process.env, ...params.context.env }, - bundledAllowlistCompat: true, - onlyPluginIds: [configuredBundledPluginId], - }) - : !hasCustomWebSearchPluginRisk(params.sourceConfig) - ? resolveBundledPluginWebSearchProviders({ - config: params.sourceConfig, - env: { ...process.env, ...params.context.env }, - bundledAllowlistCompat: true, - }) - : resolvePluginWebSearchProviders({ - config: params.sourceConfig, - env: { ...process.env, ...params.context.env }, - bundledAllowlistCompat: true, - }) - : []; const searchMetadata: RuntimeWebSearchMetadata = { providerSource: "none", diagnostics: [], }; - const searchEnabled = search?.enabled !== false; + const searchConfigured = Boolean(search); + const searchEnabled = searchConfigured && search?.enabled !== false; + const providers = sortWebSearchProvidersForAutoDetect( + searchConfigured + ? configuredBundledPluginId + ? resolveBundledPluginWebSearchProviders({ + config: params.sourceConfig, + env: { ...process.env, ...params.context.env }, + bundledAllowlistCompat: true, + onlyPluginIds: [configuredBundledPluginId], + }) + : !hasCustomWebSearchPluginRisk(params.sourceConfig) + ? resolveBundledPluginWebSearchProviders({ + config: params.sourceConfig, + env: { ...process.env, ...params.context.env }, + bundledAllowlistCompat: true, + }) + : resolvePluginWebSearchProviders({ + config: params.sourceConfig, + env: { ...process.env, ...params.context.env }, + bundledAllowlistCompat: true, + }) + : [], + ); const configuredProvider = normalizeProvider(rawProvider, providers); if (rawProvider && !configuredProvider) { @@ -348,7 +350,7 @@ export async function resolveRuntimeWebTools(params: { searchMetadata.providerSource = "configured"; } - if (searchEnabled && search) { + if (searchEnabled) { const candidates = configuredProvider ? providers.filter((provider) => provider.id === configuredProvider) : providers; @@ -514,7 +516,7 @@ export async function resolveRuntimeWebTools(params: { } } - if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) { + if (searchEnabled && !configuredProvider && searchMetadata.selectedProvider) { for (const provider of providers) { if (provider.id === searchMetadata.selectedProvider) { continue; diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index 74c0e966651dd..1a04e5b97c290 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -1,9 +1,8 @@ -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../agents/auth-profiles.js"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import { getPath, setPathCreateStrict } from "./path-utils.js"; -import { clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } from "./runtime.js"; import { listSecretTargetRegistryEntries } from "./target-registry.js"; type SecretRegistryEntry = ReturnType[number]; @@ -19,6 +18,9 @@ const mockedModuleIds = [ "../plugins/web-search-providers.runtime.js", ] as const; +let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot; +let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot; + vi.mock("../plugins/web-search-providers.js", () => ({ resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock, })); @@ -251,6 +253,11 @@ describe("secrets runtime target coverage", () => { resolvePluginWebSearchProvidersMock.mockReset(); }); + beforeEach(async () => { + vi.resetModules(); + ({ clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } = await import("./runtime.js")); + }); + afterAll(() => { for (const id of mockedModuleIds) { vi.doUnmock(id); diff --git a/src/secrets/runtime.integration.test.ts b/src/secrets/runtime.integration.test.ts index f39607cbe80cd..d52483944ef23 100644 --- a/src/secrets/runtime.integration.test.ts +++ b/src/secrets/runtime.integration.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js"; import { clearConfigCache, @@ -10,6 +10,7 @@ import { writeConfigFile, } from "../config/config.js"; import { withTempHome } from "../config/home-env.test-harness.js"; +import { captureEnv, withEnvAsync } from "../test-utils/env.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot, @@ -18,6 +19,8 @@ import { prepareSecretsRuntimeSnapshot, } from "./runtime.js"; +vi.unmock("../version.js"); + const OPENAI_ENV_KEY_REF = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const; const allowInsecureTempSecretFile = process.platform === "win32"; @@ -33,45 +36,69 @@ function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): Auth } describe("secrets runtime snapshot integration", () => { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv([ + "OPENCLAW_BUNDLED_PLUGINS_DIR", + "OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE", + "OPENCLAW_VERSION", + ]); + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + process.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE = "1"; + delete process.env.OPENCLAW_VERSION; + }); + afterEach(() => { + vi.restoreAllMocks(); + envSnapshot.restore(); clearSecretsRuntimeSnapshot(); clearConfigCache(); }); it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => { - const prepared = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - models: [], + await withEnvAsync( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_VERSION: undefined, + }, + async () => { + const prepared = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, }, - }, - }, - }), - env: { OPENAI_API_KEY: "sk-runtime" }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => - loadAuthStoreWithProfiles({ - "openai:default": { - type: "api_key", - provider: "openai", - keyRef: OPENAI_ENV_KEY_REF, - }, - }), - }); + }), + env: { OPENAI_API_KEY: "sk-runtime" }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => + loadAuthStoreWithProfiles({ + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: OPENAI_ENV_KEY_REF, + }, + }), + }); - activateSecretsRuntimeSnapshot(prepared); + activateSecretsRuntimeSnapshot(prepared); - expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime"); - expect( - ensureAuthProfileStore("/tmp/openclaw-agent-main").profiles["openai:default"], - ).toMatchObject({ - type: "api_key", - key: "sk-runtime", - }); + expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime"); + expect( + ensureAuthProfileStore("/tmp/openclaw-agent-main").profiles["openai:default"], + ).toMatchObject({ + type: "api_key", + key: "sk-runtime", + }); + }, + ); }); it("keeps active secrets runtime snapshots resolved after config writes", async () => { diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 166e16ec238d2..a93c216f1a210 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -1,16 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../agents/auth-profiles.js"; -import { clearConfigCache, type OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/config.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; -import { - activateSecretsRuntimeSnapshot, - clearSecretsRuntimeSnapshot, - getActiveRuntimeWebToolsMetadata, - prepareSecretsRuntimeSnapshot, -} from "./runtime.js"; type WebProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl"; @@ -103,6 +97,12 @@ function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { const OPENAI_ENV_KEY_REF = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const; +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; +let activateSecretsRuntimeSnapshot: typeof import("./runtime.js").activateSecretsRuntimeSnapshot; +let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot; +let getActiveRuntimeWebToolsMetadata: typeof import("./runtime.js").getActiveRuntimeWebToolsMetadata; +let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot; + function createOpenAiFileModelsConfig(): NonNullable { return { providers: { @@ -123,6 +123,17 @@ function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): Auth } describe("secrets runtime snapshot", () => { + beforeAll(async () => { + vi.resetModules(); + ({ clearConfigCache } = await import("../config/config.js")); + ({ + activateSecretsRuntimeSnapshot, + clearSecretsRuntimeSnapshot, + getActiveRuntimeWebToolsMetadata, + prepareSecretsRuntimeSnapshot, + } = await import("./runtime.js")); + }); + beforeEach(() => { resolveBundledPluginWebSearchProvidersMock.mockReset(); resolveBundledPluginWebSearchProvidersMock.mockReturnValue(buildTestWebSearchProviders()); diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index a566506ca14f1..040bcf5d9b9aa 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -154,6 +154,16 @@ function hasExplicitProviderAccountConfig( return Object.hasOwn(accounts, accountId); } +function formatChannelAccountNote(params: { + orderedAccountIds: string[]; + hasExplicitAccountPath: boolean; + accountId: string; +}): string { + return params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath + ? ` (account: ${params.accountId})` + : ""; +} + export async function collectChannelSecurityFindings(params: { cfg: OpenClawConfig; sourceConfig?: OpenClawConfig; @@ -387,8 +397,11 @@ export async function collectChannelSecurityFindings(params: { const accountConfig = (account as { config?: Record } | null | undefined) ?.config; if (isDangerousNameMatchingEnabled(accountConfig)) { - const accountNote = - orderedAccountIds.length > 1 || hasExplicitAccountPath ? ` (account: ${accountId})` : ""; + const accountNote = formatChannelAccountNote({ + orderedAccountIds, + hasExplicitAccountPath, + accountId, + }); findings.push({ checkId: `channels.${plugin.id}.allowFrom.dangerous_name_matching_enabled`, severity: "info", @@ -400,6 +413,27 @@ export async function collectChannelSecurityFindings(params: { }); } + if ( + plugin.id === "synology-chat" && + (account as { dangerouslyAllowNameMatching?: unknown } | null) + ?.dangerouslyAllowNameMatching === true + ) { + const accountNote = formatChannelAccountNote({ + orderedAccountIds, + hasExplicitAccountPath, + accountId, + }); + findings.push({ + checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled", + severity: "info", + title: `Synology Chat dangerous name matching is enabled${accountNote}`, + detail: + "dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.", + remediation: + "Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.", + }); + } + if (plugin.id === "discord") { const { isDiscordMutableAllowEntry } = await loadAuditChannelDiscordRuntimeModule(); const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule(); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 716f08c6e3101..76e9fcb70d99a 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -28,7 +28,7 @@ const execDockerRawUnavailable: NonNullable unknown; inspectAccount?: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown; @@ -152,6 +152,30 @@ const zalouserPlugin = stubChannelPlugin({ }, }); +const synologyChatPlugin = stubChannelPlugin({ + id: "synology-chat", + label: "Synology Chat", + listAccountIds: (cfg) => { + const ids = Object.keys(cfg.channels?.["synology-chat"]?.accounts ?? {}); + return ids.length > 0 ? ids : ["default"]; + }, + inspectAccount: () => null, + resolveAccount: (cfg, accountId) => { + const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default"; + const base = cfg.channels?.["synology-chat"] ?? {}; + const account = cfg.channels?.["synology-chat"]?.accounts?.[resolvedAccountId] ?? {}; + const dangerouslyAllowNameMatching = + typeof account.dangerouslyAllowNameMatching === "boolean" + ? account.dangerouslyAllowNameMatching + : base.dangerouslyAllowNameMatching === true; + return { + accountId: resolvedAccountId, + enabled: true, + dangerouslyAllowNameMatching, + }; + }, +}); + function successfulProbeResult(url: string) { return { ok: true, @@ -2618,6 +2642,60 @@ description: test skill }); }); + it.each([ + { + name: "audits Synology Chat base dangerous name matching", + cfg: { + channels: { + "synology-chat": { + token: "t", + incomingUrl: "https://nas.example.com/incoming", + dangerouslyAllowNameMatching: true, + }, + }, + } satisfies OpenClawConfig, + expectedMatch: { + checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled", + severity: "info", + title: "Synology Chat dangerous name matching is enabled", + }, + }, + { + name: "audits non-default Synology Chat accounts for dangerous name matching", + cfg: { + channels: { + "synology-chat": { + token: "t", + incomingUrl: "https://nas.example.com/incoming", + accounts: { + alpha: { + token: "a", + incomingUrl: "https://nas.example.com/incoming-alpha", + }, + beta: { + token: "b", + incomingUrl: "https://nas.example.com/incoming-beta", + dangerouslyAllowNameMatching: true, + }, + }, + }, + }, + } satisfies OpenClawConfig, + expectedMatch: { + checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled", + severity: "info", + title: expect.stringContaining("(account: beta)"), + }, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async () => { + const res = await runChannelSecurityAudit(testCase.cfg, [synologyChatPlugin]); + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedMatch)]), + ); + }); + }); + it("does not treat prototype properties as explicit Discord account config paths", async () => { await withChannelSecurityStateDir(async () => { const cfg: OpenClawConfig = { diff --git a/src/security/external-content.ts b/src/security/external-content.ts index afe42fc7c4740..71019f4a815d6 100644 --- a/src/security/external-content.ts +++ b/src/security/external-content.ts @@ -91,6 +91,10 @@ export type ExternalContentSource = | "web_fetch" | "unknown"; +// Hook-origin async runs need immutable ingress provenance because routed +// session keys can be normalized outside the hook:* namespace. +export type HookExternalContentSource = "gmail" | "webhook"; + const EXTERNAL_SOURCE_LABELS: Record = { email: "Email", webhook: "Webhook", @@ -102,6 +106,25 @@ const EXTERNAL_SOURCE_LABELS: Record = { unknown: "External", }; +export function resolveHookExternalContentSource( + sessionKey: string, +): HookExternalContentSource | undefined { + const normalized = sessionKey.trim().toLowerCase(); + if (normalized.startsWith("hook:gmail:")) { + return "gmail"; + } + if (normalized.startsWith("hook:webhook:") || normalized.startsWith("hook:")) { + return "webhook"; + } + return undefined; +} + +export function mapHookExternalContentSource( + source: HookExternalContentSource, +): Extract { + return source === "gmail" ? "email" : "webhook"; +} + const FULLWIDTH_ASCII_OFFSET = 0xfee0; // Map of Unicode angle bracket homoglyphs to their ASCII equivalents. @@ -315,29 +338,15 @@ export function buildSafeExternalPrompt(params: { * Checks if a session key indicates an external hook source. */ export function isExternalHookSession(sessionKey: string): boolean { - const normalized = sessionKey.trim().toLowerCase(); - return ( - normalized.startsWith("hook:gmail:") || - normalized.startsWith("hook:webhook:") || - normalized.startsWith("hook:") // Generic hook prefix - ); + return resolveHookExternalContentSource(sessionKey) !== undefined; } /** * Extracts the hook type from a session key. */ export function getHookType(sessionKey: string): ExternalContentSource { - const normalized = sessionKey.trim().toLowerCase(); - if (normalized.startsWith("hook:gmail:")) { - return "email"; - } - if (normalized.startsWith("hook:webhook:")) { - return "webhook"; - } - if (normalized.startsWith("hook:")) { - return "webhook"; - } - return "unknown"; + const source = resolveHookExternalContentSource(sessionKey); + return source ? mapHookExternalContentSource(source) : "unknown"; } /** diff --git a/src/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index dafc71a7cbbe1..8b058149a6c6a 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { WindowsAclEntry, WindowsAclSummary } from "./windows-acl.js"; const MOCK_USERNAME = "MockUser"; @@ -24,7 +24,7 @@ let parseIcaclsOutput: typeof import("./windows-acl.js").parseIcaclsOutput; let resolveWindowsUserPrincipal: typeof import("./windows-acl.js").resolveWindowsUserPrincipal; let summarizeWindowsAcl: typeof import("./windows-acl.js").summarizeWindowsAcl; -beforeEach(async () => { +beforeAll(async () => { vi.resetModules(); ({ createIcaclsResetCommand, @@ -37,6 +37,10 @@ beforeEach(async () => { } = await import("./windows-acl.js")); }); +beforeEach(() => { + vi.unstubAllEnvs(); +}); + function aclEntry(params: { principal: string; rights?: string[]; diff --git a/src/shared/device-auth-store.test.ts b/src/shared/device-auth-store.test.ts index b0346afff9ab7..979ddc62c6152 100644 --- a/src/shared/device-auth-store.test.ts +++ b/src/shared/device-auth-store.test.ts @@ -186,7 +186,7 @@ describe("device-auth-store", () => { expect(entry).toEqual({ token: "new-token", role: "operator", - scopes: ["operator.write"], + scopes: ["operator.read", "operator.write"], updatedAtMs: 2222, }); expect(readStore()).toEqual({ diff --git a/src/shared/device-auth.test.ts b/src/shared/device-auth.test.ts index d3018f5ba0ac9..267441d38c13e 100644 --- a/src/shared/device-auth.test.ts +++ b/src/shared/device-auth.test.ts @@ -21,4 +21,16 @@ describe("shared/device-auth", () => { "z.scope", ]); }); + + it("expands implied operator scopes for stored device auth", () => { + expect(normalizeDeviceAuthScopes(["operator.write"])).toEqual([ + "operator.read", + "operator.write", + ]); + expect(normalizeDeviceAuthScopes(["operator.admin"])).toEqual([ + "operator.admin", + "operator.read", + "operator.write", + ]); + }); }); diff --git a/src/shared/device-auth.ts b/src/shared/device-auth.ts index d093be0124a87..fd5071be56a17 100644 --- a/src/shared/device-auth.ts +++ b/src/shared/device-auth.ts @@ -26,5 +26,11 @@ export function normalizeDeviceAuthScopes(scopes: string[] | undefined): string[ out.add(trimmed); } } + if (out.has("operator.admin")) { + out.add("operator.read"); + out.add("operator.write"); + } else if (out.has("operator.write")) { + out.add("operator.read"); + } return [...out].toSorted(); } diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts new file mode 100644 index 0000000000000..be288560d5dd8 --- /dev/null +++ b/src/shared/device-bootstrap-profile.ts @@ -0,0 +1,51 @@ +import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "./device-auth.js"; + +export type DeviceBootstrapProfile = { + roles: string[]; + scopes: string[]; +}; + +export type DeviceBootstrapProfileInput = { + roles?: readonly string[]; + scopes?: readonly string[]; +}; + +export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { + roles: ["node"], + scopes: [], +}; + +function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] { + if (!Array.isArray(roles)) { + return []; + } + const out = new Set(); + for (const role of roles) { + const normalized = normalizeDeviceAuthRole(role); + if (normalized) { + out.add(normalized); + } + } + return [...out].toSorted(); +} + +export function normalizeDeviceBootstrapProfile( + input: DeviceBootstrapProfileInput | undefined, +): DeviceBootstrapProfile { + return { + roles: normalizeBootstrapRoles(input?.roles), + scopes: normalizeDeviceAuthScopes(input?.scopes ? [...input.scopes] : []), + }; +} + +export function sameDeviceBootstrapProfile( + left: DeviceBootstrapProfile, + right: DeviceBootstrapProfile, +): boolean { + return ( + left.roles.length === right.roles.length && + left.scopes.length === right.scopes.length && + left.roles.every((value, index) => value === right.roles[index]) && + left.scopes.every((value, index) => value === right.scopes[index]) + ); +} diff --git a/src/shared/pid-alive.test.ts b/src/shared/pid-alive.test.ts index 8c4238a6d1af4..36df219e2c394 100644 --- a/src/shared/pid-alive.test.ts +++ b/src/shared/pid-alive.test.ts @@ -30,7 +30,6 @@ async function withProcessPlatform( value: platform, }); try { - vi.resetModules(); return await run(); } finally { Object.defineProperty(process, "platform", originalPlatformDescriptor); @@ -62,8 +61,7 @@ describe("isPidAlive", () => { [`/proc/${zombiePid}/status`]: `Name:\tnode\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t${zombiePid}\nPid:\t${zombiePid}\n`, }); await withLinuxProcessPlatform(async () => { - const { isPidAlive: freshIsPidAlive } = await import("./pid-alive.js"); - expect(freshIsPidAlive(zombiePid)).toBe(false); + expect(isPidAlive(zombiePid)).toBe(false); }); }); @@ -74,8 +72,7 @@ describe("isPidAlive", () => { const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); await withLinuxProcessPlatform(async () => { - const { isPidAlive: freshIsPidAlive } = await import("./pid-alive.js"); - expect(freshIsPidAlive(42)).toBe(true); + expect(isPidAlive(42)).toBe(true); }); expect(readFileSyncSpy).toHaveBeenCalledWith("/proc/42/status", "utf8"); @@ -98,20 +95,18 @@ describe("getProcessStartTime", () => { }); await withLinuxProcessPlatform(async () => { - const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - expect(fresh(process.pid)).toBe(98765); - expect(fresh(42)).toBe(55555); - expect(fresh(43)).toBeNull(); - expect(fresh(44)).toBe(66666); - expect(fresh(45)).toBeNull(); - expect(fresh(46)).toBeNull(); + expect(getProcessStartTime(process.pid)).toBe(98765); + expect(getProcessStartTime(42)).toBe(55555); + expect(getProcessStartTime(43)).toBeNull(); + expect(getProcessStartTime(44)).toBe(66666); + expect(getProcessStartTime(45)).toBeNull(); + expect(getProcessStartTime(46)).toBeNull(); }); }); it("returns null on non-Linux platforms", () => { return withProcessPlatform("darwin", async () => { - const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - expect(fresh(process.pid)).toBeNull(); + expect(getProcessStartTime(process.pid)).toBeNull(); }); }); diff --git a/src/test-helpers/state-dir-env.ts b/src/test-helpers/state-dir-env.ts index e42e14cad2119..ad0b5ec5f3ff0 100644 --- a/src/test-helpers/state-dir-env.ts +++ b/src/test-helpers/state-dir-env.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { captureEnv } from "../test-utils/env.js"; +import { cleanupSessionStateForTest } from "../test-utils/session-state-cleanup.js"; export function snapshotStateDirEnv() { return captureEnv(["OPENCLAW_STATE_DIR"]); @@ -27,6 +28,7 @@ export async function withStateDirEnv( try { return await fn({ tempRoot, stateDir }); } finally { + await cleanupSessionStateForTest().catch(() => undefined); restoreStateDirEnv(snapshot); await fs.rm(tempRoot, { recursive: true, force: true }); } diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index d2ebbc459339e..713d331335a78 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -7,7 +7,7 @@ import type { } from "../channels/plugins/types.js"; import type { PluginRegistry } from "../plugins/registry.js"; -type TestChannelRegistration = { +export type TestChannelRegistration = { pluginId: string; plugin: unknown; source: string; @@ -77,7 +77,7 @@ export const createMSTeamsTestPluginBase = (): Pick< meta: { ...base.meta, selectionLabel: "Microsoft Teams (Bot Framework)", - blurb: "Bot Framework; enterprise support.", + blurb: "Teams SDK; enterprise support.", aliases: ["teams"], }, }; diff --git a/src/test-utils/session-state-cleanup.ts b/src/test-utils/session-state-cleanup.ts new file mode 100644 index 0000000000000..4ea68b6bdd663 --- /dev/null +++ b/src/test-utils/session-state-cleanup.ts @@ -0,0 +1,9 @@ +import { drainSessionWriteLockStateForTest } from "../agents/session-write-lock.js"; +import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; +import { drainFileLockStateForTest } from "../infra/file-lock.js"; + +export async function cleanupSessionStateForTest(): Promise { + clearSessionStoreCacheForTest(); + await drainFileLockStateForTest(); + await drainSessionWriteLockStateForTest(); +} diff --git a/src/test-utils/temp-home.ts b/src/test-utils/temp-home.ts index 10886cc9aaaef..fac3b014efc57 100644 --- a/src/test-utils/temp-home.ts +++ b/src/test-utils/temp-home.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { captureEnv } from "./env.js"; +import { cleanupSessionStateForTest } from "./session-state-cleanup.js"; const HOME_ENV_KEYS = [ "HOME", @@ -36,6 +37,7 @@ export async function createTempHomeEnv(prefix: string): Promise { return { home, restore: async () => { + await cleanupSessionStateForTest().catch(() => undefined); snapshot.restore(); await fs.rm(home, { recursive: true, force: true }); }, diff --git a/src/tts/edge-tts-validation.test.ts b/src/tts/edge-tts-validation.test.ts index d12c36a9ba594..85c93211efcb7 100644 --- a/src/tts/edge-tts-validation.test.ts +++ b/src/tts/edge-tts-validation.test.ts @@ -1,8 +1,9 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { edgeTTS } from "./tts-core.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let edgeTTS: typeof import("./tts-core.js").edgeTTS; let mockTtsPromise = vi.fn<(text: string, filePath: string) => Promise>(); @@ -24,10 +25,25 @@ const baseEdgeConfig = { }; describe("edgeTTS – empty audio validation", () => { - let tempDir: string; + let tempDir: string | undefined; + + beforeEach(async () => { + vi.resetModules(); + vi.doMock("node-edge-tts", () => ({ + EdgeTTS: class { + ttsPromise(text: string, filePath: string) { + return mockTtsPromise(text, filePath); + } + }, + })); + ({ edgeTTS } = await import("./tts-core.js")); + }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } }); it("throws when the output file is 0 bytes", async () => { diff --git a/src/tts/provider-registry.test.ts b/src/tts/provider-registry.test.ts index dfa283f5b231e..b0b6a08abf5e5 100644 --- a/src/tts/provider-registry.test.ts +++ b/src/tts/provider-registry.test.ts @@ -3,11 +3,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import type { SpeechProviderPlugin } from "../plugins/types.js"; -import { - getSpeechProvider, - listSpeechProviders, - normalizeSpeechProviderId, -} from "./provider-registry.js"; const loadOpenClawPluginsMock = vi.fn(); @@ -16,6 +11,10 @@ vi.mock("../plugins/loader.js", () => ({ loadOpenClawPluginsMock(...args), })); +let getSpeechProvider: typeof import("./provider-registry.js").getSpeechProvider; +let listSpeechProviders: typeof import("./provider-registry.js").listSpeechProviders; +let normalizeSpeechProviderId: typeof import("./provider-registry.js").normalizeSpeechProviderId; + function createSpeechProvider(id: string, aliases?: string[]): SpeechProviderPlugin { return { id, @@ -32,10 +31,13 @@ function createSpeechProvider(id: string, aliases?: string[]): SpeechProviderPlu } describe("speech provider registry", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); resetPluginRuntimeStateForTest(); loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock.mockReturnValue(createEmptyPluginRegistry()); + ({ getSpeechProvider, listSpeechProviders, normalizeSpeechProviderId } = + await import("./provider-registry.js")); }); afterEach(() => { diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 9ddc7b289e9fe..18987eb87f530 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -1,17 +1,16 @@ -import { completeSimple, type AssistantMessage } from "@mariozechner/pi-ai"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildElevenLabsSpeechProvider } from "../../extensions/elevenlabs/speech-provider.ts"; import { buildMicrosoftSpeechProvider } from "../../extensions/microsoft/speech-provider.ts"; import { buildOpenAISpeechProvider } from "../../extensions/openai/speech-provider.ts"; -import { ensureCustomApiRegistered } from "../agents/custom-api-registry.js"; -import { getApiKeyForModel } from "../agents/model-auth.js"; -import { resolveModelAsync } from "../agents/pi-embedded-runner/model.js"; import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { withEnv } from "../test-utils/env.js"; import * as tts from "./tts.js"; +let completeSimple: typeof import("@mariozechner/pi-ai").completeSimple; + vi.mock("@mariozechner/pi-ai", async (importOriginal) => { const original = await importOriginal(); return { @@ -128,7 +127,8 @@ function createOpenAiTelephonyCfg(model: "tts-1" | "gpt-4o-mini-tts"): OpenClawC } describe("tts", () => { - beforeEach(() => { + beforeEach(async () => { + ({ completeSimple } = await import("@mariozechner/pi-ai")); const registry = createEmptyPluginRegistry(); registry.speechProviders = [ { pluginId: "openai", provider: buildOpenAISpeechProvider(), source: "test" }, @@ -391,17 +391,54 @@ describe("tts", () => { describe("summarizeText", () => { let summarizeTextForTest: typeof summarizeText; let resolveTtsConfigForTest: typeof resolveTtsConfig; - let completeSimpleForTest: typeof completeSimple; - let getApiKeyForModelForTest: typeof getApiKeyForModel; - let resolveModelAsyncForTest: typeof resolveModelAsync; - let ensureCustomApiRegisteredForTest: typeof ensureCustomApiRegistered; + let completeSimpleForTest: typeof import("@mariozechner/pi-ai").completeSimple; + let getApiKeyForModelForTest: typeof import("../agents/model-auth.js").getApiKeyForModel; + let resolveModelAsyncForTest: typeof import("../agents/pi-embedded-runner/model.js").resolveModelAsync; + let ensureCustomApiRegisteredForTest: typeof import("../agents/custom-api-registry.js").ensureCustomApiRegistered; const baseCfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, messages: { tts: {} }, }; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("@mariozechner/pi-ai", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + completeSimple: vi.fn(), + }; + }); + vi.doMock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-ai/oauth", + ); + return { + ...actual, + getOAuthProviders: () => [], + getOAuthApiKey: vi.fn(async () => null), + }; + }); + vi.doMock("../agents/pi-embedded-runner/model.js", () => ({ + resolveModel: vi.fn((provider: string, modelId: string) => + createResolvedModel(provider, modelId), + ), + resolveModelAsync: vi.fn(async (provider: string, modelId: string) => + createResolvedModel(provider, modelId), + ), + })); + vi.doMock("../agents/model-auth.js", () => ({ + getApiKeyForModel: vi.fn(async () => ({ + apiKey: "test-api-key", + source: "test", + mode: "api-key", + })), + requireApiKey: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""), + })); + vi.doMock("../agents/custom-api-registry.js", () => ({ + ensureCustomApiRegistered: vi.fn(), + })); ({ completeSimple: completeSimpleForTest } = await import("@mariozechner/pi-ai")); ({ getApiKeyForModel: getApiKeyForModelForTest } = await import("../agents/model-auth.js")); ({ resolveModelAsync: resolveModelAsyncForTest } = @@ -411,9 +448,6 @@ describe("tts", () => { const ttsModule = await import("./tts.js"); summarizeTextForTest = ttsModule._test.summarizeText; resolveTtsConfigForTest = ttsModule.resolveTtsConfig; - }); - - beforeEach(() => { vi.mocked(completeSimpleForTest).mockResolvedValue( mockAssistantMessage([{ type: "text", text: "Summary" }]), ); diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 2073afe308d83..13d9d179d64b3 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -590,4 +590,44 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(loadHistory).toHaveBeenCalledTimes(1); }); + + it("does not reload history for local run with empty final when another run is active (#53115)", () => { + const { state, loadHistory, noteLocalRunId, handleChatEvent } = createHandlersHarness({ + state: { activeChatRunId: "run-main" }, + }); + + noteLocalRunId("run-local-empty"); + + handleChatEvent({ + runId: "run-local-empty", + sessionKey: state.currentSessionKey, + state: "final", + }); + + expect(state.activeChatRunId).toBe("run-main"); + expect(loadHistory).not.toHaveBeenCalled(); + }); + + it("flushes deferred history reload after the newer local run finishes", () => { + const { state, loadHistory, noteLocalRunId, handleChatEvent } = createHandlersHarness({ + state: { activeChatRunId: "run-main" }, + }); + + noteLocalRunId("run-local-empty"); + handleChatEvent({ + runId: "run-local-empty", + sessionKey: state.currentSessionKey, + state: "final", + }); + + noteLocalRunId("run-main"); + handleChatEvent({ + runId: "run-main", + sessionKey: state.currentSessionKey, + state: "final", + message: { content: [{ type: "text", text: "done" }] }, + }); + + expect(loadHistory).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 6fda2d8516380..5cdda82804f9d 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -61,6 +61,7 @@ export function createEventHandlers(context: EventHandlerContext) { const sessionRuns = new Map(); let streamAssembler = new TuiStreamAssembler(); let lastSessionKey = state.currentSessionKey; + let pendingHistoryRefresh = false; const pruneRunMap = (runs: Map) => { if (runs.size <= 200) { @@ -93,11 +94,20 @@ export function createEventHandlers(context: EventHandlerContext) { finalizedRuns.clear(); sessionRuns.clear(); streamAssembler = new TuiStreamAssembler(); + pendingHistoryRefresh = false; clearLocalRunIds?.(); clearLocalBtwRunIds?.(); btw.clear(); }; + const flushPendingHistoryRefreshIfIdle = () => { + if (!pendingHistoryRefresh || state.activeChatRunId) { + return; + } + pendingHistoryRefresh = false; + void loadHistory?.(); + }; + const noteSessionRun = (runId: string) => { sessionRuns.set(runId, Date.now()); pruneRunMap(sessionRuns); @@ -123,6 +133,7 @@ export function createEventHandlers(context: EventHandlerContext) { }) => { noteFinalizedRun(params.runId); clearActiveRunIfMatch(params.runId); + flushPendingHistoryRefreshIfIdle(); if (params.wasActiveRun) { setActivityStatus(params.status); } @@ -137,6 +148,7 @@ export function createEventHandlers(context: EventHandlerContext) { streamAssembler.drop(params.runId); sessionRuns.delete(params.runId); clearActiveRunIfMatch(params.runId); + flushPendingHistoryRefreshIfIdle(); if (params.wasActiveRun) { setActivityStatus(params.status); } @@ -158,13 +170,21 @@ export function createEventHandlers(context: EventHandlerContext) { const isLocalRun = isLocalRunId?.(runId) ?? false; if (isLocalRun) { forgetLocalRunId?.(runId); + // Local runs with displayable output do not need a history reload. if (!opts?.allowLocalWithoutDisplayableFinal) { return; } + // Defer the reload if a newer run is active so we preserve the pending + // user message, then flush once that active run finishes. + if (state.activeChatRunId && state.activeChatRunId !== runId) { + pendingHistoryRefresh = true; + return; + } } if (hasConcurrentActiveRun(runId)) { return; } + pendingHistoryRefresh = false; void loadHistory?.(); }; diff --git a/src/types/create-markdown-preview.d.ts b/src/types/create-markdown-preview.d.ts new file mode 100644 index 0000000000000..88a9ce8fcbdf4 --- /dev/null +++ b/src/types/create-markdown-preview.d.ts @@ -0,0 +1,7 @@ +declare module "@create-markdown/preview" { + export type PreviewThemeOptions = { + sanitize?: ((html: string) => string) | undefined; + }; + + export function applyPreviewTheme(html: string, options?: PreviewThemeOptions): string; +} diff --git a/src/types/microsoft-teams-sdk.d.ts b/src/types/microsoft-teams-sdk.d.ts new file mode 100644 index 0000000000000..979b00b40262a --- /dev/null +++ b/src/types/microsoft-teams-sdk.d.ts @@ -0,0 +1,40 @@ +declare module "@microsoft/teams.apps" { + export class App { + constructor(options: { clientId: string; clientSecret: string; tenantId?: string }); + + getBotToken(): Promise<{ toString(): string } | null>; + getAppGraphToken(): Promise<{ toString(): string } | null>; + } +} + +declare module "@microsoft/teams.api" { + export class Client { + constructor( + serviceUrl: string, + options?: { + token?: (() => Promise) | undefined; + headers?: Record | undefined; + }, + ); + + conversations: { + activities: (conversationId: string) => { + create: (activity: Record) => Promise; + }; + }; + } +} + +declare module "@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js" { + export function createServiceTokenValidator( + appId: string, + tenantId?: string, + ): { + validateAccessToken( + token: string, + options?: { + validateServiceUrl?: { expectedServiceUrl: string } | undefined; + }, + ): Promise; + }; +} diff --git a/src/utils/fetch-timeout.ts b/src/utils/fetch-timeout.ts index 150f4e119a944..d0ddc01d4b241 100644 --- a/src/utils/fetch-timeout.ts +++ b/src/utils/fetch-timeout.ts @@ -11,6 +11,40 @@ export function bindAbortRelay(controller: AbortController): () => void { return relayAbort.bind(controller); } +export function buildTimeoutAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { + signal?: AbortSignal; + cleanup: () => void; +} { + const { timeoutMs, signal } = params; + if (!timeoutMs && !signal) { + return { signal: undefined, cleanup: () => {} }; + } + if (!timeoutMs) { + return { signal, cleanup: () => {} }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs); + const onAbort = bindAbortRelay(controller); + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeoutId); + if (signal) { + signal.removeEventListener("abort", onAbort); + } + }, + }; +} + /** * Fetch wrapper that adds timeout support via AbortController. * @@ -27,11 +61,12 @@ export async function fetchWithTimeout( timeoutMs: number, fetchFn: typeof fetch = fetch, ): Promise { - const controller = new AbortController(); - const timer = setTimeout(controller.abort.bind(controller), Math.max(1, timeoutMs)); + const { signal, cleanup } = buildTimeoutAbortSignal({ + timeoutMs: Math.max(1, timeoutMs), + }); try { - return await fetchFn(url, { ...init, signal: controller.signal }); + return await fetchFn(url, { ...init, signal }); } finally { - clearTimeout(timer); + cleanup(); } } diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 147c0d48ee517..48aa24104a2c5 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; import { runWebSearch } from "./runtime.js"; type TestPluginWebSearchConfig = { @@ -13,6 +14,7 @@ type TestPluginWebSearchConfig = { describe("web search runtime", () => { afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); + clearSecretsRuntimeSnapshot(); }); it("executes searches through the active plugin registry", async () => { @@ -159,4 +161,92 @@ describe("web search runtime", () => { result: { query: "fallback", provider: "duckduckgo" }, }); }); + + it("prefers the active runtime-selected provider when callers omit runtime metadata", async () => { + const registry = createEmptyPluginRegistry(); + registry.webSearchProviders.push({ + pluginId: "alpha-search", + pluginName: "Alpha Search", + provider: { + id: "alpha", + label: "Alpha Search", + hint: "Alpha runtime provider", + envVars: ["ALPHA_SEARCH_API_KEY"], + placeholder: "alpha-...", + signupUrl: "https://example.com/alpha", + credentialPath: "tools.web.search.alpha.apiKey", + autoDetectOrder: 1, + getCredentialValue: () => "alpha-configured", + setCredentialValue: () => {}, + createTool: ({ runtimeMetadata }) => ({ + description: "alpha", + parameters: {}, + execute: async (args) => ({ + ...args, + provider: "alpha", + runtimeSelectedProvider: runtimeMetadata?.selectedProvider, + }), + }), + }, + source: "test", + }); + registry.webSearchProviders.push({ + pluginId: "beta-search", + pluginName: "Beta Search", + provider: { + id: "beta", + label: "Beta Search", + hint: "Beta runtime provider", + envVars: ["BETA_SEARCH_API_KEY"], + placeholder: "beta-...", + signupUrl: "https://example.com/beta", + credentialPath: "tools.web.search.beta.apiKey", + autoDetectOrder: 2, + getCredentialValue: () => "beta-configured", + setCredentialValue: () => {}, + createTool: ({ runtimeMetadata }) => ({ + description: "beta", + parameters: {}, + execute: async (args) => ({ + ...args, + provider: "beta", + runtimeSelectedProvider: runtimeMetadata?.selectedProvider, + }), + }), + }, + source: "test", + }); + setActivePluginRegistry(registry); + activateSecretsRuntimeSnapshot({ + sourceConfig: {}, + config: {}, + authStores: [], + warnings: [], + webTools: { + search: { + providerSource: "auto-detect", + selectedProvider: "beta", + diagnostics: [], + }, + fetch: { + firecrawl: { + active: false, + apiKeySource: "missing", + diagnostics: [], + }, + }, + diagnostics: [], + }, + }); + + await expect( + runWebSearch({ + config: {}, + args: { query: "runtime" }, + }), + ).resolves.toEqual({ + provider: "beta", + result: { query: "runtime", provider: "beta", runtimeSelectedProvider: "beta" }, + }); + }); }); diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 4c12081ae12c1..31040fb3df723 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -8,7 +8,9 @@ import type { import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js"; import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import { resolveRuntimeWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; +import { sortWebSearchProvidersForAutoDetect } from "../plugins/web-search-providers.shared.js"; import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; +import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; type WebSearchConfig = NonNullable["web"] extends infer Web @@ -116,12 +118,13 @@ export function resolveWebSearchProviderId(params: { config?: OpenClawConfig; providers?: PluginWebSearchProviderEntry[]; }): string { - const providers = + const providers = sortWebSearchProvidersForAutoDetect( params.providers ?? - resolveBundledPluginWebSearchProviders({ - config: params.config, - bundledAllowlistCompat: true, - }); + resolveBundledPluginWebSearchProviders({ + config: params.config, + bundledAllowlistCompat: true, + }), + ); const raw = params.search && "provider" in params.search && typeof params.search.provider === "string" ? params.search.provider.trim().toLowerCase() @@ -164,11 +167,12 @@ export function resolveWebSearchDefinition( options?: ResolveWebSearchDefinitionParams, ): { provider: PluginWebSearchProviderEntry; definition: WebSearchProviderToolDefinition } | null { const search = resolveSearchConfig(options?.config); + const runtimeWebSearch = options?.runtimeWebSearch ?? getActiveRuntimeWebToolsMetadata()?.search; if (!resolveWebSearchEnabled({ search, sandboxed: options?.sandboxed })) { return null; } - const providers = ( + const providers = sortWebSearchProvidersForAutoDetect( options?.preferRuntimeProviders ? resolveRuntimeWebSearchProviders({ config: options?.config, @@ -177,7 +181,7 @@ export function resolveWebSearchDefinition( : resolveBundledPluginWebSearchProviders({ config: options?.config, bundledAllowlistCompat: true, - }) + }), ).filter(Boolean); if (providers.length === 0) { return null; @@ -185,8 +189,8 @@ export function resolveWebSearchDefinition( const providerId = options?.providerId ?? - options?.runtimeWebSearch?.selectedProvider ?? - options?.runtimeWebSearch?.providerConfigured ?? + runtimeWebSearch?.selectedProvider ?? + runtimeWebSearch?.providerConfigured ?? resolveWebSearchProviderId({ config: options?.config, search, providers }); const provider = providers.find((entry) => entry.id === providerId) ?? @@ -202,7 +206,7 @@ export function resolveWebSearchDefinition( const definition = provider.createTool({ config: options?.config, searchConfig: search as Record | undefined, - runtimeMetadata: options?.runtimeWebSearch, + runtimeMetadata: runtimeWebSearch, }); if (!definition) { return null; diff --git a/src/wizard/clack-prompter.test.ts b/src/wizard/clack-prompter.test.ts index 2954e924fc88b..3db5af30e8278 100644 --- a/src/wizard/clack-prompter.test.ts +++ b/src/wizard/clack-prompter.test.ts @@ -4,32 +4,32 @@ import { tokenizedOptionFilter } from "./clack-prompter.js"; describe("tokenizedOptionFilter", () => { it("matches tokens regardless of order", () => { const option = { - value: "openai/gpt-5.2", - label: "openai/gpt-5.2", + value: "openai/gpt-5.4", + label: "openai/gpt-5.4", hint: "ctx 400k", }; - expect(tokenizedOptionFilter("gpt-5.2 openai/", option)).toBe(true); - expect(tokenizedOptionFilter("openai/ gpt-5.2", option)).toBe(true); + expect(tokenizedOptionFilter("gpt-5.4 openai/", option)).toBe(true); + expect(tokenizedOptionFilter("openai/ gpt-5.4", option)).toBe(true); }); it("requires all tokens to match", () => { const option = { - value: "openai/gpt-5.2", - label: "openai/gpt-5.2", + value: "openai/gpt-5.4", + label: "openai/gpt-5.4", }; - expect(tokenizedOptionFilter("gpt-5.2 anthropic/", option)).toBe(false); + expect(tokenizedOptionFilter("gpt-5.4 anthropic/", option)).toBe(false); }); it("matches against label, hint, and value", () => { const option = { - value: "openai/gpt-5.2", - label: "GPT 5.2", + value: "openai/gpt-5.4", + label: "GPT 5.4", hint: "provider openai", }; expect(tokenizedOptionFilter("provider openai", option)).toBe(true); - expect(tokenizedOptionFilter("openai gpt-5.2", option)).toBe(true); + expect(tokenizedOptionFilter("openai gpt-5.4", option)).toBe(true); }); }); diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 23f8e2eee91e2..f42ae0c3d9f53 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -177,6 +177,59 @@ function expectFirstOnboardingInstallPlanCallOmitsToken() { expect(firstArg && "token" in firstArg).toBe(false); } +type AdvancedFinalizeArgs = { + nextConfig?: OpenClawConfig; + prompter?: ReturnType; + runtime?: RuntimeEnv; + installDaemon?: boolean; +}; + +function createLaterPrompter() { + return buildWizardPrompter({ + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); +} + +function createEnabledFirecrawlSearchConfig(): OpenClawConfig { + return { + tools: { + web: { + search: { + provider: "firecrawl", + enabled: true, + }, + }, + }, + }; +} + +function createAdvancedFinalizeArgs(params: AdvancedFinalizeArgs = {}) { + return { + flow: "advanced" as const, + opts: { + acceptRisk: true, + authChoice: "skip" as const, + installDaemon: params.installDaemon ?? false, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: params.nextConfig ?? {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback" as const, + authMode: "token" as const, + gatewayToken: undefined, + tailscaleMode: "off" as const, + tailscaleResetOnExit: false, + }, + prompter: params.prompter ?? createLaterPrompter(), + runtime: params.runtime ?? createRuntime(), + }; +} + describe("finalizeSetupWizard", () => { beforeEach(() => { runTui.mockClear(); @@ -382,43 +435,14 @@ describe("finalizeSetupWizard", () => { }); it("reports selected providers blocked by plugin policy as unavailable", async () => { - const prompter = buildWizardPrompter({ - select: vi.fn(async () => "later") as never, - confirm: vi.fn(async () => false), - }); + const prompter = createLaterPrompter(); - await finalizeSetupWizard({ - flow: "advanced", - opts: { - acceptRisk: true, - authChoice: "skip", - installDaemon: false, - skipHealth: true, - skipUi: true, - }, - baseConfig: {}, - nextConfig: { - tools: { - web: { - search: { - provider: "firecrawl", - enabled: true, - }, - }, - }, - }, - workspaceDir: "/tmp", - settings: { - port: 18789, - bind: "loopback", - authMode: "token", - gatewayToken: undefined, - tailscaleMode: "off", - tailscaleResetOnExit: false, - }, - prompter, - runtime: createRuntime(), - }); + await finalizeSetupWizard( + createAdvancedFinalizeArgs({ + nextConfig: createEnabledFirecrawlSearchConfig(), + prompter, + }), + ); expect(prompter.note).toHaveBeenCalledWith( expect.stringContaining("selected but unavailable under the current plugin policy"), @@ -442,34 +466,9 @@ describe("finalizeSetupWizard", () => { ]); hasExistingKey.mockImplementation((_config, provider) => provider === "perplexity"); - const prompter = buildWizardPrompter({ - select: vi.fn(async () => "later") as never, - confirm: vi.fn(async () => false), - }); + const prompter = createLaterPrompter(); - await finalizeSetupWizard({ - flow: "advanced", - opts: { - acceptRisk: true, - authChoice: "skip", - installDaemon: false, - skipHealth: true, - skipUi: true, - }, - baseConfig: {}, - nextConfig: {}, - workspaceDir: "/tmp", - settings: { - port: 18789, - bind: "loopback", - authMode: "token", - gatewayToken: undefined, - tailscaleMode: "off", - tailscaleResetOnExit: false, - }, - prompter, - runtime: createRuntime(), - }); + await finalizeSetupWizard(createAdvancedFinalizeArgs({ prompter })); expect(prompter.note).toHaveBeenCalledWith( expect.stringContaining("Web search is available via Perplexity Search (auto-detected)."), @@ -491,43 +490,14 @@ describe("finalizeSetupWizard", () => { ]); hasExistingKey.mockImplementation((_config, provider) => provider === "firecrawl"); - const prompter = buildWizardPrompter({ - select: vi.fn(async () => "later") as never, - confirm: vi.fn(async () => false), - }); + const prompter = createLaterPrompter(); - await finalizeSetupWizard({ - flow: "advanced", - opts: { - acceptRisk: true, - authChoice: "skip", - installDaemon: false, - skipHealth: true, - skipUi: true, - }, - baseConfig: {}, - nextConfig: { - tools: { - web: { - search: { - provider: "firecrawl", - enabled: true, - }, - }, - }, - }, - workspaceDir: "/tmp", - settings: { - port: 18789, - bind: "loopback", - authMode: "token", - gatewayToken: undefined, - tailscaleMode: "off", - tailscaleResetOnExit: false, - }, - prompter, - runtime: createRuntime(), - }); + await finalizeSetupWizard( + createAdvancedFinalizeArgs({ + nextConfig: createEnabledFirecrawlSearchConfig(), + prompter, + }), + ); expect(prompter.note).toHaveBeenCalledWith( expect.stringContaining( diff --git a/test/architecture-smells.test.ts b/test/architecture-smells.test.ts index c64a4cdc36353..7a612e5367276 100644 --- a/test/architecture-smells.test.ts +++ b/test/architecture-smells.test.ts @@ -1,26 +1,6 @@ import { describe, expect, it } from "vitest"; import { collectArchitectureSmells, main } from "../scripts/check-architecture-smells.mjs"; - -function createCapturedIo() { - let stdout = ""; - let stderr = ""; - return { - io: { - stdout: { - write(chunk) { - stdout += String(chunk); - }, - }, - stderr: { - write(chunk) { - stderr += String(chunk); - }, - }, - }, - readStdout: () => stdout, - readStderr: () => stderr, - }; -} +import { createCapturedIo } from "./helpers/captured-io.js"; describe("architecture smell inventory", () => { it("produces stable sorted output", async () => { diff --git a/test/extension-plugin-sdk-boundary.test.ts b/test/extension-plugin-sdk-boundary.test.ts index 1c8fc86651672..f7acd5f28fdce 100644 --- a/test/extension-plugin-sdk-boundary.test.ts +++ b/test/extension-plugin-sdk-boundary.test.ts @@ -3,6 +3,7 @@ import { collectExtensionPluginSdkBoundaryInventory, main, } from "../scripts/check-extension-plugin-sdk-boundary.mjs"; +import { createCapturedIo } from "./helpers/captured-io.js"; const srcOutsideInventoryPromise = collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); @@ -27,27 +28,6 @@ async function getJsonOutput( }; } -function createCapturedIo() { - let stdout = ""; - let stderr = ""; - return { - io: { - stdout: { - write(chunk) { - stdout += String(chunk); - }, - }, - stderr: { - write(chunk) { - stderr += String(chunk); - }, - }, - }, - readStdout: () => stdout, - readStderr: () => stderr, - }; -} - describe("extension src outside plugin-sdk boundary inventory", () => { it("stays empty and sorted", async () => { const inventory = await srcOutsideInventoryPromise; diff --git a/test/fixtures/test-memory-hotspots.unit.json b/test/fixtures/test-memory-hotspots.unit.json index c5c3c975ce018..25ce45ca80add 100644 --- a/test/fixtures/test-memory-hotspots.unit.json +++ b/test/fixtures/test-memory-hotspots.unit.json @@ -1,159 +1,66 @@ { "config": "vitest.unit.config.ts", - "generatedAt": "2026-03-20T04:59:15.285Z", + "generatedAt": "2026-03-24T17:44:58.770Z", "defaultMinDeltaKb": 262144, - "lane": "unit-fast", + "lane": "unit-fast, unit-*", "files": { - "src/config/schema.help.quality.test.ts": { + "src/infra/outbound/targets.channel-resolution.test.ts": { "deltaKb": 1111491, - "sources": [ - "gha-23328306205-compat-node22:unit-fast", - "gha-23328306205-node-test-2-2:unit-fast" - ] + "sources": ["openclaw-test-memory-trace:unit-heavy-2"] }, - "src/plugins/conversation-binding.test.ts": { - "deltaKb": 787149, - "sources": ["gha-23329089711-node-test-2-2:unit-fast"] + "src/media/fetch.telegram-network.test.ts": { + "deltaKb": 1101005, + "sources": ["openclaw-test-memory-trace:unit-heavy-1"] }, - "src/plugins/contracts/wizard.contract.test.ts": { - "deltaKb": 783770, - "sources": ["gha-23329089711-node-test-1-2:unit-fast"] + "src/cron/isolated-agent/run.skill-filter.test.ts": { + "deltaKb": 1069548, + "sources": ["openclaw-test-memory-trace:unit-run.skill-filter-memory-isolated"] }, - "ui/src/ui/views/chat.test.ts": { - "deltaKb": 740864, - "sources": ["gha-23329089711-node-test-2-2:unit-fast"] + "src/infra/outbound/deliver.test.ts": { + "deltaKb": 1059062, + "sources": ["openclaw-deliver-mem:unit"] }, "src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts": { - "deltaKb": 652288, - "sources": ["gha-23328306205-node-test-1-2:unit-fast"] - }, - "src/plugins/install.test.ts": { - "deltaKb": 545485, - "sources": ["gha-23328306205-compat-node22:unit-fast"] - }, - "src/tui/tui.submit-handler.test.ts": { - "deltaKb": 528486, - "sources": ["gha-23329089711-node-test-1-2:unit-fast"] - }, - "src/media-understanding/resolve.test.ts": { - "deltaKb": 516506, - "sources": ["job1:unit-fast"] - }, - "src/infra/provider-usage.auth.normalizes-keys.test.ts": { - "deltaKb": 510362, - "sources": ["gha-23329089711-node-test-1-2:unit-fast"] - }, - "src/acp/client.test.ts": { - "deltaKb": 491213, - "sources": ["job2:unit-fast"] - }, - "src/infra/update-runner.test.ts": { - "deltaKb": 474726, - "sources": ["gha-23328306205-node-test-1-2:unit-fast"] - }, - "src/secrets/runtime-web-tools.test.ts": { - "deltaKb": 473190, - "sources": ["job1:unit-fast"] - }, - "src/cron/isolated-agent/run.cron-model-override.test.ts": { - "deltaKb": 469914, - "sources": ["gha-23328306205-node-test-2-2:unit-fast"] + "deltaKb": 1048576, + "sources": [ + "openclaw-test-memory-trace:unit-isolated-agent.uses-last-non-empty-agent-text-as-memory-isolated" + ] }, "src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts": { - "deltaKb": 457421, - "sources": ["gha-23328306205-node-test-1-2:unit-fast"] - }, - "src/cron/isolated-agent/run.skill-filter.test.ts": { - "deltaKb": 446054, - "sources": ["gha-23328306205-compat-node22:unit-fast"] - }, - "src/plugins/interactive.test.ts": { - "deltaKb": 441242, - "sources": ["gha-23329089711-node-test-1-2:unit-fast"] - }, - "src/infra/run-node.test.ts": { - "deltaKb": 427213, - "sources": ["gha-23328306205-node-test-2-2:unit-fast"] - }, - "src/media-understanding/runner.video.test.ts": { - "deltaKb": 402739, - "sources": ["job1:unit-fast"] - }, - "src/infra/provider-usage.test.ts": { - "deltaKb": 389837, - "sources": ["gha-23329089711-node-test-2-2:unit-fast"] - }, - "src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts": { - "deltaKb": 377446, - "sources": ["gha-23328306205-compat-node22:unit-fast"] - }, - "src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts": { - "deltaKb": 355840, - "sources": ["gha-23329089711-node-test-1-2:unit-fast"] - }, - "src/infra/state-migrations.test.ts": { - "deltaKb": 345805, - "sources": ["gha-23328306205-node-test-1-2:unit-fast"] - }, - "src/config/sessions/store.pruning.integration.test.ts": { - "deltaKb": 342221, - "sources": ["gha-23328306205-node-test-1-2:unit-fast"] - }, - "src/channels/plugins/contracts/outbound-payload.contract.test.ts": { - "deltaKb": 335565, - "sources": ["gha-23329089711-node-test-1-2:unit-fast"] - }, - "src/config/sessions/store.pruning.test.ts": { - "deltaKb": 333312, - "sources": ["job2:unit-fast"] - }, - "src/media-understanding/providers/moonshot/video.test.ts": { - "deltaKb": 333005, - "sources": ["gha-23329089711-node-test-2-2:unit-fast"] - }, - "src/infra/heartbeat-runner.model-override.test.ts": { - "deltaKb": 325632, - "sources": ["job1:unit-fast"] - }, - "src/config/sessions.test.ts": { - "deltaKb": 324813, - "sources": ["gha-23329089711-node-test-2-2:unit-fast"] - }, - "src/acp/translator.cancel-scoping.test.ts": { - "deltaKb": 324403, - "sources": ["gha-23328306205-node-test-1-2:unit-fast"] - }, - "src/infra/heartbeat-runner.ghost-reminder.test.ts": { - "deltaKb": 321536, - "sources": ["job1:unit-fast"] + "deltaKb": 1026355, + "sources": [ + "openclaw-test-memory-trace:unit-isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true-memory-isolated" + ] }, - "src/tui/tui-session-actions.test.ts": { - "deltaKb": 319898, - "sources": ["gha-23328306205-compat-node22:unit-fast"] + "src/cron/isolated-agent/run.cron-model-override.test.ts": { + "deltaKb": 946790, + "sources": ["openclaw-test-memory-trace:unit-run.cron-model-override-memory-isolated"] }, - "src/infra/outbound/message-action-runner.context.test.ts": { - "deltaKb": 318157, - "sources": ["gha-23328306205-compat-node22:unit-fast"] + "src/channels/plugins/plugins-core.test.ts": { + "deltaKb": 792269, + "sources": ["openclaw-plugins-core-mem:unit"] }, - "src/cron/service.store-load-invalid-main-job.test.ts": { - "deltaKb": 308019, - "sources": ["gha-23328306205-node-test-2-2:unit-fast"] + "src/plugins/manifest-registry.test.ts": { + "deltaKb": 684749, + "sources": ["openclaw-manifest-registry-mem:unit"] }, - "src/channels/plugins/outbound/signal.test.ts": { - "deltaKb": 301056, - "sources": ["job2:unit-fast"] + "src/plugins/install.test.ts": { + "deltaKb": 510874, + "sources": ["openclaw-test-memory-trace:unit-install-memory-isolated"] }, - "src/cron/service.store-migration.test.ts": { - "deltaKb": 282931, - "sources": ["gha-23328306205-node-test-2-2:unit-fast"] + "ui/src/ui/views/chat.test.ts": { + "deltaKb": 509338, + "sources": ["openclaw-test-memory-trace:unit-chat-memory-isolated"] }, - "src/media-understanding/providers/google/video.test.ts": { - "deltaKb": 274022, - "sources": ["gha-23328306205-node-test-2-2:unit-fast"] + "src/infra/provider-usage.auth.normalizes-keys.test.ts": { + "deltaKb": 494080, + "sources": [ + "openclaw-test-memory-trace:unit-provider-usage.auth.normalizes-keys-memory-isolated" + ] }, - "src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts": { - "deltaKb": 267366, - "sources": ["job2:unit-fast"] + "src/plugins/conversation-binding.test.ts": { + "deltaKb": 485786, + "sources": ["openclaw-test-memory-trace:unit-conversation-binding-memory-isolated"] } } } diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index 28c9d71c3ed1a..262c9fa11b9fb 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -12,6 +12,99 @@ { "file": "src/agents/pi-extensions/compaction-safeguard.test.ts", "reason": "Measured ~27% faster under threads than forks on base config after removing process.chdir() from the test." + }, + { + "file": "src/auto-reply/reply/followup-runner.test.ts", + "reason": "This followup-runner suite is green under direct base-thread runs but can hang when targeted through the wrapper's default base fork lane; pin it to base threads so wrapper-targeted reruns match the stable raw-threads behavior." + } + ] + }, + "channels": { + "isolatedPrefixes": ["extensions/discord/src/monitor/"], + "isolated": [ + { + "file": "extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts", + "reason": "This Discord preflight suite hoists conversation-runtime ACP binding mocks; keep it in its own forked channel lane so shared workers do not reuse monitor modules with incompatible runtime stubs." + }, + { + "file": "extensions/discord/src/monitor/monitor.threading-utils.test.ts", + "reason": "This Discord threading utility suite exercises gateway and presence caches plus auto-thread helpers; keep it in its own forked channel lane so shared workers cannot inherit mutated monitor module state." + }, + { + "file": "extensions/discord/src/monitor/monitor.agent-components.test.ts", + "reason": "This Discord agent-components suite hoists security and conversation runtime mocks for DM component authorization; keep it in its own forked channel lane so shared workers do not inherit incompatible Discord runtime stubs." + }, + { + "file": "extensions/discord/src/monitor/native-command.model-picker.test.ts", + "reason": "This Discord model-picker interaction suite relies on isolated native-command fallback state and DM channel enums; keep it in its own forked channel lane so shared workers do not reuse polluted command runtime modules." + }, + { + "file": "extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts", + "reason": "This Discord native plugin-dispatch suite hoists conversation and reply runtime mocks for slash-command routing; keep it in its own forked channel lane so shared workers do not reuse mismatched native-command modules." + }, + { + "file": "extensions/discord/src/monitor/agent-components.wildcard.test.ts", + "reason": "This Discord wildcard component suite is green alone but can inherit polluted component-registration parser state from the shared channel lane; keep it in its own forked lane for deterministic CI." + }, + { + "file": "extensions/discord/src/send.creates-thread.test.ts", + "reason": "This Discord send/create-thread suite hoists web-media mocks and retries against rate-limit seams; keep it isolated so shared channel workers do not leak send-module state into its retry assertions." + }, + { + "file": "extensions/discord/src/components.test.ts", + "reason": "This Discord component builder suite is green alone but can inherit polluted component constructor state from the shared channel lane; keep it in its own forked lane for deterministic CI." + }, + { + "file": "extensions/discord/src/api.test.ts", + "reason": "This Discord API retry suite is green alone but can inherit timer or fetch retry contamination from the shared channel lane; keep it in its own forked lane for deterministic CI." + }, + { + "file": "extensions/discord/src/monitor/provider.skill-dedupe.test.ts", + "reason": "This Discord provider skill-dedupe suite depends on agent-component classes during provider import; keep it isolated so shared channel workers do not reuse partially mocked component modules." + }, + { + "file": "extensions/discord/src/monitor/monitor.test.ts", + "reason": "This Discord monitor interaction suite imports agent-component classes directly and is green alone but can inherit partially mocked Discord component constructors from the shared channel lane." + }, + { + "file": "extensions/whatsapp/src/inbound.test.ts", + "reason": "This WhatsApp inbound helper suite is green alone but can inherit polluted web-message parsing state from the shared channel lane; keep it in its own forked lane for deterministic CI." + }, + { + "file": "extensions/whatsapp/src/inbound.media.test.ts", + "reason": "This WhatsApp inbound media suite is green alone but can inherit polluted media and inbound parsing state from the shared channel lane; keep it in its own forked lane for deterministic CI." + }, + { + "file": "src/browser/chrome.test.ts", + "reason": "This Chrome helper suite is green alone but can inherit stale fetch, websocket, or timer state from the shared channel lane; keep it isolated so its CDP timeout assertions stay deterministic." + }, + { + "file": "src/browser/server-context.remote-tab-ops.test.ts", + "reason": "This browser remote-tab aggregate wrapper measured ~1.03 GiB RSS growth locally even after explicit Playwright teardown; keep it in its own forked channel lane so the shared channels worker can recycle immediately after the hotspot file." + }, + { + "file": "extensions/telegram/src/bot.create-telegram-bot.test.ts", + "reason": "This Telegram bot bootstrap suite is green alone but can inherit stale command-registration spies and bot runtime state from the shared channel lane; keep it isolated for deterministic command assertions." + }, + { + "file": "extensions/telegram/src/bot.test.ts", + "reason": "This Telegram bot runtime suite measured ~819.9 MiB RSS growth locally; keep it in its own forked channel lane so the shared channels worker can recycle immediately after the hotspot file." + }, + { + "file": "extensions/telegram/src/fetch.test.ts", + "reason": "This Telegram transport suite measured ~759.3 MiB RSS growth locally; keep it in its own forked channel lane so the shared channels worker can recycle immediately after the hotspot file." + }, + { + "file": "extensions/telegram/src/monitor.test.ts", + "reason": "This Telegram monitor suite measured ~748.4 MiB RSS growth locally; keep it in its own forked channel lane so the shared channels worker can recycle immediately after the hotspot file." + }, + { + "file": "extensions/telegram/src/webhook.test.ts", + "reason": "This Telegram webhook suite hoists grammY bot and InputFile mocks; keep it in its own forked channel lane so shared workers do not reuse telegram runtime modules with mismatched constructors." + }, + { + "file": "extensions/slack/src/action-runtime.test.ts", + "reason": "This Slack action runtime suite measured ~766.2 MiB RSS growth locally; keep it in its own forked channel lane so the shared channels worker can recycle immediately after the hotspot file." } ] }, @@ -28,11 +121,60 @@ { "file": "extensions/nextcloud-talk/src/monitor.replay.test.ts", "reason": "The replay-handling regression is green alone but can inherit disturbed global stream/Response state from the shared extensions lane, so keep it in its own forked lane for deterministic CI." + }, + { + "file": "extensions/duckduckgo/src/ddg-search-provider.test.ts", + "reason": "This provider test hoists a client mock and imports the provider lazily; keep it in its own forked lane so shared extension workers do not reuse a previously cached module under test." + }, + { + "file": "extensions/firecrawl/src/firecrawl-scrape-tool.test.ts", + "reason": "This scrape-tool suite hoists client mocks and imports the tool lazily; keep it in its own forked lane so shared extension workers cannot bypass the mocked client via cached modules." + }, + { + "file": "extensions/firecrawl/src/firecrawl-search-provider.test.ts", + "reason": "This provider suite hoists the Firecrawl client mock and imports lazily; keep it isolated so shared extension workers do not reuse stale provider modules." + }, + { + "file": "extensions/firecrawl/src/firecrawl-search-tool.test.ts", + "reason": "This tool suite hoists the Firecrawl client mock and imports lazily; keep it isolated so shared extension workers cannot leak cached implementations across files." + }, + { + "file": "extensions/line/src/download.test.ts", + "reason": "This LINE media-download suite depends on hoisted SDK mocks; run it in its own forked lane so shared extension workers do not fall back to the real SDK client." + }, + { + "file": "extensions/line/src/monitor.lifecycle.test.ts", + "reason": "This LINE lifecycle suite hoists runtime and route-registration mocks; keep it isolated so shared extension workers do not reuse cached monitor modules with real dependencies." + }, + { + "file": "extensions/line/src/probe.test.ts", + "reason": "This LINE probe suite depends on hoisted SDK mocks; keep it isolated so shared extension workers do not reuse cached probe modules with the real SDK client." + }, + { + "file": "extensions/line/src/send.test.ts", + "reason": "This LINE send suite hoists SDK and account/token mocks; keep it isolated so shared extension workers do not reuse cached send modules that bypass those mocks." + }, + { + "file": "extensions/tavily/src/tavily-extract-tool.test.ts", + "reason": "This extract-tool suite hoists the Tavily client mock and imports lazily; keep it isolated so shared extension workers do not reuse cached tool modules and hit real API-key resolution." + }, + { + "file": "extensions/tavily/src/tavily-search-provider.test.ts", + "reason": "This provider suite hoists the Tavily client mock and imports lazily; keep it isolated so shared extension workers do not reuse stale provider modules." + }, + { + "file": "extensions/tavily/src/tavily-search-tool.test.ts", + "reason": "This search-tool suite hoists the Tavily client mock and imports lazily; keep it isolated so shared extension workers do not bypass the mocked client via cached modules." } ] }, "unit": { - "isolated": [], + "isolated": [ + { + "file": "src/cli/qr-dashboard.integration.test.ts", + "reason": "This CLI integration suite hoists runtime/config mocks and resets module state between qr and dashboard command imports; keep it in its own forked lane so shared unit-fast workers stay deterministic." + } + ], "threadPinned": [] } } diff --git a/test/fixtures/test-timings.channels.json b/test/fixtures/test-timings.channels.json new file mode 100644 index 0000000000000..f4efd016ff254 --- /dev/null +++ b/test/fixtures/test-timings.channels.json @@ -0,0 +1,1096 @@ +{ + "config": "vitest.channels.config.ts", + "generatedAt": "2026-03-24T04:15:05Z", + "defaultDurationMs": 3000, + "files": { + "extensions/telegram/src/bot.create-telegram-bot.test.ts": { + "durationMs": 13900 + }, + "extensions/telegram/src/bot-message-dispatch.test.ts": { + "durationMs": 12600 + }, + "extensions/discord/src/monitor/exec-approvals.test.ts": { + "durationMs": 11300 + }, + "extensions/telegram/src/sendchataction-401-backoff.test.ts": { + "durationMs": 10400 + }, + "extensions/telegram/src/bot.test.ts": { + "durationMs": 8500 + }, + "extensions/discord/src/monitor/message-utils.test.ts": { + "durationMs": 6000 + }, + "extensions/discord/src/monitor/reply-delivery.test.ts": { + "durationMs": 6000 + }, + "extensions/discord/src/monitor/message-handler.process.test.ts": { + "durationMs": 5700 + }, + "extensions/telegram/src/monitor.test.ts": { + "durationMs": 5600 + }, + "extensions/discord/src/send.sends-basic-channel-messages.test.ts": { + "durationMs": 5300 + }, + "extensions/discord/src/monitor/provider.test.ts": { + "durationMs": 5100 + }, + "extensions/slack/src/monitor/events/interactions.test.ts": { + "durationMs": 5100 + }, + "extensions/discord/src/send.creates-thread.test.ts": { + "durationMs": 4600 + }, + "extensions/whatsapp/src/media.test.ts": { + "durationMs": 4600 + }, + "extensions/whatsapp/src/inbound.test.ts": { + "durationMs": 4400 + }, + "extensions/slack/src/monitor.tool-result.test.ts": { + "durationMs": 4300 + }, + "extensions/discord/src/monitor/message-handler.preflight.test.ts": { + "durationMs": 4100 + }, + "extensions/discord/src/monitor/message-handler.queue.test.ts": { + "durationMs": 3900 + }, + "extensions/telegram/src/fetch.test.ts": { + "durationMs": 3500 + }, + "extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts": { + "durationMs": 3200 + }, + "extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts": { + "durationMs": 3000 + }, + "extensions/telegram/src/webhook.test.ts": { + "durationMs": 2900 + }, + "extensions/slack/src/monitor/slash.test.ts": { + "durationMs": 2800 + }, + "extensions/whatsapp/src/send.test.ts": { + "durationMs": 2700 + }, + "extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-context.test.ts": { + "durationMs": 2500 + }, + "extensions/discord/src/outbound-adapter.test.ts": { + "durationMs": 2300 + }, + "extensions/whatsapp/src/auto-reply/deliver-reply.test.ts": { + "durationMs": 2200 + }, + "extensions/slack/src/monitor/events/reactions.test.ts": { + "durationMs": 2200 + }, + "extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts": { + "durationMs": 2100 + }, + "extensions/telegram/src/bot-native-commands.session-meta.test.ts": { + "durationMs": 2100 + }, + "extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts": { + "durationMs": 2000 + }, + "extensions/signal/src/monitor/event-handler.mention-gating.test.ts": { + "durationMs": 1900 + }, + "extensions/telegram/src/bot-native-commands.test.ts": { + "durationMs": 1700 + }, + "extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts": { + "durationMs": 1700 + }, + "extensions/slack/src/monitor/events/messages.test.ts": { + "durationMs": 1700 + }, + "extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts": { + "durationMs": 1600 + }, + "extensions/whatsapp/src/resolve-target.test.ts": { + "durationMs": 1600 + }, + "extensions/telegram/src/bot-message-context.dm-threads.test.ts": { + "durationMs": 1600 + }, + "extensions/whatsapp/src/inbound/access-control.test.ts": { + "durationMs": 1600 + }, + "src/browser/routes/agent.snapshot.test.ts": { + "durationMs": 1600 + }, + "extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts": { + "durationMs": 1500 + }, + "src/browser/server-context.remote-tab-ops.test.ts": { + "durationMs": 1500 + }, + "extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts": { + "durationMs": 1500 + }, + "extensions/whatsapp/src/setup-surface.test.ts": { + "durationMs": 1400 + }, + "extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts": { + "durationMs": 1400 + }, + "extensions/discord/src/monitor/listeners.test.ts": { + "durationMs": 1300 + }, + "extensions/signal/src/monitor/event-handler.inbound-context.test.ts": { + "durationMs": 1300 + }, + "extensions/telegram/src/bot-message-context.named-account-dm.test.ts": { + "durationMs": 1200 + }, + "extensions/slack/src/monitor/events/pins.test.ts": { + "durationMs": 1200 + }, + "extensions/slack/src/monitor/events/members.test.ts": { + "durationMs": 1200 + }, + "extensions/discord/src/monitor/thread-bindings.discord-api.test.ts": { + "durationMs": 1100 + }, + "extensions/discord/src/monitor/native-command.options.test.ts": { + "durationMs": 1100 + }, + "src/browser/pw-tools-core.screenshots-element-selector.test.ts": { + "durationMs": 1000 + }, + "extensions/telegram/src/bot-message-context.audio-transcript.test.ts": { + "durationMs": 1000 + }, + "extensions/whatsapp/src/monitor-inbox.append-upsert.test.ts": { + "durationMs": 999 + }, + "src/browser/server.agent-contract-snapshot-endpoints.test.ts": { + "durationMs": 982 + }, + "extensions/slack/src/monitor/auth.test.ts": { + "durationMs": 981 + }, + "extensions/telegram/src/bot-message-context.acp-bindings.test.ts": { + "durationMs": 980 + }, + "extensions/slack/src/monitor/message-handler.test.ts": { + "durationMs": 967 + }, + "extensions/discord/src/monitor/provider.skill-dedupe.test.ts": { + "durationMs": 954 + }, + "extensions/discord/src/channel.test.ts": { + "durationMs": 950 + }, + "extensions/slack/src/send.upload.test.ts": { + "durationMs": 923 + }, + "extensions/telegram/src/bot-native-commands.registry.test.ts": { + "durationMs": 842 + }, + "extensions/slack/src/actions.download-file.test.ts": { + "durationMs": 838 + }, + "extensions/discord/src/monitor/native-command.model-picker.test.ts": { + "durationMs": 770 + }, + "extensions/slack/src/monitor/message-handler.app-mention-race.test.ts": { + "durationMs": 753 + }, + "src/browser/server-context.remote-profile-tab-ops.test.ts": { + "durationMs": 740 + }, + "extensions/whatsapp/src/inbound/send-api.test.ts": { + "durationMs": 732 + }, + "extensions/slack/src/monitor/replies.test.ts": { + "durationMs": 713 + }, + "extensions/telegram/src/send.proxy.test.ts": { + "durationMs": 709 + }, + "extensions/telegram/src/dm-access.test.ts": { + "durationMs": 704 + }, + "extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts": { + "durationMs": 701 + }, + "extensions/telegram/src/bot-message-context.thread-binding.test.ts": { + "durationMs": 695 + }, + "src/browser/pw-tools-core.waits-next-download-saves-it.test.ts": { + "durationMs": 677 + }, + "extensions/whatsapp/src/inbound.media.test.ts": { + "durationMs": 627 + }, + "extensions/telegram/src/probe.test.ts": { + "durationMs": 616 + }, + "src/browser/pw-tools-core.clamps-timeoutms-scrollintoview.test.ts": { + "durationMs": 610 + }, + "extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts": { + "durationMs": 581 + }, + "extensions/slack/src/monitor.threading.missing-thread-ts.test.ts": { + "durationMs": 570 + }, + "extensions/telegram/src/bot-native-commands.plugin-auth.test.ts": { + "durationMs": 563 + }, + "extensions/discord/src/monitor/agent-components.wildcard.test.ts": { + "durationMs": 547 + }, + "extensions/discord/src/send.components.test.ts": { + "durationMs": 511 + }, + "extensions/slack/src/monitor/events/channels.test.ts": { + "durationMs": 504 + }, + "extensions/whatsapp/src/session.test.ts": { + "durationMs": 479 + }, + "extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts": { + "durationMs": 434 + }, + "extensions/telegram/src/proxy.test.ts": { + "durationMs": 402 + }, + "extensions/discord/src/components.test.ts": { + "durationMs": 400 + }, + "extensions/telegram/src/audit.test.ts": { + "durationMs": 395 + }, + "src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts": { + "durationMs": 383 + }, + "extensions/telegram/src/polling-session.test.ts": { + "durationMs": 379 + }, + "extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts": { + "durationMs": 379 + }, + "extensions/telegram/src/bot-native-command-menu.test.ts": { + "durationMs": 359 + }, + "extensions/whatsapp/src/login.test.ts": { + "durationMs": 350 + }, + "extensions/whatsapp/src/channel.outbound.test.ts": { + "durationMs": 335 + }, + "src/browser/chrome.test.ts": { + "durationMs": 324 + }, + "extensions/whatsapp/src/outbound-adapter.poll.test.ts": { + "durationMs": 284 + }, + "src/browser/server.agent-contract-form-layout-act-commands.test.ts": { + "durationMs": 282 + }, + "extensions/discord/src/send.webhook-activity.test.ts": { + "durationMs": 246 + }, + "src/browser/routes/basic.existing-session.test.ts": { + "durationMs": 221 + }, + "extensions/imessage/src/monitor/deliver.test.ts": { + "durationMs": 220 + }, + "extensions/telegram/src/network-config.test.ts": { + "durationMs": 216 + }, + "extensions/discord/src/voice-message.test.ts": { + "durationMs": 207 + }, + "src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts": { + "durationMs": 159 + }, + "extensions/discord/src/monitor/thread-session-close.test.ts": { + "durationMs": 130 + }, + "extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts": { + "durationMs": 129 + }, + "src/browser/pw-tools-core.interactions.set-input-files.test.ts": { + "durationMs": 127 + }, + "src/browser/server.evaluate-disabled-does-not-block-storage.test.ts": { + "durationMs": 122 + }, + "src/browser/pw-tools-core.interactions.evaluate.abort.test.ts": { + "durationMs": 122 + }, + "extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts": { + "durationMs": 120 + }, + "src/browser/cdp-proxy-bypass.test.ts": { + "durationMs": 119 + }, + "src/browser/server-context.tab-selection-state.test.ts": { + "durationMs": 115 + }, + "src/browser/screenshot.test.ts": { + "durationMs": 108 + }, + "extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts": { + "durationMs": 103 + }, + "src/browser/client-fetch.loopback-auth.test.ts": { + "durationMs": 101 + }, + "src/browser/bridge-server.auth.test.ts": { + "durationMs": 94 + }, + "extensions/whatsapp/src/inbound/media.node.test.ts": { + "durationMs": 90 + }, + "src/browser/control-auth.auto-token.test.ts": { + "durationMs": 85 + }, + "extensions/telegram/src/send.test.ts": { + "durationMs": 82 + }, + "extensions/telegram/src/draft-stream.test.ts": { + "durationMs": 81 + }, + "extensions/slack/src/sent-thread-cache.test.ts": { + "durationMs": 78 + }, + "extensions/slack/src/monitor/media.test.ts": { + "durationMs": 74 + }, + "extensions/discord/src/monitor.test.ts": { + "durationMs": 71 + }, + "extensions/telegram/src/sticker-cache.test.ts": { + "durationMs": 71 + }, + "extensions/slack/src/monitor/message-handler/prepare.test.ts": { + "durationMs": 68 + }, + "src/browser/server-context.existing-session.test.ts": { + "durationMs": 67 + }, + "src/browser/server.auth-fail-closed.test.ts": { + "durationMs": 63 + }, + "extensions/telegram/src/target-writeback.test.ts": { + "durationMs": 62 + }, + "extensions/signal/src/send-reactions.test.ts": { + "durationMs": 62 + }, + "src/browser/pw-session.connections.test.ts": { + "durationMs": 55 + }, + "extensions/slack/src/monitor/monitor.test.ts": { + "durationMs": 42 + }, + "extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts": { + "durationMs": 42 + }, + "extensions/discord/src/monitor/monitor.test.ts": { + "durationMs": 40 + }, + "extensions/discord/src/monitor/provider.lifecycle.test.ts": { + "durationMs": 39 + }, + "src/browser/cdp.test.ts": { + "durationMs": 39 + }, + "extensions/telegram/src/bot/delivery.test.ts": { + "durationMs": 33 + }, + "src/browser/profiles-service.test.ts": { + "durationMs": 32 + }, + "extensions/telegram/src/format.wrap-md.test.ts": { + "durationMs": 28 + }, + "extensions/telegram/src/thread-bindings.test.ts": { + "durationMs": 25 + }, + "extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts": { + "durationMs": 24 + }, + "extensions/slack/src/send.blocks.test.ts": { + "durationMs": 21 + }, + "extensions/telegram/src/format.test.ts": { + "durationMs": 20 + }, + "src/browser/server.auth-token-gates-http.test.ts": { + "durationMs": 20 + }, + "extensions/telegram/src/bot-message-context.implicit-mention.test.ts": { + "durationMs": 20 + }, + "extensions/telegram/src/action-runtime.test.ts": { + "durationMs": 19 + }, + "extensions/telegram/src/bot-message-context.topic-agentid.test.ts": { + "durationMs": 19 + }, + "src/browser/routes/agent.existing-session.test.ts": { + "durationMs": 19 + }, + "extensions/signal/src/format.chunking.test.ts": { + "durationMs": 18 + }, + "extensions/whatsapp/src/resolve-outbound-target.test.ts": { + "durationMs": 18 + }, + "extensions/telegram/src/bot-native-commands.group-auth.test.ts": { + "durationMs": 17 + }, + "extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts": { + "durationMs": 17 + }, + "extensions/discord/src/monitor/model-picker.test.ts": { + "durationMs": 16 + }, + "src/browser/navigation-guard.test.ts": { + "durationMs": 16 + }, + "extensions/slack/src/action-runtime.test.ts": { + "durationMs": 15 + }, + "extensions/telegram/src/lane-delivery.test.ts": { + "durationMs": 14 + }, + "src/browser/server-context.reset.test.ts": { + "durationMs": 14 + }, + "extensions/discord/src/monitor/model-picker-preferences.test.ts": { + "durationMs": 14 + }, + "extensions/discord/src/actions/runtime.test.ts": { + "durationMs": 14 + }, + "extensions/discord/src/monitor/provider.proxy.test.ts": { + "durationMs": 13 + }, + "extensions/telegram/src/bot-message.test.ts": { + "durationMs": 13 + }, + "extensions/telegram/src/bot.fetch-abort.test.ts": { + "durationMs": 13 + }, + "extensions/discord/src/monitor/monitor.agent-components.test.ts": { + "durationMs": 13 + }, + "extensions/signal/src/format.test.ts": { + "durationMs": 13 + }, + "extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts": { + "durationMs": 12 + }, + "extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts": { + "durationMs": 12 + }, + "extensions/slack/src/format.test.ts": { + "durationMs": 12 + }, + "extensions/slack/src/channel.test.ts": { + "durationMs": 12 + }, + "src/browser/pw-tools-core.interactions.batch.test.ts": { + "durationMs": 12 + }, + "extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts": { + "durationMs": 11 + }, + "extensions/telegram/src/channel.test.ts": { + "durationMs": 11 + }, + "extensions/imessage/src/send.test.ts": { + "durationMs": 11 + }, + "src/browser/pw-tools-core.snapshot.navigate-guard.test.ts": { + "durationMs": 11 + }, + "extensions/telegram/src/bot-message-context.sender-prefix.test.ts": { + "durationMs": 11 + }, + "extensions/whatsapp/src/active-listener.test.ts": { + "durationMs": 11 + }, + "src/browser/paths.test.ts": { + "durationMs": 11 + }, + "src/browser/server-context.hot-reload-profiles.test.ts": { + "durationMs": 11 + }, + "src/browser/proxy-files.test.ts": { + "durationMs": 11 + }, + "src/browser/client.test.ts": { + "durationMs": 10 + }, + "extensions/signal/src/format.links.test.ts": { + "durationMs": 10 + }, + "extensions/telegram/src/token.test.ts": { + "durationMs": 10 + }, + "extensions/discord/src/resolve-users.test.ts": { + "durationMs": 10 + }, + "extensions/imessage/src/channel.outbound.test.ts": { + "durationMs": 10 + }, + "extensions/discord/src/monitor/monitor.threading-utils.test.ts": { + "durationMs": 10 + }, + "extensions/imessage/src/monitor/inbound-processing.test.ts": { + "durationMs": 10 + }, + "extensions/discord/src/resolve-channels.test.ts": { + "durationMs": 9 + }, + "src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts": { + "durationMs": 9 + }, + "extensions/discord/src/api.test.ts": { + "durationMs": 9 + }, + "extensions/discord/src/monitor/inbound-job.test.ts": { + "durationMs": 9 + }, + "extensions/discord/src/monitor/message-handler.inbound-context.test.ts": { + "durationMs": 9 + }, + "src/browser/server-lifecycle.test.ts": { + "durationMs": 9 + }, + "extensions/telegram/src/accounts.test.ts": { + "durationMs": 9 + }, + "extensions/imessage/src/monitor.gating.test.ts": { + "durationMs": 9 + }, + "extensions/signal/src/format.visual.test.ts": { + "durationMs": 9 + }, + "src/browser/config.test.ts": { + "durationMs": 8 + }, + "extensions/telegram/src/network-errors.test.ts": { + "durationMs": 8 + }, + "src/browser/server-context.loopback-direct-ws.test.ts": { + "durationMs": 8 + }, + "extensions/telegram/src/update-offset-store.test.ts": { + "durationMs": 8 + }, + "extensions/slack/src/client.test.ts": { + "durationMs": 8 + }, + "extensions/discord/src/targets.test.ts": { + "durationMs": 8 + }, + "src/browser/routes/dispatcher.abort.test.ts": { + "durationMs": 8 + }, + "extensions/signal/src/client.test.ts": { + "durationMs": 8 + }, + "extensions/discord/src/send.permissions.authz.test.ts": { + "durationMs": 8 + }, + "extensions/whatsapp/src/login.coverage.test.ts": { + "durationMs": 8 + }, + "src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts": { + "durationMs": 8 + }, + "extensions/slack/src/probe.test.ts": { + "durationMs": 8 + }, + "src/browser/chrome-mcp.test.ts": { + "durationMs": 7 + }, + "src/browser/browser-utils.test.ts": { + "durationMs": 7 + }, + "src/browser/profiles.test.ts": { + "durationMs": 7 + }, + "extensions/signal/src/monitor.tool-result.autostart.test.ts": { + "durationMs": 7 + }, + "extensions/discord/src/actions/runtime.presence.test.ts": { + "durationMs": 7 + }, + "extensions/discord/src/audit.test.ts": { + "durationMs": 7 + }, + "extensions/imessage/src/targets.test.ts": { + "durationMs": 7 + }, + "extensions/discord/src/monitor/threading.auto-thread.test.ts": { + "durationMs": 7 + }, + "extensions/telegram/src/model-buttons.test.ts": { + "durationMs": 7 + }, + "extensions/telegram/src/targets.test.ts": { + "durationMs": 7 + }, + "extensions/slack/src/actions.blocks.test.ts": { + "durationMs": 7 + }, + "extensions/whatsapp/src/logout.test.ts": { + "durationMs": 7 + }, + "extensions/discord/src/monitor/thread-bindings.shared-state.test.ts": { + "durationMs": 6 + }, + "extensions/whatsapp/src/action-runtime.test.ts": { + "durationMs": 6 + }, + "extensions/telegram/src/setup-surface.test.ts": { + "durationMs": 6 + }, + "extensions/imessage/src/monitor/self-chat-cache.test.ts": { + "durationMs": 6 + }, + "extensions/signal/src/monitor/access-policy.test.ts": { + "durationMs": 6 + }, + "extensions/discord/src/subagent-hooks.test.ts": { + "durationMs": 6 + }, + "extensions/discord/src/actions/runtime.moderation.authz.test.ts": { + "durationMs": 6 + }, + "extensions/discord/src/monitor.gateway.test.ts": { + "durationMs": 6 + }, + "src/browser/pw-session.create-page.navigation-guard.test.ts": { + "durationMs": 6 + }, + "extensions/whatsapp/src/login-qr.test.ts": { + "durationMs": 6 + }, + "extensions/discord/src/monitor/dm-command-decision.test.ts": { + "durationMs": 6 + }, + "extensions/discord/src/monitor/auto-presence.test.ts": { + "durationMs": 6 + }, + "extensions/telegram/src/bot/helpers.test.ts": { + "durationMs": 6 + }, + "extensions/telegram/src/exec-approvals-handler.test.ts": { + "durationMs": 6 + }, + "src/browser/pw-session.test.ts": { + "durationMs": 6 + }, + "extensions/discord/src/directory-live.test.ts": { + "durationMs": 6 + }, + "extensions/discord/src/gateway-logging.test.ts": { + "durationMs": 6 + }, + "extensions/discord/src/monitor/native-command-context.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/monitor/threading.parent-info.test.ts": { + "durationMs": 5 + }, + "extensions/whatsapp/src/normalize-target.test.ts": { + "durationMs": 5 + }, + "extensions/slack/src/group-policy.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/send.typing.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/monitor/startup-status.test.ts": { + "durationMs": 5 + }, + "extensions/telegram/src/status-reaction-variants.test.ts": { + "durationMs": 5 + }, + "extensions/telegram/src/reaction-level.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/token.test.ts": { + "durationMs": 5 + }, + "extensions/slack/src/http/registry.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/draft-stream.test.ts": { + "durationMs": 5 + }, + "extensions/slack/src/targets.test.ts": { + "durationMs": 5 + }, + "extensions/imessage/src/monitor/loop-rate-limiter.test.ts": { + "durationMs": 5 + }, + "extensions/signal/src/setup-allow-from.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/monitor/provider.allowlist.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/monitor/provider.rest-proxy.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/monitor/route-resolution.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/chunk.test.ts": { + "durationMs": 5 + }, + "extensions/slack/src/draft-stream.test.ts": { + "durationMs": 5 + }, + "extensions/imessage/src/monitor/reflection-guard.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/channel-actions.test.ts": { + "durationMs": 5 + }, + "src/browser/pw-role-snapshot.test.ts": { + "durationMs": 5 + }, + "extensions/slack/src/monitor/provider.reconnect.test.ts": { + "durationMs": 5 + }, + "src/browser/chrome.default-browser.test.ts": { + "durationMs": 5 + }, + "extensions/slack/src/actions.read.test.ts": { + "durationMs": 5 + }, + "extensions/discord/src/monitor/dm-command-auth.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/monitor/gateway-error-guard.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/monitor/presence.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/threading-tool-context.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/stream-mode.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/account-inspect.test.ts": { + "durationMs": 4 + }, + "src/browser/session-tab-registry.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/group-migration.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/channel-migration.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/outbound-adapter.interactive-order.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/mentions.test.ts": { + "durationMs": 4 + }, + "src/browser/control-auth.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/status-issues.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/monitor/provider.auth-errors.test.ts": { + "durationMs": 4 + }, + "extensions/whatsapp/src/status-issues.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/setup-core.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/monitor/allow-list.test.ts": { + "durationMs": 4 + }, + "extensions/signal/src/identity.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/voice.test.ts": { + "durationMs": 4 + }, + "extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/inline-buttons.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/modal-metadata.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/resolve-users.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/resolve-allowlist-common.test.ts": { + "durationMs": 4 + }, + "src/browser/routes/agent.snapshot.plan.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/monitor/thread-bindings.persona.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/shared-interactive.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/threading.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/monitor/events/message-subtype-handlers.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/button-types.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/api-fetch.test.ts": { + "durationMs": 4 + }, + "extensions/signal/src/probe.test.ts": { + "durationMs": 4 + }, + "extensions/whatsapp/src/accounts.whatsapp-auth.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/setup-account-state.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/channel-actions.test.ts": { + "durationMs": 4 + }, + "extensions/whatsapp/src/auto-reply/monitor/group-members.test.ts": { + "durationMs": 4 + }, + "extensions/whatsapp/src/reconnect.test.ts": { + "durationMs": 4 + }, + "extensions/whatsapp/src/channel.directory.test.ts": { + "durationMs": 4 + }, + "extensions/signal/src/channel.outbound.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/probe.intents.test.ts": { + "durationMs": 4 + }, + "extensions/whatsapp/src/group-policy.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/monitor/inbound-context.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/monitor.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/exec-approvals.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/sequential-key.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/voice/command.test.ts": { + "durationMs": 4 + }, + "extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts": { + "durationMs": 4 + }, + "extensions/imessage/src/monitor/sanitize-outbound.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/status-issues.test.ts": { + "durationMs": 4 + }, + "extensions/discord/src/client.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/message-action-dispatch.test.ts": { + "durationMs": 4 + }, + "extensions/signal/src/channel.test.ts": { + "durationMs": 4 + }, + "extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts": { + "durationMs": 4 + }, + "extensions/whatsapp/src/channel.test.ts": { + "durationMs": 4 + }, + "extensions/telegram/src/allowed-updates.test.ts": { + "durationMs": 4 + }, + "extensions/slack/src/monitor/message-handler.debounce-key.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/bot-message-dispatch.sticker-media.test.ts": { + "durationMs": 3 + }, + "src/browser/cdp-timeouts.test.ts": { + "durationMs": 3 + }, + "extensions/signal/src/monitor.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/draft-chunking.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/normalize.test.ts": { + "durationMs": 3 + }, + "src/browser/url-pattern.test.ts": { + "durationMs": 3 + }, + "extensions/imessage/src/group-policy.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/blocks-fallback.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/allow-from.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/message-actions.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/monitor/commands.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/shared-interactive.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/group-policy.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/resolve-allowlist-common.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/group-access.base-access.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/monitor/message-handler/dispatch.streaming.test.ts": { + "durationMs": 3 + }, + "extensions/imessage/src/probe.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/group-policy.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/normalize.test.ts": { + "durationMs": 3 + }, + "extensions/imessage/src/setup-allow-from.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/caption.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/approval-buttons.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/forum-service-message.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/bot-access.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/monitor/threading.starter.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/group-access.policy-access.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/account-inspect.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/accounts.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/monitor/provider.interop.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/monitor/context.test.ts": { + "durationMs": 3 + }, + "extensions/whatsapp/src/accounts.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/accounts.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/pluralkit.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/fetch.env-proxy-runtime.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/conversation-route.base-session-key.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/probe.parse-token.test.ts": { + "durationMs": 3 + }, + "src/browser/chrome-mcp.snapshot.test.ts": { + "durationMs": 3 + }, + "src/browser/routes/agent.storage.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/blocks-input.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/draft-chunking.test.ts": { + "durationMs": 3 + }, + "src/browser/chrome.launch-args.test.ts": { + "durationMs": 3 + }, + "src/browser/routes/agent.shared.test.ts": { + "durationMs": 3 + }, + "extensions/discord/src/session-key-normalization.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/resolve-channels.test.ts": { + "durationMs": 3 + }, + "extensions/slack/src/interactive-replies.test.ts": { + "durationMs": 3 + }, + "src/browser/pw-session.page-cdp.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/bot.helpers.test.ts": { + "durationMs": 3 + }, + "extensions/telegram/src/reasoning-lane-coordinator.test.ts": { + "durationMs": 3 + } + } +} diff --git a/test/fixtures/test-timings.unit.json b/test/fixtures/test-timings.unit.json index 76b1982bf9710..3f03566717106 100644 --- a/test/fixtures/test-timings.unit.json +++ b/test/fixtures/test-timings.unit.json @@ -1,1031 +1,1031 @@ { "config": "vitest.unit.config.ts", - "generatedAt": "2026-03-22T18:25:13.707Z", + "generatedAt": "2026-03-23T05:11:36.071Z", "defaultDurationMs": 250, "files": { + "src/media/fetch.telegram-network.test.ts": { + "durationMs": 6483.10009765625, + "testCount": 5 + }, + "src/infra/outbound/targets.channel-resolution.test.ts": { + "durationMs": 5879.394287109375, + "testCount": 2 + }, "src/cron/service.issue-regressions.test.ts": { - "durationMs": 4309.591796875, - "testCount": 38 + "durationMs": 4524.16552734375, + "testCount": 39 }, - "test/web-search-provider-boundary.test.ts": { - "durationMs": 1959.673828125, - "testCount": 1 + "src/plugin-sdk/subpaths.test.ts": { + "durationMs": 3613.611328125, + "testCount": 5 }, "src/plugins/install.test.ts": { - "durationMs": 1934.08544921875, + "durationMs": 3357.946533203125, "testCount": 29 }, + "test/web-search-provider-boundary.test.ts": { + "durationMs": 2897.3154296875, + "testCount": 1 + }, "src/plugin-sdk/index.bundle.test.ts": { - "durationMs": 1696.989013671875, + "durationMs": 2524.4013671875, "testCount": 1 }, - "src/security/audit.test.ts": { - "durationMs": 1690.143798828125, - "testCount": 71 + "src/plugin-sdk/channel-import-guardrails.test.ts": { + "durationMs": 1464.13232421875, + "testCount": 9 }, - "src/hooks/install.test.ts": { - "durationMs": 1653.20947265625, + "src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts": { + "durationMs": 1299.1962890625, "testCount": 13 }, "test/extension-plugin-sdk-boundary.test.ts": { - "durationMs": 1406.131103515625, + "durationMs": 1296.1044921875, "testCount": 3 }, - "src/infra/provider-usage.test.ts": { - "durationMs": 1105.3271484375, - "testCount": 11 + "src/hooks/install.test.ts": { + "durationMs": 1294.3115234375, + "testCount": 13 }, - "src/hooks/loader.test.ts": { - "durationMs": 931.014892578125, - "testCount": 14 + "src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts": { + "durationMs": 1233.8681640625, + "testCount": 15 }, - "test/architecture-smells.test.ts": { - "durationMs": 928.368408203125, - "testCount": 2 + "src/secrets/apply.test.ts": { + "durationMs": 1185.137939453125, + "testCount": 15 }, - "src/plugin-sdk/channel-import-guardrails.test.ts": { - "durationMs": 927.684814453125, - "testCount": 9 + "test/scripts/committer.test.ts": { + "durationMs": 1018.4521484375, + "testCount": 2 }, - "src/plugin-sdk/subpaths.test.ts": { - "durationMs": 774.1640625, + "test/plugin-extension-import-boundary.test.ts": { + "durationMs": 1014.2353515625, "testCount": 5 }, - "src/plugins/loader.test.ts": { - "durationMs": 764.44580078125, - "testCount": 56 - }, "src/infra/fs-safe.test.ts": { - "durationMs": 740.747802734375, + "durationMs": 941.44287109375, "testCount": 22 }, - "src/media-understanding/runner.proxy.test.ts": { - "durationMs": 710.939697265625, - "testCount": 3 + "src/hooks/bundled/session-memory/handler.test.ts": { + "durationMs": 893.841064453125, + "testCount": 17 }, "src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts": { - "durationMs": 692.0634765625, + "durationMs": 871.858642578125, "testCount": 11 }, - "src/security/temp-path-guard.test.ts": { - "durationMs": 655.460693359375, - "testCount": 3 + "test/architecture-smells.test.ts": { + "durationMs": 853.270263671875, + "testCount": 2 }, - "test/scripts/committer.test.ts": { - "durationMs": 613.18603515625, - "testCount": 1 + "src/memory/embeddings-ollama.test.ts": { + "durationMs": 841.48876953125, + "testCount": 4 }, - "src/hooks/bundled/session-memory/handler.test.ts": { - "durationMs": 565.391845703125, - "testCount": 17 + "src/secrets/audit.test.ts": { + "durationMs": 828.3154296875, + "testCount": 18 }, - "src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts": { - "durationMs": 560.326171875, - "testCount": 15 + "src/plugins/loader.test.ts": { + "durationMs": 790.62646484375, + "testCount": 56 }, - "src/config/config.plugin-validation.test.ts": { - "durationMs": 554.983642578125, - "testCount": 15 + "src/media-understanding/runner.proxy.test.ts": { + "durationMs": 755.4365234375, + "testCount": 3 + }, + "src/config/sessions/sessions.test.ts": { + "durationMs": 745.67529296875, + "testCount": 23 + }, + "src/config/schema.base.generated.test.ts": { + "durationMs": 729.758056640625, + "testCount": 1 + }, + "src/tui/gateway-chat.test.ts": { + "durationMs": 698.404296875, + "testCount": 14 }, "src/infra/provider-usage.auth.normalizes-keys.test.ts": { - "durationMs": 532.379638671875, + "durationMs": 636.830078125, "testCount": 19 }, - "src/infra/archive.test.ts": { - "durationMs": 516.83740234375, - "testCount": 15 + "src/security/audit.test.ts": { + "durationMs": 586.11669921875, + "testCount": 71 }, - "src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts": { - "durationMs": 454.604736328125, - "testCount": 13 + "src/memory/index.test.ts": { + "durationMs": 579.9091796875, + "testCount": 21 }, - "src/cli/config-cli.integration.test.ts": { - "durationMs": 430.17138671875, - "testCount": 4 + "src/memory/qmd-manager.test.ts": { + "durationMs": 573.174072265625, + "testCount": 57 + }, + "src/infra/host-env-security.test.ts": { + "durationMs": 555.42041015625, + "testCount": 18 + }, + "src/memory/manager.get-concurrency.test.ts": { + "durationMs": 546.220947265625, + "testCount": 3 + }, + "src/config/config.identity-defaults.test.ts": { + "durationMs": 481.40673828125, + "testCount": 7 + }, + "src/hooks/loader.test.ts": { + "durationMs": 479.452880859375, + "testCount": 14 + }, + "src/config/config.nix-integration-u3-u5-u9.test.ts": { + "durationMs": 451.165771484375, + "testCount": 19 }, "src/node-host/invoke-system-run-plan.test.ts": { - "durationMs": 418.98583984375, + "durationMs": 437.455810546875, "testCount": 41 }, + "src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts": { + "durationMs": 406.56201171875, + "testCount": 6 + }, + "src/infra/archive.test.ts": { + "durationMs": 388.169677734375, + "testCount": 15 + }, + "src/infra/provider-usage.test.ts": { + "durationMs": 384.38671875, + "testCount": 11 + }, "src/infra/fs-pinned-write-helper.test.ts": { - "durationMs": 413.991455078125, + "durationMs": 381.91845703125, "testCount": 3 }, - "src/infra/provider-usage.auth.plugin.test.ts": { - "durationMs": 413.551513671875, - "testCount": 1 + "src/secrets/resolve.test.ts": { + "durationMs": 379.8876953125, + "testCount": 17 }, - "src/daemon/schtasks.startup-fallback.test.ts": { - "durationMs": 399.1005859375, + "src/plugins/bundled-plugin-metadata.test.ts": { + "durationMs": 349.606689453125, + "testCount": 4 + }, + "src/infra/transport-ready.test.ts": { + "durationMs": 349.38427734375, "testCount": 6 }, - "src/plugins/discovery.test.ts": { - "durationMs": 394.058837890625, - "testCount": 24 + "src/pairing/pairing-store.test.ts": { + "durationMs": 326.400390625, + "testCount": 17 }, - "src/plugins/conversation-binding.test.ts": { - "durationMs": 374.38623046875, + "src/daemon/schtasks.startup-fallback.test.ts": { + "durationMs": 322.14453125, + "testCount": 6 + }, + "src/config/config.plugin-validation.test.ts": { + "durationMs": 321.5458984375, "testCount": 15 }, - "src/media-understanding/apply.test.ts": { - "durationMs": 355.076416015625, - "testCount": 32 + "src/plugins/commands.test.ts": { + "durationMs": 305.874267578125, + "testCount": 12 }, - "src/plugins/copy-bundled-plugin-metadata.test.ts": { - "durationMs": 343.914306640625, - "testCount": 8 + "src/infra/git-commit.test.ts": { + "durationMs": 305.805908203125, + "testCount": 13 }, - "src/infra/archive-staging.test.ts": { - "durationMs": 332.295166015625, - "testCount": 7 + "src/security/temp-path-guard.test.ts": { + "durationMs": 304.04638671875, + "testCount": 3 }, "src/acp/control-plane/manager.test.ts": { - "durationMs": 326.3369140625, + "durationMs": 298.658447265625, "testCount": 36 }, - "src/infra/run-node.test.ts": { - "durationMs": 319.999755859375, - "testCount": 12 - }, - "src/memory/manager.get-concurrency.test.ts": { - "durationMs": 313.843017578125, + "src/install-sh-version.test.ts": { + "durationMs": 297.4970703125, "testCount": 3 }, - "src/media-understanding/runner.auto-audio.test.ts": { - "durationMs": 304.4072265625, - "testCount": 4 + "test/scripts/test-extension.test.ts": { + "durationMs": 293.929443359375, + "testCount": 9 }, - "src/plugins/commands.test.ts": { - "durationMs": 302.8583984375, - "testCount": 12 + "src/memory/embeddings-gemini.test.ts": { + "durationMs": 293.704345703125, + "testCount": 31 }, - "src/memory/index.test.ts": { - "durationMs": 298.41162109375, - "testCount": 21 + "src/infra/archive-staging.test.ts": { + "durationMs": 292.599853515625, + "testCount": 7 }, - "src/tts/tts.test.ts": { - "durationMs": 281.066162109375, - "testCount": 27 + "src/cron/isolated-agent.subagent-model.test.ts": { + "durationMs": 292.16796875, + "testCount": 4 }, - "src/secrets/apply.test.ts": { - "durationMs": 279.487060546875, - "testCount": 15 + "src/cli/program/preaction.test.ts": { + "durationMs": 291.583251953125, + "testCount": 8 }, - "ui/src/ui/views/chat.test.ts": { - "durationMs": 270.8681640625, - "testCount": 32 + "src/infra/heartbeat-runner.returns-default-unset.test.ts": { + "durationMs": 289.12646484375, + "testCount": 25 }, - "src/infra/outbound/delivery-queue.test.ts": { - "durationMs": 268.649169921875, - "testCount": 36 + "src/infra/run-node.test.ts": { + "durationMs": 276.7587890625, + "testCount": 12 }, - "src/plugins/stage-bundled-plugin-runtime.test.ts": { - "durationMs": 266.499267578125, - "testCount": 7 + "src/cli/daemon-cli/install.integration.test.ts": { + "durationMs": 275.413330078125, + "testCount": 2 }, - "src/plugins/web-search-providers.runtime.test.ts": { - "durationMs": 255.81591796875, - "testCount": 9 + "src/plugins/manifest-registry.test.ts": { + "durationMs": 266.66015625, + "testCount": 26 }, "src/memory/embeddings.test.ts": { - "durationMs": 252.156005859375, + "durationMs": 264.777099609375, "testCount": 19 }, - "src/infra/matrix-migration-config.test.ts": { - "durationMs": 250.9267578125, - "testCount": 7 - }, - "src/infra/git-commit.test.ts": { - "durationMs": 250.502685546875, - "testCount": 13 + "src/infra/system-presence.version.test.ts": { + "durationMs": 252.56787109375, + "testCount": 6 }, - "src/node-host/invoke-system-run.test.ts": { - "durationMs": 249.957763671875, - "testCount": 39 + "src/media/store.test.ts": { + "durationMs": 250.561279296875, + "testCount": 24 }, - "src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts": { - "durationMs": 248.80712890625, - "testCount": 6 + "src/plugins/bundle-mcp.test.ts": { + "durationMs": 239.72216796875, + "testCount": 3 }, - "src/memory/qmd-manager.test.ts": { - "durationMs": 247.931640625, - "testCount": 57 + "src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts": { + "durationMs": 235.425537109375, + "testCount": 12 }, - "src/secrets/audit.test.ts": { - "durationMs": 247.82958984375, - "testCount": 18 + "src/cron/isolated-agent.direct-delivery-core-channels.test.ts": { + "durationMs": 233.672607421875, + "testCount": 4 }, - "src/config/plugin-auto-enable.test.ts": { - "durationMs": 244.74365234375, - "testCount": 25 + "src/cli/daemon-cli/restart-health.test.ts": { + "durationMs": 231.465087890625, + "testCount": 10 }, - "test/scripts/test-extension.test.ts": { - "durationMs": 236.225830078125, - "testCount": 8 + "src/tts/tts.test.ts": { + "durationMs": 229.763427734375, + "testCount": 27 }, - "src/cli/program/preaction.test.ts": { - "durationMs": 234.734619140625, - "testCount": 7 + "src/plugins/loader.git-path-regression.test.ts": { + "durationMs": 224.001953125, + "testCount": 1 }, - "src/cron/isolated-agent.subagent-model.test.ts": { - "durationMs": 234.408203125, - "testCount": 4 + "src/cron/isolated-agent.lane.test.ts": { + "durationMs": 217.71826171875, + "testCount": 3 }, "src/secrets/runtime.integration.test.ts": { - "durationMs": 224.736083984375, + "durationMs": 216.497314453125, "testCount": 5 }, - "test/plugin-extension-import-boundary.test.ts": { - "durationMs": 222.7021484375, - "testCount": 5 + "ui/src/ui/views/chat.test.ts": { + "durationMs": 215.544921875, + "testCount": 32 }, - "src/hooks/plugin-hooks.test.ts": { - "durationMs": 214.001708984375, - "testCount": 4 + "src/config/io.write-config.test.ts": { + "durationMs": 213.6318359375, + "testCount": 17 }, - "src/tui/gateway-chat.test.ts": { - "durationMs": 212.93408203125, - "testCount": 14 + "src/infra/device-bootstrap.test.ts": { + "durationMs": 213.50048828125, + "testCount": 10 }, "src/channels/plugins/plugins-core.test.ts": { - "durationMs": 205.9814453125, + "durationMs": 210.02392578125, "testCount": 39 }, - "src/infra/provider-usage.load.plugin.test.ts": { - "durationMs": 203.485595703125, - "testCount": 1 - }, - "src/entry.version-fast-path.test.ts": { - "durationMs": 201.5234375, - "testCount": 2 + "src/config/sessions/store.pruning.integration.test.ts": { + "durationMs": 204.234130859375, + "testCount": 10 }, - "src/node-host/invoke-browser.test.ts": { - "durationMs": 197.00439453125, - "testCount": 4 + "src/node-host/invoke-system-run.test.ts": { + "durationMs": 197.60498046875, + "testCount": 39 }, - "src/plugins/loader.git-path-regression.test.ts": { - "durationMs": 190.323974609375, - "testCount": 1 + "src/plugins/sdk-alias.test.ts": { + "durationMs": 193.329345703125, + "testCount": 24 }, - "src/cli/daemon-cli/install.integration.test.ts": { - "durationMs": 183.529052734375, - "testCount": 2 + "src/config/sessions.test.ts": { + "durationMs": 191.794189453125, + "testCount": 37 }, - "src/config/config-misc.test.ts": { - "durationMs": 179.05224609375, - "testCount": 38 + "src/infra/update-runner.test.ts": { + "durationMs": 185.320556640625, + "testCount": 22 }, - "src/plugins/manifest-registry.test.ts": { - "durationMs": 178.645751953125, - "testCount": 25 + "src/media-understanding/apply.test.ts": { + "durationMs": 180.3095703125, + "testCount": 32 }, - "src/install-sh-version.test.ts": { - "durationMs": 178.330078125, - "testCount": 3 + "src/channels/channels-misc.test.ts": { + "durationMs": 176.197509765625, + "testCount": 10 }, - "src/infra/host-env-security.test.ts": { - "durationMs": 171.649169921875, - "testCount": 18 + "src/channels/plugins/contracts/registry-backed.contract.test.ts": { + "durationMs": 165.671875, + "testCount": 167 }, - "src/infra/boundary-path.test.ts": { - "durationMs": 169.609130859375, - "testCount": 5 + "src/infra/device-pairing.test.ts": { + "durationMs": 165.583740234375, + "testCount": 19 }, - "src/cron/service.failure-alert.test.ts": { - "durationMs": 169.056884765625, + "src/acp/server.startup.test.ts": { + "durationMs": 156.636474609375, "testCount": 4 }, - "src/media-understanding/apply.echo-transcript.test.ts": { - "durationMs": 167.198486328125, - "testCount": 10 - }, - "src/cron/session-reaper.test.ts": { - "durationMs": 166.148193359375, - "testCount": 16 + "test/scripts/ios-team-id.test.ts": { + "durationMs": 155.50732421875, + "testCount": 3 }, - "src/cron/isolated-agent.direct-delivery-core-channels.test.ts": { - "durationMs": 165.966552734375, + "src/hooks/plugin-hooks.test.ts": { + "durationMs": 151.358642578125, "testCount": 4 }, - "src/memory/manager.embedding-batches.test.ts": { - "durationMs": 162.786376953125, - "testCount": 5 + "src/memory/manager.readonly-recovery.test.ts": { + "durationMs": 150.2568359375, + "testCount": 4 }, - "src/plugins/bundle-mcp.test.ts": { - "durationMs": 162.58984375, + "src/cron/service.delivery-plan.test.ts": { + "durationMs": 149.01416015625, "testCount": 3 }, - "src/acp/server.startup.test.ts": { - "durationMs": 160.30078125, - "testCount": 4 - }, - "src/canvas-host/server.test.ts": { - "durationMs": 158.165771484375, - "testCount": 6 + "src/plugins/contracts/auth-choice.contract.test.ts": { + "durationMs": 148.492431640625, + "testCount": 3 }, - "src/secrets/resolve.test.ts": { - "durationMs": 155.67822265625, - "testCount": 17 + "ui/src/ui/app-chat.test.ts": { + "durationMs": 143.6455078125, + "testCount": 3 }, - "src/plugins/providers.test.ts": { - "durationMs": 153.55419921875, + "src/wizard/setup.gateway-config.test.ts": { + "durationMs": 142.656494140625, "testCount": 7 }, - "src/infra/heartbeat-runner.returns-default-unset.test.ts": { - "durationMs": 150.216064453125, - "testCount": 25 - }, - "src/channels/channels-misc.test.ts": { - "durationMs": 148.318115234375, - "testCount": 10 - }, - "src/pairing/pairing-store.test.ts": { - "durationMs": 147.915283203125, - "testCount": 17 - }, - "src/plugins/sdk-alias.test.ts": { - "durationMs": 145.3447265625, - "testCount": 24 - }, - "test/git-hooks-pre-commit.test.ts": { - "durationMs": 145.269775390625, - "testCount": 1 + "src/entry.version-fast-path.test.ts": { + "durationMs": 141.65087890625, + "testCount": 2 }, "src/context-engine/context-engine.test.ts": { - "durationMs": 143.698974609375, + "durationMs": 140.483642578125, "testCount": 30 }, - "src/cron/service.session-reaper-in-finally.test.ts": { - "durationMs": 141.533203125, - "testCount": 3 - }, - "src/cli/secrets-cli.test.ts": { - "durationMs": 140.470947265625, - "testCount": 11 - }, - "src/channels/plugins/contracts/registry-backed.contract.test.ts": { - "durationMs": 139.597412109375, - "testCount": 167 - }, - "src/pairing/setup-code.test.ts": { - "durationMs": 138.535888671875, - "testCount": 15 - }, - "src/infra/update-runner.test.ts": { - "durationMs": 134.411865234375, - "testCount": 20 - }, - "src/plugins/status.test.ts": { - "durationMs": 134.25390625, - "testCount": 9 - }, - "src/cron/isolated-agent/run.owner-auth.test.ts": { - "durationMs": 133.743896484375, - "testCount": 1 - }, - "src/plugins/contracts/auth-choice.contract.test.ts": { - "durationMs": 133.6669921875, - "testCount": 3 + "src/config/sessions/targets.test.ts": { + "durationMs": 136.714111328125, + "testCount": 13 }, - "src/config/config.nix-integration-u3-u5-u9.test.ts": { - "durationMs": 132.7685546875, - "testCount": 19 + "src/config/schema.hints.test.ts": { + "durationMs": 136.534423828125, + "testCount": 7 }, - "src/hooks/hooks-install.test.ts": { - "durationMs": 132.720703125, + "test/git-hooks-pre-commit.test.ts": { + "durationMs": 134.720458984375, "testCount": 1 }, - "src/infra/outbound/outbound.test.ts": { - "durationMs": 132.556640625, - "testCount": 65 - }, - "src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts": { - "durationMs": 128.71484375, - "testCount": 12 + "src/infra/matrix-legacy-crypto.test.ts": { + "durationMs": 134.415771484375, + "testCount": 8 }, "src/memory/manager.batch.test.ts": { - "durationMs": 127.800537109375, + "durationMs": 132.914306640625, "testCount": 3 }, - "src/cron/isolated-agent.lane.test.ts": { - "durationMs": 126.55126953125, - "testCount": 3 + "src/routing/resolve-route.test.ts": { + "durationMs": 131.9150390625, + "testCount": 41 }, - "test/scripts/ios-team-id.test.ts": { - "durationMs": 126.036865234375, - "testCount": 3 + "src/canvas-host/server.test.ts": { + "durationMs": 131.867919921875, + "testCount": 6 }, - "src/hooks/workspace.test.ts": { - "durationMs": 123.74853515625, - "testCount": 7 + "src/plugins/config-state.test.ts": { + "durationMs": 127.486083984375, + "testCount": 22 }, - "src/media/store.outside-workspace.test.ts": { - "durationMs": 123.20361328125, + "src/infra/provider-usage.auth.plugin.test.ts": { + "durationMs": 127.301513671875, "testCount": 1 }, - "src/config/config.backup-rotation.test.ts": { - "durationMs": 117.84130859375, + "src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts": { + "durationMs": 127.07763671875, + "testCount": 30 + }, + "src/cli/config-cli.integration.test.ts": { + "durationMs": 126.407470703125, "testCount": 4 }, "src/security/windows-acl.test.ts": { - "durationMs": 115.3486328125, + "durationMs": 123.772705078125, "testCount": 48 }, - "src/config/config.web-search-provider.test.ts": { - "durationMs": 112.931396484375, - "testCount": 24 + "src/infra/heartbeat-runner.model-override.test.ts": { + "durationMs": 121.20263671875, + "testCount": 8 }, "src/secrets/runtime.test.ts": { - "durationMs": 111.417724609375, + "durationMs": 119.88818359375, "testCount": 55 }, - "src/media/input-files.fetch-guard.test.ts": { - "durationMs": 109.734130859375, - "testCount": 10 - }, - "src/config/io.write-config.test.ts": { - "durationMs": 109.52685546875, - "testCount": 16 + "src/cron/service.failure-alert.test.ts": { + "durationMs": 118.9111328125, + "testCount": 4 }, - "src/infra/gateway-lock.test.ts": { - "durationMs": 108.08837890625, - "testCount": 9 + "src/config/plugin-auto-enable.test.ts": { + "durationMs": 118.829833984375, + "testCount": 25 }, - "src/plugins/marketplace.test.ts": { - "durationMs": 105.039306640625, - "testCount": 3 + "src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts": { + "durationMs": 117.264404296875, + "testCount": 15 }, - "src/cron/isolated-agent.direct-delivery-forum-topics.test.ts": { - "durationMs": 104.729248046875, - "testCount": 2 + "src/config/includes.test.ts": { + "durationMs": 115.695068359375, + "testCount": 26 }, - "src/infra/matrix-legacy-crypto.test.ts": { - "durationMs": 102.9990234375, - "testCount": 8 + "src/memory/internal.test.ts": { + "durationMs": 114.050048828125, + "testCount": 18 }, - "src/memory/manager.readonly-recovery.test.ts": { - "durationMs": 102.7353515625, - "testCount": 4 + "src/memory/manager.embedding-batches.test.ts": { + "durationMs": 113.791259765625, + "testCount": 5 }, - "src/infra/device-pairing.test.ts": { - "durationMs": 101.41943359375, - "testCount": 19 + "src/infra/gateway-lock.test.ts": { + "durationMs": 113.197021484375, + "testCount": 9 }, - "src/process/command-queue.test.ts": { - "durationMs": 101.1865234375, - "testCount": 17 + "src/infra/outbound/message-action-runner.media.test.ts": { + "durationMs": 111.97216796875, + "testCount": 7 }, - "src/infra/system-presence.version.test.ts": { - "durationMs": 100.80810546875, - "testCount": 5 + "src/cron/service.persists-delivered-status.test.ts": { + "durationMs": 108.559814453125, + "testCount": 6 }, - "src/cli/pairing-cli.test.ts": { - "durationMs": 97.699951171875, - "testCount": 12 + "src/plugins/discovery.test.ts": { + "durationMs": 107.716064453125, + "testCount": 25 }, - "src/media-understanding/runner.deepgram.test.ts": { - "durationMs": 94.41259765625, - "testCount": 1 + "src/cron/run-log.test.ts": { + "durationMs": 107.50634765625, + "testCount": 11 }, - "src/media-understanding/runner.skip-tiny-audio.test.ts": { - "durationMs": 93.178955078125, + "src/cron/service.session-reaper-in-finally.test.ts": { + "durationMs": 103.7470703125, "testCount": 3 }, - "src/config/sessions/sessions.test.ts": { - "durationMs": 91.523681640625, - "testCount": 23 + "src/cron/isolated-agent.direct-delivery-forum-topics.test.ts": { + "durationMs": 103.40283203125, + "testCount": 2 }, - "src/memory/batch-gemini.test.ts": { - "durationMs": 88.894287109375, - "testCount": 1 + "src/pairing/setup-code.test.ts": { + "durationMs": 102.3203125, + "testCount": 16 }, - "src/channels/plugins/setup-wizard-helpers.test.ts": { - "durationMs": 88.8017578125, - "testCount": 83 + "src/cron/isolated-agent/run.owner-auth.test.ts": { + "durationMs": 100.48779296875, + "testCount": 1 }, - "src/memory/embeddings-voyage.test.ts": { - "durationMs": 85.089599609375, - "testCount": 4 + "src/cli/update-cli/restart-helper.test.ts": { + "durationMs": 99.556640625, + "testCount": 20 }, - "src/process/supervisor/supervisor.pty-command.test.ts": { - "durationMs": 84.798583984375, - "testCount": 2 + "src/infra/boundary-path.test.ts": { + "durationMs": 98.96142578125, + "testCount": 5 }, - "src/config/sessions/targets.test.ts": { - "durationMs": 84.7841796875, - "testCount": 13 + "src/config/schema.test.ts": { + "durationMs": 98.1875, + "testCount": 22 }, - "src/config/sessions.test.ts": { - "durationMs": 83.90380859375, - "testCount": 37 + "src/plugins/conversation-binding.test.ts": { + "durationMs": 96.423095703125, + "testCount": 15 }, - "src/hooks/message-hooks.test.ts": { - "durationMs": 83.40185546875, - "testCount": 12 + "src/plugins/web-search-providers.runtime.test.ts": { + "durationMs": 95.8154296875, + "testCount": 9 }, - "src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts": { - "durationMs": 83.1669921875, - "testCount": 15 + "src/cron/service.restart-catchup.test.ts": { + "durationMs": 93.3232421875, + "testCount": 8 }, - "src/plugin-sdk/channel-lifecycle.test.ts": { - "durationMs": 82.57861328125, + "src/infra/heartbeat-runner.ghost-reminder.test.ts": { + "durationMs": 92.972412109375, "testCount": 6 }, - "src/plugins/bundle-commands.test.ts": { - "durationMs": 80.40576171875, - "testCount": 1 + "src/media-understanding/runner.skip-tiny-audio.test.ts": { + "durationMs": 92.167236328125, + "testCount": 3 }, - "src/cron/service.persists-delivered-status.test.ts": { - "durationMs": 80.03564453125, - "testCount": 6 + "src/security/fix.test.ts": { + "durationMs": 90.6171875, + "testCount": 5 }, - "src/cron/service.runs-one-shot-main-job-disables-it.test.ts": { - "durationMs": 79.607177734375, + "src/hooks/hooks-install.test.ts": { + "durationMs": 89.53759765625, + "testCount": 1 + }, + "src/infra/outbound/delivery-queue.recovery.test.ts": { + "durationMs": 89.47412109375, "testCount": 11 }, - "src/cron/isolated-agent.auth-profile-propagation.test.ts": { - "durationMs": 78.230712890625, - "testCount": 1 + "src/infra/install-package-dir.test.ts": { + "durationMs": 86.085693359375, + "testCount": 5 }, - "src/cli/plugins-cli.test.ts": { - "durationMs": 76.291015625, + "src/process/command-queue.test.ts": { + "durationMs": 84.578125, "testCount": 17 }, - "src/config/sessions/store.pruning.integration.test.ts": { - "durationMs": 76.134033203125, - "testCount": 10 + "src/plugin-sdk/channel-lifecycle.test.ts": { + "durationMs": 82.777099609375, + "testCount": 6 }, - "src/cli/program.smoke.test.ts": { - "durationMs": 75.773193359375, + "src/plugins/stage-bundled-plugin-runtime.test.ts": { + "durationMs": 82.215576171875, + "testCount": 7 + }, + "src/memory/embeddings-voyage.test.ts": { + "durationMs": 82.2119140625, "testCount": 4 }, - "src/plugins/runtime/index.test.ts": { - "durationMs": 74.78759765625, - "testCount": 12 + "src/config/io.runtime-snapshot-write.test.ts": { + "durationMs": 81.617431640625, + "testCount": 6 }, - "src/wizard/setup.gateway-config.test.ts": { - "durationMs": 74.0263671875, + "src/config/config.pruning-defaults.test.ts": { + "durationMs": 79.002685546875, "testCount": 7 }, - "src/canvas-host/server.state-dir.test.ts": { - "durationMs": 73.72265625, - "testCount": 1 + "src/hooks/workspace.test.ts": { + "durationMs": 78.73388671875, + "testCount": 7 }, - "src/media/store.test.ts": { - "durationMs": 73.179931640625, + "src/config/config.web-search-provider.test.ts": { + "durationMs": 77.86962890625, "testCount": 24 }, - "src/daemon/systemd.test.ts": { - "durationMs": 73.161376953125, - "testCount": 43 - }, - "src/plugins/contracts/registry.contract.test.ts": { - "durationMs": 72.838134765625, - "testCount": 19 - }, - "src/security/fix.test.ts": { - "durationMs": 72.048828125, - "testCount": 5 + "src/infra/matrix-plugin-helper.test.ts": { + "durationMs": 76.61181640625, + "testCount": 4 }, - "src/cron/service.restart-catchup.test.ts": { - "durationMs": 71.440185546875, + "src/cli/plugins-cli.install.test.ts": { + "durationMs": 76.592529296875, "testCount": 8 }, - "src/plugins/bundled-web-search.test.ts": { - "durationMs": 70.187744140625, - "testCount": 3 + "src/cron/isolated-agent.auth-profile-propagation.test.ts": { + "durationMs": 75.08984375, + "testCount": 1 }, - "src/infra/heartbeat-runner.ghost-reminder.test.ts": { - "durationMs": 69.55810546875, - "testCount": 6 + "src/cron/service.store.migration.test.ts": { + "durationMs": 73.780517578125, + "testCount": 7 }, - "src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts": { - "durationMs": 68.728515625, - "testCount": 30 + "test/scripts/check-no-conflict-markers.test.ts": { + "durationMs": 73.209228515625, + "testCount": 4 }, - "src/infra/heartbeat-runner.model-override.test.ts": { - "durationMs": 67.583251953125, - "testCount": 8 + "src/cli/pairing-cli.test.ts": { + "durationMs": 72.9013671875, + "testCount": 12 }, - "src/config/config.pruning-defaults.test.ts": { - "durationMs": 65.777099609375, + "src/infra/matrix-migration-snapshot.test.ts": { + "durationMs": 72.4443359375, "testCount": 7 }, - "src/infra/outbound/outbound-send-service.test.ts": { - "durationMs": 65.419921875, - "testCount": 9 - }, - "src/channels/plugins/acp-bindings.test.ts": { - "durationMs": 64.009033203125, - "testCount": 6 + "src/config/plugins-runtime-boundary.test.ts": { + "durationMs": 71.692138671875, + "testCount": 3 }, - "src/plugins/web-search-providers.test.ts": { - "durationMs": 63.864501953125, - "testCount": 7 + "src/cron/service.read-ops-nonblocking.test.ts": { + "durationMs": 70.1806640625, + "testCount": 3 }, - "src/media-understanding/runner.vision-skip.test.ts": { - "durationMs": 62.14208984375, - "testCount": 1 + "src/cli/program.smoke.test.ts": { + "durationMs": 69.348388671875, + "testCount": 4 }, - "src/infra/update-startup.test.ts": { - "durationMs": 61.933837890625, - "testCount": 10 + "src/security/skill-scanner.test.ts": { + "durationMs": 69.302490234375, + "testCount": 27 }, - "src/config/doc-baseline.integration.test.ts": { - "durationMs": 60.52783203125, - "testCount": 8 + "src/plugins/copy-bundled-plugin-metadata.test.ts": { + "durationMs": 68.958740234375, + "testCount": 9 }, - "src/infra/jsonl-socket.test.ts": { - "durationMs": 60.36328125, - "testCount": 2 + "src/channels/status-reactions.test.ts": { + "durationMs": 68.4052734375, + "testCount": 37 }, - "src/plugins/tools.optional.test.ts": { - "durationMs": 60.07568359375, - "testCount": 8 + "src/plugins/bundle-commands.test.ts": { + "durationMs": 67.466064453125, + "testCount": 1 }, - "src/infra/session-maintenance-warning.test.ts": { - "durationMs": 58.0029296875, - "testCount": 5 + "src/media/server.outside-workspace.test.ts": { + "durationMs": 66.935791015625, + "testCount": 1 }, - "src/config/io.runtime-snapshot-write.test.ts": { - "durationMs": 57.88427734375, - "testCount": 6 + "src/cron/service.every-jobs-fire.test.ts": { + "durationMs": 66.443115234375, + "testCount": 3 }, - "src/cli/plugin-registry.test.ts": { - "durationMs": 57.8564453125, - "testCount": 2 + "src/daemon/schtasks.test.ts": { + "durationMs": 66.254638671875, + "testCount": 23 }, - "src/infra/matrix-plugin-helper.test.ts": { - "durationMs": 57.32421875, - "testCount": 4 + "src/config/sessions.cache.test.ts": { + "durationMs": 65.659423828125, + "testCount": 9 }, - "src/config/schema.test.ts": { - "durationMs": 57.17333984375, - "testCount": 22 + "src/infra/state-migrations.test.ts": { + "durationMs": 65.61279296875, + "testCount": 2 }, - "src/infra/matrix-migration-snapshot.test.ts": { - "durationMs": 56.8486328125, - "testCount": 7 + "src/config/config.talk-validation.test.ts": { + "durationMs": 65.213134765625, + "testCount": 5 }, - "src/memory/internal.test.ts": { - "durationMs": 56.38330078125, - "testCount": 18 + "src/cli/daemon-cli/status.gather.test.ts": { + "durationMs": 65.18212890625, + "testCount": 12 }, - "src/cron/service.store.migration.test.ts": { - "durationMs": 56.10693359375, - "testCount": 7 + "src/plugins/runtime/index.test.ts": { + "durationMs": 64.8515625, + "testCount": 12 }, - "src/infra/device-bootstrap.test.ts": { - "durationMs": 56.04833984375, - "testCount": 10 + "src/cron/isolated-agent/run.skill-filter.test.ts": { + "durationMs": 64.020751953125, + "testCount": 13 }, - "src/memory/batch-voyage.test.ts": { - "durationMs": 55.859375, - "testCount": 2 + "src/config/config-misc.test.ts": { + "durationMs": 63.940185546875, + "testCount": 38 }, - "src/cron/service.every-jobs-fire.test.ts": { - "durationMs": 55.80126953125, - "testCount": 3 + "src/plugin-sdk/persistent-dedupe.test.ts": { + "durationMs": 63.2900390625, + "testCount": 6 }, - "src/plugin-sdk/keyed-async-queue.test.ts": { - "durationMs": 55.430908203125, - "testCount": 4 + "src/media-understanding/apply.echo-transcript.test.ts": { + "durationMs": 63.254150390625, + "testCount": 10 }, - "src/cron/service.issue-16156-list-skips-cron.test.ts": { - "durationMs": 55.27001953125, - "testCount": 3 + "src/infra/matrix-migration-config.test.ts": { + "durationMs": 63.236572265625, + "testCount": 7 }, - "src/cli/nodes-camera.test.ts": { - "durationMs": 55.112060546875, - "testCount": 17 + "src/config/env-preserve-io.test.ts": { + "durationMs": 61.69921875, + "testCount": 4 }, - "src/media-understanding/providers/index.test.ts": { - "durationMs": 54.6015625, - "testCount": 3 + "src/daemon/service-audit.test.ts": { + "durationMs": 60.855224609375, + "testCount": 16 }, - "src/config/config.identity-defaults.test.ts": { - "durationMs": 54.26171875, - "testCount": 7 + "src/infra/infra-runtime.test.ts": { + "durationMs": 60.0595703125, + "testCount": 11 }, "src/infra/matrix-legacy-state.test.ts": { - "durationMs": 54.01318359375, + "durationMs": 59.89404296875, "testCount": 6 }, - "src/infra/outbound/deliver.test.ts": { - "durationMs": 53.683837890625, - "testCount": 44 + "src/infra/install-source-utils.test.ts": { + "durationMs": 59.5400390625, + "testCount": 16 }, - "src/media-understanding/runtime.test.ts": { - "durationMs": 53.273193359375, + "src/cron/service.heartbeat-ok-summary-suppressed.test.ts": { + "durationMs": 59.4853515625, "testCount": 2 }, - "src/infra/install-package-dir.test.ts": { - "durationMs": 52.913330078125, - "testCount": 5 - }, - "src/security/skill-scanner.test.ts": { - "durationMs": 51.723876953125, - "testCount": 27 + "src/infra/outbound/deliver.test.ts": { + "durationMs": 58.823974609375, + "testCount": 44 }, "src/plugins/contracts/discovery.contract.test.ts": { - "durationMs": 51.46240234375, + "durationMs": 58.736572265625, "testCount": 15 }, - "src/plugins/hook-runner-global.test.ts": { - "durationMs": 51.134033203125, - "testCount": 2 + "src/config/doc-baseline.integration.test.ts": { + "durationMs": 58.551025390625, + "testCount": 8 }, - "src/infra/outbound/targets.channel-resolution.test.ts": { - "durationMs": 50.57861328125, + "src/daemon/systemd.test.ts": { + "durationMs": 57.96435546875, + "testCount": 43 + }, + "src/infra/jsonl-socket.test.ts": { + "durationMs": 57.662841796875, "testCount": 2 }, - "src/infra/update-global.test.ts": { - "durationMs": 49.095703125, - "testCount": 7 + "src/cron/store.test.ts": { + "durationMs": 57.3974609375, + "testCount": 12 }, - "src/cron/service.read-ops-nonblocking.test.ts": { - "durationMs": 48.874267578125, - "testCount": 3 + "src/infra/session-cost-usage.test.ts": { + "durationMs": 55.66796875, + "testCount": 9 }, - "src/config/schema.hints.test.ts": { - "durationMs": 47.700439453125, - "testCount": 7 + "src/infra/exec-approvals-safe-bins.test.ts": { + "durationMs": 55.111572265625, + "testCount": 48 }, - "src/cli/daemon-cli/restart-health.test.ts": { - "durationMs": 47.522705078125, - "testCount": 10 + "src/infra/push-apns.store.test.ts": { + "durationMs": 54.936767578125, + "testCount": 7 }, - "src/logging/log-file-size-cap.test.ts": { - "durationMs": 47.10107421875, - "testCount": 3 + "src/infra/restart-sentinel.test.ts": { + "durationMs": 54.8037109375, + "testCount": 13 }, - "src/infra/ports.test.ts": { - "durationMs": 47.010986328125, - "testCount": 5 + "src/plugin-sdk/keyed-async-queue.test.ts": { + "durationMs": 54.710693359375, + "testCount": 4 }, - "src/process/exec.no-output-timer.test.ts": { - "durationMs": 46.45361328125, - "testCount": 1 + "src/cli/memory-cli.test.ts": { + "durationMs": 54.0625, + "testCount": 24 }, - "src/channels/plugins/whatsapp-heartbeat.test.ts": { - "durationMs": 46.3828125, - "testCount": 8 + "src/channels/plugins/setup-wizard-helpers.test.ts": { + "durationMs": 53.711669921875, + "testCount": 88 }, - "src/routing/resolve-route.test.ts": { - "durationMs": 46.355712890625, - "testCount": 41 + "src/plugins/status.test.ts": { + "durationMs": 52.908935546875, + "testCount": 9 }, - "src/infra/outbound/message-action-runner.media.test.ts": { - "durationMs": 45.20361328125, - "testCount": 7 + "src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts": { + "durationMs": 52.880615234375, + "testCount": 28 }, - "src/config/includes.test.ts": { - "durationMs": 45.02783203125, - "testCount": 26 + "src/infra/system-events.test.ts": { + "durationMs": 52.455078125, + "testCount": 15 }, - "src/plugin-sdk/persistent-dedupe.test.ts": { - "durationMs": 44.325927734375, - "testCount": 6 + "src/memory/manager.async-search.test.ts": { + "durationMs": 52.367431640625, + "testCount": 2 }, - "src/infra/session-cost-usage.test.ts": { - "durationMs": 44.1787109375, - "testCount": 9 + "src/config/config.backup-rotation.test.ts": { + "durationMs": 52.273681640625, + "testCount": 4 }, - "src/cron/run-log.test.ts": { - "durationMs": 43.77392578125, - "testCount": 11 + "src/cron/service.issue-16156-list-skips-cron.test.ts": { + "durationMs": 51.369140625, + "testCount": 3 }, - "src/cron/isolated-agent/run.skill-filter.test.ts": { - "durationMs": 43.68212890625, - "testCount": 13 + "src/media-understanding/runner.auto-audio.test.ts": { + "durationMs": 51.368408203125, + "testCount": 4 }, - "src/cli/route.test.ts": { - "durationMs": 43.54345703125, - "testCount": 3 + "src/infra/restart-stale-pids.test.ts": { + "durationMs": 51.15234375, + "testCount": 37 }, - "src/media/fetch.test.ts": { - "durationMs": 43.52294921875, - "testCount": 6 + "src/cron/service.runs-one-shot-main-job-disables-it.test.ts": { + "durationMs": 51.140869140625, + "testCount": 11 }, - "src/infra/net/proxy-fetch.test.ts": { - "durationMs": 43.135986328125, + "src/infra/update-startup.test.ts": { + "durationMs": 50.162841796875, "testCount": 10 }, - "src/cron/service.store-migration.test.ts": { - "durationMs": 42.965087890625, + "src/infra/json-files.test.ts": { + "durationMs": 49.891845703125, "testCount": 5 }, - "src/infra/state-migrations.test.ts": { - "durationMs": 42.954345703125, - "testCount": 2 - }, - "src/cron/isolated-agent/run.payload-fallbacks.test.ts": { - "durationMs": 42.942626953125, - "testCount": 3 - }, - "src/cron/isolated-agent/run.message-tool-policy.test.ts": { - "durationMs": 42.89453125, + "src/logging/log-file-size-cap.test.ts": { + "durationMs": 49.63330078125, "testCount": 3 }, - "src/cli/program/config-guard.test.ts": { - "durationMs": 42.253173828125, - "testCount": 8 + "src/memory/manager.atomic-reindex.test.ts": { + "durationMs": 49.58642578125, + "testCount": 1 }, - "src/infra/outbound/channel-resolution.test.ts": { - "durationMs": 42.127685546875, - "testCount": 6 + "src/cli/nodes-camera.test.ts": { + "durationMs": 48.5517578125, + "testCount": 17 }, - "src/infra/push-apns.store.test.ts": { - "durationMs": 42.03955078125, - "testCount": 7 + "src/infra/secret-file.test.ts": { + "durationMs": 47.966796875, + "testCount": 11 }, - "src/cron/isolated-agent/run.interim-retry.test.ts": { - "durationMs": 42.005615234375, - "testCount": 3 + "src/media/server.test.ts": { + "durationMs": 47.458740234375, + "testCount": 9 }, "src/config/config.compaction-settings.test.ts": { - "durationMs": 41.615478515625, + "durationMs": 47.290771484375, "testCount": 5 }, - "src/acp/client.test.ts": { - "durationMs": 41.4873046875, + "src/cron/session-reaper.test.ts": { + "durationMs": 47.266357421875, + "testCount": 16 + }, + "src/media/mime.test.ts": { + "durationMs": 46.57568359375, "testCount": 44 }, - "src/config/io.compat.test.ts": { - "durationMs": 41.089111328125, - "testCount": 7 + "src/cron/service.store-migration.test.ts": { + "durationMs": 45.800048828125, + "testCount": 5 + }, + "src/media-understanding/runner.deepgram.test.ts": { + "durationMs": 45.664794921875, + "testCount": 1 + }, + "src/infra/outbound/delivery-queue.storage.test.ts": { + "durationMs": 45.38427734375, + "testCount": 10 + }, + "src/memory/manager.mistral-provider.test.ts": { + "durationMs": 44.930908203125, + "testCount": 3 }, - "src/config/mcp-config.test.ts": { - "durationMs": 40.832275390625, + "src/config/sessions/store.session-key-normalization.test.ts": { + "durationMs": 44.8798828125, + "testCount": 4 + }, + "src/infra/machine-name.test.ts": { + "durationMs": 44.325439453125, "testCount": 2 }, - "src/plugins/schema-validator.test.ts": { - "durationMs": 40.46240234375, + "src/config/io.compat.test.ts": { + "durationMs": 43.653564453125, "testCount": 7 }, - "src/process/exec.windows.test.ts": { - "durationMs": 40.332763671875, - "testCount": 2 + "src/cron/isolated-agent/run.interim-retry.test.ts": { + "durationMs": 43.59423828125, + "testCount": 3 }, - "src/cron/service.store-load-invalid-main-job.test.ts": { - "durationMs": 39.620849609375, - "testCount": 1 + "src/plugins/uninstall.test.ts": { + "durationMs": 43.580322265625, + "testCount": 23 }, - "src/config/env-preserve-io.test.ts": { - "durationMs": 39.529052734375, - "testCount": 4 + "src/infra/heartbeat-runner.transcript-prune.test.ts": { + "durationMs": 43.103759765625, + "testCount": 2 }, - "src/acp/persistent-bindings.lifecycle.test.ts": { - "durationMs": 39.294921875, - "testCount": 1 + "src/channels/plugins/whatsapp-heartbeat.test.ts": { + "durationMs": 42.790771484375, + "testCount": 8 }, - "src/infra/secret-file.test.ts": { - "durationMs": 39.18359375, - "testCount": 11 + "src/version.test.ts": { + "durationMs": 42.5732421875, + "testCount": 10 }, - "src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts": { - "durationMs": 39.019287109375, - "testCount": 1 + "src/infra/system-presence.test.ts": { + "durationMs": 42.49365234375, + "testCount": 5 }, "src/cron/isolated-agent/run.cron-model-override.test.ts": { - "durationMs": 38.840576171875, + "durationMs": 42.2236328125, "testCount": 6 }, - "src/infra/outbound/message-action-runner.plugin-dispatch.test.ts": { - "durationMs": 38.831298828125, - "testCount": 11 + "src/plugins/web-search-providers.test.ts": { + "durationMs": 42.105224609375, + "testCount": 7 }, - "src/config/plugins-runtime-boundary.test.ts": { - "durationMs": 38.781494140625, - "testCount": 3 + "src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts": { + "durationMs": 41.898193359375, + "testCount": 1 }, - "src/infra/outbound/session-binding-service.test.ts": { - "durationMs": 37.824951171875, - "testCount": 8 + "src/plugins/contracts/registry.contract.test.ts": { + "durationMs": 41.73974609375, + "testCount": 19 }, - "src/media/server.test.ts": { - "durationMs": 37.70556640625, - "testCount": 9 + "src/infra/openclaw-root.test.ts": { + "durationMs": 41.5361328125, + "testCount": 10 }, - "src/cron/service.delivery-plan.test.ts": { - "durationMs": 37.392578125, + "src/acp/client.test.ts": { + "durationMs": 41.49267578125, + "testCount": 44 + }, + "src/cron/isolated-agent/run.fast-mode.test.ts": { + "durationMs": 41.228759765625, "testCount": 3 }, "src/infra/exec-approvals-store.test.ts": { - "durationMs": 37.160400390625, + "durationMs": 40.9560546875, "testCount": 8 }, - "src/memory/manager.atomic-reindex.test.ts": { - "durationMs": 36.92236328125, - "testCount": 1 + "src/cli/mcp-cli.test.ts": { + "durationMs": 40.920166015625, + "testCount": 2 }, - "src/cron/store.test.ts": { - "durationMs": 36.14990234375, - "testCount": 11 + "src/config/config.identity-avatar.test.ts": { + "durationMs": 40.639892578125, + "testCount": 3 }, - "src/plugins/contracts/runtime.contract.test.ts": { - "durationMs": 35.927001953125, - "testCount": 27 + "src/infra/outbound/targets.test.ts": { + "durationMs": 40.48193359375, + "testCount": 49 }, - "src/config/config.talk-validation.test.ts": { - "durationMs": 35.7470703125, - "testCount": 5 + "src/infra/outbound/message-action-runner.plugin-dispatch.test.ts": { + "durationMs": 39.700927734375, + "testCount": 11 }, - "src/infra/hardlink-guards.test.ts": { - "durationMs": 35.63037109375, - "testCount": 2 + "src/security/dm-policy-shared.test.ts": { + "durationMs": 38.9951171875, + "testCount": 66 }, - "src/memory/manager.async-search.test.ts": { - "durationMs": 35.413818359375, - "testCount": 2 + "src/tui/tui-formatters.test.ts": { + "durationMs": 38.393798828125, + "testCount": 26 }, - "src/cron/isolated-agent/run.fast-mode.test.ts": { - "durationMs": 35.00634765625, + "src/channels/plugins/actions/actions.test.ts": { + "durationMs": 38.15234375, + "testCount": 25 + }, + "src/cron/isolated-agent/run.message-tool-policy.test.ts": { + "durationMs": 38.073486328125, "testCount": 3 }, - "src/cli/daemon-cli.coverage.test.ts": { - "durationMs": 34.6416015625, + "src/infra/session-maintenance-warning.test.ts": { + "durationMs": 37.757568359375, "testCount": 5 }, - "src/config/sessions.cache.test.ts": { - "durationMs": 34.439697265625, - "testCount": 9 + "src/memory/qmd-process.test.ts": { + "durationMs": 37.728759765625, + "testCount": 3 }, - "src/config/io.owner-display-secret.test.ts": { - "durationMs": 33.561767578125, - "testCount": 1 + "src/plugins/contracts/runtime.contract.test.ts": { + "durationMs": 37.444091796875, + "testCount": 27 }, - "src/infra/transport-ready.test.ts": { - "durationMs": 33.1494140625, - "testCount": 6 + "src/memory/manager.watcher-config.test.ts": { + "durationMs": 37.2939453125, + "testCount": 2 }, - "src/infra/infra-runtime.test.ts": { - "durationMs": 32.81396484375, - "testCount": 11 + "src/plugins/interactive.test.ts": { + "durationMs": 37.029541015625, + "testCount": 10 }, - "src/channels/plugins/contracts/inbound.contract.test.ts": { - "durationMs": 32.615478515625, - "testCount": 5 + "src/tui/theme/theme.test.ts": { + "durationMs": 36.656982421875, + "testCount": 25 }, - "src/cron/service.main-job-passes-heartbeat-target-last.test.ts": { - "durationMs": 32.451171875, - "testCount": 2 + "src/plugins/bundled-plugin-naming.test.ts": { + "durationMs": 36.5302734375, + "testCount": 4 }, - "src/infra/provider-usage.fetch.claude.test.ts": { - "durationMs": 32.3544921875, - "testCount": 13 + "src/infra/node-pairing.test.ts": { + "durationMs": 35.856689453125, + "testCount": 4 }, - "src/infra/json-files.test.ts": { - "durationMs": 32.349609375, - "testCount": 5 + "src/infra/outbound/target-normalization.test.ts": { + "durationMs": 35.790283203125, + "testCount": 7 }, - "src/version.test.ts": { - "durationMs": 31.30322265625, - "testCount": 10 + "src/config/redact-snapshot.test.ts": { + "durationMs": 35.75, + "testCount": 28 }, - "src/cli/config-cli.test.ts": { - "durationMs": 31.265380859375, - "testCount": 48 + "src/plugins/contracts/provider.contract.test.ts": { + "durationMs": 35.133056640625, + "testCount": 38 }, - "src/infra/install-source-utils.test.ts": { - "durationMs": 30.666259765625, - "testCount": 16 + "src/infra/outbound/session-binding-service.test.ts": { + "durationMs": 34.947509765625, + "testCount": 8 }, - "src/infra/heartbeat-runner.transcript-prune.test.ts": { - "durationMs": 30.657470703125, - "testCount": 2 + "src/plugins/bundled-web-search.test.ts": { + "durationMs": 34.859130859375, + "testCount": 4 }, - "src/infra/provider-usage.fetch.shared.test.ts": { - "durationMs": 30.14111328125, - "testCount": 9 + "src/node-host/invoke-browser.test.ts": { + "durationMs": 34.781982421875, + "testCount": 4 }, - "src/memory/manager.mistral-provider.test.ts": { - "durationMs": 30.119140625, + "src/plugins/marketplace.test.ts": { + "durationMs": 34.758544921875, "testCount": 3 }, - "src/cli/mcp-cli.test.ts": { - "durationMs": 29.941650390625, - "testCount": 2 - }, - "src/media/read-response-with-limit.test.ts": { - "durationMs": 29.72216796875, - "testCount": 5 - }, - "src/plugins/contracts/provider.contract.test.ts": { - "durationMs": 29.39111328125, - "testCount": 38 + "src/plugins/wired-hooks-compaction.test.ts": { + "durationMs": 34.751220703125, + "testCount": 8 }, - "src/infra/outbound/targets.test.ts": { - "durationMs": 29.384521484375, - "testCount": 49 + "src/media-understanding/runner.video.test.ts": { + "durationMs": 33.72509765625, + "testCount": 2 }, - "src/config/sessions/store.session-key-normalization.test.ts": { - "durationMs": 29.240478515625, - "testCount": 4 + "src/media-understanding/provider-registry.test.ts": { + "durationMs": 33.62744140625, + "testCount": 3 }, - "src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts": { - "durationMs": 29.127685546875, - "testCount": 28 + "src/cli/update-cli.test.ts": { + "durationMs": 33.624267578125, + "testCount": 20 } } } diff --git a/test/helpers/captured-io.ts b/test/helpers/captured-io.ts new file mode 100644 index 0000000000000..62abef11c02e9 --- /dev/null +++ b/test/helpers/captured-io.ts @@ -0,0 +1,20 @@ +export function createCapturedIo() { + let stdout = ""; + let stderr = ""; + return { + io: { + stdout: { + write(chunk: unknown) { + stdout += String(chunk); + }, + }, + stderr: { + write(chunk: unknown) { + stderr += String(chunk); + }, + }, + }, + readStdout: () => stdout, + readStderr: () => stderr, + }; +} diff --git a/test/helpers/extensions/configured-binding-runtime.ts b/test/helpers/extensions/configured-binding-runtime.ts new file mode 100644 index 0000000000000..e37206a0d8398 --- /dev/null +++ b/test/helpers/extensions/configured-binding-runtime.ts @@ -0,0 +1,16 @@ +export async function createConfiguredBindingConversationRuntimeModuleMock( + params: { + ensureConfiguredBindingRouteReadyMock: (...args: unknown[]) => unknown; + resolveConfiguredBindingRouteMock: (...args: unknown[]) => unknown; + }, + importOriginal: () => Promise, +) { + const actual = await importOriginal(); + return { + ...actual, + ensureConfiguredBindingRouteReady: (...args: unknown[]) => + params.ensureConfiguredBindingRouteReadyMock(...args), + resolveConfiguredBindingRoute: (...args: unknown[]) => + params.resolveConfiguredBindingRouteMock(...args), + }; +} diff --git a/test/helpers/extensions/discord-component-runtime.ts b/test/helpers/extensions/discord-component-runtime.ts new file mode 100644 index 0000000000000..a5191d915d4d3 --- /dev/null +++ b/test/helpers/extensions/discord-component-runtime.ts @@ -0,0 +1,85 @@ +import { vi } from "vitest"; + +const runtimeMocks = vi.hoisted(() => ({ + readAllowFromStoreMock: vi.fn(), + upsertPairingRequestMock: vi.fn(), + recordInboundSessionMock: vi.fn(), + resolvePluginConversationBindingApprovalMock: vi.fn(), + buildPluginBindingResolvedTextMock: vi.fn(), +})); + +export const readAllowFromStoreMock = runtimeMocks.readAllowFromStoreMock; +export const upsertPairingRequestMock = runtimeMocks.upsertPairingRequestMock; +export const recordInboundSessionMock = runtimeMocks.recordInboundSessionMock; +export const resolvePluginConversationBindingApprovalMock = + runtimeMocks.resolvePluginConversationBindingApprovalMock; +export const buildPluginBindingResolvedTextMock = runtimeMocks.buildPluginBindingResolvedTextMock; + +async function createConversationRuntimeMock( + importOriginal: () => Promise, +) { + const actual = await importOriginal(); + return { + ...actual, + upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), + resolvePluginConversationBindingApproval: (...args: unknown[]) => + resolvePluginConversationBindingApprovalMock(...args), + buildPluginBindingResolvedText: (...args: unknown[]) => + buildPluginBindingResolvedTextMock(...args), + recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), + }; +} + +vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readStoreAllowFromForDmPolicy: async (params: { + provider: string; + accountId: string; + dmPolicy?: string | null; + shouldRead?: boolean | null; + }) => { + if (params.shouldRead === false || params.dmPolicy === "allowlist") { + return []; + } + return await readAllowFromStoreMock(params.provider, params.accountId); + }, + }; +}); + +vi.mock("openclaw/plugin-sdk/conversation-runtime", createConversationRuntimeMock); +vi.mock("openclaw/plugin-sdk/conversation-runtime.js", createConversationRuntimeMock); + +export function resetDiscordComponentRuntimeMocks() { + readAllowFromStoreMock.mockClear().mockResolvedValue([]); + upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true }); + recordInboundSessionMock.mockClear().mockResolvedValue(undefined); + resolvePluginConversationBindingApprovalMock.mockReset().mockResolvedValue({ + status: "approved", + binding: { + bindingId: "binding-1", + pluginId: "openclaw-codex-app-server", + pluginName: "OpenClaw App Server", + pluginRoot: "/plugins/codex", + channel: "discord", + accountId: "default", + conversationId: "user:123456789", + boundAt: Date.now(), + }, + request: { + id: "approval-1", + pluginId: "openclaw-codex-app-server", + pluginName: "OpenClaw App Server", + pluginRoot: "/plugins/codex", + requestedAt: Date.now(), + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:123456789", + }, + }, + decision: "allow-once", + }); + buildPluginBindingResolvedTextMock.mockReset().mockReturnValue("Binding approved."); +} diff --git a/test/helpers/extensions/feishu-lifecycle.ts b/test/helpers/extensions/feishu-lifecycle.ts new file mode 100644 index 0000000000000..83287cc045849 --- /dev/null +++ b/test/helpers/extensions/feishu-lifecycle.ts @@ -0,0 +1,405 @@ +import { expect, vi } from "vitest"; +import type { + ClawdbotConfig, + PluginRuntime, + RuntimeEnv, +} from "../../../extensions/feishu/runtime-api.js"; +import { monitorSingleAccount } from "../../../extensions/feishu/src/monitor.account.js"; +import { setFeishuRuntime } from "../../../extensions/feishu/src/runtime.js"; +import type { ResolvedFeishuAccount } from "../../../extensions/feishu/src/types.js"; +import { createPluginRuntimeMock } from "./plugin-runtime-mock.js"; + +type InboundDebouncerParams = { + onFlush?: (items: T[]) => Promise; + onError?: (err: unknown, items: T[]) => void; +}; + +export function setFeishuLifecycleStateDir(prefix: string) { + process.env.OPENCLAW_STATE_DIR = `/tmp/${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function restoreFeishuLifecycleStateDir(originalStateDir: string | undefined) { + if (originalStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + return; + } + process.env.OPENCLAW_STATE_DIR = originalStateDir; +} + +export const FEISHU_PREFETCHED_BOT_OPEN_ID_SOURCE = { + kind: "prefetched", + botOpenId: "ou_bot_1", + botName: "Bot", +} as const; + +export function createFeishuLifecycleReplyDispatcher() { + return { + dispatcher: { + sendToolResult: vi.fn(() => false), + sendBlockReply: vi.fn(() => false), + sendFinalReply: vi.fn(async () => true), + waitForIdle: vi.fn(async () => {}), + getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), + markComplete: vi.fn(), + }, + replyOptions: {}, + markDispatchIdle: vi.fn(), + }; +} + +export function createImmediateInboundDebounce() { + return { + resolveInboundDebounceMs: vi.fn(() => 0), + createInboundDebouncer: (params: InboundDebouncerParams) => ({ + enqueue: async (item: T) => { + try { + await params.onFlush?.([item]); + } catch (err) { + params.onError?.(err, [item]); + } + }, + flushKey: async () => {}, + }), + }; +} + +export function installFeishuLifecycleRuntime(params: { + resolveAgentRoute: PluginRuntime["channel"]["routing"]["resolveAgentRoute"]; + finalizeInboundContext: PluginRuntime["channel"]["reply"]["finalizeInboundContext"]; + dispatchReplyFromConfig: PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"]; + withReplyDispatcher: PluginRuntime["channel"]["reply"]["withReplyDispatcher"]; + resolveStorePath: PluginRuntime["channel"]["session"]["resolveStorePath"]; + hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"]; + shouldComputeCommandAuthorized?: PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"]; + resolveCommandAuthorizedFromAuthorizers?: PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"]; + readAllowFromStore?: PluginRuntime["channel"]["pairing"]["readAllowFromStore"]; + upsertPairingRequest?: PluginRuntime["channel"]["pairing"]["upsertPairingRequest"]; + buildPairingReply?: PluginRuntime["channel"]["pairing"]["buildPairingReply"]; + detectMime?: PluginRuntime["media"]["detectMime"]; +}) { + setFeishuRuntime( + createPluginRuntimeMock({ + channel: { + debounce: createImmediateInboundDebounce(), + text: { + hasControlCommand: params.hasControlCommand ?? vi.fn(() => false), + }, + routing: { + resolveAgentRoute: params.resolveAgentRoute, + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn(() => ({})), + formatAgentEnvelope: vi.fn((value: { body: string }) => value.body), + finalizeInboundContext: params.finalizeInboundContext, + dispatchReplyFromConfig: params.dispatchReplyFromConfig, + withReplyDispatcher: params.withReplyDispatcher, + }, + commands: { + shouldComputeCommandAuthorized: + params.shouldComputeCommandAuthorized ?? vi.fn(() => false), + resolveCommandAuthorizedFromAuthorizers: + params.resolveCommandAuthorizedFromAuthorizers ?? vi.fn(() => false), + }, + session: { + readSessionUpdatedAt: vi.fn(), + resolveStorePath: params.resolveStorePath, + }, + pairing: { + readAllowFromStore: params.readAllowFromStore ?? vi.fn().mockResolvedValue([]), + upsertPairingRequest: params.upsertPairingRequest ?? vi.fn(), + buildPairingReply: params.buildPairingReply ?? vi.fn(), + }, + }, + media: { + detectMime: params.detectMime ?? vi.fn(async () => "text/plain"), + }, + }) as unknown as PluginRuntime, + ); +} + +export function installFeishuLifecycleReplyRuntime(params: { + resolveAgentRouteMock: unknown; + finalizeInboundContextMock: unknown; + dispatchReplyFromConfigMock: unknown; + withReplyDispatcherMock: unknown; + storePath: string; +}) { + installFeishuLifecycleRuntime({ + resolveAgentRoute: + params.resolveAgentRouteMock as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], + finalizeInboundContext: + params.finalizeInboundContextMock as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + dispatchReplyFromConfig: + params.dispatchReplyFromConfigMock as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"], + withReplyDispatcher: + params.withReplyDispatcherMock as PluginRuntime["channel"]["reply"]["withReplyDispatcher"], + resolveStorePath: vi.fn(() => params.storePath), + }); +} + +export function mockFeishuReplyOnceDispatch(params: { + dispatchReplyFromConfigMock: { + mockImplementation: ( + fn: (args: { + ctx?: unknown; + dispatcher?: { + sendFinalReply?: (payload: { text: string }) => Promise; + }; + }) => Promise, + ) => void; + }; + replyText: string; + shouldSendFinalReply?: (ctx: unknown) => boolean; +}) { + params.dispatchReplyFromConfigMock.mockImplementation(async ({ ctx, dispatcher }) => { + const shouldSendFinalReply = params.shouldSendFinalReply?.(ctx) ?? true; + if (shouldSendFinalReply && typeof dispatcher?.sendFinalReply === "function") { + await dispatcher.sendFinalReply({ text: params.replyText }); + } + return { + queuedFinal: false, + counts: { final: shouldSendFinalReply ? 1 : 0 }, + }; + }); +} + +export function createFeishuLifecycleConfig(params: { + accountId: string; + appId: string; + appSecret: string; + channelConfig?: Record; + accountConfig?: Record; + extraConfig?: Record; +}): ClawdbotConfig { + const extraConfig = params.extraConfig ?? {}; + return { + ...extraConfig, + channels: { + ...((extraConfig.channels as Record | undefined) ?? {}), + feishu: { + enabled: true, + requireMention: false, + resolveSenderNames: false, + ...(params.channelConfig ?? {}), + accounts: { + [params.accountId]: { + enabled: true, + appId: params.appId, + appSecret: params.appSecret, // pragma: allowlist secret + connectionMode: "websocket", + requireMention: false, + resolveSenderNames: false, + ...(params.accountConfig ?? {}), + }, + }, + }, + }, + messages: { + inbound: { + debounceMs: 0, + byChannel: { + feishu: 0, + }, + }, + }, + } as ClawdbotConfig; +} + +export function createFeishuLifecycleFixture(params: { + accountId: string; + appId: string; + appSecret: string; + channelConfig?: Record; + accountConfig?: Record; + extraConfig?: Record; +}) { + return { + cfg: createFeishuLifecycleConfig(params), + account: createResolvedFeishuLifecycleAccount({ + accountId: params.accountId, + appId: params.appId, + appSecret: params.appSecret, + config: { + ...((params.channelConfig as Record | undefined) ?? {}), + ...((params.accountConfig as Record | undefined) ?? {}), + }, + }), + }; +} + +export function createResolvedFeishuLifecycleAccount(params: { + accountId: string; + appId: string; + appSecret: string; + config: Record; +}): ResolvedFeishuAccount { + return { + accountId: params.accountId, + selectionSource: "explicit", + enabled: true, + configured: true, + appId: params.appId, + appSecret: params.appSecret, // pragma: allowlist secret + domain: "feishu", + config: { + enabled: true, + connectionMode: "websocket", + requireMention: false, + resolveSenderNames: false, + ...params.config, + }, + } as unknown as ResolvedFeishuAccount; +} + +export function createFeishuTextMessageEvent(params: { + messageId: string; + chatId: string; + text: string; + chatType?: "group" | "p2p"; + senderOpenId?: string; + rootId?: string; + threadId?: string; +}) { + return { + sender: { + sender_id: { open_id: params.senderOpenId ?? "ou_sender_1" }, + sender_type: "user", + }, + message: { + message_id: params.messageId, + ...(params.rootId ? { root_id: params.rootId } : {}), + ...(params.threadId ? { thread_id: params.threadId } : {}), + chat_id: params.chatId, + chat_type: params.chatType ?? "group", + message_type: "text", + content: JSON.stringify({ text: params.text }), + create_time: "1710000000000", + }, + }; +} + +export async function replayFeishuLifecycleEvent(params: { + handler: (data: unknown) => Promise; + event: unknown; + waitForFirst: () => void | Promise; + waitForSecond?: () => void | Promise; +}) { + await params.handler(params.event); + await vi.waitFor(params.waitForFirst); + await params.handler(params.event); + await vi.waitFor(params.waitForSecond ?? params.waitForFirst); +} + +export async function runFeishuLifecycleSequence( + deliveries: Array<() => Promise>, + waits: Array<() => void | Promise>, +) { + for (const [index, deliver] of deliveries.entries()) { + await deliver(); + await vi.waitFor(waits[index] ?? waits.at(-1) ?? (() => {})); + } +} + +export async function expectFeishuSingleEffectAcrossReplay(params: { + handler: (data: unknown) => Promise; + event: unknown; + effectMock: ReturnType; + effectCount?: number; +}) { + const effectCount = params.effectCount ?? 1; + await replayFeishuLifecycleEvent({ + handler: params.handler, + event: params.event, + waitForFirst: () => { + expect(params.effectMock).toHaveBeenCalledTimes(effectCount); + }, + }); +} + +export async function expectFeishuReplyPipelineDedupedAcrossReplay(params: { + handler: (data: unknown) => Promise; + event: unknown; + dispatchReplyFromConfigMock: ReturnType; + createFeishuReplyDispatcherMock: ReturnType; +}) { + await replayFeishuLifecycleEvent({ + handler: params.handler, + event: params.event, + waitForFirst: () => { + expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + }, + waitForSecond: () => { + expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(params.createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1); + }, + }); +} + +export async function expectFeishuReplyPipelineDedupedAfterPostSendFailure(params: { + handler: (data: unknown) => Promise; + event: unknown; + dispatchReplyFromConfigMock: ReturnType; + runtimeErrorMock: ReturnType; +}) { + await replayFeishuLifecycleEvent({ + handler: params.handler, + event: params.event, + waitForFirst: () => { + expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(params.runtimeErrorMock).toHaveBeenCalledTimes(1); + }, + waitForSecond: () => { + expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(params.runtimeErrorMock).toHaveBeenCalledTimes(1); + }, + }); +} + +export function expectFeishuReplyDispatcherSentFinalReplyOnce(params: { + createFeishuReplyDispatcherMock: ReturnType; +}) { + const dispatcher = params.createFeishuReplyDispatcherMock.mock.results[0]?.value.dispatcher as { + sendFinalReply: ReturnType; + }; + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); +} + +export async function setupFeishuLifecycleHandler(params: { + createEventDispatcherMock: { + mockReturnValue: (value: unknown) => unknown; + mockReturnValueOnce: (value: unknown) => unknown; + }; + onRegister: (registered: Record Promise>) => void; + runtime: T; + cfg: ClawdbotConfig; + account: ResolvedFeishuAccount; + handlerKey: string; + missingHandlerMessage: string; + once?: boolean; +}): Promise<(data: unknown) => Promise> { + const register = vi.fn((registered: Record Promise>) => { + params.onRegister(registered); + }); + if (params.once) { + params.createEventDispatcherMock.mockReturnValueOnce({ register }); + } else { + params.createEventDispatcherMock.mockReturnValue({ register }); + } + + await monitorSingleAccount({ + cfg: params.cfg, + account: params.account, + runtime: params.runtime, + botOpenIdSource: FEISHU_PREFETCHED_BOT_OPEN_ID_SOURCE, + }); + + const handlers: Record Promise> = {}; + for (const [key, value] of Object.entries(register.mock.calls[0]?.[0] ?? {})) { + handlers[key] = value as (data: unknown) => Promise; + } + const handler = handlers[params.handlerKey]; + if (!handler) { + throw new Error(params.missingHandlerMessage); + } + return handler; +} diff --git a/test/helpers/extensions/jiti-runtime-api.ts b/test/helpers/extensions/jiti-runtime-api.ts new file mode 100644 index 0000000000000..2288ff4c69b64 --- /dev/null +++ b/test/helpers/extensions/jiti-runtime-api.ts @@ -0,0 +1,238 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const JITI_EXTENSIONS = [ + ".ts", + ".tsx", + ".mts", + ".cts", + ".mtsx", + ".ctsx", + ".js", + ".mjs", + ".cjs", + ".json", +] as const; + +const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/"; +const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const; + +type SourceModuleRef = { + specifier: string; + typeOnly: boolean; +}; + +function listPluginSdkExportedSubpaths(root: string): string[] { + const packageJsonPath = path.join(root, "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + exports?: Record; + }; + return Object.keys(packageJson.exports ?? {}) + .filter((key) => key.startsWith("./plugin-sdk/")) + .map((key) => key.slice("./plugin-sdk/".length)); +} + +function resolvePluginSdkAliasTarget(root: string, subpath: string): string | null { + const distCandidate = path.join(root, "dist", "plugin-sdk", `${subpath}.js`); + if (existsSync(distCandidate)) { + return distCandidate; + } + + for (const ext of SOURCE_MODULE_EXTENSIONS) { + const srcCandidate = path.join(root, "src", "plugin-sdk", `${subpath}${ext}`); + if (existsSync(srcCandidate)) { + return srcCandidate; + } + } + + return null; +} + +function resolveLocalModulePath(filePath: string, specifier: string): string | null { + const basePath = path.resolve(path.dirname(filePath), specifier); + const candidates = new Set([basePath]); + + for (const ext of SOURCE_MODULE_EXTENSIONS) { + candidates.add(`${basePath}${ext}`); + } + + if (/\.[cm]?[jt]sx?$/u.test(basePath)) { + const withoutExt = basePath.replace(/\.[cm]?[jt]sx?$/u, ""); + for (const ext of SOURCE_MODULE_EXTENSIONS) { + candidates.add(`${withoutExt}${ext}`); + } + } + + for (const ext of SOURCE_MODULE_EXTENSIONS) { + candidates.add(path.join(basePath, `index${ext}`)); + } + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function collectSourceModuleRefs(filePath: string): SourceModuleRef[] { + const sourceText = readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true); + const refs: SourceModuleRef[] = []; + + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) { + const specifier = + statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + if (specifier) { + refs.push({ + specifier, + typeOnly: Boolean(statement.importClause?.isTypeOnly), + }); + } + continue; + } + + if (!ts.isExportDeclaration(statement)) { + continue; + } + + const specifier = + statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + if (!specifier) { + continue; + } + + const typeOnly = Boolean( + statement.isTypeOnly || + (statement.exportClause && + ts.isNamedExports(statement.exportClause) && + statement.exportClause.elements.length > 0 && + statement.exportClause.elements.every((element) => element.isTypeOnly)), + ); + + refs.push({ specifier, typeOnly }); + } + + return refs; +} + +function collectPluginSdkAliases(params: { + modulePath: string; + root: string; + realPluginSdkSpecifiers?: readonly string[]; +}): Record { + const realSpecifiers = new Set(); + const stubSpecifiers = new Set(); + const visitedFiles = new Set(); + const stubPath = path.join(params.root, "test", "helpers", "extensions", "plugin-sdk-stub.cjs"); + const explicitRealSpecifiers = new Set(params.realPluginSdkSpecifiers ?? []); + + function visitModule(filePath: string, rootModule: boolean): void { + if (visitedFiles.has(filePath)) { + return; + } + visitedFiles.add(filePath); + + for (const ref of collectSourceModuleRefs(filePath)) { + if (ref.specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) { + const shouldKeepReal = + rootModule && + !ref.typeOnly && + (explicitRealSpecifiers.size === 0 || explicitRealSpecifiers.has(ref.specifier)); + if (shouldKeepReal) { + realSpecifiers.add(ref.specifier); + const subpath = ref.specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length); + const target = resolvePluginSdkAliasTarget(params.root, subpath); + if (target?.endsWith(".ts")) { + visitModule(target, false); + } + } else { + stubSpecifiers.add(ref.specifier); + } + continue; + } + + if (!ref.specifier.startsWith(".")) { + continue; + } + + const resolved = resolveLocalModulePath(filePath, ref.specifier); + if (resolved) { + visitModule(resolved, false); + } + } + } + + visitModule(params.modulePath, true); + + const aliasEntries = new Map(); + for (const specifier of listPluginSdkExportedSubpaths(params.root).map( + (subpath) => `${PLUGIN_SDK_SPECIFIER_PREFIX}${subpath}`, + )) { + if (realSpecifiers.has(specifier)) { + const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length); + aliasEntries.set(specifier, resolvePluginSdkAliasTarget(params.root, subpath) ?? stubPath); + continue; + } + if (stubSpecifiers.has(specifier)) { + aliasEntries.set(specifier, stubPath); + } + } + + return Object.fromEntries(aliasEntries); +} + +export function loadRuntimeApiExportTypesViaJiti(params: { + modulePath: string; + exportNames: readonly string[]; + additionalAliases?: Record; + realPluginSdkSpecifiers?: readonly string[]; +}): Record { + const root = process.cwd(); + const alias = { + ...collectPluginSdkAliases({ + modulePath: params.modulePath, + root, + realPluginSdkSpecifiers: params.realPluginSdkSpecifiers, + }), + ...params.additionalAliases, + }; + + const script = ` +import path from "node:path"; +import { createJiti } from "jiti"; + +const modulePath = ${JSON.stringify(params.modulePath)}; +const exportNames = ${JSON.stringify(params.exportNames)}; +const alias = ${JSON.stringify(alias)}; +const jiti = createJiti(path.join(${JSON.stringify(root)}, "openclaw.mjs"), { + interopDefault: true, + tryNative: false, + fsCache: false, + moduleCache: false, + extensions: ${JSON.stringify(JITI_EXTENSIONS)}, + alias, +}); +const mod = jiti(modulePath); +console.log( + JSON.stringify( + Object.fromEntries(exportNames.map((name) => [name, typeof mod[name]])), + ), +); +`; + + const raw = execFileSync(process.execPath, ["--input-type=module", "--eval", script], { + cwd: root, + encoding: "utf-8", + }); + + return JSON.parse(raw) as Record; +} diff --git a/test/helpers/extensions/plugin-sdk-stub.cjs b/test/helpers/extensions/plugin-sdk-stub.cjs new file mode 100644 index 0000000000000..1b693f991a037 --- /dev/null +++ b/test/helpers/extensions/plugin-sdk-stub.cjs @@ -0,0 +1,65 @@ +"use strict"; + +let stub; + +stub = new Proxy( + function pluginSdkStub() { + return stub; + }, + { + apply() { + return stub; + }, + construct() { + return stub; + }, + get(_target, prop) { + if (prop === "__esModule") { + return true; + } + if (prop === "default") { + return stub; + } + if (prop === "then") { + return undefined; + } + if (prop === Symbol.toPrimitive) { + return () => ""; + } + if (prop === "toJSON") { + return () => undefined; + } + if (prop === "toString") { + return () => ""; + } + if (prop === "valueOf") { + return () => 0; + } + return stub; + }, + ownKeys() { + return []; + }, + getOwnPropertyDescriptor(_target, prop) { + if (prop === "__esModule") { + return { + configurable: true, + enumerable: false, + value: true, + writable: false, + }; + } + if (prop === "default") { + return { + configurable: true, + enumerable: false, + value: stub, + writable: false, + }; + } + return undefined; + }, + }, +); + +module.exports = stub; diff --git a/test/helpers/extensions/provider-registration.ts b/test/helpers/extensions/provider-registration.ts new file mode 100644 index 0000000000000..b03cbe54041f4 --- /dev/null +++ b/test/helpers/extensions/provider-registration.ts @@ -0,0 +1,63 @@ +import { createTestPluginApi } from "./plugin-api.js"; + +type RegisteredProviderCollections = { + providers: unknown[]; + speechProviders: unknown[]; + mediaProviders: unknown[]; + imageProviders: unknown[]; +}; + +type ProviderPluginModule = { + register(api: ReturnType): void; +}; + +export function registerProviderPlugin(params: { + plugin: ProviderPluginModule; + id: string; + name: string; +}): RegisteredProviderCollections { + const providers: unknown[] = []; + const speechProviders: unknown[] = []; + const mediaProviders: unknown[] = []; + const imageProviders: unknown[] = []; + + params.plugin.register( + createTestPluginApi({ + id: params.id, + name: params.name, + source: "test", + config: {}, + runtime: {} as never, + registerProvider: (provider) => { + providers.push(provider); + }, + registerSpeechProvider: (provider) => { + speechProviders.push(provider); + }, + registerMediaUnderstandingProvider: (provider) => { + mediaProviders.push(provider); + }, + registerImageGenerationProvider: (provider) => { + imageProviders.push(provider); + }, + }), + ); + + return { providers, speechProviders, mediaProviders, imageProviders }; +} + +export function requireRegisteredProvider( + entries: unknown[], + id: string, + label = "provider", +): T { + const entry = entries.find( + (candidate) => + // oxlint-disable-next-line typescript/no-explicit-any + (candidate as any).id === id, + ); + if (!entry) { + throw new Error(`${label} ${id} was not registered`); + } + return entry as T; +} diff --git a/test/helpers/extensions/zalo-lifecycle.ts b/test/helpers/extensions/zalo-lifecycle.ts index ac61fee103f46..7134febd129b4 100644 --- a/test/helpers/extensions/zalo-lifecycle.ts +++ b/test/helpers/extensions/zalo-lifecycle.ts @@ -1,14 +1,16 @@ -import { vi } from "vitest"; +import { expect, vi } from "vitest"; import type { ResolvedZaloAccount } from "../../../extensions/zalo/src/accounts.js"; import { clearZaloWebhookSecurityStateForTest, monitorZaloProvider, } from "../../../extensions/zalo/src/monitor.js"; +import type { PluginRuntime } from "../../../extensions/zalo/src/runtime-api.js"; import type { OpenClawConfig } from "../../../extensions/zalo/src/runtime-api.js"; import { normalizeSecretInputString } from "../../../extensions/zalo/src/secret-input.js"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import { withServer } from "../http-test-server.js"; +import { createPluginRuntimeMock } from "./plugin-runtime-mock.js"; import { createRuntimeEnv } from "./runtime-env.js"; export { withServer }; @@ -110,6 +112,19 @@ export function createLifecycleAccount(params: { } as ResolvedZaloAccount; } +export function createLifecycleMonitorSetup(params: { + accountId: string; + dmPolicy: "open" | "pairing"; + allowFrom?: string[]; + webhookUrl?: string; + webhookSecret?: string; +}) { + return { + account: createLifecycleAccount(params), + config: createLifecycleConfig(params), + }; +} + export function createTextUpdate(params: { messageId: string; userId: string; @@ -129,6 +144,131 @@ export function createTextUpdate(params: { }; } +export function createImageUpdate(params?: { + messageId?: string; + userId?: string; + displayName?: string; + chatId?: string; + photoUrl?: string; + date?: number; +}) { + return { + event_name: "message.image.received", + message: { + date: params?.date ?? 1774086023728, + chat: { chat_type: "PRIVATE" as const, id: params?.chatId ?? "chat-123" }, + caption: "", + message_id: params?.messageId ?? "msg-123", + message_type: "CHAT_PHOTO", + from: { + id: params?.userId ?? "user-123", + is_bot: false, + display_name: params?.displayName ?? "Test User", + }, + photo_url: params?.photoUrl ?? "https://example.com/test-image.jpg", + }, + }; +} + +export function setLifecycleRuntimeCore( + channel: NonNullable[0]>["channel"]>, +) { + getZaloRuntimeMock.mockReturnValue( + createPluginRuntimeMock({ + channel, + }), + ); +} + +export function createImageLifecycleCore() { + const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); + const recordInboundSessionMock = vi.fn(async () => undefined); + const fetchRemoteMediaMock = vi.fn(async () => ({ + buffer: Buffer.from("image-bytes"), + contentType: "image/jpeg", + })); + const saveMediaBufferMock = vi.fn(async () => ({ + path: "/tmp/zalo-photo.jpg", + contentType: "image/jpeg", + })); + const core = createPluginRuntimeMock({ + channel: { + media: { + fetchRemoteMedia: + fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], + saveMediaBuffer: + saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], + }, + reply: { + finalizeInboundContext: + finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => undefined, + ) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], + }, + session: { + recordInboundSession: + recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"], + }, + commands: { + shouldComputeCommandAuthorized: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"], + resolveCommandAuthorizedFromAuthorizers: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"], + isControlCommandMessage: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"], + }, + }, + }); + return { + core, + finalizeInboundContextMock, + recordInboundSessionMock, + fetchRemoteMediaMock, + saveMediaBufferMock, + }; +} + +export function expectImageLifecycleDelivery(params: { + fetchRemoteMediaMock: ReturnType; + saveMediaBufferMock: ReturnType; + finalizeInboundContextMock: ReturnType; + recordInboundSessionMock: ReturnType; + photoUrl?: string; + senderName?: string; + mediaPath?: string; + mediaType?: string; +}) { + const photoUrl = params.photoUrl ?? "https://example.com/test-image.jpg"; + const senderName = params.senderName ?? "Test User"; + const mediaPath = params.mediaPath ?? "/tmp/zalo-photo.jpg"; + const mediaType = params.mediaType ?? "image/jpeg"; + expect(params.fetchRemoteMediaMock).toHaveBeenCalledWith({ + url: photoUrl, + maxBytes: 5 * 1024 * 1024, + }); + expect(params.saveMediaBufferMock).toHaveBeenCalledTimes(1); + expect(params.finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + SenderName: senderName, + MediaPath: mediaPath, + MediaType: mediaType, + }), + ); + expect(params.recordInboundSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + SenderName: senderName, + MediaPath: mediaPath, + MediaType: mediaType, + }), + }), + ); +} + export async function settleAsyncWork(): Promise { for (let i = 0; i < 6; i += 1) { await Promise.resolve(); @@ -152,6 +292,21 @@ export async function postWebhookUpdate(params: { }); } +export async function postWebhookReplay(params: { + baseUrl: string; + path: string; + secret: string; + payload: Record; + settleBeforeReplay?: boolean; +}) { + const first = await postWebhookUpdate(params); + if (params.settleBeforeReplay) { + await settleAsyncWork(); + } + const replay = await postWebhookUpdate(params); + return { first, replay }; +} + export async function startWebhookLifecycleMonitor(params: { account: ResolvedZaloAccount; config: OpenClawConfig; diff --git a/test/helpers/pattern-file.ts b/test/helpers/pattern-file.ts new file mode 100644 index 0000000000000..4244ca7b2750a --- /dev/null +++ b/test/helpers/pattern-file.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export function createPatternFileHelper(prefix: string) { + const tempDirs = new Set(); + + return { + cleanup() { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.clear(); + }, + writePatternFile(basename: string, value: unknown) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.add(dir); + const filePath = path.join(dir, basename); + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); + return filePath; + }, + }; +} diff --git a/test/helpers/temp-home.ts b/test/helpers/temp-home.ts index a19df15249a15..633310cd6dc9e 100644 --- a/test/helpers/temp-home.ts +++ b/test/helpers/temp-home.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { cleanupSessionStateForTest } from "../../src/test-utils/session-state-cleanup.js"; type EnvValue = string | undefined | ((home: string) => string | undefined); @@ -129,6 +130,7 @@ export async function withTempHome( try { return await fn(base); } finally { + await cleanupSessionStateForTest().catch(() => undefined); restoreExtraEnv(envSnapshot); restoreEnv(snapshot); try { diff --git a/test/mocks/baileys.ts b/test/mocks/baileys.ts index f3cf089488a82..e88dfbcb26113 100644 --- a/test/mocks/baileys.ts +++ b/test/mocks/baileys.ts @@ -7,6 +7,13 @@ type MakeCacheableSignalKeyStoreFn = BaileysExports["makeCacheableSignalKeyStore type MakeWASocketFn = BaileysExports["makeWASocket"]; type UseMultiFileAuthStateFn = BaileysExports["useMultiFileAuthState"]; type DownloadMediaMessageFn = BaileysExports["downloadMediaMessage"]; +type ExtractMessageContentFn = BaileysExports["extractMessageContent"]; +type GetContentTypeFn = BaileysExports["getContentType"]; +type NormalizeMessageContentFn = BaileysExports["normalizeMessageContent"]; +type IsJidGroupFn = BaileysExports["isJidGroup"]; +type MessageContentInput = Parameters[0]; +type MessageContentOutput = ReturnType; +type MessageContentType = ReturnType; export type MockBaileysSocket = { ev: EventEmitter; @@ -19,15 +26,105 @@ export type MockBaileysSocket = { export type MockBaileysModule = { DisconnectReason: { loggedOut: number }; + extractMessageContent: ReturnType>; fetchLatestBaileysVersion: ReturnType>; + getContentType: ReturnType>; + isJidGroup: ReturnType>; makeCacheableSignalKeyStore: ReturnType>; makeWASocket: ReturnType>; + normalizeMessageContent: ReturnType>; useMultiFileAuthState: ReturnType>; jidToE164?: (jid: string) => string | null; proto?: unknown; downloadMediaMessage?: ReturnType>; }; +const MESSAGE_WRAPPER_KEYS = [ + "ephemeralMessage", + "viewOnceMessage", + "viewOnceMessageV2", + "viewOnceMessageV2Extension", + "documentWithCaptionMessage", +] as const; + +const MESSAGE_CONTENT_KEYS = [ + "conversation", + "extendedTextMessage", + "imageMessage", + "videoMessage", + "audioMessage", + "documentMessage", + "stickerMessage", + "locationMessage", + "liveLocationMessage", + "contactMessage", + "contactsArrayMessage", + "buttonsResponseMessage", + "listResponseMessage", + "templateButtonReplyMessage", + "interactiveResponseMessage", + "buttonsMessage", + "listMessage", +] as const; + +type MessageLike = Record; + +export function mockNormalizeMessageContent(message: MessageContentInput): MessageContentOutput { + let current = message as unknown; + while (current && typeof current === "object") { + let unwrapped = false; + for (const key of MESSAGE_WRAPPER_KEYS) { + const candidate = (current as MessageLike)[key]; + if ( + candidate && + typeof candidate === "object" && + "message" in (candidate as MessageLike) && + (candidate as { message?: unknown }).message + ) { + current = (candidate as { message: unknown }).message; + unwrapped = true; + break; + } + } + if (!unwrapped) { + break; + } + } + return current as MessageContentOutput; +} + +export function mockGetContentType(message: MessageContentInput): MessageContentType { + const normalized = mockNormalizeMessageContent(message); + if (!normalized || typeof normalized !== "object") { + return undefined; + } + for (const key of MESSAGE_CONTENT_KEYS) { + if ((normalized as MessageLike)[key] != null) { + return key as MessageContentType; + } + } + return undefined; +} + +export function mockExtractMessageContent(message: MessageContentInput): MessageContentOutput { + const normalized = mockNormalizeMessageContent(message); + if (!normalized || typeof normalized !== "object") { + return normalized; + } + const contentType = mockGetContentType(normalized); + if (!contentType || contentType === "conversation") { + return normalized; + } + const candidate = (normalized as MessageLike)[contentType]; + return ( + candidate && typeof candidate === "object" ? candidate : normalized + ) as MessageContentOutput; +} + +export function mockIsJidGroup(jid: string | undefined | null): boolean { + return typeof jid === "string" && jid.endsWith("@g.us"); +} + export function createMockBaileys(): { mod: MockBaileysModule; lastSocket: () => MockBaileysSocket; @@ -50,11 +147,19 @@ export function createMockBaileys(): { const mod: MockBaileysModule = { DisconnectReason: { loggedOut: 401 }, + extractMessageContent: vi.fn((message) => + mockExtractMessageContent(message), + ), fetchLatestBaileysVersion: vi .fn() .mockResolvedValue({ version: [1, 2, 3], isLatest: true }), + getContentType: vi.fn((message) => mockGetContentType(message)), + isJidGroup: vi.fn((jid) => mockIsJidGroup(jid)), makeCacheableSignalKeyStore: vi.fn((keys) => keys), makeWASocket, + normalizeMessageContent: vi.fn((message) => + mockNormalizeMessageContent(message), + ), useMultiFileAuthState: vi.fn(async () => ({ state: { creds: {}, keys: {} } as Awaited>["state"], saveCreds: vi.fn(), diff --git a/test/official-channel-catalog.test.ts b/test/official-channel-catalog.test.ts new file mode 100644 index 0000000000000..6a91d72f1ab61 --- /dev/null +++ b/test/official-channel-catalog.test.ts @@ -0,0 +1,146 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildOfficialChannelCatalog, + OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH, + writeOfficialChannelCatalog, +} from "../scripts/write-official-channel-catalog.mjs"; + +const tempDirs: string[] = []; + +function makeRepoRoot(prefix: string): string { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(repoRoot); + return repoRoot; +} + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("buildOfficialChannelCatalog", () => { + it("includes publishable official channel plugins and skips non-publishable entries", () => { + const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-"); + writeJson(path.join(repoRoot, "extensions", "whatsapp", "package.json"), { + name: "@openclaw/whatsapp", + version: "2026.3.23", + description: "OpenClaw WhatsApp channel plugin", + openclaw: { + channel: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp (QR link)", + detailLabel: "WhatsApp Web", + docsPath: "/channels/whatsapp", + blurb: "works with your own number; recommend a separate phone + eSIM.", + }, + install: { + npmSpec: "@openclaw/whatsapp", + localPath: "extensions/whatsapp", + defaultChoice: "npm", + }, + release: { + publishToNpm: true, + }, + }, + }); + writeJson(path.join(repoRoot, "extensions", "local-only", "package.json"), { + name: "@openclaw/local-only", + openclaw: { + channel: { + id: "local-only", + label: "Local Only", + selectionLabel: "Local Only", + docsPath: "/channels/local-only", + blurb: "dev only", + }, + install: { + localPath: "extensions/local-only", + }, + release: { + publishToNpm: false, + }, + }, + }); + + expect(buildOfficialChannelCatalog({ repoRoot })).toEqual({ + entries: [ + { + name: "@openclaw/whatsapp", + version: "2026.3.23", + description: "OpenClaw WhatsApp channel plugin", + openclaw: { + channel: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp (QR link)", + detailLabel: "WhatsApp Web", + docsPath: "/channels/whatsapp", + blurb: "works with your own number; recommend a separate phone + eSIM.", + }, + install: { + npmSpec: "@openclaw/whatsapp", + localPath: "extensions/whatsapp", + defaultChoice: "npm", + }, + }, + }, + ], + }); + }); + + it("writes the official catalog under dist", () => { + const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-write-"); + writeJson(path.join(repoRoot, "extensions", "whatsapp", "package.json"), { + name: "@openclaw/whatsapp", + openclaw: { + channel: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "wa", + }, + install: { + npmSpec: "@openclaw/whatsapp", + }, + release: { + publishToNpm: true, + }, + }, + }); + + writeOfficialChannelCatalog({ repoRoot }); + + const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH); + expect(fs.existsSync(outputPath)).toBe(true); + expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({ + entries: [ + { + name: "@openclaw/whatsapp", + openclaw: { + channel: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "wa", + }, + install: { + npmSpec: "@openclaw/whatsapp", + }, + }, + }, + ], + }); + }); +}); diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts new file mode 100644 index 0000000000000..577ebf924a2d8 --- /dev/null +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + buildPublishedInstallScenarios, + collectInstalledPackageErrors, +} from "../scripts/openclaw-npm-postpublish-verify.ts"; + +describe("buildPublishedInstallScenarios", () => { + it("uses a single fresh scenario for plain stable releases", () => { + expect(buildPublishedInstallScenarios("2026.3.23")).toEqual([ + { + name: "fresh-exact", + installSpecs: ["openclaw@2026.3.23"], + expectedVersion: "2026.3.23", + }, + ]); + }); + + it("adds a stable-to-correction upgrade scenario for correction releases", () => { + expect(buildPublishedInstallScenarios("2026.3.23-2")).toEqual([ + { + name: "fresh-exact", + installSpecs: ["openclaw@2026.3.23-2"], + expectedVersion: "2026.3.23-2", + }, + { + name: "upgrade-from-base-stable", + installSpecs: ["openclaw@2026.3.23", "openclaw@2026.3.23-2"], + expectedVersion: "2026.3.23-2", + }, + ]); + }); +}); + +describe("collectInstalledPackageErrors", () => { + it("flags version mismatches and missing runtime sidecars", () => { + expect( + collectInstalledPackageErrors({ + expectedVersion: "2026.3.23-2", + installedVersion: "2026.3.23", + packageRoot: "/tmp/empty-openclaw", + }), + ).toEqual([ + "installed package version mismatch: expected 2026.3.23-2, found 2026.3.23.", + "installed package is missing required bundled runtime sidecar: dist/extensions/whatsapp/light-runtime-api.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/whatsapp/runtime-api.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/matrix/helper-api.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/matrix/runtime-api.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/matrix/thread-bindings-runtime.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/msteams/runtime-api.js", + ]); + }); +}); diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 6ce0d35cfdb42..30e6709423a1f 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { + collectControlUiPackErrors, collectReleasePackageMetadataErrors, collectReleaseTagErrors, + parseNpmPackJsonOutput, parseReleaseTagVersion, parseReleaseVersion, + resolveNpmCommandInvocation, utcCalendarDayDistance, } from "../scripts/openclaw-npm-release-check.ts"; @@ -11,6 +14,7 @@ describe("parseReleaseVersion", () => { it("parses stable CalVer releases", () => { expect(parseReleaseVersion("2026.3.10")).toMatchObject({ version: "2026.3.10", + baseVersion: "2026.3.10", channel: "stable", year: 2026, month: 3, @@ -21,6 +25,7 @@ describe("parseReleaseVersion", () => { it("parses beta CalVer releases", () => { expect(parseReleaseVersion("2026.3.10-beta.2")).toMatchObject({ version: "2026.3.10-beta.2", + baseVersion: "2026.3.10", channel: "beta", year: 2026, month: 3, @@ -29,20 +34,33 @@ describe("parseReleaseVersion", () => { }); }); + it("parses stable correction releases", () => { + expect(parseReleaseVersion("2026.3.10-1")).toMatchObject({ + version: "2026.3.10-1", + baseVersion: "2026.3.10", + channel: "stable", + year: 2026, + month: 3, + day: 10, + correctionNumber: 1, + }); + }); + it("rejects legacy and malformed release formats", () => { - expect(parseReleaseVersion("2026.3.10-1")).toBeNull(); expect(parseReleaseVersion("2026.03.09")).toBeNull(); expect(parseReleaseVersion("v2026.3.10")).toBeNull(); expect(parseReleaseVersion("2026.2.30")).toBeNull(); + expect(parseReleaseVersion("2026.3.10-0")).toBeNull(); expect(parseReleaseVersion("2.0.0-beta2")).toBeNull(); }); }); describe("parseReleaseTagVersion", () => { - it("accepts fallback correction tags for stable releases", () => { + it("accepts correction release tags", () => { expect(parseReleaseTagVersion("2026.3.10-2")).toMatchObject({ version: "2026.3.10-2", - packageVersion: "2026.3.10", + packageVersion: "2026.3.10-2", + baseVersion: "2026.3.10", channel: "stable", correctionNumber: 2, }); @@ -62,6 +80,90 @@ describe("utcCalendarDayDistance", () => { }); }); +describe("resolveNpmCommandInvocation", () => { + it("uses npm_execpath when it points to npm", () => { + expect( + resolveNpmCommandInvocation({ + npmExecPath: "/usr/local/lib/node_modules/npm/bin/npm-cli.js", + nodeExecPath: "/usr/local/bin/node", + platform: "linux", + }), + ).toEqual({ + command: "/usr/local/bin/node", + args: ["/usr/local/lib/node_modules/npm/bin/npm-cli.js"], + }); + }); + + it("falls back to the npm command when npm_execpath points to pnpm", () => { + expect( + resolveNpmCommandInvocation({ + npmExecPath: "/home/test/.cache/node/corepack/v1/pnpm/10.23.0/bin/pnpm.cjs", + nodeExecPath: "/usr/local/bin/node", + platform: "linux", + }), + ).toEqual({ + command: "npm", + args: [], + }); + }); + + it("uses the platform npm command when npm_execpath is missing", () => { + expect(resolveNpmCommandInvocation({ platform: "win32" })).toEqual({ + command: "npm.cmd", + args: [], + }); + }); +}); + +describe("parseNpmPackJsonOutput", () => { + it("parses a plain npm pack JSON array", () => { + expect(parseNpmPackJsonOutput('[{"filename":"openclaw.tgz","files":[]}]')).toEqual([ + { filename: "openclaw.tgz", files: [] }, + ]); + }); + + it("parses the trailing JSON payload after npm lifecycle logs", () => { + const stdout = [ + 'npm warn Unknown project config "node-linker".', + "", + "> openclaw@2026.3.23 prepack", + "> pnpm build && pnpm ui:build", + "", + "[copy-hook-metadata] Copied 4 hook metadata files.", + '[{"filename":"openclaw.tgz","files":[{"path":"dist/control-ui/index.html"}]}]', + ].join("\n"); + + expect(parseNpmPackJsonOutput(stdout)).toEqual([ + { + filename: "openclaw.tgz", + files: [{ path: "dist/control-ui/index.html" }], + }, + ]); + }); + + it("returns null when no JSON payload is present", () => { + expect(parseNpmPackJsonOutput("> openclaw@2026.3.23 prepack")).toBeNull(); + }); +}); + +describe("collectControlUiPackErrors", () => { + it("rejects packs that ship the dashboard HTML without the asset payload", () => { + expect(collectControlUiPackErrors(["dist/control-ui/index.html"])).toEqual([ + 'npm package is missing Control UI asset payload under "dist/control-ui/assets/". Refuse release when the dashboard tarball would be empty.', + ]); + }); + + it("accepts packs that ship dashboard HTML and bundled assets", () => { + expect( + collectControlUiPackErrors([ + "dist/control-ui/index.html", + "dist/control-ui/assets/index-Bu8rSoJV.js", + "dist/control-ui/assets/index-BK0yXA_h.css", + ]), + ).toEqual([]); + }); +}); + describe("collectReleaseTagErrors", () => { it("accepts versions within the two-day CalVer window", () => { expect( @@ -93,6 +195,16 @@ describe("collectReleaseTagErrors", () => { ).toEqual([]); }); + it("accepts correction package versions paired with matching correction tags", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.10-1", + releaseTag: "v2026.3.10-1", + now: new Date("2026-03-10T00:00:00Z"), + }), + ).toEqual([]); + }); + it("rejects beta package versions paired with fallback correction tags", () => { expect( collectReleaseTagErrors({ diff --git a/test/plugin-extension-import-boundary.test.ts b/test/plugin-extension-import-boundary.test.ts index 45c6d3620a9e8..226854c64bd84 100644 --- a/test/plugin-extension-import-boundary.test.ts +++ b/test/plugin-extension-import-boundary.test.ts @@ -6,6 +6,7 @@ import { diffInventory, main, } from "../scripts/check-plugin-extension-import-boundary.mjs"; +import { createCapturedIo } from "./helpers/captured-io.js"; const repoRoot = process.cwd(); const baselinePath = path.join( @@ -16,27 +17,6 @@ const baselinePath = path.join( ); const baseline = JSON.parse(readFileSync(baselinePath, "utf8")); -function createCapturedIo() { - let stdout = ""; - let stderr = ""; - return { - io: { - stdout: { - write(chunk) { - stdout += String(chunk); - }, - }, - stderr: { - write(chunk) { - stderr += String(chunk); - }, - }, - }, - readStdout: () => stdout, - readStderr: () => stderr, - }; -} - describe("plugin extension import boundary inventory", () => { it("keeps dedicated web-search registry shims out of the remaining inventory", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts index 383d97b9ab940..af718c53d7577 100644 --- a/test/plugin-npm-release.test.ts +++ b/test/plugin-npm-release.test.ts @@ -110,7 +110,7 @@ describe("collectPublishablePluginPackageErrors", () => { ).toEqual([ 'package name must start with "@openclaw/"; found "broken".', "package.json private must not be true.", - 'package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "latest".', + 'package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "latest".', "openclaw.extensions must contain only non-empty strings.", ]); }); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 09f19d2babbac..cb158663f6a20 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; +import { listBundledPluginPackArtifacts } from "../scripts/lib/bundled-plugin-build-entries.mjs"; +import { listPluginSdkDistArtifacts } from "../scripts/lib/plugin-sdk-entries.mjs"; import { collectAppcastSparkleVersionErrors, collectBundledExtensionManifestErrors, collectForbiddenPackPaths, + collectMissingPackPaths, collectPackUnpackedSizeErrors, } from "../scripts/release-check.ts"; @@ -14,6 +17,9 @@ function makePackResult(filename: string, unpackedSize: number) { return { filename, unpackedSize }; } +const requiredPluginSdkPackPaths = [...listPluginSdkDistArtifacts(), "dist/plugin-sdk/compat.js"]; +const requiredBundledPluginPackPaths = listBundledPluginPackArtifacts(); + describe("collectAppcastSparkleVersionErrors", () => { it("accepts legacy 9-digit calver builds before lane-floor cutover", () => { const xml = `${makeItem("2026.2.26", "202602260")}`; @@ -115,6 +121,63 @@ describe("collectForbiddenPackPaths", () => { }); }); +describe("collectMissingPackPaths", () => { + it("requires the shipped channel catalog, control ui, and optional bundled metadata", () => { + const missing = collectMissingPackPaths([ + "dist/index.js", + "dist/entry.js", + "dist/plugin-sdk/compat.js", + "dist/plugin-sdk/index.js", + "dist/plugin-sdk/index.d.ts", + "dist/plugin-sdk/root-alias.cjs", + "dist/build-info.json", + ]); + + expect(missing).toEqual( + expect.arrayContaining([ + "dist/channel-catalog.json", + "dist/control-ui/index.html", + "dist/extensions/matrix/helper-api.js", + "dist/extensions/matrix/runtime-api.js", + "dist/extensions/matrix/thread-bindings-runtime.js", + "dist/extensions/matrix/openclaw.plugin.json", + "dist/extensions/matrix/package.json", + "dist/extensions/whatsapp/light-runtime-api.js", + "dist/extensions/whatsapp/runtime-api.js", + "dist/extensions/whatsapp/openclaw.plugin.json", + "dist/extensions/whatsapp/package.json", + ]), + ); + }); + + it("accepts the shipped upgrade surface when optional bundled metadata is present", () => { + expect( + collectMissingPackPaths([ + "dist/index.js", + "dist/entry.js", + "dist/control-ui/index.html", + ...requiredBundledPluginPackPaths, + ...requiredPluginSdkPackPaths, + "dist/plugin-sdk/root-alias.cjs", + "dist/build-info.json", + "dist/channel-catalog.json", + ]), + ).toEqual([]); + }); + + it("requires bundled plugin runtime sidecars that dynamic plugin boundaries resolve at runtime", () => { + expect(requiredBundledPluginPackPaths).toEqual( + expect.arrayContaining([ + "dist/extensions/matrix/helper-api.js", + "dist/extensions/matrix/runtime-api.js", + "dist/extensions/matrix/thread-bindings-runtime.js", + "dist/extensions/whatsapp/light-runtime-api.js", + "dist/extensions/whatsapp/runtime-api.js", + ]), + ); + }); +}); + describe("collectPackUnpackedSizeErrors", () => { it("accepts pack results within the unpacked size budget", () => { expect( @@ -126,7 +189,7 @@ describe("collectPackUnpackedSizeErrors", () => { expect( collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.12.tgz", 224_002_564)]), ).toEqual([ - "openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 167772160 bytes (160.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", + "openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 199229440 bytes (190.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", ]); }); diff --git a/test/scripts/audit-seams.test.ts b/test/scripts/audit-seams.test.ts new file mode 100644 index 0000000000000..5649ef8e39b9a --- /dev/null +++ b/test/scripts/audit-seams.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { + HELP_TEXT, + describeSeamKinds, + determineSeamTestStatus, +} from "../../scripts/audit-seams.mjs"; + +describe("audit-seams cron seam classification", () => { + it("detects cron agent handoff and outbound delivery boundaries", () => { + const source = ` + import { runCliAgent } from "../../agents/cli-runner.js"; + import { runWithModelFallback } from "../../agents/model-fallback.js"; + import { registerAgentRunContext } from "../../infra/agent-events.js"; + import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; + import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; + + export async function runCronIsolatedAgentTurn() { + registerAgentRunContext({}); + await runWithModelFallback(() => runCliAgent({})); + await deliverOutboundPayloads({ payloads: [{ text: "done" }] }); + return buildOutboundSessionContext({}); + } + `; + + expect(describeSeamKinds("src/cron/isolated-agent/run.ts", source)).toEqual([ + "cron-agent-handoff", + "cron-outbound-delivery", + ]); + }); + + it("detects scheduler-state seams in cron service orchestration", () => { + const source = ` + import { recomputeNextRuns, computeJobNextRunAtMs } from "./jobs.js"; + import { ensureLoaded, persist } from "./store.js"; + import { armTimer, runMissedJobs } from "./timer.js"; + + export async function start(state) { + await ensureLoaded(state); + recomputeNextRuns(state); + await persist(state); + armTimer(state); + await runMissedJobs(state); + return computeJobNextRunAtMs(state.store.jobs[0], Date.now()); + } + `; + + expect(describeSeamKinds("src/cron/service/ops.ts", source)).toContain("cron-scheduler-state"); + }); +}); + +describe("audit-seams subagent seam classification", () => { + it("detects subagent spawn and cleanup handoff boundaries", () => { + const source = ` + import { callGateway } from "../gateway/call.js"; + import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; + import { registerSubagentRun } from "./subagent-registry.js"; + + export async function spawnSubagentDirect() { + const response = await callGateway({ method: "agent.run", params: { task: "do it" } }); + registerSubagentRun({ childSessionKey: "agent:main:subagent:child" }); + await callGateway({ method: "sessions.delete", params: { key: "agent:main:subagent:child" } }); + emitSessionLifecycleEvent({ sessionKey: "agent:main:subagent:child", type: "spawned" }); + return response; + } + `; + + expect(describeSeamKinds("src/agents/subagent-spawn.ts", source)).toEqual([ + "subagent-lifecycle-registry", + "subagent-session-cleanup", + "subagent-session-spawn", + ]); + }); + + it("detects subagent lifecycle registry and announce delivery seams", () => { + const source = ` + import { resolveContextEngine } from "../context-engine/registry.js"; + import { captureSubagentCompletionReply, runSubagentAnnounceFlow } from "./subagent-announce.js"; + import { emitSubagentEndedHookOnce } from "./subagent-registry-completion.js"; + import { persistSubagentRunsToDisk } from "./subagent-registry-state.js"; + + export async function completeRun(entry) { + await resolveContextEngine({}); + await captureSubagentCompletionReply(entry.childSessionKey); + await emitSubagentEndedHookOnce({ runId: entry.runId }); + persistSubagentRunsToDisk(new Map()); + return runSubagentAnnounceFlow({ childSessionKey: entry.childSessionKey }); + } + `; + + expect(describeSeamKinds("src/agents/subagent-registry.ts", source)).toEqual([ + "subagent-announce-delivery", + "subagent-lifecycle-registry", + ]); + }); + + it("detects parent-stream seams for ACP spawn relays", () => { + const source = ` + import { onAgentEvent } from "../infra/agent-events.js"; + import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; + import { enqueueSystemEvent } from "../infra/system-events.js"; + + export function startAcpSpawnParentStreamRelay() { + onAgentEvent("agent-output", () => {}); + requestHeartbeatNow({ sessionKey: "agent:main" }); + enqueueSystemEvent("progress", { sessionKey: "agent:main", contextKey: "stream" }); + return { streamTo: "parent" }; + } + `; + + expect(describeSeamKinds("src/agents/acp-spawn-parent-stream.ts", source)).toEqual([ + "subagent-parent-stream", + ]); + }); +}); + +describe("audit-seams status/help", () => { + it("keeps cron seam statuses conservative when nearby tests exist", () => { + expect( + determineSeamTestStatus( + ["cron-agent-handoff"], + [{ file: "src/cron/service.issue-regressions.test.ts", matchQuality: "path-nearby" }], + ), + ).toEqual({ + status: "partial", + reason: + "Nearby tests exist (best match: path-nearby), but this inventory does not prove cross-layer seam coverage end to end.", + }); + }); + + it("keeps subagent seam statuses conservative when nearby tests exist", () => { + expect( + determineSeamTestStatus( + ["subagent-session-spawn"], + [{ file: "src/agents/subagent-spawn.workspace.test.ts", matchQuality: "direct-import" }], + ), + ).toEqual({ + status: "partial", + reason: + "Nearby tests exist (best match: direct-import), but this inventory does not prove cross-layer seam coverage end to end.", + }); + }); + + it("documents cron and subagent seam coverage in help text", () => { + expect(HELP_TEXT).toContain("cron orchestration seams"); + expect(HELP_TEXT).toContain("subagent seams"); + expect(HELP_TEXT).toContain("announce delivery"); + expect(HELP_TEXT).toContain("parent streaming"); + }); +}); diff --git a/test/scripts/run-vitest-profile.test.ts b/test/scripts/run-vitest-profile.test.ts new file mode 100644 index 0000000000000..8d9b31ea71c1c --- /dev/null +++ b/test/scripts/run-vitest-profile.test.ts @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildVitestProfileCommand, + parseArgs, + resolveVitestProfileDir, +} from "../../scripts/run-vitest-profile.mjs"; + +describe("scripts/run-vitest-profile", () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it("defaults profile output outside the repo", () => { + const outputDir = resolveVitestProfileDir({ mode: "main", outputDir: "" }); + tempDirs.push(outputDir); + + expect(outputDir.startsWith(os.tmpdir())).toBe(true); + expect(outputDir.startsWith(process.cwd())).toBe(false); + }); + + it("keeps explicit output directories", () => { + expect( + resolveVitestProfileDir({ mode: "runner", outputDir: ".artifacts/custom-profile" }), + ).toBe(path.resolve(".artifacts/custom-profile")); + }); + + it("builds main-thread cpu profiling args", () => { + expect(buildVitestProfileCommand({ mode: "main", outputDir: "/tmp/profile-main" })).toEqual({ + command: process.execPath, + args: [ + "--cpu-prof", + "--cpu-prof-dir=/tmp/profile-main", + "./node_modules/vitest/vitest.mjs", + "run", + "--config", + "vitest.unit.config.ts", + "--no-file-parallelism", + ], + }); + }); + + it("builds runner cpu and heap profiling args", () => { + expect(buildVitestProfileCommand({ mode: "runner", outputDir: "/tmp/profile-runner" })).toEqual( + { + command: "pnpm", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + "--no-file-parallelism", + "--execArgv=--cpu-prof", + "--execArgv=--cpu-prof-dir=/tmp/profile-runner", + "--execArgv=--heap-prof", + "--execArgv=--heap-prof-dir=/tmp/profile-runner", + ], + }, + ); + }); + + it("parses mode and explicit output dir", () => { + expect(parseArgs(["runner", "--output-dir", "/tmp/out"])).toEqual({ + mode: "runner", + outputDir: "/tmp/out", + }); + }); +}); diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts new file mode 100644 index 0000000000000..0c675bdec6679 --- /dev/null +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -0,0 +1,120 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveNpmRunner } from "../../scripts/stage-bundled-plugin-runtime-deps.mjs"; + +describe("resolveNpmRunner", () => { + it("anchors npm staging to the active node toolchain when npm-cli.js exists", () => { + const execPath = "/Users/test/.nodenv/versions/24.13.0/bin/node"; + const expectedNpmCliPath = path.posix.resolve( + path.posix.dirname(execPath), + "../lib/node_modules/npm/bin/npm-cli.js", + ); + + const runner = resolveNpmRunner({ + execPath, + env: {}, + existsSync: (candidate: string) => candidate === expectedNpmCliPath, + platform: "darwin", + }); + + expect(runner).toEqual({ + command: execPath, + args: [expectedNpmCliPath], + shell: false, + }); + }); + + it("anchors Windows npm staging to the adjacent npm-cli.js without a shell", () => { + const execPath = "C:\\nodejs\\node.exe"; + const expectedNpmCliPath = path.win32.resolve( + path.win32.dirname(execPath), + "node_modules/npm/bin/npm-cli.js", + ); + + const runner = resolveNpmRunner({ + execPath, + env: {}, + existsSync: (candidate: string) => candidate === expectedNpmCliPath, + platform: "win32", + }); + + expect(runner).toEqual({ + command: execPath, + args: [expectedNpmCliPath], + shell: false, + }); + }); + + it("uses an adjacent npm.exe on Windows without a shell", () => { + const execPath = "C:\\nodejs\\node.exe"; + const expectedNpmExePath = path.win32.resolve(path.win32.dirname(execPath), "npm.exe"); + + const runner = resolveNpmRunner({ + execPath, + env: {}, + existsSync: (candidate: string) => candidate === expectedNpmExePath, + npmArgs: ["install", "--silent"], + platform: "win32", + }); + + expect(runner).toEqual({ + command: expectedNpmExePath, + args: ["install", "--silent"], + shell: false, + }); + }); + + it("wraps an adjacent npm.cmd via cmd.exe without enabling shell mode", () => { + const execPath = "C:\\nodejs\\node.exe"; + const npmCmdPath = path.win32.resolve(path.win32.dirname(execPath), "npm.cmd"); + + const runner = resolveNpmRunner({ + comSpec: "C:\\Windows\\System32\\cmd.exe", + execPath, + env: {}, + existsSync: (candidate: string) => candidate === npmCmdPath, + npmArgs: ["install", "--omit=dev"], + platform: "win32", + }); + + expect(runner).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", `${npmCmdPath} install --omit=dev`], + shell: false, + windowsVerbatimArguments: true, + }); + }); + + it("prefixes PATH with the active node dir when falling back to bare npm", () => { + expect( + resolveNpmRunner({ + execPath: "/tmp/node", + env: { + PATH: "/usr/bin:/bin", + }, + existsSync: () => false, + platform: "linux", + }), + ).toEqual({ + command: "npm", + args: [], + shell: false, + env: { + PATH: `/tmp${path.delimiter}/usr/bin:/bin`, + }, + }); + }); + + it("fails closed on Windows when no toolchain-local npm CLI exists", () => { + expect(() => + resolveNpmRunner({ + execPath: "C:\\node\\node.exe", + env: { + Path: "C:\\Windows\\System32", + }, + existsSync: () => false, + platform: "win32", + }), + ).toThrow("OpenClaw refuses to shell out to bare npm on Windows"); + }); +}); diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 6b905197062d6..b2b4869173d0a 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -5,6 +5,7 @@ import { detectChangedExtensionIds, listAvailableExtensionIds, listChangedExtensionIds, + partitionExtensionTestFiles, resolveExtensionTestPlan, } from "../../scripts/test-extension.mjs"; @@ -35,6 +36,15 @@ describe("scripts/test-extension.mjs", () => { expect(plan.testFiles.some((file) => file.startsWith("extensions/slack/"))).toBe(true); }); + it("splits channel monitor files into isolated runs", () => { + const plan = resolveExtensionTestPlan({ targetArg: "discord", cwd: process.cwd() }); + + expect(plan.config).toBe("vitest.channels.config.ts"); + expect(plan.isolatedTestFiles).toContain("extensions/discord/src/monitor/provider.test.ts"); + expect(plan.sharedTestFiles).toContain("extensions/discord/src/channel.test.ts"); + expect(plan.sharedTestFiles).not.toContain("extensions/discord/src/monitor/provider.test.ts"); + }); + it("resolves provider extensions onto the extensions vitest config", () => { const plan = resolveExtensionTestPlan({ targetArg: "firecrawl", cwd: process.cwd() }); @@ -43,6 +53,19 @@ describe("scripts/test-extension.mjs", () => { expect(plan.testFiles.some((file) => file.startsWith("extensions/firecrawl/"))).toBe(true); }); + it("applies exact isolated files for non-channel extensions", () => { + const { isolatedTestFiles, sharedTestFiles } = partitionExtensionTestFiles({ + config: "vitest.extensions.config.ts", + testFiles: [ + "extensions/firecrawl/src/firecrawl-scrape-tool.test.ts", + "extensions/firecrawl/src/index.test.ts", + ], + }); + + expect(isolatedTestFiles).toEqual(["extensions/firecrawl/src/firecrawl-scrape-tool.test.ts"]); + expect(sharedTestFiles).toEqual(["extensions/firecrawl/src/index.test.ts"]); + }); + it("includes paired src roots when they contain tests", () => { const plan = resolveExtensionTestPlan({ targetArg: "line", cwd: process.cwd() }); diff --git a/test/scripts/test-parallel.test.ts b/test/scripts/test-parallel.test.ts index 0f4a91c85a455..30994b5b8641e 100644 --- a/test/scripts/test-parallel.test.ts +++ b/test/scripts/test-parallel.test.ts @@ -1,3 +1,5 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { parseCompletedTestFileLines, @@ -108,3 +110,39 @@ describe("scripts/test-parallel memory trace parsing", () => { }); }); }); + +describe("scripts/test-parallel lane planning", () => { + it("keeps serial profile on split unit lanes instead of one giant unit worker", () => { + const repoRoot = path.resolve(import.meta.dirname, "../.."); + const output = execFileSync("node", ["scripts/test-parallel.mjs"], { + cwd: repoRoot, + env: { + ...process.env, + OPENCLAW_TEST_LIST_LANES: "1", + OPENCLAW_TEST_PROFILE: "serial", + }, + encoding: "utf8", + }); + + expect(output).toContain("unit-fast"); + expect(output).not.toContain("unit filters=all maxWorkers=1"); + }); + + it("recycles default local unit-fast runs into bounded batches", () => { + const repoRoot = path.resolve(import.meta.dirname, "../.."); + const output = execFileSync("node", ["scripts/test-parallel.mjs"], { + cwd: repoRoot, + env: { + ...process.env, + CI: "", + OPENCLAW_TEST_LIST_LANES: "1", + OPENCLAW_TEST_UNIT_FAST_LANES: "1", + OPENCLAW_TEST_UNIT_FAST_BATCH_TARGET_MS: "1", + }, + encoding: "utf8", + }); + + expect(output).toContain("unit-fast-batch-"); + expect(output).not.toContain("unit-fast filters=all maxWorkers="); + }); +}); diff --git a/test/scripts/test-runner-manifest.test.ts b/test/scripts/test-runner-manifest.test.ts index 0fac87c25e17e..67e2b58e3f537 100644 --- a/test/scripts/test-runner-manifest.test.ts +++ b/test/scripts/test-runner-manifest.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { dedupeFilesPreserveOrder, packFilesByDuration, + packFilesByDurationWithBaseLoads, selectMemoryHeavyFiles, selectTimedHeavyFiles, selectUnitHeavyFileGroups, @@ -133,4 +134,21 @@ describe("packFilesByDuration", () => { ["src/b.test.ts", "src/c.test.ts"], ]); }); + + it("accounts for existing shard load when packing new work", () => { + const durationByFile = { + "src/a.test.ts": 100, + "src/b.test.ts": 90, + "src/c.test.ts": 20, + } satisfies Record; + + expect( + packFilesByDurationWithBaseLoads( + Object.keys(durationByFile), + 3, + (file) => durationByFile[file] ?? 0, + [0, 200, 10], + ), + ).toEqual([["src/a.test.ts", "src/c.test.ts"], [], ["src/b.test.ts"]]); + }); }); diff --git a/test/scripts/test-update-memory-hotspots-utils.test.ts b/test/scripts/test-update-memory-hotspots-utils.test.ts new file mode 100644 index 0000000000000..18c2fe318da93 --- /dev/null +++ b/test/scripts/test-update-memory-hotspots-utils.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { matchesHotspotSummaryLane } from "../../scripts/test-update-memory-hotspots-utils.mjs"; + +describe("test-update-memory-hotspots lane matching", () => { + it("matches the exact target lane", () => { + expect(matchesHotspotSummaryLane("unit-fast", "unit-fast")).toBe(true); + }); + + it("matches configured lane prefixes", () => { + expect(matchesHotspotSummaryLane("unit-chat-memory-isolated", "unit-fast", ["unit-"])).toBe( + true, + ); + }); + + it("rejects unrelated lanes", () => { + expect(matchesHotspotSummaryLane("extensions", "unit-fast", ["unit-"])).toBe(false); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index c393fe59c9e06..e37fd124a55ab 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -10,6 +10,27 @@ vi.mock("@mariozechner/pi-ai", async (importOriginal) => { }; }); +vi.mock("@mariozechner/clipboard", () => ({ + availableFormats: () => [], + getText: async () => "", + setText: async () => {}, + hasText: () => false, + getImageBinary: async () => [], + getImageBase64: async () => "", + setImageBinary: async () => {}, + setImageBase64: async () => {}, + hasImage: () => false, + getHtml: async () => "", + setHtml: async () => {}, + hasHtml: () => false, + getRtf: async () => "", + setRtf: async () => {}, + hasRtf: () => false, + clear: async () => {}, + watch: () => {}, + callThreadsafeFunction: () => {}, +})); + // Ensure Vitest environment is properly set process.env.VITEST = "true"; // Config validation walks plugin manifests; keep an aggressive cache in tests to avoid @@ -22,6 +43,13 @@ if (process.getMaxListeners() > 0 && process.getMaxListeners() < TEST_PROCESS_MA process.setMaxListeners(TEST_PROCESS_MAX_LISTENERS); } +import { resetContextWindowCacheForTest } from "../src/agents/context.js"; +import { resetModelsJsonReadyCacheForTest } from "../src/agents/models-config.js"; +import { + drainSessionWriteLockStateForTest, + resetSessionWriteLockStateForTest, +} from "../src/agents/session-write-lock.js"; +import { createTopLevelChannelReplyToModeResolver } from "../src/channels/plugins/threading-helpers.js"; import type { ChannelId, ChannelOutboundAdapter, @@ -31,11 +59,12 @@ import type { OpenClawConfig } from "../src/config/config.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; import { installProcessWarningFilter } from "../src/infra/warning-filter.js"; import type { PluginRegistry } from "../src/plugins/registry.js"; +import { createTestRegistry } from "../src/test-utils/channel-plugins.js"; +import { cleanupSessionStateForTest } from "../src/test-utils/session-state-cleanup.js"; import { withIsolatedTestHome } from "./test-env.js"; // Set HOME/state isolation before importing any runtime OpenClaw modules. const testEnv = withIsolatedTestHome(); -afterAll(() => testEnv.cleanup()); installProcessWarningFilter(); @@ -49,12 +78,6 @@ type RegistryState = { version: number; }; -type TestChannelRegistration = { - pluginId: string; - plugin: unknown; - source: string; -}; - const globalRegistryState = (() => { const globalState = globalThis as typeof globalThis & { [REGISTRY_STATE]?: RegistryState; @@ -75,59 +98,40 @@ const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { return deps?.[id] as ((...args: unknown[]) => Promise) | undefined; }; -type VitestEvaluatedModuleNode = { - promise?: unknown; - exports?: unknown; - evaluated?: boolean; - importers: Set; -}; - -type VitestEvaluatedModules = { - idToModuleMap: Map; -}; - -const resetVitestWorkerModules = (resetMocks: boolean) => { - const workerState = ( - globalThis as typeof globalThis & { - __vitest_worker__?: { - evaluatedModules?: VitestEvaluatedModules; - }; +function resolveSlackStubReplyToMode(params: { + cfg: OpenClawConfig; + chatType?: string | null; +}): "off" | "first" | "all" { + const entry = ( + params.cfg.channels as + | Record< + string, + { + replyToMode?: "off" | "first" | "all"; + replyToModeByChatType?: Partial< + Record<"direct" | "group" | "channel", "off" | "first" | "all"> + >; + dm?: { replyToMode?: "off" | "first" | "all" }; + } + > + | undefined + )?.slack; + const normalizedChatType = params.chatType?.trim().toLowerCase(); + if ( + normalizedChatType === "direct" || + normalizedChatType === "group" || + normalizedChatType === "channel" + ) { + const byChatType = entry?.replyToModeByChatType?.[normalizedChatType]; + if (byChatType) { + return byChatType; } - ).__vitest_worker__; - const modules = workerState?.evaluatedModules; - if (!modules) { - return; - } - - const skipPaths = [ - /\/vitest\/dist\//, - /vitest-virtual-\w+\/dist/u, - /@vitest\/dist/u, - ...(resetMocks ? [] : [/^mock:/u]), - ]; - - modules.idToModuleMap.forEach((node, modulePath) => { - if (skipPaths.some((pattern) => pattern.test(modulePath))) { - return; + if (normalizedChatType === "direct" && entry?.dm?.replyToMode) { + return entry.dm.replyToMode; } - node.promise = undefined; - node.exports = undefined; - node.evaluated = false; - node.importers.clear(); - }); -}; - -const resetVitestWorkerFileState = () => { - const mocker = ( - globalThis as typeof globalThis & { - __vitest_mocker__?: { - reset?: () => void; - }; - } - ).__vitest_mocker__; - mocker?.reset?.(); - resetVitestWorkerModules(true); -}; + } + return entry?.replyToMode ?? "off"; +} const createStubOutbound = ( id: ChannelId, @@ -164,6 +168,11 @@ const createStubPlugin = (params: { aliases?: string[]; deliveryMode?: ChannelOutboundAdapter["deliveryMode"]; preferSessionLookupForAnnounceTarget?: boolean; + resolveReplyToMode?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + chatType?: string | null; + }) => "off" | "first" | "all"; }): ChannelPlugin => ({ id: params.id, meta: { @@ -176,6 +185,11 @@ const createStubPlugin = (params: { preferSessionLookupForAnnounceTarget: params.preferSessionLookupForAnnounceTarget, }, capabilities: { chatTypes: ["direct", "group"] }, + threading: params.resolveReplyToMode + ? { + resolveReplyToMode: params.resolveReplyToMode, + } + : undefined, config: { listAccountIds: (cfg: OpenClawConfig) => { const channels = cfg.channels as Record | undefined; @@ -205,48 +219,34 @@ const createStubPlugin = (params: { outbound: createStubOutbound(params.id, params.deliveryMode), }); -const createTestRegistry = (channels: TestChannelRegistration[] = []): PluginRegistry => ({ - plugins: [], - tools: [], - hooks: [], - typedHooks: [], - channels: channels as unknown as PluginRegistry["channels"], - channelSetups: channels.map((entry) => ({ - pluginId: entry.pluginId, - plugin: entry.plugin as PluginRegistry["channelSetups"][number]["plugin"], - source: entry.source, - enabled: true, - })), - providers: [], - speechProviders: [], - mediaUnderstandingProviders: [], - imageGenerationProviders: [], - webSearchProviders: [], - gatewayHandlers: {}, - httpRoutes: [], - cliRegistrars: [], - services: [], - commands: [], - conversationBindingResolvedHandlers: [], - diagnostics: [], -}); - const createDefaultRegistry = () => createTestRegistry([ { pluginId: "discord", - plugin: createStubPlugin({ id: "discord", label: "Discord" }), + plugin: createStubPlugin({ + id: "discord", + label: "Discord", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("discord"), + }), source: "test", }, { pluginId: "slack", - plugin: createStubPlugin({ id: "slack", label: "Slack" }), + plugin: createStubPlugin({ + id: "slack", + label: "Slack", + resolveReplyToMode: ({ cfg, chatType }) => resolveSlackStubReplyToMode({ cfg, chatType }), + }), source: "test", }, { pluginId: "telegram", plugin: { - ...createStubPlugin({ id: "telegram", label: "Telegram" }), + ...createStubPlugin({ + id: "telegram", + label: "Telegram", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"), + }), status: { buildChannelSummary: async () => ({ configured: false, @@ -322,23 +322,20 @@ beforeAll(() => { installDefaultPluginRegistry(); }); -afterEach(() => { +afterEach(async () => { + await cleanupSessionStateForTest(); + resetContextWindowCacheForTest(); + resetModelsJsonReadyCacheForTest(); + resetSessionWriteLockStateForTest(); if (globalRegistryState.registry !== DEFAULT_PLUGIN_REGISTRY) { installDefaultPluginRegistry(); globalRegistryState.key = null; globalRegistryState.version += 1; } - // Always normalize timer/date state. Some suites call `vi.setSystemTime()` - // without leaving fake timers enabled, which still leaks mocked time into - // later files under `--isolate=false`. - vi.useRealTimers(); - // Non-isolated runs reuse the same module graph across files. Clear it so - // hoisted per-file mocks still apply when later files import the same modules. - vi.resetModules(); }); -afterAll(() => { - // Mirror Vitest's isolate-mode file cleanup so `--isolate=false` does not - // carry hoisted mocks or stale module graphs into the next test file. - resetVitestWorkerFileState(); +afterAll(async () => { + await cleanupSessionStateForTest(); + await drainSessionWriteLockStateForTest(); + testEnv.cleanup(); }); diff --git a/test/test-runner-manifest.test.ts b/test/test-runner-manifest.test.ts new file mode 100644 index 0000000000000..ebad441811ffc --- /dev/null +++ b/test/test-runner-manifest.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + loadChannelTimingManifest, + loadTestRunnerBehavior, +} from "../scripts/test-runner-manifest.mjs"; + +describe("loadTestRunnerBehavior", () => { + it("loads channel isolated entries from the behavior manifest", () => { + const behavior = loadTestRunnerBehavior(); + const files = behavior.channels.isolated.map((entry) => entry.file); + + expect(files).toContain( + "extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts", + ); + }); + + it("loads channel isolated prefixes from the behavior manifest", () => { + const behavior = loadTestRunnerBehavior(); + + expect(behavior.channels.isolatedPrefixes).toContain("extensions/discord/src/monitor/"); + }); + + it("loads channel timing metadata from the timing manifest", () => { + const timings = loadChannelTimingManifest(); + + expect(timings.config).toBe("vitest.channels.config.ts"); + expect(Object.keys(timings.files).length).toBeGreaterThan(0); + }); +}); diff --git a/test/vitest-extensions-config.test.ts b/test/vitest-extensions-config.test.ts index 8cc3ded11ad8a..f9228f0446678 100644 --- a/test/vitest-extensions-config.test.ts +++ b/test/vitest-extensions-config.test.ts @@ -1,33 +1,20 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { loadIncludePatternsFromEnv } from "../vitest.extensions.config.ts"; +import { createPatternFileHelper } from "./helpers/pattern-file.js"; -const tempDirs = new Set(); +const patternFiles = createPatternFileHelper("openclaw-vitest-extensions-config-"); afterEach(() => { - for (const dir of tempDirs) { - fs.rmSync(dir, { recursive: true, force: true }); - } - tempDirs.clear(); + patternFiles.cleanup(); }); -const writePatternFile = (basename: string, value: unknown) => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-extensions-config-")); - tempDirs.add(dir); - const filePath = path.join(dir, basename); - fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); - return filePath; -}; - describe("extensions vitest include patterns", () => { it("returns null when no include file is configured", () => { expect(loadIncludePatternsFromEnv({})).toBeNull(); }); it("loads include patterns from a JSON file", () => { - const filePath = writePatternFile("include.json", [ + const filePath = patternFiles.writePatternFile("include.json", [ "extensions/feishu/index.test.ts", 42, "", @@ -42,7 +29,7 @@ describe("extensions vitest include patterns", () => { }); it("throws when the configured file is not a JSON array", () => { - const filePath = writePatternFile("include.json", { + const filePath = patternFiles.writePatternFile("include.json", { include: ["extensions/feishu/index.test.ts"], }); diff --git a/test/vitest-performance-config.test.ts b/test/vitest-performance-config.test.ts index 213d73bd72393..aac2533012647 100644 --- a/test/vitest-performance-config.test.ts +++ b/test/vitest-performance-config.test.ts @@ -3,7 +3,7 @@ import { loadVitestExperimentalConfig } from "../vitest.performance-config.ts"; describe("loadVitestExperimentalConfig", () => { it("enables the filesystem module cache by default", () => { - expect(loadVitestExperimentalConfig({})).toEqual({ + expect(loadVitestExperimentalConfig({}, "linux")).toEqual({ experimental: { fsModuleCache: true, }, @@ -12,9 +12,31 @@ describe("loadVitestExperimentalConfig", () => { it("enables the filesystem module cache explicitly", () => { expect( - loadVitestExperimentalConfig({ - OPENCLAW_VITEST_FS_MODULE_CACHE: "1", - }), + loadVitestExperimentalConfig( + { + OPENCLAW_VITEST_FS_MODULE_CACHE: "1", + }, + "linux", + ), + ).toEqual({ + experimental: { + fsModuleCache: true, + }, + }); + }); + + it("disables the filesystem module cache by default on Windows", () => { + expect(loadVitestExperimentalConfig({}, "win32")).toEqual({}); + }); + + it("still allows enabling the filesystem module cache explicitly on Windows", () => { + expect( + loadVitestExperimentalConfig( + { + OPENCLAW_VITEST_FS_MODULE_CACHE: "1", + }, + "win32", + ), ).toEqual({ experimental: { fsModuleCache: true, @@ -24,18 +46,24 @@ describe("loadVitestExperimentalConfig", () => { it("allows disabling the filesystem module cache explicitly", () => { expect( - loadVitestExperimentalConfig({ - OPENCLAW_VITEST_FS_MODULE_CACHE: "0", - }), + loadVitestExperimentalConfig( + { + OPENCLAW_VITEST_FS_MODULE_CACHE: "0", + }, + "linux", + ), ).toEqual({}); }); it("enables import timing output and import breakdown reporting", () => { expect( - loadVitestExperimentalConfig({ - OPENCLAW_VITEST_IMPORT_DURATIONS: "true", - OPENCLAW_VITEST_PRINT_IMPORT_BREAKDOWN: "1", - }), + loadVitestExperimentalConfig( + { + OPENCLAW_VITEST_IMPORT_DURATIONS: "true", + OPENCLAW_VITEST_PRINT_IMPORT_BREAKDOWN: "1", + }, + "linux", + ), ).toEqual({ experimental: { fsModuleCache: true, @@ -44,4 +72,8 @@ describe("loadVitestExperimentalConfig", () => { }, }); }); + + it("uses RUNNER_OS to detect Windows even when the platform is not win32", () => { + expect(loadVitestExperimentalConfig({ RUNNER_OS: "Windows" }, "linux")).toEqual({}); + }); }); diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 7f6310d508c20..c22cc08bc82f5 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -1,6 +1,10 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -import channelsConfig from "../vitest.channels.config.ts"; -import extensionsConfig from "../vitest.extensions.config.ts"; +import { createChannelsVitestConfig } from "../vitest.channels.config.ts"; +import { createExtensionsVitestConfig } from "../vitest.extensions.config.ts"; +import { createGatewayVitestConfig } from "../vitest.gateway.config.ts"; import { createScopedVitestConfig, resolveVitestIsolation } from "../vitest.scoped-config.ts"; describe("resolveVitestIsolation", () => { @@ -17,24 +21,76 @@ describe("resolveVitestIsolation", () => { describe("createScopedVitestConfig", () => { it("applies non-isolated mode by default", () => { - const config = createScopedVitestConfig(["src/example.test.ts"]); + const config = createScopedVitestConfig(["src/example.test.ts"], { env: {} }); expect(config.test?.isolate).toBe(false); + expect(config.test?.runner).toBe("./test/non-isolated-runner.ts"); }); it("passes through a scoped root dir when provided", () => { const config = createScopedVitestConfig(["src/example.test.ts"], { dir: "src", + env: {}, }); expect(config.test?.dir).toBe("src"); + expect(config.test?.include).toEqual(["example.test.ts"]); + }); + + it("relativizes scoped include and exclude patterns to the configured dir", () => { + const config = createScopedVitestConfig(["extensions/**/*.test.ts"], { + dir: "extensions", + env: {}, + exclude: ["extensions/channel/**", "dist/**"], + }); + + expect(config.test?.include).toEqual(["**/*.test.ts"]); + expect(config.test?.exclude).toEqual(expect.arrayContaining(["channel/**", "dist/**"])); }); }); describe("scoped vitest configs", () => { + const defaultChannelsConfig = createChannelsVitestConfig({}); + const defaultExtensionsConfig = createExtensionsVitestConfig({}); + const defaultGatewayConfig = createGatewayVitestConfig({}); + it("defaults channel tests to non-isolated mode", () => { - expect(channelsConfig.test?.isolate).toBe(false); + expect(defaultChannelsConfig.test?.isolate).toBe(false); + }); + + it("loads channel include overrides from OPENCLAW_VITEST_INCLUDE_FILE", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-channels-")); + try { + const includeFile = path.join(tempDir, "include.json"); + fs.writeFileSync( + includeFile, + JSON.stringify([ + "extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts", + ]), + "utf8", + ); + + const config = createChannelsVitestConfig({ + OPENCLAW_VITEST_INCLUDE_FILE: includeFile, + }); + + expect(config.test?.include).toEqual([ + "extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts", + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); it("defaults extension tests to non-isolated mode", () => { - expect(extensionsConfig.test?.isolate).toBe(false); + expect(defaultExtensionsConfig.test?.isolate).toBe(false); + }); + + it("normalizes extension include patterns relative to the scoped dir", () => { + expect(defaultExtensionsConfig.test?.dir).toBe("extensions"); + expect(defaultExtensionsConfig.test?.include).toEqual(["**/*.test.ts"]); + }); + + it("normalizes gateway include patterns relative to the scoped dir", () => { + expect(defaultGatewayConfig.test?.dir).toBe("src/gateway"); + expect(defaultGatewayConfig.test?.include).toEqual(["**/*.test.ts"]); }); }); diff --git a/test/vitest-unit-config.test.ts b/test/vitest-unit-config.test.ts index 0af3f4f7ae288..3f67c203125d4 100644 --- a/test/vitest-unit-config.test.ts +++ b/test/vitest-unit-config.test.ts @@ -1,37 +1,24 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import unitConfig from "../vitest.unit.config.ts"; import { + createUnitVitestConfig, loadExtraExcludePatternsFromEnv, loadIncludePatternsFromEnv, } from "../vitest.unit.config.ts"; +import { createPatternFileHelper } from "./helpers/pattern-file.js"; -const tempDirs = new Set(); +const patternFiles = createPatternFileHelper("openclaw-vitest-unit-config-"); afterEach(() => { - for (const dir of tempDirs) { - fs.rmSync(dir, { recursive: true, force: true }); - } - tempDirs.clear(); + patternFiles.cleanup(); }); -const writePatternFile = (basename: string, value: unknown) => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-unit-config-")); - tempDirs.add(dir); - const filePath = path.join(dir, basename); - fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); - return filePath; -}; - describe("loadIncludePatternsFromEnv", () => { it("returns null when no include file is configured", () => { expect(loadIncludePatternsFromEnv({})).toBeNull(); }); it("loads include patterns from a JSON file", () => { - const filePath = writePatternFile("include.json", [ + const filePath = patternFiles.writePatternFile("include.json", [ "src/infra/update-runner.test.ts", 42, "", @@ -52,7 +39,7 @@ describe("loadExtraExcludePatternsFromEnv", () => { }); it("loads extra exclude patterns from a JSON file", () => { - const filePath = writePatternFile("extra-exclude.json", [ + const filePath = patternFiles.writePatternFile("extra-exclude.json", [ "src/infra/update-runner.test.ts", 42, "", @@ -67,7 +54,7 @@ describe("loadExtraExcludePatternsFromEnv", () => { }); it("throws when the configured file is not a JSON array", () => { - const filePath = writePatternFile("extra-exclude.json", { + const filePath = patternFiles.writePatternFile("extra-exclude.json", { exclude: ["src/infra/update-runner.test.ts"], }); @@ -81,6 +68,7 @@ describe("loadExtraExcludePatternsFromEnv", () => { describe("unit vitest config", () => { it("defaults unit tests to non-isolated mode", () => { + const unitConfig = createUnitVitestConfig({}); expect(unitConfig.test?.isolate).toBe(false); }); }); diff --git a/test/web-search-provider-boundary.test.ts b/test/web-search-provider-boundary.test.ts index 4d0ee25d49fdf..6aa0a1bbb8a00 100644 --- a/test/web-search-provider-boundary.test.ts +++ b/test/web-search-provider-boundary.test.ts @@ -3,31 +3,11 @@ import { collectWebSearchProviderBoundaryInventory, main, } from "../scripts/check-web-search-provider-boundaries.mjs"; +import { createCapturedIo } from "./helpers/captured-io.js"; const inventoryPromise = collectWebSearchProviderBoundaryInventory(); const jsonOutputPromise = getJsonOutput(); -function createCapturedIo() { - let stdout = ""; - let stderr = ""; - return { - io: { - stdout: { - write(chunk) { - stdout += String(chunk); - }, - }, - stderr: { - write(chunk) { - stderr += String(chunk); - }, - }, - }, - readStdout: () => stdout, - readStderr: () => stderr, - }; -} - async function getJsonOutput() { const captured = createCapturedIo(); const exitCode = await main(["--json"], captured.io); diff --git a/tsdown.config.ts b/tsdown.config.ts index a12bbfc4eb6e7..76f388bee89c6 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { defineConfig, type UserConfig } from "tsdown"; -import { shouldBuildBundledCluster } from "./scripts/lib/optional-bundled-clusters.mjs"; +import { listBundledPluginBuildEntries } from "./scripts/lib/bundled-plugin-build-entries.mjs"; import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs"; type InputOptionsFactory = Extract, Function>; @@ -80,60 +80,6 @@ function nodeBuildConfig(config: UserConfig): UserConfig { }; } -function listBundledPluginBuildEntries(): Record { - const extensionsRoot = path.join(process.cwd(), "extensions"); - const entries: Record = {}; - - for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { - if (!dirent.isDirectory()) { - continue; - } - if (!shouldBuildBundledCluster(dirent.name, process.env)) { - continue; - } - - const pluginDir = path.join(extensionsRoot, dirent.name); - const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); - if (!fs.existsSync(manifestPath)) { - continue; - } - - const packageJsonPath = path.join(pluginDir, "package.json"); - let packageEntries: string[] = []; - if (fs.existsSync(packageJsonPath)) { - try { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - openclaw?: { extensions?: unknown; setupEntry?: unknown }; - }; - packageEntries = Array.isArray(packageJson.openclaw?.extensions) - ? packageJson.openclaw.extensions.filter( - (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, - ) - : []; - const setupEntry = - typeof packageJson.openclaw?.setupEntry === "string" && - packageJson.openclaw.setupEntry.trim().length > 0 - ? packageJson.openclaw.setupEntry - : undefined; - if (setupEntry) { - packageEntries = Array.from(new Set([...packageEntries, setupEntry])); - } - } catch { - packageEntries = []; - } - } - - const sourceEntries = packageEntries.length > 0 ? packageEntries : ["./index.ts"]; - for (const entry of sourceEntries) { - const normalizedEntry = entry.replace(/^\.\//, ""); - const entryKey = `extensions/${dirent.name}/${normalizedEntry.replace(/\.[^.]+$/u, "")}`; - entries[entryKey] = path.join("extensions", dirent.name, normalizedEntry); - } - } - - return entries; -} - const bundledPluginBuildEntries = listBundledPluginBuildEntries(); function buildBundledHookEntries(): Record { @@ -173,6 +119,12 @@ function buildCoreDistEntries(): Record { // it by a deterministic path instead of a content-hashed chunk name. // See https://github.com/openclaw/openclaw/issues/51676 "cli/memory-cli": "src/cli/memory-cli.ts", + // Keep long-lived lazy runtime boundaries on stable filenames so rebuilt + // dist/ trees do not strand already-running gateways on stale hashed chunks. + "agents/auth-profiles.runtime": "src/agents/auth-profiles.runtime.ts", + "agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.ts", + "commands/status.summary.runtime": "src/commands/status.summary.runtime.ts", + "plugins/provider-runtime.runtime": "src/plugins/provider-runtime.runtime.ts", extensionAPI: "src/extensionAPI.ts", "infra/warning-filter": "src/infra/warning-filter.ts", "telegram/audit": "extensions/telegram/src/audit.ts", diff --git a/ui/package.json b/ui/package.json index 659a566365960..f9205c022ffde 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,6 +9,7 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { + "@create-markdown/preview": "^2.0.0", "@noble/ed25519": "3.0.1", "dompurify": "^3.3.3", "lit": "^3.3.2", diff --git a/ui/src/styles.css b/ui/src/styles.css index 2ed83bca3f97d..6e27717f8eac9 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -5,3 +5,13 @@ @import "./styles/chat.css"; @import "./styles/config.css"; @import "./styles/usage.css"; +@import "@create-markdown/preview/themes/system.css"; + +.cm-preview { + --cm-mono: var(--mono); + --cm-link: var(--accent); + --cm-info: var(--info); + --cm-warning: var(--warn); + --cm-success: var(--ok); + --cm-danger: var(--danger); +} diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 660038703cee4..3e90aca90aba3 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -25,7 +25,7 @@ --text-strong: #f4f4f5; --chat-text: #d4d4d8; --muted: #838387; - --muted-strong: #62626a; + --muted-strong: #75757d; --muted-foreground: #838387; /* Border - Whisper-thin, barely there */ @@ -68,7 +68,7 @@ /* Focus */ --focus: rgba(255, 92, 92, 0.2); - --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 60%, transparent); + --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 80%, transparent); --focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow); /* Grid */ @@ -172,7 +172,7 @@ --info: #2563eb; --focus: rgba(220, 38, 38, 0.15); - --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent); + --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 70%, transparent); --focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 12px var(--accent-glow); --grid-line: rgba(0, 0, 0, 0.04); @@ -189,32 +189,139 @@ /* Theme families override accent tokens while keeping shared surfaces/layout. */ :root[data-theme="openknot"] { - --ring: #4f8ff7; - --accent: #4f8ff7; - --accent-hover: #6da3f9; - --accent-muted: #4f8ff7; - --accent-subtle: rgba(79, 143, 247, 0.12); - --accent-glow: rgba(79, 143, 247, 0.22); - --primary: #4f8ff7; - --primary-foreground: #0e1015; + /* + * Accent — crimson red on true black + * + * WCAG 2.1 AA audit (bg = #080808, relative luminance ≈ 0.003): + * --accent #e5243b L ≈ 0.118 contrast ≈ 5.8:1 AA text ✓ + * --accent-hover #f03e52 L ≈ 0.163 contrast ≈ 7.2:1 ✓ + * --primary-foreground #fafafa on #e5243b button: 4.6:1 AA ✓ + * focus-ring at 70% opacity: ≈ 3.4:1 against bg ✓ (non-text 3:1 required) + */ + --ring: #e5243b; + --accent: #e5243b; + --accent-hover: #f03e52; + --accent-muted: #e5243b; + --accent-subtle: rgba(229, 36, 59, 0.12); + --accent-glow: rgba(229, 36, 59, 0.24); + --primary: #e5243b; + --primary-foreground: #fafafa; + + /* Focus — crimson ring */ + --focus: rgba(229, 36, 59, 0.2); + --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 70%, transparent); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow); + + /* Surfaces — true black canvas */ + --bg: #080808; + --bg-accent: #0d0d0f; + --bg-elevated: #141416; + --bg-hover: #1a1a1e; + --bg-muted: #1a1a1e; + + --card: #111113; + --card-foreground: #f0f0f2; + --card-highlight: rgba(255, 255, 255, 0.03); + --popover: #141416; + --popover-foreground: #f0f0f2; - --accent-2: #38bdf8; - --accent-2-muted: rgba(56, 189, 248, 0.7); - --accent-2-subtle: rgba(56, 189, 248, 0.1); + --panel: #080808; + --panel-strong: #141416; + --panel-hover: #1a1a1e; + --chrome: rgba(8, 8, 8, 0.96); + --chrome-strong: rgba(8, 8, 8, 0.98); + + /* Text — high contrast on black */ + --text: #e0e0e2; + --text-strong: #f5f5f7; + --chat-text: #e0e0e2; + --muted: #7a7a80; + --muted-strong: #6e6e76; + --muted-foreground: #7a7a80; + + /* Borders — whisper-thin, barely visible */ + --border: #1a1a1e; + --border-strong: #2a2a30; + --border-hover: #3a3a42; + --input: #1a1a1e; + + --secondary: #111113; + --secondary-foreground: #f0f0f2; + --accent-2: #8b1a2b; + --accent-2-muted: rgba(139, 26, 43, 0.7); + --accent-2-subtle: rgba(139, 26, 43, 0.12); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.6); + + --grid-line: rgba(255, 255, 255, 0.025); } :root[data-theme="openknot-light"] { - --ring: #2563eb; - --accent: #2563eb; - --accent-hover: #1d4ed8; - --accent-muted: #2563eb; - --accent-subtle: rgba(37, 99, 235, 0.1); - --accent-glow: rgba(37, 99, 235, 0.14); - --primary: #2563eb; + /* + * Accent — deep red on cool white + * + * WCAG 2.1 AA audit (bg = #f9f9fb, relative luminance ≈ 0.941): + * --accent #c41e30 L ≈ 0.078 contrast ≈ 7.5:1 AAA text ✓ + * --accent-hover #a8192a L ≈ 0.054 contrast ≈ 9.1:1 ✓ + * --primary-foreground #ffffff on #c41e30 button: 5.0:1 AA ✓ + * focus-ring at 60% opacity: ≈ 3.2:1 against bg ✓ (non-text 3:1 required) + */ + --ring: #c41e30; + --accent: #c41e30; + --accent-hover: #a8192a; + --accent-muted: #c41e30; + --accent-subtle: rgba(196, 30, 48, 0.08); + --accent-glow: rgba(196, 30, 48, 0.12); + --primary: #c41e30; + --primary-foreground: #ffffff; + + /* Surfaces — cool white with subtle warmth */ + --bg: #f9f9fb; + --bg-accent: #f2f2f5; + --bg-elevated: #ffffff; + --bg-hover: #eaeaef; + --bg-muted: #eaeaef; + --bg-content: #f2f2f5; - --accent-2: #0284c7; - --accent-2-muted: rgba(2, 132, 199, 0.75); - --accent-2-subtle: rgba(2, 132, 199, 0.08); + --card: #ffffff; + --card-foreground: #18181b; + --card-highlight: rgba(0, 0, 0, 0.02); + --popover: #ffffff; + --popover-foreground: #18181b; + + --panel: #f9f9fb; + --panel-strong: #f2f2f5; + --panel-hover: #e4e4ea; + --chrome: rgba(249, 249, 251, 0.96); + --chrome-strong: rgba(249, 249, 251, 0.98); + + /* Text — crisp dark on white */ + --text: #3a3a42; + --text-strong: #18181b; + --chat-text: #3a3a42; + --muted: #6e6e78; + --muted-strong: #52525a; + --muted-foreground: #6e6e78; + + /* Borders — clean, minimal */ + --border: #e2e2e8; + --border-strong: #ccccd4; + --border-hover: #adadb8; + --input: #e2e2e8; + + --secondary: #f2f2f5; + --secondary-foreground: #3a3a42; + --accent-2: #7a1420; + --accent-2-muted: rgba(122, 20, 32, 0.75); + --accent-2-subtle: rgba(122, 20, 32, 0.08); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.08); + + --grid-line: rgba(0, 0, 0, 0.04); } :root[data-theme="dash"] { @@ -264,7 +371,7 @@ --text-strong: #f0e4da; --chat-text: #d8c8b8; --muted: #9a8878; - --muted-strong: #7a6858; + --muted-strong: #8a7868; --muted-foreground: #9a8878; --border: #302418; @@ -360,7 +467,7 @@ body { body { margin: 0; - font: 400 13.5px/1.55 var(--font-body); + font: 400 14px/1.55 var(--font-body); letter-spacing: -0.01em; background: var(--bg); color: var(--text); @@ -368,6 +475,12 @@ body { -moz-osx-font-smoothing: grayscale; } +@media (min-width: 1600px) { + body { + font-size: 15px; + } +} + /* Theme transition */ @keyframes theme-circle-transition { 0% { @@ -410,11 +523,12 @@ openclaw-app { a { color: var(--accent); - text-decoration: none; + text-decoration: underline; + text-underline-offset: 2px; } a:hover { - text-decoration: underline; + text-decoration-thickness: 2px; } button, @@ -584,6 +698,17 @@ select { animation-delay: 250ms; } +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + /* Focus visible styles */ :focus-visible { outline: none; diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 36677d39427aa..ac795ce2f4cb7 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -66,7 +66,9 @@ background: none; border: none; cursor: pointer; - padding: 2px; + padding: 4px; + min-width: 24px; + min-height: 24px; border-radius: var(--radius-sm, 4px); color: var(--muted); opacity: 0; @@ -133,7 +135,7 @@ .chat-divider__label { padding: 2px 10px; border: 1px solid var(--border); - border-radius: 999px; + border-radius: var(--radius-full); background: rgba(255, 255, 255, 0.02); } @@ -141,7 +143,7 @@ .chat-avatar { width: 36px; height: 36px; - border-radius: 10px; + border-radius: var(--radius-md); background: var(--panel-strong); display: grid; place-items: center; @@ -228,15 +230,8 @@ img.chat-avatar { .chat-copy-btn, .chat-expand-btn { - border: 1px solid var(--border); background: var(--bg); color: var(--muted); - border-radius: var(--radius-md); - padding: 4px 6px; - font-size: 14px; - line-height: 1; - cursor: pointer; - transition: background 120ms ease-out; } .chat-copy-btn__icon { @@ -246,14 +241,10 @@ img.chat-avatar { position: relative; } -.chat-copy-btn__icon svg { +.chat-copy-btn__icon svg, +.chat-expand-btn__icon svg { width: 14px; height: 14px; - stroke: currentColor; - fill: none; - stroke-width: 1.5px; - stroke-linecap: round; - stroke-linejoin: round; } .chat-copy-btn__icon-copy, @@ -276,11 +267,6 @@ img.chat-avatar { opacity: 1; } -.chat-copy-btn:hover, -.chat-expand-btn:hover { - background: var(--bg-hover); -} - .chat-copy-btn[data-copying="1"] { opacity: 0; pointer-events: none; @@ -310,16 +296,6 @@ img.chat-avatar { height: 14px; } -.chat-expand-btn__icon svg { - width: 14px; - height: 14px; - stroke: currentColor; - fill: none; - stroke-width: 1.5px; - stroke-linecap: round; - stroke-linejoin: round; -} - /* Light mode: restore borders */ :root[data-theme-mode="light"] .chat-bubble { border-color: var(--border); diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 56472b3a402d5..26b833b77f5ad 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -59,7 +59,7 @@ margin: 0 0 0 0; min-height: 0; /* Allow shrinking for flex scroll behavior */ - border-radius: 12px; + border-radius: var(--radius-md); background: transparent; } @@ -121,7 +121,7 @@ color: var(--text); background: var(--panel-strong); border: 1px solid var(--border); - border-radius: 999px; + border-radius: var(--radius-full); cursor: pointer; white-space: nowrap; z-index: 10; @@ -154,7 +154,7 @@ gap: 8px; padding: 7px 14px; margin: 0 auto 8px; - border-radius: 999px; + border-radius: var(--radius-full); border: 1px solid color-mix(in srgb, var(--ctx-color, #d97706) 35%, transparent); background: var(--ctx-bg, rgba(217, 119, 6, 0.12)); color: var(--ctx-color, #d97706); @@ -199,7 +199,7 @@ gap: 8px; padding: 8px; background: var(--panel); - border-radius: 8px; + border-radius: var(--radius-md); border: 1px solid var(--border); width: fit-content; max-width: 100%; @@ -211,7 +211,7 @@ position: relative; width: 80px; height: 80px; - border-radius: 6px; + border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border); background: var(--bg); @@ -227,8 +227,8 @@ position: absolute; top: 4px; right: 4px; - width: 20px; - height: 20px; + width: 24px; + height: 24px; border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.7); @@ -285,7 +285,7 @@ .chat-message-image { max-width: 300px; max-height: 200px; - border-radius: 8px; + border-radius: var(--radius-md); object-fit: contain; cursor: pointer; transition: transform 150ms ease-out; @@ -331,7 +331,7 @@ min-height: 40px; max-height: 150px; padding: 9px 12px; - border-radius: 8px; + border-radius: var(--radius-md); overflow-y: auto; resize: none; white-space: pre-wrap; @@ -373,7 +373,6 @@ border: 1px solid var(--border); border-radius: var(--radius-lg); flex-shrink: 0; - overflow: hidden; transition: border-color var(--duration-fast) ease, box-shadow var(--duration-fast) ease; @@ -431,7 +430,7 @@ } .agent-chat__input-btn, -.agent-chat__toolbar .btn-ghost { +.agent-chat__toolbar .btn--ghost { display: inline-flex; align-items: center; justify-content: center; @@ -448,24 +447,24 @@ } .agent-chat__input-btn svg, -.agent-chat__toolbar .btn-ghost svg { +.agent-chat__toolbar .btn--ghost svg { width: 16px; height: 16px; - stroke: currentColor; fill: none; + stroke: currentColor; stroke-width: 1.5px; stroke-linecap: round; stroke-linejoin: round; } .agent-chat__input-btn:hover:not(:disabled), -.agent-chat__toolbar .btn-ghost:hover:not(:disabled) { +.agent-chat__toolbar .btn--ghost:hover:not(:disabled) { color: var(--text); background: var(--bg-hover); } .agent-chat__input-btn:disabled, -.agent-chat__toolbar .btn-ghost:disabled { +.agent-chat__toolbar .btn--ghost:disabled { opacity: 0.4; cursor: not-allowed; } @@ -645,6 +644,38 @@ color: var(--text); } +.slash-menu-badge { + font-size: 0.65rem; + font-weight: 600; + padding: 1px 6px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + white-space: nowrap; + flex-shrink: 0; +} + +.slash-menu-footer { + display: flex; + gap: 10px; + padding: 6px 10px 4px; + font-size: 0.68rem; + color: var(--muted); + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + margin-top: 4px; +} + +.slash-menu-footer kbd { + display: inline-block; + padding: 1px 4px; + font-size: 0.65rem; + font-family: var(--mono); + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg); + line-height: 1.3; +} + .chat-attachments-preview { display: flex; gap: 8px; @@ -671,8 +702,8 @@ position: absolute; top: 2px; right: 2px; - width: 18px; - height: 18px; + width: 24px; + height: 24px; border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.6); @@ -734,18 +765,6 @@ font-size: 13px; } -/* Icon button style */ -.btn--icon { - padding: 8px !important; - min-width: 36px; - height: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.06); -} - /* Controls separator */ .chat-controls__separator { color: rgba(255, 255, 255, 0.4); @@ -758,57 +777,6 @@ color: rgba(16, 24, 40, 0.3); } -.btn--icon:hover { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.2); -} - -/* Light theme icon button overrides */ -:root[data-theme-mode="light"] .btn--icon { - background: #ffffff; - border-color: var(--border); - box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); - color: var(--muted); -} - -:root[data-theme-mode="light"] .btn--icon:hover { - background: #ffffff; - border-color: var(--border-strong); - color: var(--text); -} - -/* Light theme icon button overrides */ -:root[data-theme-mode="light"] .btn--icon { - background: #ffffff; - border-color: var(--border); - box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); - color: var(--muted); -} - -:root[data-theme-mode="light"] .btn--icon:hover { - background: #ffffff; - border-color: var(--border-strong); - color: var(--text); -} - -:root[data-theme-mode="light"] .chat-controls .btn--icon.active { - border-color: var(--accent); - background: var(--accent-subtle); - color: var(--accent); - box-shadow: 0 0 0 1px var(--accent-subtle); -} - -.btn--icon svg { - display: block; - width: 18px; - height: 18px; - stroke: currentColor; - fill: none; - stroke-width: 1.5px; - stroke-linecap: round; - stroke-linejoin: round; -} - .chat-controls__session select { padding: 6px 10px; font-size: 13px; @@ -828,7 +796,7 @@ font-size: 12px; padding: 4px 10px; background: rgba(255, 255, 255, 0.04); - border-radius: 6px; + border-radius: var(--radius-sm); border: 1px solid var(--border); } @@ -929,7 +897,7 @@ .agent-chat__avatar--logo { width: 48px; height: 48px; - border-radius: 14px; + border-radius: var(--radius-lg); background: var(--panel-strong); border: 1px solid var(--border); display: grid; @@ -959,7 +927,7 @@ color: var(--muted); background: var(--panel); border: 1px solid var(--border); - border-radius: 100px; + border-radius: var(--radius-full); padding: 4px 12px; } @@ -982,7 +950,7 @@ font-family: var(--font-mono); background: var(--panel-strong); border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-sm); } .agent-chat__suggestions { @@ -997,7 +965,7 @@ .agent-chat__suggestion { font-size: 13px; padding: 8px 16px; - border-radius: 100px; + border-radius: var(--radius-full); border: 1px solid var(--border); background: var(--panel); color: var(--foreground); diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index de6010f3ed7d9..79ea1e4769088 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -79,9 +79,207 @@ .sidebar-markdown { font-size: 14px; - line-height: 1.5; + line-height: 1.6; + color: var(--text); } +/* ── Headings ── */ + +.sidebar-markdown :where(h1) { + font-size: 1.65em; + font-weight: 700; + letter-spacing: -0.025em; + margin: 1.6em 0 0.6em; + padding-bottom: 0.35em; + border-bottom: 1px solid var(--border); + line-height: 1.25; +} + +.sidebar-markdown :where(h2) { + font-size: 1.35em; + font-weight: 650; + letter-spacing: -0.02em; + margin: 1.4em 0 0.5em; + padding-bottom: 0.25em; + border-bottom: 1px solid var(--border); + line-height: 1.3; +} + +.sidebar-markdown :where(h3) { + font-size: 1.15em; + font-weight: 600; + letter-spacing: -0.01em; + margin: 1.2em 0 0.4em; + line-height: 1.35; +} + +.sidebar-markdown :where(h4, h5, h6) { + font-size: 1em; + font-weight: 600; + margin: 1em 0 0.35em; + line-height: 1.4; + color: var(--text-strong); +} + +.sidebar-markdown > :where(h1, h2, h3, h4, h5, h6):first-child { + margin-top: 0; +} + +/* ── Paragraphs & spacing ── */ + +.sidebar-markdown :where(p, ul, ol, pre, blockquote, table, details) { + margin: 0; +} + +.sidebar-markdown :where(p + p, p + ul, p + ol, p + pre, p + blockquote, p + table, p + details) { + margin-top: 0.75em; +} + +.sidebar-markdown :where(h1 + p, h2 + p, h3 + p, h4 + p) { + margin-top: 0; +} + +/* ── Lists ── */ + +.sidebar-markdown :where(ul, ol) { + padding-left: 1.6em; +} + +.sidebar-markdown :where(li + li) { + margin-top: 0.3em; +} + +.sidebar-markdown :where(li > p) { + margin-top: 0.25em; +} + +.sidebar-markdown :where(li > ul, li > ol) { + margin-top: 0.25em; +} + +/* ── Links ── */ + +.sidebar-markdown :where(a) { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); + transition: text-decoration-color var(--duration-fast) ease; +} + +.sidebar-markdown :where(a:hover) { + text-decoration-color: var(--accent); +} + +/* ── Inline code ── */ + +.sidebar-markdown code { + font-family: var(--mono); + font-size: 0.88em; +} + +.sidebar-markdown :where(:not(pre) > code) { + padding: 0.15em 0.4em; + border-radius: var(--radius-sm); + background: rgba(0, 0, 0, 0.12); + border: 1px solid var(--border); +} + +:root[data-theme-mode="light"] .sidebar-markdown :where(:not(pre) > code) { + background: rgba(0, 0, 0, 0.06); +} + +/* ── Code blocks ── */ + +.sidebar-markdown pre { + background: rgba(0, 0, 0, 0.12); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 14px 16px; + overflow-x: auto; + margin-top: 0.75em; +} + +:root[data-theme-mode="light"] .sidebar-markdown pre { + background: rgba(0, 0, 0, 0.04); +} + +.sidebar-markdown :where(pre code) { + background: none; + border: none; + padding: 0; + font-size: 12.5px; + line-height: 1.55; +} + +/* ── Blockquotes ── */ + +.sidebar-markdown :where(blockquote) { + border-left: 3px solid var(--border-strong); + padding: 8px 14px; + margin-left: 0; + margin-top: 0.75em; + color: var(--muted); + background: rgba(255, 255, 255, 0.02); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +:root[data-theme-mode="light"] .sidebar-markdown :where(blockquote) { + background: rgba(0, 0, 0, 0.025); +} + +.sidebar-markdown :where(blockquote blockquote) { + margin-top: 8px; +} + +/* ── Tables ── */ + +.sidebar-markdown :where(table) { + margin-top: 0.75em; + border-collapse: collapse; + width: 100%; + font-size: 13px; + display: block; + overflow-x: auto; +} + +.sidebar-markdown :where(th, td) { + border: 1px solid var(--border); + padding: 8px 12px; + text-align: left; + vertical-align: top; +} + +.sidebar-markdown :where(th) { + font-weight: 600; + font-size: 12px; + color: var(--muted); + background: var(--secondary); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.sidebar-markdown :where(tbody tr:hover) { + background: var(--bg-hover); +} + +/* ── Horizontal rules ── */ + +.sidebar-markdown :where(hr) { + border: none; + border-top: 1px solid var(--border); + margin: 1.5em 0; +} + +/* ── Bold / italic ── */ + +.sidebar-markdown :where(strong) { + font-weight: 600; + color: var(--text-strong); +} + +/* ── Images ── */ + .sidebar-markdown .markdown-inline-image { display: block; max-width: 100%; @@ -92,18 +290,34 @@ border-radius: var(--radius-md); background: color-mix(in srgb, var(--secondary) 70%, transparent); object-fit: contain; + margin-top: 0.75em; } -.sidebar-markdown pre { - background: rgba(0, 0, 0, 0.12); - border-radius: 4px; - padding: 12px; - overflow-x: auto; +/* ── Details / summary ── */ + +.sidebar-markdown :where(details) { + margin-top: 0.75em; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + overflow: hidden; } -.sidebar-markdown code { - font-family: var(--mono); +.sidebar-markdown :where(summary) { + cursor: pointer; + padding: 8px 12px; + font-weight: 500; font-size: 13px; + user-select: none; + transition: background var(--duration-fast) ease; +} + +.sidebar-markdown :where(summary:hover) { + background: var(--bg-hover); +} + +.sidebar-markdown :where(details[open] > :not(summary)) { + padding: 0 12px 10px; } /* Mobile: Full-screen modal */ diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 2f2d11565da15..ca2227658db98 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -5,7 +5,7 @@ .chat-thinking { margin-bottom: 10px; padding: 10px 12px; - border-radius: 10px; + border-radius: var(--radius-md); border: 1px dashed rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.04); color: var(--muted); @@ -72,14 +72,14 @@ .chat-text :where(:not(pre) > code) { background: rgba(0, 0, 0, 0.15); padding: 0.15em 0.4em; - border-radius: 4px; + border-radius: var(--radius-sm); overflow-wrap: normal; word-break: keep-all; } .chat-text :where(pre) { background: rgba(0, 0, 0, 0.15); - border-radius: 6px; + border-radius: var(--radius-sm); padding: 10px 12px; overflow-x: auto; } @@ -162,7 +162,7 @@ .chat-text :where(pre) { padding: 8px 10px; font-size: 12px; - border-radius: 4px; + border-radius: var(--radius-sm); } .chat-text :where(.markdown-inline-image) { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index e3d51bd040fdb..896334fa93ed8 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -165,7 +165,9 @@ align-items: center; justify-content: center; margin-left: 8px; - padding: 2px; + padding: 4px; + min-width: 24px; + min-height: 24px; background: none; border: none; cursor: pointer; @@ -521,6 +523,69 @@ animation: none; } +.statusDot.muted { + background: var(--muted); + box-shadow: none; + animation: none; + opacity: 0.5; +} + +/* =========================================== + Skill Toggle Switch + =========================================== */ + +.skill-toggle-wrap { + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.skill-toggle { + appearance: none; + width: 36px; + height: 20px; + border-radius: var(--radius-full); + background: var(--border); + position: relative; + cursor: pointer; + border: none; + outline: none; + transition: + background var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); + flex-shrink: 0; +} + +.skill-toggle::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: var(--radius-full); + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform var(--duration-fast) var(--ease-out); +} + +.skill-toggle:checked { + background: var(--accent); +} + +.skill-toggle:checked::after { + transform: translateX(16px); +} + +.skill-toggle:focus-visible { + box-shadow: var(--focus-ring); +} + +.skill-toggle:disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* =========================================== Buttons - Tactile with personality =========================================== */ @@ -588,7 +653,7 @@ font-size: 11px; font-weight: 500; line-height: 1; - border-radius: 4px; + border-radius: var(--radius-sm); background: rgba(255, 255, 255, 0.15); color: inherit; opacity: 0.8; @@ -627,6 +692,48 @@ font-size: 12px; } +.btn--xs { + padding: 4px 6px; + font-size: 12px; + line-height: 1; +} + +.btn--icon { + padding: 8px; + min-width: 36px; + height: 36px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.06); +} + +.btn--icon:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); +} + +.btn--icon svg { + display: block; + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.btn--ghost { + border-color: transparent; + background: transparent; + color: var(--muted); +} + +.btn--ghost:hover { + background: var(--bg-hover); + color: var(--text); + border-color: transparent; +} + .btn:disabled { opacity: 0.5; cursor: not-allowed; @@ -864,9 +971,6 @@ .field textarea[aria-invalid="true"], .field select[aria-invalid="true"] { border-color: var(--danger); - box-shadow: - inset 0 1px 0 var(--card-highlight), - 0 0 0 1px rgba(239, 68, 68, 0.2); } .cron-form-status { @@ -1176,6 +1280,34 @@ border-color: var(--accent); } +:root[data-theme-mode="light"] .btn--icon { + background: #ffffff; + border-color: var(--border); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); + color: var(--muted); +} + +:root[data-theme-mode="light"] .btn--icon:hover { + background: #ffffff; + border-color: var(--border-strong); + color: var(--text); +} + +:root[data-theme-mode="light"] .chat-controls .btn--icon.active { + border-color: var(--accent); + background: var(--accent-subtle); + color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-subtle); +} + +:root[data-theme-mode="light"] .btn--ghost { + background: transparent; +} + +:root[data-theme-mode="light"] .btn--ghost:hover { + background: var(--bg-hover); +} + /* =========================================== Utilities =========================================== */ @@ -1230,7 +1362,7 @@ line-height: 1.2; padding: 6px 14px; margin-bottom: 8px; - border-radius: 999px; + border-radius: var(--radius-full); border: 1px solid var(--border); background: var(--panel-strong); color: var(--text); @@ -1316,14 +1448,14 @@ .code-block-wrapper { position: relative; - border-radius: 6px; + border-radius: var(--radius-sm); overflow: hidden; margin-top: 0.75em; } .code-block-wrapper pre { margin: 0; - border-radius: 0 0 6px 6px; + border-radius: 0 0 var(--radius-sm) var(--radius-sm); } .code-block-header { @@ -2319,7 +2451,7 @@ td.data-table-key-col { .chat-new-messages { align-self: center; margin: 8px auto 0; - border-radius: 999px; + border-radius: var(--radius-full); padding: 6px 12px; font-size: 12px; line-height: 1; @@ -2858,23 +2990,6 @@ td.data-table-key-col { min-width: 0; } -.agents-toolbar-label { - font-size: 12px; - font-weight: 600; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.04em; - flex-shrink: 0; -} - -.agents-control-row { - display: flex; - align-items: center; - gap: 8px; - flex: 1; - min-width: 0; -} - .agents-control-select { flex: 1; min-width: 0; @@ -2909,80 +3024,32 @@ td.data-table-key-col { box-shadow: var(--focus-ring); } -.agents-control-actions { +.agents-toolbar-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; + margin-left: auto; } .agents-refresh-btn { white-space: nowrap; } -.agent-actions-wrap { - position: relative; -} - -.agent-actions-toggle { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg-elevated); +.btn--ghost { + background: transparent; + border-color: transparent; color: var(--muted); - font-size: 14px; - cursor: pointer; - transition: - background var(--duration-fast) ease, - border-color var(--duration-fast) ease; } -.agent-actions-toggle:hover { +.btn--ghost:hover:not(:disabled) { background: var(--bg-hover); - border-color: var(--border-strong); -} - -.agent-actions-menu { - position: absolute; - top: calc(100% + 4px); - right: 0; - z-index: 10; - min-width: 160px; - padding: 4px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); - box-shadow: var(--shadow-md); - display: grid; - gap: 1px; -} - -.agent-actions-menu button { - display: block; - width: 100%; - padding: 7px 10px; - border: none; - border-radius: var(--radius-sm); - background: transparent; color: var(--text); - font-size: 12px; - text-align: left; - cursor: pointer; - transition: background var(--duration-fast) ease; -} - -.agent-actions-menu button:hover:not(:disabled) { - background: var(--bg-hover); } -.agent-actions-menu button:disabled { - color: var(--muted); - cursor: not-allowed; +.btn--ghost:disabled { opacity: 0.5; + cursor: not-allowed; } .agents-main { @@ -3127,6 +3194,19 @@ td.data-table-key-col { opacity: 0.7; } +.agent-tab--missing { + opacity: 0.5; +} + +.agent-tab-badge { + margin-left: 4px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + color: var(--warn); + opacity: 0.8; +} + .agents-overview-grid { display: grid; gap: 12px; @@ -3203,15 +3283,16 @@ td.data-table-key-col { .agent-chip-input:focus-within { border-color: var(--accent); - box-shadow: var(--focus-ring); } -.agent-chip-input input { +.agent-chip-input input, +.agent-chip-input input:focus { flex: 1; min-width: 120px; border: none; background: transparent; outline: none; + box-shadow: none; font-size: 13px; padding: 0; } @@ -3222,92 +3303,233 @@ td.data-table-key-col { min-width: 200px; } -.agent-files-grid { - display: grid; - grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); - gap: 14px; +.agent-file-field { + min-height: clamp(320px, 56vh, 720px); } -.agent-files-list { - display: grid; - gap: 8px; +.field textarea.agent-file-textarea { + min-height: clamp(320px, 56vh, 720px); + transition: filter var(--duration-fast) ease; +} + +.field textarea.agent-file-textarea:not(:focus) { + filter: blur(6px); } -.agent-file-row { +.agent-file-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; - width: 100%; - text-align: left; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--card); - padding: 10px 12px; - cursor: pointer; - transition: border-color var(--duration-fast) ease; + flex-wrap: wrap; } -.agent-file-row:hover { - border-color: var(--border-strong); +.agent-file-sub { + color: var(--muted); + font-size: 12px; + margin-top: 4px; } -.agent-file-row.active { - border-color: var(--accent); - box-shadow: var(--focus-ring); +.agent-file-actions { + display: flex; + gap: 8px; } -.agent-file-name { +.md-preview-dialog { + width: min(980px, calc(100vw - 32px)); + max-width: none; + max-height: min(88vh, 960px); + padding: 0; + border: none; + border-radius: calc(var(--radius-xl) + 4px); + background: transparent; + color: inherit; + overflow: hidden; +} + +.md-preview-dialog::backdrop { + background: rgba(5, 8, 15, 0.72); + backdrop-filter: blur(10px); +} + +.md-preview-dialog__panel { + display: flex; + flex-direction: column; + min-height: min(70vh, 720px); + max-height: min(88vh, 960px); + border: 1px solid var(--border-strong); + border-radius: calc(var(--radius-xl) + 4px); + background: color-mix(in srgb, var(--panel) 92%, black 8%); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.45); + overflow: hidden; +} + +.md-preview-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 88%, black 12%); +} + +.md-preview-dialog__title { + min-width: 0; + font-size: 14px; font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.agent-file-meta { - color: var(--muted); - font-size: 12px; - margin-top: 4px; +.md-preview-dialog__body { + flex: 1; + overflow: auto; + padding: clamp(20px, 3vw, 32px); +} + +.md-preview-dialog__body.sidebar-markdown { + font-size: 15px; + line-height: 1.7; + color: var(--text); +} + +.md-preview-dialog__body.sidebar-markdown > * { + width: min(100%, 92ch); + max-width: 100%; + margin-left: 0; + margin-right: 0; +} + +.md-preview-dialog__body.sidebar-markdown > :first-child { + margin-top: 0; +} + +.md-preview-dialog__body.sidebar-markdown > :last-child { + margin-bottom: 0; } -.agent-files-editor { +.md-preview-dialog__body.sidebar-markdown :is(h1, h2, h3, h4, h5, h6) { + margin: 0 0 0.9em; + line-height: 1.18; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +.md-preview-dialog__body.sidebar-markdown h1 { + font-size: clamp(2rem, 4vw, 2.6rem); +} + +.md-preview-dialog__body.sidebar-markdown h2 { + margin-top: 2.2rem; + padding-top: 1.2rem; + font-size: clamp(1.4rem, 2.4vw, 1.85rem); + border-top: 1px solid var(--border); +} + +.md-preview-dialog__body.sidebar-markdown h3 { + margin-top: 1.8rem; + font-size: clamp(1.16rem, 1.8vw, 1.35rem); +} + +.md-preview-dialog__body.sidebar-markdown :is(p, ul, ol, blockquote, pre, table, hr) { + margin-top: 0; + margin-bottom: 1rem; +} + +.md-preview-dialog__body.sidebar-markdown :is(ul, ol) { + padding-left: 1.1rem; +} + +.md-preview-dialog__body.sidebar-markdown :is(ul ul, ul ol, ol ul, ol ol) { + margin-top: 0.45rem; + margin-bottom: 0.45rem; + padding-left: 0.95rem; +} + +.md-preview-dialog__body.sidebar-markdown li { + padding-left: 0.1rem; +} + +.md-preview-dialog__body.sidebar-markdown li + li { + margin-top: 0.35rem; +} + +.md-preview-dialog__body.sidebar-markdown blockquote { + padding: 0.85rem 1rem; + border-left: 3px solid var(--accent); + border-radius: 0 var(--radius-md) var(--radius-md) 0; + background: color-mix(in srgb, var(--secondary) 60%, transparent); + color: var(--text-soft, var(--text)); +} + +.md-preview-dialog__body.sidebar-markdown pre { + padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 16px; - background: var(--card); + background: color-mix(in srgb, var(--bg-elevated) 84%, black 16%); } -.agent-file-field { - min-height: clamp(320px, 56vh, 720px); +.md-preview-dialog__body.sidebar-markdown :not(pre) > code { + padding: 0.16rem 0.42rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--secondary) 62%, transparent); + font-size: 0.92em; } -.field textarea.agent-file-textarea { - min-height: clamp(320px, 56vh, 720px); - transition: filter var(--duration-fast) ease; +.md-preview-dialog__body.sidebar-markdown hr { + border: 0; + border-top: 1px solid var(--border); } -.field textarea.agent-file-textarea:not(:focus) { - filter: blur(6px); +.md-preview-dialog__body.sidebar-markdown table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); } -.agent-file-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - flex-wrap: wrap; +.md-preview-dialog__body.sidebar-markdown :is(th, td) { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; } -.agent-file-title { +.md-preview-dialog__body.sidebar-markdown th { + background: color-mix(in srgb, var(--secondary) 56%, transparent); font-weight: 600; } -.agent-file-sub { - color: var(--muted); - font-size: 12px; - margin-top: 4px; +.md-preview-dialog__body.sidebar-markdown tr:last-child td { + border-bottom: none; } -.agent-file-actions { - display: flex; - gap: 8px; +@media (max-width: 720px) { + .md-preview-dialog { + width: min(calc(100vw - 12px), 100vw); + max-height: calc(100vh - 12px); + } + + .md-preview-dialog__panel { + min-height: calc(100vh - 12px); + max-height: calc(100vh - 12px); + border-radius: var(--radius-xl); + } + + .md-preview-dialog__header { + padding: 12px 14px; + } + + .md-preview-dialog__body { + padding: 16px; + } } .agent-tools-meta { @@ -3449,10 +3671,6 @@ td.data-table-key-col { justify-items: start; } - .agent-files-grid { - grid-template-columns: 1fr; - } - .agent-tools-list { grid-template-columns: 1fr; } @@ -3469,8 +3687,9 @@ td.data-table-key-col { max-width: none; } - .agents-toolbar-label { - display: none; + .agents-toolbar-actions { + margin-left: 0; + justify-content: flex-end; } } @@ -4087,6 +4306,111 @@ details[open] > .ov-expandable-toggle::after { transform: rotate(-90deg); } +/* =========================================== + Markdown Preview Dialog + =========================================== */ + +.md-preview-dialog { + border: none; + background: transparent; + padding: 0; + max-width: none; + max-height: none; + width: 100vw; + height: 100vh; + overflow: hidden; +} + +.md-preview-dialog::backdrop { + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(6px); +} + +.md-preview-dialog__panel { + width: min(780px, calc(100vw - 48px)); + max-height: calc(100vh - 64px); + margin: 32px auto; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + overflow: hidden; + animation: scale-in 0.2s var(--ease-out); + transition: + width 0.2s var(--ease-out), + max-height 0.2s var(--ease-out), + margin 0.2s var(--ease-out); +} + +.md-preview-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: var(--card); + flex-shrink: 0; +} + +.md-preview-dialog__title { + font-size: 13px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.01em; +} + +.md-preview-dialog__actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.md-preview-dialog__body { + flex: 1; + overflow-y: auto; + padding: 16px 20px 24px; +} + +.md-preview-dialog__panel.fullscreen { + width: calc(100vw - 32px); + max-width: none; + max-height: calc(100vh - 32px); + margin: 16px auto; +} + +.md-preview-expand-btn .when-fullscreen { + display: none; +} + +.md-preview-expand-btn.is-fullscreen .when-normal { + display: none; +} + +.md-preview-expand-btn.is-fullscreen .when-fullscreen { + display: inline; +} + +@media (max-width: 640px) { + .md-preview-dialog__panel { + width: calc(100vw - 16px); + max-height: calc(100vh - 32px); + margin: 16px auto; + border-radius: var(--radius-md); + } + + .md-preview-dialog__header { + padding: 12px 16px; + } + + .md-preview-dialog__body { + padding: 14px 12px 20px; + } +} + @media (max-width: 600px) { .ov-cards { grid-template-columns: repeat(2, 1fr); diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index bb1a61e2b09d9..391a387a4f0ab 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -103,8 +103,8 @@ right: 8px; top: 50%; transform: translateY(-50%); - width: 22px; - height: 22px; + width: 24px; + height: 24px; border: none; border-radius: var(--radius-full); background: var(--bg-hover); @@ -261,7 +261,13 @@ gap: 6px; min-width: 0; flex: 1 1 auto; - flex-wrap: wrap; + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; +} + +.config-top-tabs__scroller::-webkit-scrollbar { + display: none; } .config-top-tabs__tab { @@ -298,13 +304,6 @@ background: var(--accent-subtle); } -.config-top-tabs__right { - display: flex; - justify-content: flex-end; - flex-shrink: 0; - min-width: 0; -} - /* Diff Panel */ .config-diff { margin: 12px 20px 0; @@ -554,110 +553,57 @@ color: var(--text-strong); } -/* Roundness slider */ -.settings-slider { - display: grid; - gap: 10px; -} - -.settings-slider__header { +/* Roundness options */ +.settings-roundness__options { display: flex; - align-items: center; - justify-content: space-between; + gap: 6px; } -.settings-slider__label { +.settings-roundness__btn { + flex: 1; display: flex; + flex-direction: column; align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: var(--muted); -} - -.settings-slider__key-swatch { - display: inline-block; - width: 14px; - height: 14px; - border: 1.5px solid var(--muted); - flex-shrink: 0; -} - -.settings-slider__key-swatch--sharp { - border-radius: 0; -} - -.settings-slider__key-swatch--round { - border-radius: 5px; -} - -.settings-slider__value { - font-size: 12px; - font-weight: 600; - color: var(--muted); - font-variant-numeric: tabular-nums; -} - -.settings-slider__input { - -webkit-appearance: none; - appearance: none; - width: 100%; - height: 6px; - border-radius: var(--radius-full); - background: var(--bg-muted); - outline: none; + gap: 8px; + padding: 12px 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg); cursor: pointer; - transition: background var(--duration-fast) ease; + transition: + background var(--duration-fast) ease, + border-color var(--duration-fast) ease; } -.settings-slider__input:hover { - background: var(--border-strong); +.settings-roundness__btn:hover { + background: var(--bg-hover); } -.settings-slider__input::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 18px; - height: 18px; - border-radius: 50%; - background: var(--accent); - border: 2px solid var(--bg-elevated); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); - cursor: pointer; - transition: - transform var(--duration-fast) ease, - box-shadow var(--duration-fast) ease; +.settings-roundness__btn.active { + border-color: var(--accent); + background: var(--accent-subtle); } -.settings-slider__input::-webkit-slider-thumb:hover { - transform: scale(1.15); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +.settings-roundness__swatch { + width: 28px; + height: 20px; + background: var(--bg-muted); + border: 1.5px solid var(--border-strong); + transition: border-radius var(--duration-fast) ease; } -.settings-slider__input::-moz-range-thumb { - width: 18px; - height: 18px; - border-radius: 50%; - background: var(--accent); - border: 2px solid var(--bg-elevated); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); - cursor: pointer; +.settings-roundness__btn.active .settings-roundness__swatch { + border-color: var(--accent); } -.settings-slider__preview { - display: flex; - gap: 8px; - align-items: center; - justify-content: center; - padding: 8px 0 0; +.settings-roundness__label { + font-size: 11px; + font-weight: 600; + color: var(--muted); } -.settings-slider__preview-swatch { - width: 32px; - height: 22px; - background: var(--bg-muted); - border: 1px solid var(--border); - transition: border-radius var(--duration-fast) ease; +.settings-roundness__btn.active .settings-roundness__label { + color: var(--accent); } .settings-info-grid { @@ -923,6 +869,21 @@ border: 1px solid rgba(239, 68, 68, 0.3); } +.cfg-field--error .cfg-input, +.cfg-field--error .cfg-textarea, +.cfg-field--error .cfg-select, +.cfg-field--error .cfg-number { + border-color: transparent; + box-shadow: none; +} + +.cfg-field--error .cfg-input:focus, +.cfg-field--error .cfg-textarea:focus, +.cfg-field--error .cfg-select:focus { + border-color: var(--border); + box-shadow: none; +} + .cfg-field__label { font-size: 12.5px; font-weight: 600; @@ -1751,18 +1712,10 @@ } .config-top-tabs__scroller { - flex: 1 1 100%; - } - - .config-top-tabs__right { - flex: 1 1 100%; - } - - .config-top-tabs__right .config-mode-toggle { - width: 100%; + flex: 1 1 auto; } - .config-top-tabs__right .config-mode-toggle__btn { + .config-actions__left .config-mode-toggle__btn { flex: 1 1 50%; } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 34d4fb26c8c5c..d67843456e021 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -208,7 +208,7 @@ min-height: 38px; padding: 0 14px; border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); - border-radius: 999px; + border-radius: var(--radius-full); background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); color: var(--muted); font-size: 13px; @@ -256,7 +256,7 @@ gap: 2px; padding: 3px; border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); - border-radius: 999px; + border-radius: var(--radius-full); background: color-mix(in srgb, var(--bg-elevated) 78%, transparent); } @@ -268,7 +268,7 @@ justify-content: center; padding: 0; border: 1px solid transparent; - border-radius: 999px; + border-radius: var(--radius-full); background: transparent; color: var(--muted); cursor: pointer; @@ -374,7 +374,7 @@ } .sidebar-shell__footer { - padding: 12px 8px 0; + padding: 12px 0 0; border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent); } @@ -389,7 +389,7 @@ width: 32px; height: 32px; flex-shrink: 0; - border-radius: 10px; + border-radius: var(--radius-md); box-shadow: 0 8px 18px color-mix(in srgb, black 12%, transparent); } @@ -440,7 +440,7 @@ justify-content: center; background: color-mix(in srgb, var(--bg-elevated) 88%, transparent); border: 1px solid color-mix(in srgb, var(--border-strong) 68%, transparent); - border-radius: 999px; + border-radius: var(--radius-full); cursor: pointer; transition: background var(--duration-fast) ease, @@ -507,7 +507,7 @@ min-height: 28px; background: transparent; border: none; - border-radius: 10px; + border-radius: var(--radius-md); color: color-mix(in srgb, var(--muted) 72%, var(--text) 28%); cursor: pointer; text-align: left; @@ -558,7 +558,7 @@ gap: 8px; min-height: 40px; padding: 0 9px; - border-radius: 12px; + border-radius: var(--radius-md); border: 1px solid transparent; background: transparent; color: var(--muted); @@ -653,7 +653,7 @@ min-height: 44px; padding: 0; margin: 0 auto; - border-radius: 16px; + border-radius: var(--radius-lg); border-color: transparent; box-shadow: none; } @@ -681,7 +681,7 @@ top: 10px; bottom: 10px; width: 3px; - border-radius: 999px; + border-radius: var(--radius-full); background: color-mix(in srgb, var(--accent) 86%, transparent); box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 34%, transparent); } @@ -712,7 +712,7 @@ .sidebar--collapsed .sidebar-brand__logo { width: 34px; height: 34px; - border-radius: 12px; + border-radius: var(--radius-md); box-shadow: 0 10px 20px color-mix(in srgb, black 20%, transparent), inset 0 1px 0 color-mix(in srgb, white 10%, transparent); @@ -734,7 +734,7 @@ gap: 10px; min-height: 40px; padding: 0 12px; - border-radius: 14px; + border-radius: var(--radius-lg); background: color-mix(in srgb, var(--bg-elevated) 72%, transparent); border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); } @@ -795,7 +795,7 @@ min-height: 44px; padding: 0; justify-content: center; - border-radius: 16px; + border-radius: var(--radius-lg); } .sidebar--collapsed .sidebar-version__status { diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 2828b6a4dfccc..21048053994c5 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -77,7 +77,7 @@ height: 38px; padding: 0; border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); - border-radius: 999px; + border-radius: var(--radius-full); background: color-mix(in srgb, var(--bg-elevated) 80%, transparent); color: var(--muted); box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); @@ -152,7 +152,7 @@ min-height: 40px; padding: 0 12px; font-size: 13px; - border-radius: 12px; + border-radius: var(--radius-md); white-space: nowrap; flex: 0 0 auto; width: auto; @@ -164,7 +164,7 @@ min-height: 44px; padding: 0; margin: 0 auto; - border-radius: 16px; + border-radius: var(--radius-lg); } .sidebar--collapsed .nav-item--active::before, @@ -190,7 +190,7 @@ top: 10px; bottom: 10px; width: 3px; - border-radius: 999px; + border-radius: var(--radius-full); background: color-mix(in srgb, var(--accent) 86%, transparent); box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 34%, transparent); } @@ -261,7 +261,7 @@ height: 38px; padding: 0; border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); - border-radius: 999px; + border-radius: var(--radius-full); background: color-mix(in srgb, var(--bg-elevated) 80%, transparent); color: var(--muted); box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); @@ -358,7 +358,7 @@ z-index: 100; background: var(--card, #161b22); border: 1px solid var(--border, #30363d); - border-radius: 10px; + border-radius: var(--radius-md); padding: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); flex-direction: column; @@ -522,13 +522,13 @@ } .agent-chat__input-btn, - .agent-chat__toolbar .btn-ghost { + .agent-chat__toolbar .btn--ghost { width: 28px; height: 28px; } .agent-chat__input-btn svg, - .agent-chat__toolbar .btn-ghost svg { + .agent-chat__toolbar .btn--ghost svg { width: 14px; height: 14px; } diff --git a/ui/src/styles/usage.css b/ui/src/styles/usage.css index 8acce5e7146e1..7879c35a01260 100644 --- a/ui/src/styles/usage.css +++ b/ui/src/styles/usage.css @@ -17,11 +17,8 @@ animation: rise 0.25s var(--ease-out) backwards; } -.usage-page .btn.btn-sm, -.usage-page button.btn-sm { +.usage-page .btn.btn--sm { min-height: 30px; - padding: 7px 11px; - font-size: 12px; line-height: 1.15; } @@ -31,16 +28,17 @@ } .usage-page-title { - font-size: 24px; + font-size: 22px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: -0.025em; color: var(--text-strong); } .usage-page-subtitle { max-width: 720px; color: var(--muted); - font-size: 14px; + font-size: 13px; + line-height: 1.5; } .usage-section-title { @@ -61,6 +59,7 @@ .usage-header { position: relative; overflow: visible; + z-index: 2; } .usage-header.pinned { @@ -72,13 +71,13 @@ .usage-header { display: grid; - gap: 14px; + gap: 16px; } .usage-header-row { display: flex; flex-wrap: wrap; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 12px; } @@ -95,20 +94,21 @@ flex-wrap: wrap; align-items: center; justify-content: flex-end; - gap: 8px; + gap: 6px; } .usage-refresh-indicator, .usage-loading-badge { display: inline-flex; align-items: center; - gap: 8px; - padding: 6px 10px; - border-radius: 999px; - background: var(--accent-subtle); + gap: 6px; + padding: 5px 10px; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-muted)); color: var(--accent); - font-size: 12px; + font-size: 11px; font-weight: 600; + letter-spacing: 0.01em; } .usage-loading-badge { @@ -120,7 +120,7 @@ height: 12px; border: 2px solid currentcolor; border-right-color: transparent; - border-radius: 999px; + border-radius: var(--radius-full); animation: usage-spin 0.8s linear infinite; } @@ -155,7 +155,7 @@ .usage-skeleton-block { min-height: 96px; - border-radius: 16px; + border-radius: var(--radius-lg); border: 1px solid var(--border); background: linear-gradient(90deg, var(--bg-muted) 20%, var(--bg-hover) 50%, var(--bg-muted) 80%); background-size: 200% 100%; @@ -171,7 +171,7 @@ flex: 1 1 100%; flex-wrap: wrap; align-items: center; - gap: 10px; + gap: 8px; } .usage-presets, @@ -181,7 +181,35 @@ .usage-filter-actions { display: inline-flex; align-items: center; - gap: 8px; + gap: 6px; +} + +.chart-toggle { + padding: 3px; + background: var(--bg-muted); + border: 1px solid var(--border); + border-radius: var(--radius-md); + gap: 2px; +} + +.chart-toggle .toggle-btn { + border: none; + border-radius: calc(var(--radius-md) - 3px); + background: transparent; + min-height: 28px; + padding: 4px 12px; + font-size: 12px; +} + +.chart-toggle .toggle-btn:hover:not(.active) { + background: var(--bg-hover); + color: var(--text); +} + +.chart-toggle .toggle-btn.active { + background: var(--accent-subtle); + border: none; + box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 12%, transparent); } .usage-date-range { @@ -197,12 +225,13 @@ .usage-query-input, .usage-filters-inline select, .usage-filters-inline input[type="text"] { - min-height: 36px; + min-height: 32px; border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg); + border-radius: var(--radius-md); + background: var(--bg-elevated); color: var(--text); font: inherit; + font-size: 12px; transition: border-color 0.18s var(--ease-out), box-shadow 0.18s var(--ease-out), @@ -211,7 +240,12 @@ .usage-date-input, .usage-select { - padding: 7px 10px; + padding: 6px 10px; +} + +.usage-date-input:hover, +.usage-select:hover { + border-color: var(--border-strong); } .usage-date-input:focus, @@ -220,8 +254,8 @@ .usage-filters-inline select:focus, .usage-filters-inline input[type="text"]:focus { outline: none; - border-color: color-mix(in srgb, var(--accent) 65%, var(--border)); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); + border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent); } .usage-separator, @@ -237,105 +271,94 @@ .session-log-meta, .session-logs-header-count { color: var(--muted); + font-size: 11px; } -.usage-metric-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 7px 11px; - border: 1px solid color-mix(in srgb, var(--accent) 12%, var(--border)); - border-radius: 999px; - background: color-mix(in srgb, var(--accent) 8%, var(--card)); +.usage-separator { font-size: 12px; - color: var(--text); } -.usage-metric-badge strong { - color: var(--text-strong); +.usage-query-hint { + font-size: 11px; + letter-spacing: 0.01em; } -.usage-pin-btn, -.usage-export-button, -.usage-action-btn, -.session-copy-btn, -.context-expand-btn, -.session-close-btn { - appearance: none; +.usage-metric-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px; border: 1px solid var(--border); - background: var(--secondary); - color: var(--secondary-foreground); - cursor: pointer; - transition: - transform 0.18s var(--ease-out), - border-color 0.18s var(--ease-out), - background 0.18s var(--ease-out), - color 0.18s var(--ease-out), - box-shadow 0.18s var(--ease-out); + border-radius: var(--radius-full); + background: var(--bg-elevated); + font-size: 11px; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.01em; + transition: border-color 0.18s var(--ease-out); } -.usage-pin-btn, -.usage-export-button, -.context-expand-btn, -.session-copy-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - min-height: 34px; - padding: 8px 12px; - border-radius: 10px; - font-size: 12px; - font-weight: 600; +.usage-metric-badge:hover { + border-color: var(--border-strong); } -.usage-pin-btn:hover, -.usage-export-button:hover, -.usage-action-btn:hover, -.session-copy-btn:hover, -.context-expand-btn:hover, -.session-close-btn:hover { - border-color: color-mix(in srgb, var(--accent) 28%, var(--border)); - background: color-mix(in srgb, var(--accent) 10%, var(--secondary)); +.usage-metric-badge strong { color: var(--text-strong); + font-weight: 700; + font-variant-numeric: tabular-nums; } +/* Toggle-btn active state layers on .btn for segmented controls */ .usage-pin-btn.active, -.usage-action-btn.usage-primary-btn, .chart-toggle .toggle-btn.active { border-color: var(--accent); background: var(--accent); color: var(--primary-foreground); - box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 28%, transparent); + font-weight: 600; + box-shadow: 0 1px 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.usage-action-btn.usage-primary-btn:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); + box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 25%, transparent); +} + +.chart-toggle .toggle-btn.active { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + background: var(--accent-subtle); + color: var(--accent); + font-weight: 600; } -.usage-primary-btn:disabled, -.usage-secondary-btn:disabled, .usage-export-item:disabled { - opacity: 0.55; + opacity: 0.45; cursor: not-allowed; box-shadow: none; } -.usage-secondary-btn { - background: var(--secondary); - color: var(--secondary-foreground); -} - .usage-query-section { display: grid; gap: 10px; + padding-top: 2px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); } .usage-query-bar { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 10px; + gap: 8px; } .usage-query-input { width: 100%; - padding: 10px 12px; + padding: 8px 12px; + font-size: 12px; +} + +.usage-query-input::placeholder { + color: var(--muted); + opacity: 0.7; } .usage-query-actions { @@ -347,7 +370,7 @@ display: flex; flex-wrap: wrap; align-items: center; - gap: 8px; + gap: 6px; } details.usage-filter-select, @@ -356,7 +379,7 @@ details.usage-filter-select, } details.usage-filter-select summary, -.usage-export-button { +.usage-export-menu summary { list-style: none; } @@ -368,57 +391,67 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-filter-select summary { display: inline-flex; align-items: center; - gap: 8px; - min-height: 34px; - padding: 8px 12px; + gap: 6px; + min-height: 30px; + padding: 5px 10px; border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg); + border-radius: var(--radius-md); + background: var(--bg-elevated); color: var(--text); cursor: pointer; font-size: 12px; + font-weight: 500; transition: border-color 0.18s var(--ease-out), box-shadow 0.18s var(--ease-out), background 0.18s var(--ease-out); } -.usage-filter-select[open] summary, .usage-filter-select summary:hover { - border-color: color-mix(in srgb, var(--accent) 25%, var(--border)); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 10%, transparent); + border-color: var(--border-strong); + background: var(--bg-hover); +} + +.usage-filter-select[open] summary { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 8%, transparent); } .usage-filter-badge { display: inline-flex; align-items: center; justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - border-radius: 999px; - background: var(--accent-subtle); - color: var(--accent); - font-size: 11px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: var(--radius-full); + background: var(--bg-muted); + color: var(--muted); + font-size: 10px; font-weight: 700; + letter-spacing: 0.02em; } .usage-filter-popover, .usage-export-popover { position: absolute; - right: 0; top: calc(100% + 8px); width: min(320px, calc(100vw - 48px)); padding: 12px; border: 1px solid var(--border-strong); - border-radius: 14px; + border-radius: var(--radius-lg); background: var(--popover); color: var(--popover-foreground); box-shadow: var(--shadow-lg); z-index: 12; } +.usage-filter-popover { + left: 0; +} + .usage-export-popover { + right: 0; width: 220px; } @@ -433,26 +466,28 @@ details.usage-filter-select summary::-webkit-details-marker, grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: center; - padding: 8px 10px; - border-radius: 10px; - background: color-mix(in srgb, var(--bg-muted) 72%, transparent); + padding: 6px 8px; + border-radius: var(--radius-md); + background: transparent; font-size: 12px; + transition: background 0.12s ease; } .usage-filter-option:hover { - background: color-mix(in srgb, var(--accent) 10%, var(--bg-muted)); + background: var(--bg-hover); } .usage-export-item { width: 100%; - min-height: 36px; - padding: 9px 10px; + min-height: 32px; + padding: 7px 10px; border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg); + border-radius: var(--radius-md); + background: var(--bg-elevated); color: var(--text); text-align: left; font: inherit; + font-size: 12px; cursor: pointer; transition: border-color 0.18s var(--ease-out), @@ -461,8 +496,8 @@ details.usage-filter-select summary::-webkit-details-marker, } .usage-export-item:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--accent) 24%, var(--border)); - background: color-mix(in srgb, var(--accent) 10%, var(--bg)); + border-color: var(--border-strong); + background: var(--bg-hover); color: var(--text-strong); } @@ -481,13 +516,14 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-empty-state__feature { display: inline-flex; align-items: center; - gap: 6px; - padding: 8px 10px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--accent) 16%, var(--border)); - background: color-mix(in srgb, var(--accent) 8%, var(--card)); + gap: 5px; + padding: 5px 10px; + border-radius: var(--radius-full); + border: 1px solid var(--border); + background: var(--bg-elevated); color: var(--text); - font-size: 12px; + font-size: 11px; + font-weight: 500; } .usage-query-chip button { @@ -503,15 +539,15 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-query-suggestion { cursor: pointer; transition: - transform 0.18s var(--ease-out), border-color 0.18s var(--ease-out), - background 0.18s var(--ease-out); + background 0.18s var(--ease-out), + color 0.18s var(--ease-out); } .usage-query-suggestion:hover { - transform: translateY(-1px); - border-color: color-mix(in srgb, var(--accent) 28%, var(--border)); - background: color-mix(in srgb, var(--accent) 12%, var(--card)); + border-color: var(--border-strong); + background: var(--bg-hover); + color: var(--text-strong); } .usage-callout { @@ -549,28 +585,27 @@ details.usage-filter-select summary::-webkit-details-marker, } .usage-empty-block { - padding: 20px 14px; - border: 1px dashed var(--border); - border-radius: 14px; - background: color-mix(in srgb, var(--bg-muted) 62%, transparent); + padding: 18px 14px; + border: 1px dashed color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); text-align: center; color: var(--muted); - font-size: 13px; + font-size: 12px; } .usage-empty-block--compact { - padding: 12px; + padding: 10px; border-style: solid; } .usage-overview-card { display: grid; - gap: 16px; + gap: 14px; } .usage-overview-layout { display: grid; - gap: 16px; + gap: 14px; } .usage-summary-grid, @@ -579,7 +614,7 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-mosaic-grid, .context-breakdown-grid { display: grid; - gap: 12px; + gap: 10px; } .usage-summary-grid { @@ -590,13 +625,13 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-summary-card, .session-summary-card { - min-height: 118px; - gap: 8px; + min-height: 108px; + gap: 6px; } .usage-summary-card.stat, .session-summary-card.stat { - padding: 16px; + padding: 14px 16px; } .usage-summary-card { @@ -605,7 +640,7 @@ details.usage-filter-select summary::-webkit-details-marker, grid-auto-rows: min-content; min-height: 132px; border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); - border-radius: 18px; + border-radius: var(--radius-xl); background: linear-gradient( 180deg, color-mix(in srgb, var(--card) 92%, var(--bg-elevated)) 0%, @@ -622,9 +657,9 @@ details.usage-filter-select summary::-webkit-details-marker, } .usage-summary-card--hero { - min-height: 168px; - gap: 12px; - padding-block: 18px; + min-height: 148px; + gap: 10px; + padding-block: 16px; } .usage-summary-card--throughput { @@ -648,27 +683,28 @@ details.usage-filter-select summary::-webkit-details-marker, .sessions-sort span, .cost-breakdown-header, .usage-mosaic-section-title { - font-size: 12px; - font-weight: 700; + font-size: 11px; + font-weight: 600; color: var(--muted); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; } .usage-summary-value, .session-summary-value { - font-size: clamp(1.55rem, 2vw, 2rem); - line-height: 1.05; + font-size: clamp(1.4rem, 2vw, 1.85rem); + line-height: 1.1; color: var(--text-strong); + font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .usage-summary-card--hero .usage-summary-value { - font-size: clamp(2rem, 3vw, 2.8rem); + font-size: clamp(1.8rem, 2.5vw, 2.4rem); } .usage-summary-value--compact { - font-size: clamp(1.2rem, 1.75vw, 1.7rem); + font-size: clamp(1.15rem, 1.5vw, 1.5rem); line-height: 1.15; } @@ -689,39 +725,55 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-list-sub, .usage-error-sub { font-size: 12px; + color: var(--muted); + font-variant-numeric: tabular-nums; } .usage-summary-hint { display: inline-flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; - margin-left: 6px; - border-radius: 999px; - border: 1px solid var(--border); - background: color-mix(in srgb, var(--bg-muted) 82%, transparent); + width: 16px; + height: 16px; + margin-left: 4px; + border-radius: var(--radius-full); + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); color: var(--muted); - font-size: 10px; + font-size: 9px; cursor: help; + opacity: 0.7; + transition: opacity 0.15s var(--ease-out); +} + +.usage-summary-hint:hover { + opacity: 1; } .usage-insights-grid { - grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); } .usage-insights-grid--tight { - margin-top: 12px; + margin-top: 10px; } .usage-insight-card { display: grid; - gap: 10px; - min-height: 168px; + gap: 12px; + align-content: start; + min-height: 148px; padding: 16px; border: 1px solid var(--border); - border-radius: 16px; - background: color-mix(in srgb, var(--card) 82%, var(--bg-muted)); + border-radius: var(--radius-lg); + background: var(--card); + transition: + border-color 0.18s var(--ease-out), + box-shadow 0.18s var(--ease-out); +} + +.usage-insight-card:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-sm); } .usage-insight-card--wide { @@ -732,7 +784,7 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-error-list, .context-breakdown-list { display: grid; - gap: 10px; + gap: 0; } .usage-list-item, @@ -740,8 +792,24 @@ details.usage-filter-select summary::-webkit-details-marker, .context-breakdown-item { display: flex; justify-content: space-between; - gap: 10px; + gap: 12px; align-items: baseline; + padding: 8px 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + font-size: 13px; +} + +.usage-list-item:last-child, +.usage-error-row:last-child, +.context-breakdown-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.usage-list-item:first-child, +.usage-error-row:first-child, +.context-breakdown-item:first-child { + padding-top: 0; } .usage-list-value { @@ -750,17 +818,20 @@ details.usage-filter-select summary::-webkit-details-marker, justify-content: flex-end; gap: 6px; color: var(--text-strong); + font-weight: 600; + font-variant-numeric: tabular-nums; } .usage-error-rate { - font-size: 16px; + font-size: 15px; font-weight: 700; color: var(--danger); + font-variant-numeric: tabular-nums; } .usage-mosaic { display: grid; - gap: 14px; + gap: 12px; } .usage-mosaic-header, @@ -775,7 +846,7 @@ details.usage-filter-select summary::-webkit-details-marker, flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 10px; + gap: 8px; } .usage-mosaic-title, @@ -786,13 +857,14 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-mosaic-title, .session-detail-title { - font-size: 16px; + font-size: 15px; font-weight: 700; } .usage-mosaic-total { - font-size: 14px; - font-weight: 700; + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; } .usage-mosaic-grid { @@ -801,14 +873,14 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-mosaic-section { display: grid; - gap: 10px; + gap: 8px; } .usage-mosaic-section-title { display: flex; align-items: center; justify-content: space-between; - gap: 10px; + gap: 8px; } .usage-daypart-grid { @@ -819,7 +891,7 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-daypart-cell, .usage-hour-cell { - border-radius: 12px; + border-radius: var(--radius-md); border: 1px solid color-mix(in srgb, var(--accent) 14%, var(--border)); background: color-mix(in srgb, var(--accent) 6%, transparent); } @@ -948,7 +1020,7 @@ details.usage-filter-select summary::-webkit-details-marker, width: min(100%, var(--bar-max-width)); min-height: 6px; border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border)); - border-radius: 10px 10px 4px 4px; + border-radius: var(--radius-md) var(--radius-md) var(--radius-sm) var(--radius-sm); background: linear-gradient( 180deg, color-mix(in srgb, var(--accent) 82%, white 6%), @@ -990,7 +1062,7 @@ details.usage-filter-select summary::-webkit-details-marker, max-width: 220px; padding: 8px 10px; border: 1px solid var(--border); - border-radius: 12px; + border-radius: var(--radius-md); background: var(--popover); color: var(--popover-foreground); box-shadow: var(--shadow-md); @@ -1013,7 +1085,7 @@ details.usage-filter-select summary::-webkit-details-marker, width: 100%; min-height: 16px; overflow: hidden; - border-radius: 999px; + border-radius: var(--radius-full); background: color-mix(in srgb, var(--bg-muted) 82%, transparent); border: 1px solid var(--border); } @@ -1049,7 +1121,7 @@ details.usage-filter-select summary::-webkit-details-marker, .legend-dot { width: 10px; height: 10px; - border-radius: 999px; + border-radius: var(--radius-full); } .cost-segment.output, @@ -1118,7 +1190,7 @@ details.usage-filter-select summary::-webkit-details-marker, min-height: 34px; padding: 7px 10px; border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-md); background: var(--bg); color: var(--text); } @@ -1149,7 +1221,7 @@ details.usage-filter-select summary::-webkit-details-marker, gap: 12px; padding: 12px; border: 1px solid var(--border); - border-radius: 14px; + border-radius: var(--radius-lg); background: color-mix(in srgb, var(--card) 86%, var(--bg-muted)); cursor: pointer; transition: @@ -1194,10 +1266,6 @@ details.usage-filter-select summary::-webkit-details-marker, flex: 0 0 auto; } -.session-copy-btn { - min-width: 58px; -} - .session-bar-value { font-size: 13px; font-weight: 700; @@ -1244,18 +1312,6 @@ details.usage-filter-select summary::-webkit-details-marker, font-size: 13px; } -.session-close-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - padding: 0; - border-radius: 10px; - font-size: 18px; - line-height: 1; -} - .session-detail-content, .session-detail-row, .session-detail-bottom { @@ -1280,7 +1336,7 @@ details.usage-filter-select summary::-webkit-details-marker, .session-logs-compact { padding: 14px; border: 1px solid var(--border); - border-radius: 16px; + border-radius: var(--radius-lg); background: color-mix(in srgb, var(--card) 88%, var(--bg-muted)); } @@ -1351,7 +1407,7 @@ details.usage-filter-select summary::-webkit-details-marker, display: flex; min-height: 18px; overflow: hidden; - border-radius: 999px; + border-radius: var(--radius-full); border: 1px solid var(--border); background: color-mix(in srgb, var(--bg-muted) 82%, transparent); } @@ -1367,7 +1423,7 @@ details.usage-filter-select summary::-webkit-details-marker, gap: 10px; padding: 14px; border: 1px solid var(--border); - border-radius: 14px; + border-radius: var(--radius-lg); background: var(--bg); } @@ -1422,7 +1478,7 @@ details.usage-filter-select summary::-webkit-details-marker, gap: 8px; padding: 12px; border: 1px solid var(--border); - border-radius: 14px; + border-radius: var(--radius-lg); background: var(--bg); } @@ -1476,7 +1532,7 @@ details.usage-filter-select summary::-webkit-details-marker, display: inline-flex; align-items: center; padding: 6px 8px; - border-radius: 999px; + border-radius: var(--radius-full); background: color-mix(in srgb, var(--accent) 8%, var(--card)); border: 1px solid color-mix(in srgb, var(--accent) 16%, var(--border)); font-size: 11px; @@ -1551,7 +1607,7 @@ details.usage-filter-select summary::-webkit-details-marker, align-items: flex-start; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--danger) 16%, var(--border)); - border-radius: 14px; + border-radius: var(--radius-lg); background: color-mix(in srgb, var(--danger) 5%, var(--bg-muted)); flex-direction: column; gap: 4px; @@ -1652,10 +1708,13 @@ details.usage-filter-select summary::-webkit-details-marker, } .usage-filter-popover, + .usage-export-popover { + width: min(320px, calc(100vw - 40px)); + } + .usage-export-popover { right: auto; left: 0; - width: min(320px, calc(100vw - 40px)); } .usage-daypart-grid { diff --git a/ui/src/types/create-markdown-preview.d.ts b/ui/src/types/create-markdown-preview.d.ts new file mode 100644 index 0000000000000..88a9ce8fcbdf4 --- /dev/null +++ b/ui/src/types/create-markdown-preview.d.ts @@ -0,0 +1,7 @@ +declare module "@create-markdown/preview" { + export type PreviewThemeOptions = { + sanitize?: ((html: string) => string) | undefined; + }; + + export function applyPreviewTheme(html: string, options?: PreviewThemeOptions): string; +} diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 9356aa8f883eb..49aff34a6e730 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -136,10 +136,12 @@ describe("handleSendChat", () => { } throw new Error(`Unexpected request: ${method}`); }); + const onSlashAction = vi.fn(); const host = makeHost({ client: { request } as unknown as ChatHost["client"], sessionKey: "main", chatMessage: "/model gpt-5-mini", + onSlashAction, }); await handleSendChat(host); @@ -152,6 +154,7 @@ describe("handleSendChat", () => { kind: "qualified", value: "openai/gpt-5-mini", }); + expect(onSlashAction).toHaveBeenCalledWith("refresh-tools-effective"); }); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index dc8eaf39be6cd..465504f0c1dc9 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -215,14 +215,14 @@ export async function handleSendChat( // Intercept local slash commands (/status, /model, /compact, etc.) const parsed = parseSlashCommand(message); if (parsed?.command.executeLocal) { - if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.name)) { + if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.key)) { if (messageOverride == null) { host.chatMessage = ""; host.chatAttachments = []; } enqueueChatMessage(host, message, undefined, isChatResetCommand(message), { args: parsed.args, - name: parsed.command.name, + name: parsed.command.key, }); return; } @@ -231,7 +231,7 @@ export async function handleSendChat( host.chatMessage = ""; host.chatAttachments = []; } - await dispatchSlashCommand(host, parsed.command.name, parsed.args, { + await dispatchSlashCommand(host, parsed.command.key, parsed.args, { previousDraft: prevDraft, restoreDraft: Boolean(messageOverride && opts?.restoreDraft), }); @@ -260,7 +260,7 @@ export async function handleSendChat( } function shouldQueueLocalSlashCommand(name: string): boolean { - return !["stop", "focus", "export"].includes(name); + return !["stop", "focus", "export-session"].includes(name); } // ── Slash Command Dispatch ── @@ -295,7 +295,7 @@ async function dispatchSlashCommand( case "focus": host.onSlashAction?.("toggle-focus"); return; - case "export": + case "export-session": host.onSlashAction?.("export"); return; } @@ -305,7 +305,9 @@ async function dispatchSlashCommand( } const targetSessionKey = host.sessionKey; - const result = await executeSlashCommand(host.client, targetSessionKey, name, args); + const result = await executeSlashCommand(host.client, targetSessionKey, name, args, { + chatModelCatalog: host.chatModelCatalog, + }); if (result.content) { injectCommandResult(host, result.content); @@ -316,6 +318,7 @@ async function dispatchSlashCommand( ...host.chatModelOverrides, [targetSessionKey]: result.sessionPatch.modelOverride ?? null, }; + host.onSlashAction?.("refresh-tools-effective"); } if (result.action === "refresh") { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 6deb01ba530f5..c46d10903f7a4 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -6,20 +6,19 @@ import { refreshChat } from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { OpenClawApp } from "./app.ts"; +import { createChatModelOverride } from "./chat-model-ref.ts"; import { - buildChatModelOption, - createChatModelOverride, - formatChatModelDisplay, - normalizeChatModelOverrideValue, - resolveServerChatModelValue, -} from "./chat-model-ref.ts"; + resolveChatModelOverrideValue, + resolveChatModelSelectState, +} from "./chat-model-select-state.ts"; +import { refreshVisibleToolsEffectiveForCurrentSession } from "./controllers/agents.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts"; import { loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts"; import type { ThemeMode, ThemeName } from "./theme.ts"; -import type { ModelCatalogEntry, SessionsListResult } from "./types.ts"; +import type { SessionsListResult } from "./types.ts"; type SessionDefaultsSnapshot = { mainSessionKey?: string; @@ -119,7 +118,7 @@ function renderCronFilterIcon(hiddenCount: number) { right: -6px; background: var(--color-accent, #6366f1); color: #fff; - border-radius: 999px; + border-radius: var(--radius-full); font-size: 9px; line-height: 1; padding: 1px 3px; @@ -521,78 +520,8 @@ async function refreshSessionOptions(state: AppViewState) { }); } -function resolveActiveSessionRow(state: AppViewState) { - return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); -} - -function resolveModelOverrideValue(state: AppViewState): string { - // Prefer the local cache — it reflects in-flight patches before sessionsResult refreshes. - const cached = state.chatModelOverrides[state.sessionKey]; - if (cached) { - return normalizeChatModelOverrideValue(cached, state.chatModelCatalog ?? []); - } - // cached === null means explicitly cleared to default. - if (cached === null) { - return ""; - } - // No local override recorded yet — fall back to server data. - // Include provider prefix so the value matches option keys (provider/model). - const activeRow = resolveActiveSessionRow(state); - if (activeRow && typeof activeRow.model === "string" && activeRow.model.trim()) { - return resolveServerChatModelValue(activeRow.model, activeRow.modelProvider); - } - return ""; -} - -function resolveDefaultModelValue(state: AppViewState): string { - const defaults = state.sessionsResult?.defaults; - return resolveServerChatModelValue(defaults?.model, defaults?.modelProvider); -} - -function buildChatModelOptions( - catalog: ModelCatalogEntry[], - currentOverride: string, - defaultModel: string, -): Array<{ value: string; label: string }> { - const seen = new Set(); - const options: Array<{ value: string; label: string }> = []; - const addOption = (value: string, label?: string) => { - const trimmed = value.trim(); - if (!trimmed) { - return; - } - const key = trimmed.toLowerCase(); - if (seen.has(key)) { - return; - } - seen.add(key); - options.push({ value: trimmed, label: label ?? trimmed }); - }; - - for (const entry of catalog) { - const option = buildChatModelOption(entry); - addOption(option.value, option.label); - } - - if (currentOverride) { - addOption(currentOverride); - } - if (defaultModel) { - addOption(defaultModel); - } - return options; -} - function renderChatModelSelect(state: AppViewState) { - const currentOverride = resolveModelOverrideValue(state); - const defaultModel = resolveDefaultModelValue(state); - const options = buildChatModelOptions( - state.chatModelCatalog ?? [], - currentOverride, - defaultModel, - ); - const defaultDisplay = formatChatModelDisplay(defaultModel); - const defaultLabel = defaultModel ? `Default (${defaultDisplay})` : "Default model"; + const { currentOverride, defaultLabel, options } = resolveChatModelSelectState(state); const busy = state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null; const disabled = @@ -626,7 +555,7 @@ async function switchChatModel(state: AppViewState, nextModel: string) { if (!state.client || !state.connected) { return; } - const currentOverride = resolveModelOverrideValue(state); + const currentOverride = resolveChatModelOverrideValue(state); if (currentOverride === nextModel) { return; } @@ -643,6 +572,7 @@ async function switchChatModel(state: AppViewState, nextModel: string) { key: targetSessionKey, model: nextModel || null, }); + void refreshVisibleToolsEffectiveForCurrentSession(state); await refreshSessionOptions(state); } catch (err) { // Roll back so the picker reflects the actual server model. diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7ccc386de0b91..486500f39b662 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { buildAgentMainSessionKey, parseAgentSessionKey, + resolveAgentIdFromSessionKey, } from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; @@ -20,7 +21,14 @@ import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; import { loadAgentSkills } from "./controllers/agent-skills.ts"; -import { loadAgents, loadToolsCatalog, saveAgentsConfig } from "./controllers/agents.ts"; +import { + buildToolsEffectiveRequestKey, + loadAgents, + loadToolsCatalog, + loadToolsEffective, + refreshVisibleToolsEffectiveForCurrentSession, + saveAgentsConfig, +} from "./controllers/agents.ts"; import { loadChannels } from "./controllers/channels.ts"; import { loadChatHistory } from "./controllers/chat.ts"; import { @@ -253,6 +261,8 @@ const INFRASTRUCTURE_SECTION_KEYS = [ "canvasHost", "discovery", "media", + "acp", + "mcp", ] as const; const AI_AGENTS_SECTION_KEYS = [ "agents", @@ -321,6 +331,10 @@ export function renderApp(state: AppViewState) { state.agentsList?.defaultId ?? state.agentsList?.agents?.[0]?.id ?? null; + const activeSessionAgentId = resolveAgentIdFromSessionKey(state.sessionKey); + const toolsPanelUsesActiveSession = Boolean( + resolvedAgentId && activeSessionAgentId && resolvedAgentId === activeSessionAgentId, + ); const getCurrentConfigValue = () => state.configForm ?? (state.configSnapshot?.config as Record | null); const findAgentIndex = (agentId: string) => @@ -964,6 +978,14 @@ export function renderApp(state: AppViewState) { error: state.toolsCatalogError, result: state.toolsCatalogResult, }, + toolsEffective: { + loading: state.toolsEffectiveLoading, + error: state.toolsEffectiveError, + result: state.toolsEffectiveResult, + }, + runtimeSessionKey: state.sessionKey, + runtimeSessionMatchesSelectedAgent: toolsPanelUsesActiveSession, + modelCatalog: state.chatModelCatalog ?? [], onRefresh: async () => { await loadAgents(state); const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? []; @@ -983,6 +1005,12 @@ export function renderApp(state: AppViewState) { } if (state.agentsPanel === "tools" && refreshedAgentId) { void loadToolsCatalog(state, refreshedAgentId); + if (refreshedAgentId === resolveAgentIdFromSessionKey(state.sessionKey)) { + void loadToolsEffective(state, { + agentId: refreshedAgentId, + sessionKey: state.sessionKey, + }); + } } if (state.agentsPanel === "channels") { void loadChannels(state, false); @@ -1008,12 +1036,23 @@ export function renderApp(state: AppViewState) { state.toolsCatalogResult = null; state.toolsCatalogError = null; state.toolsCatalogLoading = false; + state.toolsEffectiveResult = null; + state.toolsEffectiveResultKey = null; + state.toolsEffectiveError = null; + state.toolsEffectiveLoading = false; + state.toolsEffectiveLoadingKey = null; void loadAgentIdentity(state, agentId); if (state.agentsPanel === "files") { void loadAgentFiles(state, agentId); } if (state.agentsPanel === "tools") { void loadToolsCatalog(state, agentId); + if (agentId === resolveAgentIdFromSessionKey(state.sessionKey)) { + void loadToolsEffective(state, { + agentId, + sessionKey: state.sessionKey, + }); + } } if (state.agentsPanel === "skills") { void loadAgentSkills(state, agentId); @@ -1043,6 +1082,27 @@ export function renderApp(state: AppViewState) { ) { void loadToolsCatalog(state, resolvedAgentId); } + if (resolvedAgentId === resolveAgentIdFromSessionKey(state.sessionKey)) { + const toolsRequestKey = buildToolsEffectiveRequestKey(state, { + agentId: resolvedAgentId, + sessionKey: state.sessionKey, + }); + if ( + state.toolsEffectiveResultKey !== toolsRequestKey || + state.toolsEffectiveError + ) { + void loadToolsEffective(state, { + agentId: resolvedAgentId, + sessionKey: state.sessionKey, + }); + } + } else { + state.toolsEffectiveResult = null; + state.toolsEffectiveResultKey = null; + state.toolsEffectiveError = null; + state.toolsEffectiveLoading = false; + state.toolsEffectiveLoadingKey = null; + } } if (panel === "channels") { void loadChannels(state, false); @@ -1182,22 +1242,23 @@ export function renderApp(state: AppViewState) { const basePath = ["agents", "list", index, "model"]; if (!modelId) { removeConfigFormValue(state, basePath); - return; - } - const entry = Array.isArray(list) - ? (list[index] as { model?: unknown }) - : undefined; - const existing = entry?.model; - if (existing && typeof existing === "object" && !Array.isArray(existing)) { - const fallbacks = (existing as { fallbacks?: unknown }).fallbacks; - const next = { - primary: modelId, - ...(Array.isArray(fallbacks) ? { fallbacks } : {}), - }; - updateConfigFormValue(state, basePath, next); } else { - updateConfigFormValue(state, basePath, modelId); + const entry = Array.isArray(list) + ? (list[index] as { model?: unknown }) + : undefined; + const existing = entry?.model; + if (existing && typeof existing === "object" && !Array.isArray(existing)) { + const fallbacks = (existing as { fallbacks?: unknown }).fallbacks; + const next = { + primary: modelId, + ...(Array.isArray(fallbacks) ? { fallbacks } : {}), + }; + updateConfigFormValue(state, basePath, next); + } else { + updateConfigFormValue(state, basePath, modelId); + } } + void refreshVisibleToolsEffectiveForCurrentSession(state); }, onModelFallbacksChange: (agentId, fallbacks) => { const normalized = fallbacks.map((name) => name.trim()).filter(Boolean); @@ -1276,16 +1337,21 @@ export function renderApp(state: AppViewState) { report: state.skillsReport, error: state.skillsError, filter: state.skillsFilter, + statusFilter: state.skillsStatusFilter, edits: state.skillEdits, messages: state.skillMessages, busyKey: state.skillsBusyKey, + detailKey: state.skillsDetailKey, onFilterChange: (next) => (state.skillsFilter = next), + onStatusFilterChange: (next) => (state.skillsStatusFilter = next), onRefresh: () => loadSkills(state, { clearMessages: true }), onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), onEdit: (key, value) => updateSkillEdit(state, key, value), onSaveKey: (key) => saveSkillApiKey(state, key), onInstall: (skillKey, name, installId) => installSkill(state, skillKey, name, installId), + onDetailOpen: (key) => (state.skillsDetailKey = key), + onDetailClose: () => (state.skillsDetailKey = null), }), ) : nothing diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 809ff9986771e..6532f08cec450 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -320,7 +320,7 @@ export function detachThemeListener(host: SettingsHost) { host.systemThemeCleanup = null; } -const BASE_RADII = { sm: 6, md: 10, lg: 14, xl: 20, default: 10 }; +const BASE_RADII = { sm: 6, md: 10, lg: 14, xl: 20, full: 9999, default: 10 }; export function applyBorderRadius(value: number) { if (typeof document === "undefined") { @@ -332,6 +332,7 @@ export function applyBorderRadius(value: number) { root.style.setProperty("--radius-md", `${Math.round(BASE_RADII.md * scale)}px`); root.style.setProperty("--radius-lg", `${Math.round(BASE_RADII.lg * scale)}px`); root.style.setProperty("--radius-xl", `${Math.round(BASE_RADII.xl * scale)}px`); + root.style.setProperty("--radius-full", `${Math.round(BASE_RADII.full * scale)}px`); root.style.setProperty("--radius", `${Math.round(BASE_RADII.default * scale)}px`); } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 4eedc881fba26..da586aee6eac2 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -163,6 +163,11 @@ export type AppViewState = { toolsCatalogLoading: boolean; toolsCatalogError: string | null; toolsCatalogResult: ToolsCatalogResult | null; + toolsEffectiveLoading: boolean; + toolsEffectiveLoadingKey: string | null; + toolsEffectiveResultKey: string | null; + toolsEffectiveError: string | null; + toolsEffectiveResult: import("./types.js").ToolsEffectiveResult | null; agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; agentFilesLoading: boolean; agentFilesError: string | null; @@ -267,9 +272,11 @@ export type AppViewState = { skillsReport: SkillStatusReport | null; skillsError: string | null; skillsFilter: string; + skillsStatusFilter: "all" | "ready" | "needs-setup" | "disabled"; skillEdits: Record; skillMessages: Record; skillsBusyKey: string | null; + skillsDetailKey: string | null; healthLoading: boolean; healthResult: HealthSummary | null; healthError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 8092dfc4e00c6..b96167783bd80 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -1,5 +1,6 @@ import { LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js"; import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts"; import { handleChannelConfigReload as handleChannelConfigReloadInternal, @@ -54,6 +55,10 @@ import { import type { AppViewState } from "./app-view-state.ts"; import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { exportChatMarkdown } from "./chat/export.ts"; +import { + loadToolsEffective as loadToolsEffectiveInternal, + refreshVisibleToolsEffectiveForCurrentSession as refreshVisibleToolsEffectiveForCurrentSessionInternal, +} from "./controllers/agents.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; @@ -84,6 +89,7 @@ import type { StatusSummary, NostrProfile, ToolsCatalogResult, + ToolsEffectiveResult, } from "./types.ts"; import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; @@ -259,8 +265,12 @@ export class OpenClawApp extends LitElement { @state() toolsCatalogLoading = false; @state() toolsCatalogError: string | null = null; @state() toolsCatalogResult: ToolsCatalogResult | null = null; - @state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" = - "overview"; + @state() toolsEffectiveLoading = false; + @state() toolsEffectiveLoadingKey: string | null = null; + @state() toolsEffectiveResultKey: string | null = null; + @state() toolsEffectiveError: string | null = null; + @state() toolsEffectiveResult: ToolsEffectiveResult | null = null; + @state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" = "files"; @state() agentFilesLoading = false; @state() agentFilesError: string | null = null; @state() agentFilesList: AgentsFilesListResult | null = null; @@ -398,9 +408,11 @@ export class OpenClawApp extends LitElement { @state() skillsReport: SkillStatusReport | null = null; @state() skillsError: string | null = null; @state() skillsFilter = ""; + @state() skillsStatusFilter: "all" | "ready" | "needs-setup" | "disabled" = "all"; @state() skillEdits: Record = {}; @state() skillsBusyKey: string | null = null; @state() skillMessages: Record = {}; + @state() skillsDetailKey: string | null = null; @state() healthLoading = false; @state() healthResult: HealthSummary | null = null; @@ -477,6 +489,10 @@ export class OpenClawApp extends LitElement { case "export": exportChatMarkdown(this.chatMessages, this.assistantName); break; + case "refresh-tools-effective": { + void refreshVisibleToolsEffectiveForCurrentSessionInternal(this); + break; + } } }; document.addEventListener("keydown", this.globalKeydownHandler); @@ -495,6 +511,22 @@ export class OpenClawApp extends LitElement { protected updated(changed: Map) { handleUpdated(this as unknown as Parameters[0], changed); + if (!changed.has("sessionKey") || this.agentsPanel !== "tools") { + return; + } + const activeSessionAgentId = resolveAgentIdFromSessionKey(this.sessionKey); + if (this.agentsSelectedId && this.agentsSelectedId === activeSessionAgentId) { + void loadToolsEffectiveInternal(this, { + agentId: this.agentsSelectedId, + sessionKey: this.sessionKey, + }); + return; + } + this.toolsEffectiveResult = null; + this.toolsEffectiveResultKey = null; + this.toolsEffectiveError = null; + this.toolsEffectiveLoading = false; + this.toolsEffectiveLoadingKey = null; } connect() { diff --git a/ui/src/ui/chat-model-ref.test.ts b/ui/src/ui/chat-model-ref.test.ts index 86b46f3fe7f7a..a0ec9d749b8a6 100644 --- a/ui/src/ui/chat-model-ref.test.ts +++ b/ui/src/ui/chat-model-ref.test.ts @@ -4,14 +4,22 @@ import { createChatModelOverride, formatChatModelDisplay, normalizeChatModelOverrideValue, + resolveChatModelOverride, + resolvePreferredServerChatModel, resolveServerChatModelValue, } from "./chat-model-ref.ts"; -import type { ModelCatalogEntry } from "./types.ts"; +import { + createAmbiguousModelCatalog, + createModelCatalog, + DEEPSEEK_CHAT_MODEL, + OPENAI_GPT5_MINI_MODEL, +} from "./chat-model.test-helpers.ts"; -const catalog: ModelCatalogEntry[] = [ - { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }, - { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic" }, -]; +const catalog = createModelCatalog(OPENAI_GPT5_MINI_MODEL, { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + provider: "anthropic", +}); describe("chat-model-ref helpers", () => { it("builds provider-qualified option values and labels", () => { @@ -28,13 +36,11 @@ describe("chat-model-ref helpers", () => { }); it("keeps ambiguous raw overrides unchanged", () => { - const ambiguousCatalog: ModelCatalogEntry[] = [ - { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }, - { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openrouter" }, - ]; - expect( - normalizeChatModelOverrideValue(createChatModelOverride("gpt-5-mini"), ambiguousCatalog), + normalizeChatModelOverrideValue( + createChatModelOverride("gpt-5-mini"), + createAmbiguousModelCatalog("gpt-5-mini", "openai", "openrouter"), + ), ).toBe("gpt-5-mini"); }); @@ -47,4 +53,50 @@ describe("chat-model-ref helpers", () => { expect(resolveServerChatModelValue("gpt-5-mini", "openai")).toBe("openai/gpt-5-mini"); expect(resolveServerChatModelValue("alias-only", null)).toBe("alias-only"); }); + + it("reports the override resolution source for unique catalog matches", () => { + expect(resolveChatModelOverride(createChatModelOverride("gpt-5-mini"), catalog)).toEqual({ + value: "openai/gpt-5-mini", + source: "catalog", + }); + }); + + it("reports ambiguous raw overrides without guessing a provider", () => { + expect( + resolveChatModelOverride( + createChatModelOverride("gpt-5-mini"), + createAmbiguousModelCatalog("gpt-5-mini", "openai", "openrouter"), + ), + ).toEqual({ + value: "gpt-5-mini", + source: "raw", + reason: "ambiguous", + }); + }); + + it("prefers the catalog provider over a stale server provider when the match is unique", () => { + expect(resolvePreferredServerChatModel("deepseek-chat", "zai", [DEEPSEEK_CHAT_MODEL])).toEqual({ + value: "deepseek/deepseek-chat", + source: "catalog", + }); + }); + + it("falls back to the server provider when the catalog misses or is ambiguous", () => { + expect(resolvePreferredServerChatModel("gpt-5-mini", "openai", [])).toEqual({ + value: "openai/gpt-5-mini", + source: "server", + reason: "missing", + }); + expect( + resolvePreferredServerChatModel( + "gpt-5-mini", + "openai", + createAmbiguousModelCatalog("gpt-5-mini", "openai", "openrouter"), + ), + ).toEqual({ + value: "openai/gpt-5-mini", + source: "server", + reason: "ambiguous", + }); + }); }); diff --git a/ui/src/ui/chat-model-ref.ts b/ui/src/ui/chat-model-ref.ts index 351b8544bad3b..7b54ec5e2b0b1 100644 --- a/ui/src/ui/chat-model-ref.ts +++ b/ui/src/ui/chat-model-ref.ts @@ -10,6 +10,16 @@ export type ChatModelOverride = value: string; }; +export type ChatModelResolutionSource = "empty" | "qualified" | "catalog" | "raw" | "server"; + +export type ChatModelResolutionReason = "empty" | "missing" | "ambiguous"; + +export type ChatModelResolution = { + value: string; + source: ChatModelResolutionSource; + reason?: ChatModelResolutionReason; +}; + export function buildQualifiedChatModelValue(model: string, provider?: string | null): string { const trimmedModel = model.trim(); if (!trimmedModel) { @@ -34,15 +44,22 @@ export function normalizeChatModelOverrideValue( override: ChatModelOverride | null | undefined, catalog: ModelCatalogEntry[], ): string { + return resolveChatModelOverride(override, catalog).value; +} + +export function resolveChatModelOverride( + override: ChatModelOverride | null | undefined, + catalog: ModelCatalogEntry[], +): ChatModelResolution { if (!override) { - return ""; + return { value: "", source: "empty", reason: "empty" }; } const trimmed = override?.value.trim(); if (!trimmed) { - return ""; + return { value: "", source: "empty", reason: "empty" }; } if (override.kind === "qualified") { - return trimmed; + return { value: trimmed, source: "qualified" }; } let matchedValue = ""; @@ -56,10 +73,13 @@ export function normalizeChatModelOverrideValue( continue; } if (matchedValue.toLowerCase() !== candidate.toLowerCase()) { - return trimmed; + return { value: trimmed, source: "raw", reason: "ambiguous" }; } } - return matchedValue || trimmed; + if (matchedValue) { + return { value: matchedValue, source: "catalog" }; + } + return { value: trimmed, source: "raw", reason: "missing" }; } export function resolveServerChatModelValue( @@ -72,6 +92,42 @@ export function resolveServerChatModelValue( return buildQualifiedChatModelValue(model, provider); } +export function resolvePreferredServerChatModel( + model: string | null | undefined, + provider: string | null | undefined, + catalog: ModelCatalogEntry[], +): ChatModelResolution { + if (typeof model !== "string") { + return { value: "", source: "empty", reason: "empty" }; + } + const trimmedModel = model.trim(); + if (!trimmedModel) { + return { value: "", source: "empty", reason: "empty" }; + } + + const overrideResolution = resolveChatModelOverride( + createChatModelOverride(trimmedModel), + catalog, + ); + if (overrideResolution.source === "qualified" || overrideResolution.source === "catalog") { + return overrideResolution; + } + + return { + value: resolveServerChatModelValue(trimmedModel, provider), + source: "server", + reason: overrideResolution.reason, + }; +} + +export function resolvePreferredServerChatModelValue( + model: string | null | undefined, + provider: string | null | undefined, + catalog: ModelCatalogEntry[], +): string { + return resolvePreferredServerChatModel(model, provider, catalog).value; +} + export function formatChatModelDisplay(value: string): string { const trimmed = value.trim(); if (!trimmed) { diff --git a/ui/src/ui/chat-model-select-state.test.ts b/ui/src/ui/chat-model-select-state.test.ts new file mode 100644 index 0000000000000..001b282a34116 --- /dev/null +++ b/ui/src/ui/chat-model-select-state.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + resolveChatModelOverrideValue, + resolveChatModelSelectState, +} from "./chat-model-select-state.ts"; +import { + createModelCatalog, + createSessionsListResult, + DEEPSEEK_CHAT_MODEL, + DEFAULT_CHAT_MODEL_CATALOG, +} from "./chat-model.test-helpers.ts"; + +describe("chat-model-select-state", () => { + it("prefers the catalog provider when the active session provider is stale", () => { + const state = { + sessionKey: "main", + chatModelOverrides: {}, + chatModelCatalog: createModelCatalog(DEEPSEEK_CHAT_MODEL), + sessionsResult: createSessionsListResult({ + model: "deepseek-chat", + modelProvider: "zai", + }), + }; + + expect(resolveChatModelOverrideValue(state)).toBe("deepseek/deepseek-chat"); + }); + + it("falls back to the server-qualified value when catalog lookup fails", () => { + const state = { + sessionKey: "main", + chatModelOverrides: {}, + chatModelCatalog: [], + sessionsResult: createSessionsListResult({ + model: "gpt-5-mini", + modelProvider: "openai", + }), + }; + + expect(resolveChatModelOverrideValue(state)).toBe("openai/gpt-5-mini"); + }); + + it("builds picker options without introducing a bare duplicate", () => { + const state = { + sessionKey: "main", + chatModelOverrides: {}, + chatModelCatalog: createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG), + sessionsResult: createSessionsListResult({ + model: "gpt-5-mini", + modelProvider: "openai", + }), + }; + + const resolved = resolveChatModelSelectState(state); + expect(resolved.currentOverride).toBe("openai/gpt-5-mini"); + expect(resolved.options.map((option) => option.value)).toContain("openai/gpt-5-mini"); + expect(resolved.options.map((option) => option.value)).not.toContain("gpt-5-mini"); + }); +}); diff --git a/ui/src/ui/chat-model-select-state.ts b/ui/src/ui/chat-model-select-state.ts new file mode 100644 index 0000000000000..eede05c54c852 --- /dev/null +++ b/ui/src/ui/chat-model-select-state.ts @@ -0,0 +1,105 @@ +import type { AppViewState } from "./app-view-state.ts"; +import { + buildChatModelOption, + formatChatModelDisplay, + normalizeChatModelOverrideValue, + resolvePreferredServerChatModelValue, +} from "./chat-model-ref.ts"; +import type { ModelCatalogEntry } from "./types.ts"; + +type ChatModelSelectStateInput = Pick< + AppViewState, + "sessionKey" | "chatModelOverrides" | "chatModelCatalog" | "sessionsResult" +>; + +export type ChatModelSelectOption = { + value: string; + label: string; +}; + +export type ChatModelSelectState = { + currentOverride: string; + defaultModel: string; + defaultDisplay: string; + defaultLabel: string; + options: ChatModelSelectOption[]; +}; + +function resolveActiveSessionRow(state: ChatModelSelectStateInput) { + return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); +} + +export function resolveChatModelOverrideValue(state: ChatModelSelectStateInput): string { + const catalog = state.chatModelCatalog ?? []; + + // Prefer the local cache — it reflects in-flight patches before sessionsResult refreshes. + const cached = state.chatModelOverrides[state.sessionKey]; + if (cached) { + return normalizeChatModelOverrideValue(cached, catalog); + } + if (cached === null) { + return ""; + } + + const activeRow = resolveActiveSessionRow(state); + return resolvePreferredServerChatModelValue(activeRow?.model, activeRow?.modelProvider, catalog); +} + +function resolveDefaultModelValue(state: ChatModelSelectStateInput): string { + return resolvePreferredServerChatModelValue( + state.sessionsResult?.defaults?.model, + state.sessionsResult?.defaults?.modelProvider, + state.chatModelCatalog ?? [], + ); +} + +function buildChatModelOptions( + catalog: ModelCatalogEntry[], + currentOverride: string, + defaultModel: string, +): ChatModelSelectOption[] { + const seen = new Set(); + const options: ChatModelSelectOption[] = []; + + const addOption = (value: string, label?: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return; + } + const key = trimmed.toLowerCase(); + if (seen.has(key)) { + return; + } + seen.add(key); + options.push({ value: trimmed, label: label ?? trimmed }); + }; + + for (const entry of catalog) { + const option = buildChatModelOption(entry); + addOption(option.value, option.label); + } + + if (currentOverride) { + addOption(currentOverride); + } + if (defaultModel) { + addOption(defaultModel); + } + return options; +} + +export function resolveChatModelSelectState( + state: ChatModelSelectStateInput, +): ChatModelSelectState { + const currentOverride = resolveChatModelOverrideValue(state); + const defaultModel = resolveDefaultModelValue(state); + const defaultDisplay = formatChatModelDisplay(defaultModel); + + return { + currentOverride, + defaultModel, + defaultDisplay, + defaultLabel: defaultModel ? `Default (${defaultDisplay})` : "Default model", + options: buildChatModelOptions(state.chatModelCatalog ?? [], currentOverride, defaultModel), + }; +} diff --git a/ui/src/ui/chat-model.test-helpers.ts b/ui/src/ui/chat-model.test-helpers.ts new file mode 100644 index 0000000000000..a1af5d27d9b55 --- /dev/null +++ b/ui/src/ui/chat-model.test-helpers.ts @@ -0,0 +1,110 @@ +import type { + GatewaySessionRow, + ModelCatalogEntry, + SessionsListResult, + SessionsPatchResult, +} from "./types.ts"; + +export const OPENAI_GPT5_MODEL: ModelCatalogEntry = { + id: "gpt-5", + name: "GPT-5", + provider: "openai", +}; + +export const OPENAI_GPT5_MINI_MODEL: ModelCatalogEntry = { + id: "gpt-5-mini", + name: "GPT-5 Mini", + provider: "openai", +}; + +export const DEEPSEEK_CHAT_MODEL: ModelCatalogEntry = { + id: "deepseek-chat", + name: "DeepSeek Chat", + provider: "deepseek", +}; + +export const DEFAULT_CHAT_MODEL_CATALOG = [ + OPENAI_GPT5_MODEL, + OPENAI_GPT5_MINI_MODEL, +] satisfies ModelCatalogEntry[]; + +export function createModelCatalog(...entries: ModelCatalogEntry[]): ModelCatalogEntry[] { + return [...entries]; +} + +export function createAmbiguousModelCatalog( + id: string, + ...providers: string[] +): ModelCatalogEntry[] { + return providers.map((provider) => ({ + id, + name: id, + provider, + })); +} + +export function createMainSessionRow( + overrides: Partial = {}, +): GatewaySessionRow { + return { + key: "main", + kind: "direct", + updatedAt: null, + ...overrides, + }; +} + +export function createSessionsListResult( + params: { + model?: string | null; + modelProvider?: string | null; + defaultsModel?: string | null; + defaultsProvider?: string | null; + omitSessionFromList?: boolean; + } = {}, +): SessionsListResult { + const { + model = null, + modelProvider = model ? "openai" : null, + defaultsModel = "gpt-5", + defaultsProvider = defaultsModel ? "openai" : null, + omitSessionFromList = false, + } = params; + + return { + ts: 0, + path: "", + count: omitSessionFromList ? 0 : 1, + defaults: { + modelProvider: defaultsProvider, + model: defaultsModel, + contextTokens: null, + }, + sessions: omitSessionFromList + ? [] + : [ + createMainSessionRow({ + ...(modelProvider ? { modelProvider } : {}), + ...(model ? { model } : {}), + }), + ], + }; +} + +export function createResolvedModelPatch( + model: string, + modelProvider?: string | null, +): SessionsPatchResult { + return { + ok: true, + path: "", + key: "main", + entry: { + sessionId: "main", + }, + resolved: { + model, + modelProvider: modelProvider ?? undefined, + }, + }; +} diff --git a/ui/src/ui/chat/copy-as-markdown.ts b/ui/src/ui/chat/copy-as-markdown.ts index 12aeb6999e6f5..7081fd1959f81 100644 --- a/ui/src/ui/chat/copy-as-markdown.ts +++ b/ui/src/ui/chat/copy-as-markdown.ts @@ -34,7 +34,7 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult { const idleLabel = options.label ?? COPY_LABEL; return html` +
Primary Model
@@ -140,6 +153,7 @@ export function renderAgentChannels(params: { error: string | null; lastSuccess: number | null; onRefresh: () => void; + onSelectPanel: (panel: AgentsPanel) => void; }) { const entries = resolveChannelEntries(params.snapshot); const lastSuccessLabel = params.lastSuccess @@ -147,7 +161,7 @@ export function renderAgentChannels(params: { : "never"; return html`
- ${renderAgentContextCard(params.context, "Workspace, identity, and model configuration.")} + ${renderAgentContextCard(params.context, "Workspace, identity, and model configuration.", params.onSelectPanel)}
@@ -247,11 +261,12 @@ export function renderAgentCron(params: { error: string | null; onRefresh: () => void; onRunNow: (jobId: string) => void; + onSelectPanel: (panel: AgentsPanel) => void; }) { const jobs = params.jobs.filter((job) => job.agentId === params.agentId); return html`
- ${renderAgentContextCard(params.context, "Workspace and scheduling targets.")} + ${renderAgentContextCard(params.context, "Workspace and scheduling targets.", params.onSelectPanel)}
@@ -373,7 +388,7 @@ export function renderAgentFiles(params: {
${ list - ? html`
Workspace: ${list.workspace}
` + ? html`
Workspace: ${list.workspace}
` : nothing } ${ @@ -388,96 +403,132 @@ export function renderAgentFiles(params: { Load the agent workspace files to edit core instructions.
` - : html` -
-
- ${ - files.length === 0 - ? html` -
No files found.
- ` - : files.map((file) => - renderAgentFileRow(file, active, () => params.onSelectFile(file.name)), - ) - } + : files.length === 0 + ? html` +
No files found.
+ ` + : html` +
+ ${files.map((file) => { + const isActive = active === file.name; + const label = file.name.replace(/\.md$/i, ""); + return html` + + `; + })}
-
- ${ - !activeEntry - ? html` -
Select a file to edit.
- ` - : html` -
-
-
${activeEntry.name}
-
${activeEntry.path}
-
-
- - - -
+ ${ + !activeEntry + ? html` +
Select a file to edit.
+ ` + : html` +
+
+
${activeEntry.path}
- ${ - activeEntry.missing - ? html` -
- This file is missing. Saving will create it in the agent workspace. -
- ` - : nothing - } - - { - const dialog = e.currentTarget as HTMLDialogElement; - if (e.target === dialog) { - dialog.close(); - } - }} - > -
-
-
${activeEntry.name}
+
+ + + +
+
+ ${ + activeEntry.missing + ? html` +
+ This file is missing. Saving will create it in the agent workspace. +
+ ` + : nothing + } + + { + const dialog = e.currentTarget as HTMLDialogElement; + if (e.target === dialog) { + dialog.close(); + } + }} + @close=${(e: Event) => { + const dialog = e.currentTarget as HTMLElement; + dialog + .querySelector(".md-preview-dialog__panel") + ?.classList.remove("fullscreen"); + }} + > +
+
+
${activeEntry.name}
+
+ +
-
-
- ` - } -
-
- ` +
+ ${unsafeHTML(applyPreviewTheme(marked.parse(draft, { gfm: true, breaks: true }) as string, { sanitize: (h: string) => DOMPurify.sanitize(h) }))} +
+
+ + ` + } + ` }
`; } - -function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) { - const status = file.missing - ? "Missing" - : `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`; - return html` - - `; -} diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts index 1917e982e4491..a1cb98d96b1bd 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -16,6 +16,11 @@ function createBaseParams(overrides: Partial toolsCatalogLoading: false, toolsCatalogError: null, toolsCatalogResult: null, + toolsEffectiveLoading: false, + toolsEffectiveError: null, + toolsEffectiveResult: null, + runtimeSessionKey: "main", + runtimeSessionMatchesSelectedAgent: true, onProfileChange: () => undefined, onOverridesChange: () => undefined, onConfigReload: () => undefined, @@ -99,4 +104,42 @@ describe("agents tools panel (browser)", () => { expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); }); + + it("renders effective runtime tools separately from the config catalog", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsEffectiveResult: { + agentId: "main", + profile: "messaging", + groups: [ + { + id: "channel", + label: "Channel tools", + source: "channel", + tools: [ + { + id: "message", + label: "Message Actions", + description: "Send and manage messages in this channel", + rawDescription: "Send and manage messages in this channel", + source: "channel", + channelId: "discord", + }, + ], + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + const text = container.textContent ?? ""; + expect(text).toContain("Available Right Now"); + expect(text).toContain("Message Actions"); + expect(text).toContain("Channel: discord"); + }); }); diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 413c0ccae217d..f54e3a4b8a037 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -1,6 +1,11 @@ import { html, nothing } from "lit"; import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js"; -import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts"; +import type { + SkillStatusEntry, + SkillStatusReport, + ToolsCatalogResult, + ToolsEffectiveResult, +} from "../types.ts"; import { type AgentToolEntry, type AgentToolSection, @@ -41,6 +46,20 @@ function renderToolBadges(section: AgentToolSection, tool: AgentToolEntry) { `; } +function renderEffectiveToolBadge(tool: { + source: "core" | "plugin" | "channel"; + pluginId?: string; + channelId?: string; +}) { + if (tool.source === "plugin") { + return tool.pluginId ? `Connected: ${tool.pluginId}` : "Connected"; + } + if (tool.source === "channel") { + return tool.channelId ? `Channel: ${tool.channelId}` : "Channel"; + } + return "Built-in"; +} + export function renderAgentTools(params: { agentId: string; configForm: Record | null; @@ -50,6 +69,11 @@ export function renderAgentTools(params: { toolsCatalogLoading: boolean; toolsCatalogError: string | null; toolsCatalogResult: ToolsCatalogResult | null; + toolsEffectiveLoading: boolean; + toolsEffectiveError: string | null; + toolsEffectiveResult: ToolsEffectiveResult | null; + runtimeSessionKey: string; + runtimeSessionMatchesSelectedAgent: boolean; onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void; onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void; onConfigReload: () => void; @@ -144,15 +168,15 @@ export function renderAgentTools(params: { return html`
-
-
+
+
Tool Access
Profile + per-tool overrides for this agent. ${enabledCount}/${toolIds.length} enabled.
-
+
@@ -237,6 +261,66 @@ export function renderAgentTools(params: { }
+
+
Available Right Now
+
+ What this agent can use in the current chat session. + ${params.runtimeSessionKey || "no session"} +
+ ${ + !params.runtimeSessionMatchesSelectedAgent + ? html` +
+ Switch chat to this agent to view its live runtime tools. +
+ ` + : params.toolsEffectiveLoading && + !params.toolsEffectiveResult && + !params.toolsEffectiveError + ? html` +
Loading available tools…
+ ` + : params.toolsEffectiveError + ? html` +
+ Could not load available tools for this session. +
+ ` + : (params.toolsEffectiveResult?.groups?.length ?? 0) === 0 + ? html` +
+ No tools are available for this session right now. +
+ ` + : html` +
+ ${params.toolsEffectiveResult?.groups.map( + (group) => html` +
+
${group.label}
+
+ ${group.tools.map((tool) => { + return html` +
+
+
${tool.label}
+
${tool.description}
+
+ ${renderEffectiveToolBadge(tool)} +
+
+
+ `; + })} +
+
+ `, + )} +
+ ` + } +
+
Quick Presets
@@ -346,8 +430,8 @@ export function renderAgentSkills(params: { return html`
-
-
+
+
Skills
Per-agent skill allowlist and workspace skills. diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index e0c06c4138620..5d329ac34df31 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -1,4 +1,4 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { expandToolGroups, normalizeToolName, @@ -8,6 +8,7 @@ import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult, + ModelCatalogEntry, ToolCatalogProfile, ToolsCatalogResult, } from "../types.ts"; @@ -574,16 +575,38 @@ function resolveConfiguredModels( export function buildModelOptions( configForm: Record | null, current?: string | null, + catalog?: ModelCatalogEntry[], ) { - const options = resolveConfiguredModels(configForm); - const hasCurrent = current ? options.some((option) => option.value === current) : false; - if (current && !hasCurrent) { + const seen = new Set(); + const options: ConfiguredModelOption[] = []; + const addOption = (value: string, label: string) => { + const key = value.toLowerCase(); + if (seen.has(key)) { + return; + } + seen.add(key); + options.push({ value, label }); + }; + + for (const opt of resolveConfiguredModels(configForm)) { + addOption(opt.value, opt.label); + } + + if (catalog) { + for (const entry of catalog) { + const provider = entry.provider?.trim(); + const value = provider ? `${provider}/${entry.id}` : entry.id; + const label = provider ? `${entry.id} · ${provider}` : entry.id; + addOption(value, label); + } + } + + if (current && !seen.has(current.toLowerCase())) { options.unshift({ value: current, label: `Current (${current})` }); } + if (options.length === 0) { - return html` - - `; + return nothing; } return options.map((option) => html``); } diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index f763877937ae1..0735790ee7a1f 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -86,6 +86,14 @@ function createProps(overrides: Partial = {}): AgentsProps { error: null, result: null, }, + toolsEffective: { + loading: false, + error: null, + result: null, + }, + runtimeSessionKey: "main", + runtimeSessionMatchesSelectedAgent: false, + modelCatalog: [], onRefresh: () => undefined, onSelectAgent: () => undefined, onSelectPanel: () => undefined, diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 4e8b9a065baa3..972cf154869f2 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -6,8 +6,10 @@ import type { ChannelsStatusSnapshot, CronJob, CronStatus, + ModelCatalogEntry, SkillStatusReport, ToolsCatalogResult, + ToolsEffectiveResult, } from "../types.ts"; import { renderAgentOverview } from "./agents-panels-overview.ts"; import { @@ -65,6 +67,12 @@ export type ToolsCatalogState = { result: ToolsCatalogResult | null; }; +export type ToolsEffectiveState = { + loading: boolean; + error: string | null; + result: ToolsEffectiveResult | null; +}; + export type AgentsProps = { basePath: string; loading: boolean; @@ -81,6 +89,10 @@ export type AgentsProps = { agentIdentityById: Record; agentSkills: AgentSkillsState; toolsCatalog: ToolsCatalogState; + toolsEffective: ToolsEffectiveState; + runtimeSessionKey: string; + runtimeSessionMatchesSelectedAgent: boolean; + modelCatalog: ModelCatalogEntry[]; onRefresh: () => void; onSelectAgent: (agentId: string) => void; onSelectPanel: (panel: AgentsPanel) => void; @@ -135,72 +147,51 @@ export function renderAgents(props: AgentsProps) {
- Agent -
-
- props.onSelectAgent((e.target as HTMLSelectElement).value)} + > + ${ + agents.length === 0 + ? html` + + ` + : agents.map( + (agent) => html` `, - ) - } - -
-
- ${ - selectedAgent - ? html` -
- - ${ - actionsMenuOpen - ? html` -
- - -
- ` - : nothing - } -
- ` - : nothing + ) } - -
+ +
+
+ ${ + selectedAgent + ? html` + + + ` + : nothing + } +
${ @@ -234,6 +225,7 @@ export function renderAgents(props: AgentsProps) { configLoading: props.config.loading, configSaving: props.config.saving, configDirty: props.config.dirty, + modelCatalog: props.modelCatalog, onConfigReload: props.onConfigReload, onConfigSave: props.onConfigSave, onModelChange: props.onModelChange, @@ -272,6 +264,12 @@ export function renderAgents(props: AgentsProps) { toolsCatalogLoading: props.toolsCatalog.loading, toolsCatalogError: props.toolsCatalog.error, toolsCatalogResult: props.toolsCatalog.result, + toolsEffectiveLoading: props.toolsEffective.loading, + toolsEffectiveError: props.toolsEffective.error, + toolsEffectiveResult: props.toolsEffective.result, + runtimeSessionKey: props.runtimeSessionKey, + runtimeSessionMatchesSelectedAgent: + props.runtimeSessionMatchesSelectedAgent, onProfileChange: props.onToolsProfileChange, onOverridesChange: props.onToolsOverridesChange, onConfigReload: props.onConfigReload, @@ -318,6 +316,7 @@ export function renderAgents(props: AgentsProps) { error: props.channels.error, lastSuccess: props.channels.lastSuccess, onRefresh: props.onChannelsRefresh, + onSelectPanel: props.onSelectPanel, }) : nothing } @@ -338,6 +337,7 @@ export function renderAgents(props: AgentsProps) { error: props.cron.error, onRefresh: props.onCronRefresh, onRunNow: props.onCronRunNow, + onSelectPanel: props.onSelectPanel, }) : nothing } @@ -348,8 +348,6 @@ export function renderAgents(props: AgentsProps) { `; } -let actionsMenuOpen = false; - function renderAgentTabs( active: AgentsPanel, onSelect: (panel: AgentsPanel) => void, diff --git a/ui/src/ui/views/channels.nostr-profile-form.ts b/ui/src/ui/views/channels.nostr-profile-form.ts index 62e4669f39702..098c059e3347a 100644 --- a/ui/src/ui/views/channels.nostr-profile-form.ts +++ b/ui/src/ui/views/channels.nostr-profile-form.ts @@ -101,7 +101,7 @@ export function renderNostrProfileForm(params: { placeholder=${placeholder ?? ""} maxlength=${maxLength ?? 2000} rows="3" - style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; resize: vertical; font-family: inherit;" + style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); resize: vertical; font-family: inherit;" @input=${(e: InputEvent) => { const target = e.target as HTMLTextAreaElement; callbacks.onFieldChange(field, target.value); @@ -125,7 +125,7 @@ export function renderNostrProfileForm(params: { .value=${value} placeholder=${placeholder ?? ""} maxlength=${maxLength ?? 256} - style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px;" + style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: var(--radius-sm);" @input=${(e: InputEvent) => { const target = e.target as HTMLInputElement; callbacks.onFieldChange(field, target.value); @@ -164,7 +164,7 @@ export function renderNostrProfileForm(params: { }; return html` -
+
Edit Profile
Account: ${accountId}
diff --git a/ui/src/ui/views/channels.nostr.ts b/ui/src/ui/views/channels.nostr.ts index 88adcd3916650..3229ab75b1e75 100644 --- a/ui/src/ui/views/channels.nostr.ts +++ b/ui/src/ui/views/channels.nostr.ts @@ -121,14 +121,14 @@ export function renderNostrCard(params: { const hasAnyProfileData = name || displayName || about || picture || nip05; return html` -
+
Profile
${ summaryConfigured ? html` ${ canAbort && (isBusy || props.sending) ? html` - ` @@ -1354,6 +1360,7 @@ export function renderChat(props: ChatProps) { }} ?disabled=${!props.connected || props.sending} title=${isBusy ? "Queue" : "Send"} + aria-label=${isBusy ? "Queue message" : "Send message"} > ${icons.send} diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 5f26383c2f57b..dc6cebe3e363d 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -231,6 +231,40 @@ const sectionIcons = { `, + diagnostics: html` + + + + `, + cli: html` + + + + + `, + secrets: html` + + + + `, + acp: html` + + + + + + + `, + mcp: html` + + + + + + + `, default: html` @@ -275,6 +309,17 @@ export const SECTION_META: Record = { + 0: "None", + 25: "Slight", + 50: "Default", + 75: "Round", + 100: "Full", +}; + export type ConfigProps = { raw: string; originalRaw: string; @@ -281,6 +291,40 @@ const sidebarIcons = { `, + diagnostics: html` + + + + `, + cli: html` + + + + + `, + secrets: html` + + + + `, + acp: html` + + + + + + + `, + mcp: html` + + + + + + + `, __appearance__: html` @@ -319,6 +363,9 @@ const SECTION_CATEGORIES: SectionCategory[] = [ { key: "update", label: "Updates" }, { key: "meta", label: "Meta" }, { key: "logging", label: "Logging" }, + { key: "diagnostics", label: "Diagnostics" }, + { key: "cli", label: "Cli" }, + { key: "secrets", label: "Secrets" }, ], }, { @@ -367,13 +414,15 @@ const SECTION_CATEGORIES: SectionCategory[] = [ { key: "canvasHost", label: "CanvasHost" }, { key: "discovery", label: "Discovery" }, { key: "media", label: "Media" }, + { key: "acp", label: "Acp" }, + { key: "mcp", label: "Mcp" }, ], }, { id: "appearance", label: "Appearance", sections: [ - { key: "__appearance__", label: "Appearance" }, + { key: "__appearance__", label: "Theme" }, { key: "ui", label: "UI" }, { key: "wizard", label: "Setup Wizard" }, ], @@ -506,13 +555,16 @@ function truncateValue(value: unknown, maxLen = 40): string { } function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints): string { + if (isSensitiveConfigPath(path) && value != null && truncateValue(value).trim() !== "") { + return REDACTED_PLACEHOLDER; + } return truncateValue(value); } type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult }; const THEME_OPTIONS: ThemeOption[] = [ { id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap }, - { id: "knot", label: "Knot", description: "Blue contrast", icon: icons.link }, + { id: "knot", label: "Knot", description: "Black & red", icon: icons.link }, { id: "dash", label: "Dash", description: "Chocolate blueprint", icon: icons.barChart }, ]; @@ -553,43 +605,23 @@ function renderAppearanceSection(props: ConfigProps) {

Roundness

Adjust corner radius across the UI.

-
-
- - - Square - - ${props.borderRadius}% - - Round - - -
- { - const v = Number((e.target as HTMLInputElement).value); - props.setBorderRadius(v); - }} - /> -
-
-
-
+
+
+ ${BORDER_RADIUS_STOPS.map( + (stop) => html` + + `, + )}
@@ -752,6 +784,28 @@ export function renderConfig(props: ConfigProps) {
+ ${ + showModeToggle + ? html` +
+ + +
+ ` + : nothing + } ${ hasChanges ? html` @@ -833,6 +887,7 @@ export function renderConfig(props: ConfigProps) { type="text" class="config-search__input" placeholder="Search settings..." + aria-label="Search settings" .value=${props.searchQuery} @input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)} @@ -842,6 +897,7 @@ export function renderConfig(props: ConfigProps) { ? html`
-
- ${ - showModeToggle - ? html` -
- - -
- ` - : nothing - } -
${ diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index b9338971c8e26..d3626acbe4f4a 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; import type { SkillMessageMap } from "../controllers/skills.ts"; import { clampText } from "../format.ts"; +import { resolveSafeExternalUrl } from "../open-external-url.ts"; import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; import { groupSkills } from "./skills-grouping.ts"; import { @@ -9,33 +10,102 @@ import { renderSkillStatusChips, } from "./skills-shared.ts"; +function safeExternalHref(raw?: string): string | null { + if (!raw) { + return null; + } + return resolveSafeExternalUrl(raw, window.location.href); +} + +export type SkillsStatusFilter = "all" | "ready" | "needs-setup" | "disabled"; + export type SkillsProps = { connected: boolean; loading: boolean; report: SkillStatusReport | null; error: string | null; filter: string; + statusFilter: SkillsStatusFilter; edits: Record; busyKey: string | null; messages: SkillMessageMap; + detailKey: string | null; onFilterChange: (next: string) => void; + onStatusFilterChange: (next: SkillsStatusFilter) => void; onRefresh: () => void; onToggle: (skillKey: string, enabled: boolean) => void; onEdit: (skillKey: string, value: string) => void; onSaveKey: (skillKey: string) => void; onInstall: (skillKey: string, name: string, installId: string) => void; + onDetailOpen: (skillKey: string) => void; + onDetailClose: () => void; }; +type StatusTabDef = { id: SkillsStatusFilter; label: string }; + +const STATUS_TABS: StatusTabDef[] = [ + { id: "all", label: "All" }, + { id: "ready", label: "Ready" }, + { id: "needs-setup", label: "Needs Setup" }, + { id: "disabled", label: "Disabled" }, +]; + +function skillMatchesStatus(skill: SkillStatusEntry, status: SkillsStatusFilter): boolean { + switch (status) { + case "all": + return true; + case "ready": + return !skill.disabled && skill.eligible; + case "needs-setup": + return !skill.disabled && !skill.eligible; + case "disabled": + return skill.disabled; + } +} + +function skillStatusClass(skill: SkillStatusEntry): string { + if (skill.disabled) { + return "muted"; + } + return skill.eligible ? "ok" : "warn"; +} + export function renderSkills(props: SkillsProps) { const skills = props.report?.skills ?? []; + + const statusCounts: Record = { + all: skills.length, + ready: 0, + "needs-setup": 0, + disabled: 0, + }; + for (const s of skills) { + if (s.disabled) { + statusCounts.disabled++; + } else if (s.eligible) { + statusCounts.ready++; + } else { + statusCounts["needs-setup"]++; + } + } + + const afterStatus = + props.statusFilter === "all" + ? skills + : skills.filter((s) => skillMatchesStatus(s, props.statusFilter)); + const filter = props.filter.trim().toLowerCase(); const filtered = filter - ? skills.filter((skill) => + ? afterStatus.filter((skill) => [skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter), ) - : skills; + : afterStatus; const groups = groupSkills(filtered); + const detailSkill = props.detailKey + ? (skills.find((s) => s.skillKey === props.detailKey) ?? null) + : null; + return html`
@@ -44,13 +114,26 @@ export function renderSkills(props: SkillsProps) {
Installed skills and their status.
-
+ + ${detailSkill ? renderSkillDetail(detailSkill, props) : nothing} `; } function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { + const busy = props.busyKey === skill.skillKey; + const dotClass = skillStatusClass(skill); + + return html` +
props.onDetailOpen(skill.skillKey)} + > +
+
+ + ${skill.emoji ? html`${skill.emoji}` : nothing} + ${skill.name} +
+
${clampText(skill.description, 140)}
+
+
+ +
+
+ `; +} + +function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) { const busy = props.busyKey === skill.skillKey; const apiKey = props.edits[skill.skillKey] ?? ""; const message = props.messages[skill.skillKey] ?? null; @@ -116,92 +238,130 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { const showBundledBadge = Boolean(skill.bundled && skill.source !== "openclaw-bundled"); const missing = computeSkillMissing(skill); const reasons = computeSkillReasons(skill); + return html` -
-
-
- ${skill.emoji ? `${skill.emoji} ` : ""}${skill.name} + { + if ((e.target as HTMLElement).classList.contains("md-preview-dialog")) { + props.onDetailClose(); + } + }}> +
+
+
+ + ${skill.emoji ? html`${skill.emoji}` : nothing} + ${skill.name} +
+
-
${clampText(skill.description, 140)}
- ${renderSkillStatusChips({ skill, showBundledBadge })} - ${ - missing.length > 0 - ? html` -
- Missing: ${missing.join(", ")} -
- ` - : nothing - } - ${ - reasons.length > 0 - ? html` -
- Reason: ${reasons.join(", ")} -
- ` - : nothing - } -
-
- - ${ - message - ? html`
- ${message.message} -
` - : nothing - } - ${ - skill.primaryEnv - ? html` -
- API key - - props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)} - /> -
- - ` - : nothing - }
-
+ `; } diff --git a/ui/src/ui/views/usage-render-details.ts b/ui/src/ui/views/usage-render-details.ts index dea48824c5f55..17a494c776e00 100644 --- a/ui/src/ui/views/usage-render-details.ts +++ b/ui/src/ui/views/usage-render-details.ts @@ -284,9 +284,10 @@ function renderSessionDetailPanel( }
@@ -473,7 +474,7 @@ function renderTimeSeriesCompact( hasSelection ? html`
-
@@ -482,13 +483,13 @@ function renderTimeSeriesCompact( }
` : nothing @@ -1018,7 +1019,7 @@ function renderSessionLogsCompact( (${displayedCount} ${t("usage.overview.messages").toLowerCase()}) -
@@ -1026,6 +1027,7 @@ function renderSessionLogsCompact( onFilterToolsChange( Array.from((event.target as HTMLSelectElement).selectedOptions).map( @@ -1065,10 +1068,11 @@ function renderSessionLogsCompact( onFilterQueryChange((event.target as HTMLInputElement).value)} /> -
diff --git a/ui/src/ui/views/usage-render-overview.ts b/ui/src/ui/views/usage-render-overview.ts index 616a875e98d93..99ea2d19db7a2 100644 --- a/ui/src/ui/views/usage-render-overview.ts +++ b/ui/src/ui/views/usage-render-overview.ts @@ -102,6 +102,7 @@ function renderFilterChips( class="filter-chip-remove" @click=${onClearDays} title=${t("usage.filters.remove")} + aria-label="Remove days filter" > × @@ -118,6 +119,7 @@ function renderFilterChips( class="filter-chip-remove" @click=${onClearHours} title=${t("usage.filters.remove")} + aria-label="Remove hours filter" > × @@ -134,6 +136,7 @@ function renderFilterChips( class="filter-chip-remove" @click=${onClearSessions} title=${t("usage.filters.remove")} + aria-label="Remove session filter" > × @@ -144,7 +147,7 @@ function renderFilterChips( ${ (selectedDays.length > 0 || selectedHours.length > 0) && selectedSessions.length > 0 ? html` - ` @@ -175,6 +178,22 @@ function renderDailyChartCompact( const values = daily.map((d) => (isTokenMode ? d.totalTokens : d.totalCost)); const maxValue = Math.max(...values, isTokenMode ? 1 : 0.0001); + // Adaptive scaling: when the spread between largest and smallest non-zero + // values is extreme (>50×), use square-root compression so small bars stay + // visible instead of collapsing to a single pixel. + const nonZero = values.filter((v) => v > 0); + const minNonZero = nonZero.length > 0 ? Math.min(...nonZero) : maxValue; + const spread = maxValue / minNonZero; + const chartAreaPx = 200; + const minBarPx = 6; + const barHeights = values.map((v): number => { + if (v <= 0) { + return 0; + } + const ratio = spread > 50 ? Math.sqrt(v / maxValue) : v / maxValue; + return Math.max(minBarPx, ratio * chartAreaPx); + }); + // Calculate bar width based on number of days const barMaxWidth = daily.length > 30 ? 12 : daily.length > 20 ? 18 : daily.length > 14 ? 24 : 32; const showTotals = daily.length <= 14; @@ -184,13 +203,13 @@ function renderDailyChartCompact(
` diff --git a/ui/src/ui/views/usage.ts b/ui/src/ui/views/usage.ts index 8c5496356b022..957b6a52b8c83 100644 --- a/ui/src/ui/views/usage.ts +++ b/ui/src/ui/views/usage.ts @@ -128,7 +128,7 @@ function renderUsageEmptyState(onRefresh: () => void) { ${t("usage.empty.featureTimeline")}
-
@@ -389,7 +389,7 @@ export function renderUsage(props: UsageProps) {
`, @@ -584,6 +584,7 @@ export function renderUsage(props: UsageProps) { type="date" .value=${filters.startDate} title=${t("usage.filters.startDate")} + aria-label=${t("usage.filters.startDate")} @change=${(e: Event) => filterActions.onStartDateChange((e.target as HTMLInputElement).value)} /> @@ -593,6 +594,7 @@ export function renderUsage(props: UsageProps) { type="date" .value=${filters.endDate} title=${t("usage.filters.endDate")} + aria-label=${t("usage.filters.endDate")} @change=${(e: Event) => filterActions.onEndDateChange((e.target as HTMLInputElement).value)} /> @@ -600,6 +602,7 @@ export function renderUsage(props: UsageProps) {