diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f69c7ae2698..5e8a797ce74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,14 @@ on: pull_request: concurrency: - group: ci-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android). # Lint and format always run. Fail-safe: if detection fails, run everything. docs-scope: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 outputs: docs_only: ${{ steps.check.outputs.docs_only }} docs_changed: ${{ steps.check.outputs.docs_changed }} @@ -33,7 +33,7 @@ jobs: changed-scope: needs: [docs-scope] if: needs.docs-scope.outputs.docs_only != 'true' - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 outputs: run_node: ${{ steps.scope.outputs.run_node }} run_macos: ${{ steps.scope.outputs.run_macos }} @@ -204,6 +204,14 @@ jobs: if: matrix.task == 'test' && matrix.runtime == 'node' run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV" + - name: Configure Node test resources + if: matrix.task == 'test' && matrix.runtime == 'node' + run: | + # `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes. + # Default heap limits have been too low on Linux CI (V8 OOM near 4GB). + echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV" + echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV" + - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) run: ${{ matrix.command }} @@ -664,7 +672,8 @@ jobs: uses: actions/setup-java@v4 with: distribution: temurin - java-version: 21 + # setup-android's sdkmanager currently crashes on JDK 21 in CI. + java-version: 17 - name: Setup Android SDK uses: android-actions/setup-android@v3 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index a286026ae32..05e63005dd5 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -13,6 +13,10 @@ on: - ".agents/**" - "skills/**" +concurrency: + group: docker-release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} diff --git a/.github/workflows/formal-conformance.yml b/.github/workflows/formal-conformance.yml index a8ec86bfce7..8ba6d7e56b8 100644 --- a/.github/workflows/formal-conformance.yml +++ b/.github/workflows/formal-conformance.yml @@ -108,6 +108,7 @@ jobs: - name: Comment on PR (informational) if: steps.drift.outputs.drift == 'true' + continue-on-error: true uses: actions/github-script@v7 with: script: | diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index e6c0914f018..45154a5fab4 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -7,8 +7,8 @@ on: workflow_dispatch: concurrency: - group: install-smoke-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true + group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: docs-scope: @@ -33,19 +33,17 @@ jobs: - name: Checkout CLI uses: actions/checkout@v4 - - name: Setup pnpm (corepack retry) - run: | - set -euo pipefail - corepack enable - for attempt in 1 2 3; do - if corepack prepare pnpm@10.23.0 --activate; then - pnpm -v - exit 0 - fi - echo "corepack prepare failed (attempt $attempt/3). Retrying..." - sleep $((attempt * 10)) - done - exit 1 + - name: Setup Node.js + uses: actions/setup-node@v4 + 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" - name: Install pnpm deps (minimal) run: pnpm install --ignore-scripts --frozen-lockfile diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index 27c18aea572..c92a05c3aeb 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -14,8 +14,8 @@ on: - scripts/sandbox-common-setup.sh concurrency: - group: sandbox-common-smoke-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true + group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: sandbox-common-smoke: diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 14fe6ae429f..438a71162da 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -6,8 +6,8 @@ on: branches: [main] concurrency: - group: workflow-sanity-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true + group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: no-tabs: diff --git a/.gitignore b/.gitignore index e8c8baf330e..ea7f13ee132 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ apps/android/.cxx/ *.bun-build apps/macos/.build/ apps/shared/MoltbotKit/.build/ +apps/shared/OpenClawKit/.build/ +apps/shared/OpenClawKit/Package.resolved **/ModuleCache/ bin/ bin/clawdbot-mac diff --git a/AGENTS.md b/AGENTS.md index 8a48c040243..3aaed9009a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,19 @@ - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them. +## GHSA (Repo Advisory) Patch/Publish + +- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/` +- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"` +- Private fork PRs must be closed: + `fork=$(gh api /repos/openclaw/openclaw/security-advisories/ | jq -r .private_fork.full_name)` + `gh pr list -R "$fork" --state open` (must be empty) +- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings) +- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json` +- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/ --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint) +- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs +- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing + ## Troubleshooting - Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`). @@ -182,3 +195,39 @@ - Publish: `npm publish --access public --otp=""` (run from the package dir). - Verify without local npmrc side effects: `npm view version --userconfig "$(mktemp)"`. - Kill the tmux session after publish. + +## Plugin Release Fast Path (no core `openclaw` publish) + +- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list". +- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption: + - `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)` + - `eval "$(op signin --account my.1password.com)"` +- 1Password helpers: + - password used by `npm login`: + `op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'` + - OTP: + `op read 'op://Private/Npmjs/one-time password?attribute=otp'` +- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean): + - compare local plugin `version` to `npm view version` + - only run `npm publish --access public --otp=""` when versions differ + - skip if package is missing on npm or version already matches. +- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested. +- Post-check for each release: + - per-plugin: `npm view @openclaw/ version --userconfig "$(mktemp)"` should be `2026.2.16` + - core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested. + +## Changelog Release Notes + +- When cutting a mac release with beta GitHub prerelease: + - Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`). + - Create prerelease with title `openclaw YYYY.M.D-beta.N`. + - Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate). + - Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available. + +- Keep top version entries in `CHANGELOG.md` sorted by impact: + - `### Changes` first. + - `### Fixes` deduped and ranked with user-facing fixes first. +- Before tagging/publishing, run: + - `node --import tsx scripts/release-check.ts` + - `pnpm release:check` + - `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec5a51b207..8ec200ba620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,33 +2,67 @@ Docs: https://docs.openclaw.ai -## 2026.2.15 (Unreleased) +## 2026.2.16 (Unreleased) ### Changes +- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow. +- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. +- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread. - Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. +- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x. +- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal. +- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal. +- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow. ### Fixes -- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. +- Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh. +- Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent. +- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent. +- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh. +- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n. +- Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (`allowInsecureAuth` / `dangerouslyDisableDeviceAuth`) when device identity is unavailable, preventing false `missing scope` failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird. +- LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann. +- Skills/Security: restrict `download` installer `targetDir` to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code. +- Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly. +- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168. - Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez. -- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204. -- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come. -- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane. -- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07. -- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie. +- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou. +- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz. - Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n. - Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz. -- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz. -- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n. -- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07. +- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code. +- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent. +- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot. +- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07. +- Agents/Context: derive `lookupContextTokens()` from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07. - Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07. -- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07. +- Memory/FTS: make `buildFtsQuery` Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471. +- Auto-reply/Compaction: resolve `memory/YYYY-MM-DD.md` placeholders with timezone-aware runtime dates and append a `Current time:` line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07. +- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07. +- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204. +- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone. +- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber. +- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. +- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. - Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023. - Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. - Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus. +- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk. +- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus. +- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd. +- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme. +- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu. +- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras. +- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96. +- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie. - Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz. -- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber. +- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come. +- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane. +- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07. +- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry. +- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07. ## 2026.2.14 @@ -43,6 +77,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Sessions/Telegram: restrict session tool targeting by default to the current session tree (`tools.sessions.visibility`, default `tree`) with sandbox clamping, and pass configured per-account Telegram webhook secrets in webhook mode when no explicit override is provided. Thanks @aether-ai-agent. - CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang. - CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras. - WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr. @@ -82,9 +117,11 @@ Docs: https://docs.openclaw.ai - Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace. - Gateway/Config: make `config.patch` merge object arrays by `id` (for example `agents.list`) instead of replacing the whole array, so partial agent updates do not silently delete unrelated agents. (#6766) Thanks @lightclient. - Webchat/Prompts: stop injecting direct-chat `conversation_label` into inbound untrusted metadata context blocks, preventing internal label noise from leaking into visible chat replies. (#16556) Thanks @nberardi. +- Auto-reply/Prompts: include trusted inbound `message_id`, `chat_id`, `reply_to_id`, and optional `message_id_full` metadata fields so action tools (for example reactions) can target the triggering message without relying on user text. (#17662) Thanks @MaikiMolto. - Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn. - Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla. - Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev. +- Agents/Tools: make required-parameter validation errors list missing fields and instruct: "Supply correct parameters before retrying," reducing repeated invalid tool-call loops (for example `read({})`). (#14729) - Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader. - Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman. - Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras. @@ -96,6 +133,7 @@ Docs: https://docs.openclaw.ai - Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false β€œexact text not found” misses from block-form params. (#16778) Thanks @danielpipernz. - Ollama/Agents: avoid forcing `` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg. - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. +- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc. - Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard. - Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks. - Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors. @@ -109,6 +147,7 @@ Docs: https://docs.openclaw.ai - Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`. - Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui. - Memory/QMD: avoid multi-collection `query` ranking corruption by running one `qmd query -c ` per managed collection and merging by best score (also used for `search`/`vsearch` fallback-to-query). (#16740) Thanks @volarian-vai. +- Memory/QMD: rebind managed collections when existing collection metadata drifts (including sessions name-only listings), preventing non-default agents from reusing another agent's `sessions` collection path. (#17194) Thanks @jonathanadams96. - Memory/QMD: make `openclaw memory index` verify and print the active QMD index file path/size, and fail when QMD leaves a missing or zero-byte index artifact after an update. (#16775) Thanks @Shunamxiao. - Memory/QMD: detect null-byte `ENOTDIR` update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms. - Memory/QMD/Security: add `rawKeyPrefix` support for QMD scope rules and preserve legacy `keyPrefix: "agent:..."` matching, preventing scoped deny bypass when operators match agent-prefixed session keys. @@ -126,6 +165,7 @@ Docs: https://docs.openclaw.ai - Media/Security: allow local media reads from OpenClaw state `workspace/` and `sandboxes/` roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji. - Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739) - Media/Security: allow outbound local media reads from the active agent workspace (including `workspace-`) via agent-scoped local roots, avoiding broad global allowlisting of all per-agent workspaces. (#17136) Thanks @MisterGuy420. +- Outbound/Media: thread explicit `agentId` through core `sendMessage` direct-delivery path so agent-scoped local media roots apply even when mirror metadata is absent. (#17268) Thanks @gumadeiras. - Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files. - Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky. - Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password. @@ -141,6 +181,7 @@ Docs: https://docs.openclaw.ai - Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc. - Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson. - Security/Slack: compute command authorization for DM slash commands even when `dmPolicy=open`, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth. +- Security/Pairing: scope pairing allowlist writes/reads to channel accounts (for example `telegram:yy`), and propagate account-aware pairing approvals so multi-account channels do not share a single per-channel pairing allowFrom store. (#17631) Thanks @crazytan. - Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc. - Security/Google Chat: deprecate `users/` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc. - Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc. @@ -196,6 +237,7 @@ Docs: https://docs.openclaw.ai - Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh. - Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable. - Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories. +- Security/Sessions: preserve inter-session input provenance for routed prompts so delegated/internal sessions are not treated as direct external user instructions. Thanks @anbecker. - Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale. - Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj. - BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y. @@ -326,6 +368,7 @@ Docs: https://docs.openclaw.ai - Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445. - Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55. - Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica. +- Gateway/Control UI: keep partial assistant output visible when runs are aborted, and persist aborted partials to session transcripts for follow-up context. - Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini. - Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon. - Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5e9164a94d..355fb5c6890 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,24 +13,33 @@ Welcome to the lobster tank! 🦞 - **Peter Steinberger** - Benevolent Dictator - GitHub: [@steipete](https://github.com/steipete) Β· X: [@steipete](https://x.com/steipete) -- **Shadow** - Discord + Slack subsystem +- **Shadow** - Discord subsystem, Discord admin - GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) Β· X: [@4shad0wed](https://x.com/4shad0wed) -- **Vignesh** - Memory (QMD), formal modeling, TUI, and Lobster +- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster - GitHub: [@vignesh07](https://github.com/vignesh07) Β· X: [@\_vgnsh](https://x.com/_vgnsh) - **Jos** - Telegram, API, Nix mode - GitHub: [@joshp123](https://github.com/joshp123) Β· X: [@jjpcodes](https://x.com/jjpcodes) +- **Ayaan Zaidi** - Telegram subsystem, iOS app + - GitHub: [@obviyus](https://github.com/obviyus) Β· X: [@0bviyus](https://x.com/0bviyus) + +- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app + - GitHub: [@tyler6204](https://github.com/tyler6204) Β· X: [@tyleryust](https://x.com/tyleryust) + +- **Mariano Belinky** - iOS app, Security + - GitHub: [@mbelinky](https://github.com/mbelinky) Β· X: [@belimad](https://x.com/belimad) + +- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening + - GitHub: [@sebslight](https://github.com/sebslight) Β· X: [@sebslig](https://x.com/sebslig) + - **Christoph Nakazawa** - JS Infra - GitHub: [@cpojer](https://github.com/cpojer) Β· X: [@cnakazawa](https://x.com/cnakazawa) - **Gustavo Madeira Santana** - Multi-agents, CLI, web UI - GitHub: [@gumadeiras](https://github.com/gumadeiras) Β· X: [@gumadeiras](https://x.com/gumadeiras) -- **Maximilian Nussbaumer** - DevOps, CI, Code Sanity - - GitHub: [@quotentiroler](https://github.com/quotentiroler) Β· X: [@quotentiroler](https://x.com/quotentiroler) - ## How to Contribute 1. **Bugs & small fixes** β†’ Open a PR! diff --git a/appcast.xml b/appcast.xml index 02d053bd5cd..2b3ed8fcc9a 100644 --- a/appcast.xml +++ b/appcast.xml @@ -140,6 +140,74 @@ ]]> + + 2026.2.15 + Mon, 16 Feb 2026 05:04:34 +0100 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 11213 + 2026.2.15 + 15.0 + OpenClaw 2026.2.15 +

Changes

+
    +
  • Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
  • +
  • Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
  • +
  • Plugins: expose llm_input and llm_output hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
  • +
  • Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set agents.defaults.subagents.maxSpawnDepth: 2 to allow sub-agents to spawn their own children. Includes maxChildrenPerAgent limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
  • +
  • Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
  • +
  • Cron/Gateway: add finished-run webhook delivery toggle (notify) and dedicated webhook auth token support (cron.webhookToken) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
  • +
  • Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
  • +
+

Fixes

+
    +
  • Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
  • +
  • Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
  • +
  • Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
  • +
  • Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
  • +
  • Gateway/Security: redact sensitive session/path details from status responses for non-admin clients; full details remain available to operator.admin. (#8590) Thanks @fr33d3m0n.
  • +
  • Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (allowInsecureAuth / dangerouslyDisableDeviceAuth) when device identity is unavailable, preventing false missing scope failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.
  • +
  • LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.
  • +
  • Skills/Security: restrict download installer targetDir to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
  • +
  • Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
  • +
  • Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
  • +
  • Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving passwordFile path exemptions, preventing accidental redaction of non-secret config values like maxTokens and IRC password-file paths. (#16042) Thanks @akramcodez.
  • +
  • Dev tooling: harden git pre-commit hook against option injection from malicious filenames (for example --force), preventing accidental staging of ignored files. Thanks @mrthankyou.
  • +
  • Gateway/Agent: reject malformed agent:-prefixed session keys (for example, agent:main) in agent and agent.identity.get instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
  • +
  • Gateway/Chat: harden chat.send inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
  • +
  • Gateway/Send: return an actionable error when send targets internal-only webchat, guiding callers to use chat.send or a deliverable channel. (#15703) Thanks @rodrigouroz.
  • +
  • Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing script-src 'self'. Thanks @Adam55A-code.
  • +
  • Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
  • +
  • Agents/Sandbox: clarify system prompt path guidance so sandbox bash/exec uses container paths (for example /workspace) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
  • +
  • Agents/Context: apply configured model contextWindow overrides after provider discovery so lookupContextTokens() honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
  • +
  • Agents/Context: derive lookupContextTokens() from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.
  • +
  • Agents/OpenAI: force store=true for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
  • +
  • Memory/FTS: make buildFtsQuery Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.
  • +
  • Auto-reply/Compaction: resolve memory/YYYY-MM-DD.md placeholders with timezone-aware runtime dates and append a Current time: line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.
  • +
  • Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
  • +
  • Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
  • +
  • Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
  • +
  • Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
  • +
  • Subagents/Models: preserve agents.defaults.model.fallbacks when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
  • +
  • Telegram: omit message_thread_id for DM sends/draft previews and keep forum-topic handling (id=1 general omitted, non-general kept), preventing DM failures with 400 Bad Request: message thread not found. (#10942) Thanks @garnetlyx.
  • +
  • Telegram: replace inbound placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
  • +
  • Telegram: retry inbound media getFile calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
  • +
  • Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
  • +
  • Discord: preserve channel session continuity when runtime payloads omit message.channelId by falling back to event/raw channel_id values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as sessionKey=unknown. (#17622) Thanks @shakkernerd.
  • +
  • Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with _2 suffixes. (#17365) Thanks @seewhyme.
  • +
  • Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
  • +
  • Web UI/Agents: hide BOOTSTRAP.md in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
  • +
  • Auto-reply/WhatsApp/TUI/Web: when a final assistant message is NO_REPLY and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show NO_REPLY placeholders. (#7010) Thanks @Morrowind-Xie.
  • +
  • Cron: infer payload.kind="agentTurn" for model-only cron.update payload patches, so partial agent-turn updates do not fail validation when kind is omitted. (#15664) Thanks @rodrigouroz.
  • +
  • TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
  • +
  • TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
  • +
  • TUI: suppress false (no output) placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
  • +
  • TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
  • +
  • CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
  • +
+

View full changelog

+]]>
+ +
2026.2.13 Sat, 14 Feb 2026 04:30:23 +0100 @@ -241,101 +309,5 @@ ]]> - - 2026.2.12 - Fri, 13 Feb 2026 03:17:54 +0100 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 9500 - 2026.2.12 - 15.0 - OpenClaw 2026.2.12 -

Changes

-
    -
  • CLI: add openclaw logs --local-time to display log timestamps in local timezone. (#13818) Thanks @xialonglee.
  • -
  • Telegram: render blockquotes as native
    tags instead of stripping them. (#14608)
  • -
  • Config: avoid redacting maxTokens-like fields during config snapshot redaction, preventing round-trip validation failures in /config. (#14006) Thanks @constansino.
  • -
-

Breaking

-
    -
  • Hooks: POST /hooks/agent now rejects payload sessionKey overrides by default. To keep fixed hook context, set hooks.defaultSessionKey (recommended with hooks.allowedSessionKeyPrefixes: ["hook:"]). If you need legacy behavior, explicitly set hooks.allowRequestSessionKey: true. Thanks @alpernae for reporting.
  • -
-

Fixes

-
    -
  • Gateway/OpenResponses: harden URL-based input_file/input_image handling with explicit SSRF deny policy, hostname allowlists (files.urlAllowlist / images.urlAllowlist), per-request URL input caps (maxUrlParts), blocked-fetch audit logging, and regression coverage/docs updates.
  • -
  • Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.
  • -
  • Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.
  • -
  • Security/Audit: add hook session-routing hardening checks (hooks.defaultSessionKey, hooks.allowRequestSessionKey, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.
  • -
  • Security/Sandbox: confine mirrored skill sync destinations to the sandbox skills/ root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.
  • -
  • Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip toolResult.details from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.
  • -
  • Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (429 + Retry-After). Thanks @akhmittra.
  • -
  • Security/Browser: require auth for loopback browser control HTTP routes, auto-generate gateway.auth.token when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle.
  • -
  • Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.
  • -
  • Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.
  • -
  • Logging/CLI: use local timezone timestamps for console prefixing, and include Β±HH:MM offsets when using openclaw logs --local-time to avoid ambiguity. (#14771) Thanks @0xRaini.
  • -
  • Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.
  • -
  • Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.
  • -
  • Gateway: prevent undefined/missing token in auth config. (#13809) Thanks @asklee-klawd.
  • -
  • Gateway: handle async EPIPE on stdout/stderr during shutdown. (#13414) Thanks @keshav55.
  • -
  • Gateway/Control UI: resolve missing dashboard assets when openclaw is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.
  • -
  • Cron: use requested agentId for isolated job auth resolution. (#13983) Thanks @0xRaini.
  • -
  • Cron: prevent cron jobs from skipping execution when nextRunAtMs advances. (#14068) Thanks @WalterSumbon.
  • -
  • Cron: pass agentId to runHeartbeatOnce for main-session jobs. (#14140) Thanks @ishikawa-pro.
  • -
  • Cron: re-arm timers when onTimer fires while a job is still executing. (#14233) Thanks @tomron87.
  • -
  • Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
  • -
  • Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
  • -
  • Cron: prevent one-shot at jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
  • -
  • Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after requests-in-flight skips. (#14901) Thanks @joeykrug.
  • -
  • Cron: honor stored session model overrides for isolated-agent runs while preserving hooks.gmail.model precedence for Gmail hook sessions. (#14983) Thanks @shtse8.
  • -
  • Logging/Browser: fall back to os.tmpdir()/openclaw for default log, browser trace, and browser download temp paths when /tmp/openclaw is unavailable.
  • -
  • WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10.
  • -
  • WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib.
  • -
  • WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr.
  • -
  • Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.
  • -
  • Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.
  • -
  • BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.
  • -
  • Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
  • -
  • Slack: detect control commands when channel messages start with bot mention prefixes (for example, @Bot /new). (#14142) Thanks @beefiker.
  • -
  • Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.
  • -
  • Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.
  • -
  • Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.
  • -
  • Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.
  • -
  • Signal: render mention placeholders as @uuid/@phone so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.
  • -
  • Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.
  • -
  • Onboarding/Providers: add Z.AI endpoint-specific auth choices (zai-coding-global, zai-coding-cn, zai-global, zai-cn) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.
  • -
  • Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include minimax-m2.5 in modern model filtering. (#14865) Thanks @adao-max.
  • -
  • Ollama: use configured models.providers.ollama.baseUrl for model discovery and normalize /v1 endpoints to the native Ollama API root. (#14131) Thanks @shtse8.
  • -
  • Voice Call: pass Twilio stream auth token via instead of query string. (#14029) Thanks @mcwigglesmcgee.
  • -
  • Feishu: pass Buffer directly to the Feishu SDK upload APIs instead of Readable.from(...) to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
  • -
  • Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
  • -
  • Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
  • -
  • Feishu DocX: preserve top-level converted block order using firstLevelBlockIds when writing/appending documents. (#13994) Thanks @Cynosure159.
  • -
  • Feishu plugin packaging: remove workspace:* openclaw dependency from extensions/feishu and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.
  • -
  • CLI/Wizard: exit with code 1 when configure, agents add, or interactive onboard wizards are canceled, so set -e automation stops correctly. (#14156) Thanks @0xRaini.
  • -
  • Media: strip MEDIA: lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini.
  • -
  • Config/Cron: exclude maxTokens from config redaction and honor deleteAfterRun on skipped cron jobs. (#13342) Thanks @niceysam.
  • -
  • Config: ignore meta field changes in config file watcher. (#13460) Thanks @brandonwise.
  • -
  • Cron: use requested agentId for isolated job auth resolution. (#13983) Thanks @0xRaini.
  • -
  • Cron: pass agentId to runHeartbeatOnce for main-session jobs. (#14140) Thanks @ishikawa-pro.
  • -
  • Cron: prevent cron jobs from skipping execution when nextRunAtMs advances. (#14068) Thanks @WalterSumbon.
  • -
  • Cron: re-arm timers when onTimer fires while a job is still executing. (#14233) Thanks @tomron87.
  • -
  • Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
  • -
  • Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
  • -
  • Cron: prevent one-shot at jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
  • -
  • Daemon: suppress EPIPE error when restarting LaunchAgent. (#14343) Thanks @0xRaini.
  • -
  • Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic.
  • -
  • Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26.
  • -
  • Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002.
  • -
  • Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi.
  • -
  • Agents: keep followup-runner session totalTokens aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.
  • -
  • Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.
  • -
  • Hooks/Tools: dispatch before_tool_call and after_tool_call hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.
  • -
  • Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.
  • -
  • Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
  • -
  • Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
  • -
-

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 b7689b252b3..148b2e58a75 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "ai.openclaw.android" minSdk = 31 targetSdk = 36 - versionCode = 202602150 - versionName = "2026.2.15" + versionCode = 202602160 + versionName = "2026.2.16" ndk { // Support all major ABIs β€” native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") diff --git a/apps/ios/Sources/Calendar/CalendarService.swift b/apps/ios/Sources/Calendar/CalendarService.swift index 9ac83dd3928..94b2d9ea3f5 100644 --- a/apps/ios/Sources/Calendar/CalendarService.swift +++ b/apps/ios/Sources/Calendar/CalendarService.swift @@ -6,7 +6,7 @@ final class CalendarService: CalendarServicing { func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload { let store = EKEventStore() let status = EKEventStore.authorizationStatus(for: .event) - let authorized = await Self.ensureAuthorization(store: store, status: status) + let authorized = EventKitAuthorization.allowsRead(status: status) guard authorized else { throw NSError(domain: "Calendar", code: 1, userInfo: [ NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission", @@ -39,7 +39,7 @@ final class CalendarService: CalendarServicing { func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload { let store = EKEventStore() let status = EKEventStore.authorizationStatus(for: .event) - let authorized = await Self.ensureWriteAuthorization(store: store, status: status) + let authorized = EventKitAuthorization.allowsWrite(status: status) guard authorized else { throw NSError(domain: "Calendar", code: 2, userInfo: [ NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission", @@ -95,38 +95,6 @@ final class CalendarService: CalendarServicing { return OpenClawCalendarAddPayload(event: payload) } - private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { - switch status { - case .authorized: - return true - case .notDetermined: - // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts. - return false - case .restricted, .denied: - return false - case .fullAccess: - return true - case .writeOnly: - return false - @unknown default: - return false - } - } - - private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { - switch status { - case .authorized, .fullAccess, .writeOnly: - return true - case .notDetermined: - // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts. - return false - case .restricted, .denied: - return false - @unknown default: - return false - } - } - private static func resolveCalendar( store: EKEventStore, calendarId: String?, diff --git a/apps/ios/Sources/Camera/CameraController.swift b/apps/ios/Sources/Camera/CameraController.swift index e76dbeeabb9..1e9c10bc44c 100644 --- a/apps/ios/Sources/Camera/CameraController.swift +++ b/apps/ios/Sources/Camera/CameraController.swift @@ -93,14 +93,10 @@ actor CameraController { } withExtendedLifetime(delegate) {} - let maxPayloadBytes = 5 * 1024 * 1024 - // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). - let maxEncodedBytes = (maxPayloadBytes / 4) * 3 - let res = try JPEGTranscoder.transcodeToJPEG( - imageData: rawData, + let res = try PhotoCapture.transcodeJPEGForGateway( + rawData: rawData, maxWidthPx: maxWidth, - quality: quality, - maxBytes: maxEncodedBytes) + quality: quality) return ( format: format.rawValue, @@ -335,8 +331,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat func photoOutput( _ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, - error: Error?) - { + error: Error? + ) { guard !self.didResume else { return } self.didResume = true @@ -364,8 +360,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat func photoOutput( _ output: AVCapturePhotoOutput, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, - error: Error?) - { + error: Error? + ) { guard let error else { return } guard !self.didResume else { return } self.didResume = true diff --git a/apps/ios/Sources/EventKit/EventKitAuthorization.swift b/apps/ios/Sources/EventKit/EventKitAuthorization.swift new file mode 100644 index 00000000000..c27e9a3efde --- /dev/null +++ b/apps/ios/Sources/EventKit/EventKitAuthorization.swift @@ -0,0 +1,34 @@ +import EventKit + +enum EventKitAuthorization { + static func allowsRead(status: EKAuthorizationStatus) -> Bool { + switch status { + case .authorized, .fullAccess: + return true + case .writeOnly: + return false + case .notDetermined: + // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts. + return false + case .restricted, .denied: + return false + @unknown default: + return false + } + } + + static func allowsWrite(status: EKAuthorizationStatus) -> Bool { + switch status { + case .authorized, .fullAccess, .writeOnly: + return true + case .notDetermined: + // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts. + return false + case .restricted, .denied: + return false + @unknown default: + return false + } + } +} + diff --git a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift index 223cfda5c90..ce1ba4bf2cb 100644 --- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -136,43 +136,9 @@ final class GatewayDiscoveryModel { } private func updateStatusText() { - let states = Array(self.statesByDomain.values) - if states.isEmpty { - self.statusText = self.browsers.isEmpty ? "Idle" : "Setup" - return - } - - if let failed = states.first(where: { state in - if case .failed = state { return true } - return false - }) { - if case let .failed(err) = failed { - self.statusText = "Failed: \(err)" - return - } - } - - if let waiting = states.first(where: { state in - if case .waiting = state { return true } - return false - }) { - if case let .waiting(err) = waiting { - self.statusText = "Waiting: \(err)" - return - } - } - - if states.contains(where: { if case .ready = $0 { true } else { false } }) { - self.statusText = "Searching…" - return - } - - if states.contains(where: { if case .setup = $0 { true } else { false } }) { - self.statusText = "Setup" - return - } - - self.statusText = "Searching…" + self.statusText = GatewayDiscoveryStatusText.make( + states: Array(self.statesByDomain.values), + hasBrowsers: !self.browsers.isEmpty) } private static func prettyState(_ state: NWBrowser.State) -> String { diff --git a/apps/ios/Sources/Gateway/GatewaySetupCode.swift b/apps/ios/Sources/Gateway/GatewaySetupCode.swift new file mode 100644 index 00000000000..8ccbab42da7 --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewaySetupCode.swift @@ -0,0 +1,42 @@ +import Foundation + +struct GatewaySetupPayload: Codable { + var url: String? + var host: String? + var port: Int? + var tls: Bool? + var token: String? + var password: String? +} + +enum GatewaySetupCode { + static func decode(raw: String) -> GatewaySetupPayload? { + if let payload = decodeFromJSON(raw) { + return payload + } + if let decoded = decodeBase64Payload(raw), + let payload = decodeFromJSON(decoded) + { + return payload + } + return nil + } + + private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data) + } + + private static func decodeBase64Payload(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let normalized = trimmed + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = normalized.count % 4 + let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding) + guard let data = Data(base64Encoded: padded) else { return nil } + return String(data: data, encoding: .utf8) + } +} + diff --git a/apps/ios/Sources/Gateway/TCPProbe.swift b/apps/ios/Sources/Gateway/TCPProbe.swift new file mode 100644 index 00000000000..e22da96298f --- /dev/null +++ b/apps/ios/Sources/Gateway/TCPProbe.swift @@ -0,0 +1,43 @@ +import Foundation +import Network +import os + +enum TCPProbe { + static func probe(host: String, port: Int, timeoutSeconds: Double, queueLabel: String) async -> Bool { + guard port >= 1, port <= 65535 else { return false } + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false } + + let endpointHost = NWEndpoint.Host(host) + let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp) + + return await withCheckedContinuation { cont in + let queue = DispatchQueue(label: queueLabel) + let finished = OSAllocatedUnfairLock(initialState: false) + let finish: @Sendable (Bool) -> Void = { ok in + let shouldResume = finished.withLock { flag -> Bool in + if flag { return false } + flag = true + return true + } + guard shouldResume else { return } + connection.cancel() + cont.resume(returning: ok) + } + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + finish(true) + case .failed, .cancelled: + finish(false) + default: + break + } + } + + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) } + } + } +} + diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 3a4de04847a..6fe56394eed 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.15 + 2026.2.16 CFBundleVersion - 20260215 + 20260216 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift index 372f8361d30..e8dce2cd30c 100644 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -61,37 +61,10 @@ extension NodeAppModel { private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool { guard let host = url.host, !host.isEmpty else { return false } let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80) - guard portInt >= 1, portInt <= 65535 else { return false } - guard let nwPort = NWEndpoint.Port(rawValue: UInt16(portInt)) else { return false } - - let endpointHost = NWEndpoint.Host(host) - let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp) - return await withCheckedContinuation { cont in - let queue = DispatchQueue(label: "a2ui.preflight") - let finished = OSAllocatedUnfairLock(initialState: false) - let finish: @Sendable (Bool) -> Void = { ok in - let shouldResume = finished.withLock { flag -> Bool in - if flag { return false } - flag = true - return true - } - guard shouldResume else { return } - connection.cancel() - cont.resume(returning: ok) - } - - connection.stateUpdateHandler = { state in - switch state { - case .ready: - finish(true) - case .failed, .cancelled: - finish(false) - default: - break - } - } - connection.start(queue: queue) - queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) } - } + return await TCPProbe.probe( + host: host, + port: portInt, + timeoutSeconds: timeoutSeconds, + queueLabel: "a2ui.preflight") } } diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift index 09c9e2429a6..bf6c0ba2d18 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift @@ -257,15 +257,6 @@ private struct ManualEntryStep: View { self.manualPassword = "" } - private struct SetupPayload: Codable { - var url: String? - var host: String? - var port: Int? - var tls: Bool? - var token: String? - var password: String? - } - private func applySetupCode() { let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { @@ -273,7 +264,7 @@ private struct ManualEntryStep: View { return } - guard let payload = self.decodeSetupPayload(raw: raw) else { + guard let payload = GatewaySetupCode.decode(raw: raw) else { self.setupStatusText = "Setup code not recognized." return } @@ -323,34 +314,7 @@ private struct ManualEntryStep: View { } } - private func decodeSetupPayload(raw: String) -> SetupPayload? { - if let payload = decodeSetupPayloadFromJSON(raw) { - return payload - } - if let decoded = decodeBase64Payload(raw), - let payload = decodeSetupPayloadFromJSON(decoded) - { - return payload - } - return nil - } - - private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? { - guard let data = json.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(SetupPayload.self, from: data) - } - - private func decodeBase64Payload(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let normalized = trimmed - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padding = normalized.count % 4 - let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding) - guard let data = Data(base64Encoded: padded) else { return nil } - return String(data: data, encoding: .utf8) - } + // (GatewaySetupCode) decode raw setup codes. } private struct ConnectionStatusBox: View { diff --git a/apps/ios/Sources/Reminders/RemindersService.swift b/apps/ios/Sources/Reminders/RemindersService.swift index 36eea522178..249f439fb17 100644 --- a/apps/ios/Sources/Reminders/RemindersService.swift +++ b/apps/ios/Sources/Reminders/RemindersService.swift @@ -6,7 +6,7 @@ final class RemindersService: RemindersServicing { func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload { let store = EKEventStore() let status = EKEventStore.authorizationStatus(for: .reminder) - let authorized = await Self.ensureAuthorization(store: store, status: status) + let authorized = EventKitAuthorization.allowsRead(status: status) guard authorized else { throw NSError(domain: "Reminders", code: 1, userInfo: [ NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission", @@ -50,7 +50,7 @@ final class RemindersService: RemindersServicing { func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload { let store = EKEventStore() let status = EKEventStore.authorizationStatus(for: .reminder) - let authorized = await Self.ensureWriteAuthorization(store: store, status: status) + let authorized = EventKitAuthorization.allowsWrite(status: status) guard authorized else { throw NSError(domain: "Reminders", code: 2, userInfo: [ NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission", @@ -100,38 +100,6 @@ final class RemindersService: RemindersServicing { return OpenClawRemindersAddPayload(reminder: payload) } - private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { - switch status { - case .authorized: - return true - case .notDetermined: - // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts. - return false - case .restricted, .denied: - return false - case .fullAccess: - return true - case .writeOnly: - return false - @unknown default: - return false - } - } - - private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { - switch status { - case .authorized, .fullAccess, .writeOnly: - return true - case .notDetermined: - // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts. - return false - case .restricted, .denied: - return false - @unknown default: - return false - } - } - private static func resolveList( store: EKEventStore, listId: String?, diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 514e1b4cc47..c8f13eef407 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -256,64 +256,11 @@ private struct CanvasContent: View { } private var statusActivity: StatusPill.Activity? { - // Status pill owns transient activity state so it doesn't overlap the connection indicator. - if self.appModel.isBackgrounded { - return StatusPill.Activity( - title: "Foreground required", - systemImage: "exclamationmark.triangle.fill", - tint: .orange) - } - - let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let gatewayLower = gatewayStatus.lowercased() - if gatewayLower.contains("repair") { - return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) - } - if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { - return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if self.appModel.screenRecordActive { - return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) - } - - if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { - let systemImage: String - let tint: Color? - switch cameraHUDKind { - case .photo: - systemImage = "camera.fill" - tint = nil - case .recording: - systemImage = "video.fill" - tint = .red - case .success: - systemImage = "checkmark.circle.fill" - tint = .green - case .error: - systemImage = "exclamationmark.triangle.fill" - tint = .red - } - return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) - } - - if self.voiceWakeEnabled { - let voiceStatus = self.appModel.voiceWake.statusText - if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { - return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) - } - if voiceStatus == "Paused" { - // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. - if self.appModel.talkMode.isEnabled { - return nil - } - let suffix = self.appModel.isBackgrounded ? " (background)" : "" - return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") - } - } - - return nil + StatusActivityBuilder.build( + appModel: self.appModel, + voiceWakeEnabled: self.voiceWakeEnabled, + cameraHUDText: self.cameraHUDText, + cameraHUDKind: self.cameraHUDKind) } } diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 278e56d6150..35786fa89a6 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -104,66 +104,10 @@ struct RootTabs: View { } private var statusActivity: StatusPill.Activity? { - // Keep the top pill consistent across tabs (camera + voice wake + pairing states). - if self.appModel.isBackgrounded { - return StatusPill.Activity( - title: "Foreground required", - systemImage: "exclamationmark.triangle.fill", - tint: .orange) - } - - let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let gatewayLower = gatewayStatus.lowercased() - if gatewayLower.contains("repair") { - return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) - } - if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { - return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if self.appModel.screenRecordActive { - return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) - } - - if let cameraHUDText = self.appModel.cameraHUDText, - let cameraHUDKind = self.appModel.cameraHUDKind, - !cameraHUDText.isEmpty - { - let systemImage: String - let tint: Color? - switch cameraHUDKind { - case .photo: - systemImage = "camera.fill" - tint = nil - case .recording: - systemImage = "video.fill" - tint = .red - case .success: - systemImage = "checkmark.circle.fill" - tint = .green - case .error: - systemImage = "exclamationmark.triangle.fill" - tint = .red - } - return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) - } - - if self.voiceWakeEnabled { - let voiceStatus = self.appModel.voiceWake.statusText - if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { - return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) - } - if voiceStatus == "Paused" { - // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. - if self.appModel.talkMode.isEnabled { - return nil - } - let suffix = self.appModel.isBackgrounded ? " (background)" : "" - return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") - } - } - - return nil + StatusActivityBuilder.build( + appModel: self.appModel, + voiceWakeEnabled: self.voiceWakeEnabled, + cameraHUDText: self.appModel.cameraHUDText, + cameraHUDKind: self.appModel.cameraHUDKind) } } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 662a22cb049..8eb725df4a1 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -304,7 +304,7 @@ struct SettingsTab: View { } } .onAppear { - self.localIPAddress = Self.primaryIPv4Address() + self.localIPAddress = NetworkInterfaces.primaryIPv4Address() self.lastLocationModeRaw = self.locationEnabledModeRaw self.syncManualPortText() let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -590,15 +590,6 @@ struct SettingsTab: View { } } - private struct SetupPayload: Codable { - var url: String? - var host: String? - var port: Int? - var tls: Bool? - var token: String? - var password: String? - } - private func applySetupCodeAndConnect() async { self.setupStatusText = nil guard self.applySetupCode() else { return } @@ -626,7 +617,7 @@ struct SettingsTab: View { return false } - guard let payload = self.decodeSetupPayload(raw: raw) else { + guard let payload = GatewaySetupCode.decode(raw: raw) else { self.setupStatusText = "Setup code not recognized." return false } @@ -727,67 +718,14 @@ struct SettingsTab: View { } private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool { - guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false } - let endpointHost = NWEndpoint.Host(host) - let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp) - return await withCheckedContinuation { cont in - let queue = DispatchQueue(label: "gateway.preflight") - let finished = OSAllocatedUnfairLock(initialState: false) - let finish: @Sendable (Bool) -> Void = { ok in - let shouldResume = finished.withLock { flag -> Bool in - if flag { return false } - flag = true - return true - } - guard shouldResume else { return } - connection.cancel() - cont.resume(returning: ok) - } - connection.stateUpdateHandler = { state in - switch state { - case .ready: - finish(true) - case .failed, .cancelled: - finish(false) - default: - break - } - } - connection.start(queue: queue) - queue.asyncAfter(deadline: .now() + timeoutSeconds) { - finish(false) - } - } + await TCPProbe.probe( + host: host, + port: port, + timeoutSeconds: timeoutSeconds, + queueLabel: "gateway.preflight") } - private func decodeSetupPayload(raw: String) -> SetupPayload? { - if let payload = decodeSetupPayloadFromJSON(raw) { - return payload - } - if let decoded = decodeBase64Payload(raw), - let payload = decodeSetupPayloadFromJSON(decoded) - { - return payload - } - return nil - } - - private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? { - guard let data = json.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(SetupPayload.self, from: data) - } - - private func decodeBase64Payload(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let normalized = trimmed - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padding = normalized.count % 4 - let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding) - guard let data = Data(base64Encoded: padded) else { return nil } - return String(data: data, encoding: .utf8) - } + // (GatewaySetupCode) decode raw setup codes. private func connectManual() async { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) @@ -852,44 +790,6 @@ struct SettingsTab: View { return nil } - private static func primaryIPv4Address() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - var fallback: String? - var en0: String? - - for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { - let flags = Int32(ptr.pointee.ifa_flags) - let isUp = (flags & IFF_UP) != 0 - let isLoopback = (flags & IFF_LOOPBACK) != 0 - let name = String(cString: ptr.pointee.ifa_name) - let family = ptr.pointee.ifa_addr.pointee.sa_family - if !isUp || isLoopback || family != UInt8(AF_INET) { continue } - - var addr = ptr.pointee.ifa_addr.pointee - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - &addr, - socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard result == 0 else { continue } - let len = buffer.prefix { $0 != 0 } - let bytes = len.map { UInt8(bitPattern: $0) } - guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } - - if name == "en0" { en0 = ip; break } - if fallback == nil { fallback = ip } - } - - return en0 ?? fallback - } - private static func hasTailnetIPv4() -> Bool { var addrList: UnsafeMutablePointer? guard getifaddrs(&addrList) == 0, let first = addrList else { return false } diff --git a/apps/ios/Sources/Status/StatusActivityBuilder.swift b/apps/ios/Sources/Status/StatusActivityBuilder.swift new file mode 100644 index 00000000000..a335e2f4643 --- /dev/null +++ b/apps/ios/Sources/Status/StatusActivityBuilder.swift @@ -0,0 +1,70 @@ +import SwiftUI + +enum StatusActivityBuilder { + static func build( + appModel: NodeAppModel, + voiceWakeEnabled: Bool, + cameraHUDText: String?, + cameraHUDKind: NodeAppModel.CameraHUDKind? + ) -> StatusPill.Activity? { + // Keep the top pill consistent across tabs (camera + voice wake + pairing states). + if appModel.isBackgrounded { + return StatusPill.Activity( + title: "Foreground required", + systemImage: "exclamationmark.triangle.fill", + tint: .orange) + } + + let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayLower = gatewayStatus.lowercased() + if gatewayLower.contains("repair") { + return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) + } + if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { + return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") + } + // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. + + if appModel.screenRecordActive { + return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) + } + + if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { + let systemImage: String + let tint: Color? + switch cameraHUDKind { + case .photo: + systemImage = "camera.fill" + tint = nil + case .recording: + systemImage = "video.fill" + tint = .red + case .success: + systemImage = "checkmark.circle.fill" + tint = .green + case .error: + systemImage = "exclamationmark.triangle.fill" + tint = .red + } + return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) + } + + if voiceWakeEnabled { + let voiceStatus = appModel.voiceWake.statusText + if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { + return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) + } + if voiceStatus == "Paused" { + // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. + if appModel.talkMode.isEnabled { + return nil + } + let suffix = appModel.isBackgrounded ? " (background)" : "" + return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") + } + } + + return nil + } +} + diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 257686822d5..e738e064fcd 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.15 + 2026.2.16 CFBundleVersion - 20260215 + 20260216 diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 60cbce1608f..4231172b777 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -81,8 +81,8 @@ targets: properties: CFBundleDisplayName: OpenClaw CFBundleIconName: AppIcon - CFBundleShortVersionString: "2026.2.15" - CFBundleVersion: "20260215" + CFBundleShortVersionString: "2026.2.16" + CFBundleVersion: "20260216" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -130,5 +130,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.2.15" - CFBundleVersion: "20260215" + CFBundleShortVersionString: "2026.2.16" + CFBundleVersion: "20260216" diff --git a/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift b/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift index d3226839f80..3cb8f54e396 100644 --- a/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift @@ -1,6 +1,5 @@ import Foundation import OpenClawKit -import OpenClawProtocol // Prefer the OpenClawKit wrapper to keep gateway request payloads consistent. typealias AnyCodable = OpenClawKit.AnyCodable @@ -42,40 +41,3 @@ extension AnyCodable { } } } - -extension OpenClawProtocol.AnyCodable { - var stringValue: String? { - self.value as? String - } - - var boolValue: Bool? { - self.value as? Bool - } - - var intValue: Int? { - self.value as? Int - } - - var doubleValue: Double? { - self.value as? Double - } - - var dictionaryValue: [String: OpenClawProtocol.AnyCodable]? { - self.value as? [String: OpenClawProtocol.AnyCodable] - } - - var arrayValue: [OpenClawProtocol.AnyCodable]? { - self.value as? [OpenClawProtocol.AnyCodable] - } - - var foundationValue: Any { - switch self.value { - case let dict as [String: OpenClawProtocol.AnyCodable]: - dict.mapValues { $0.foundationValue } - case let array as [OpenClawProtocol.AnyCodable]: - array.map(\.foundationValue) - default: - self.value - } - } -} diff --git a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift index cfc8c2cde51..24717ec5536 100644 --- a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift +++ b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift @@ -106,14 +106,16 @@ actor CameraCaptureService { } withExtendedLifetime(delegate) {} - let maxPayloadBytes = 5 * 1024 * 1024 - // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). - let maxEncodedBytes = (maxPayloadBytes / 4) * 3 - let res = try JPEGTranscoder.transcodeToJPEG( - imageData: rawData, - maxWidthPx: maxWidth, - quality: quality, - maxBytes: maxEncodedBytes) + let res: (data: Data, widthPx: Int, heightPx: Int) + do { + res = try PhotoCapture.transcodeJPEGForGateway( + rawData: rawData, + maxWidthPx: maxWidth, + quality: quality) + } catch { + throw CameraError.captureFailed(error.localizedDescription) + } + return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx)) } @@ -355,8 +357,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat func photoOutput( _ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, - error: Error?) - { + error: Error? + ) { guard !self.didResume, let cont else { return } self.didResume = true self.cont = nil @@ -378,8 +380,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat func photoOutput( _ output: AVCapturePhotoOutput, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, - error: Error?) - { + error: Error? + ) { guard let error else { return } guard !self.didResume, let cont else { return } self.didResume = true diff --git a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift index 3cf800fd108..3ed0d67ffbc 100644 --- a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift +++ b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift @@ -1,17 +1,13 @@ -import CoreServices import Foundation final class CanvasFileWatcher: @unchecked Sendable { - private let url: URL - private let queue: DispatchQueue - private var stream: FSEventStreamRef? - private var pending = false - private let onChange: () -> Void + private let watcher: CoalescingFSEventsWatcher init(url: URL, onChange: @escaping () -> Void) { - self.url = url - self.queue = DispatchQueue(label: "ai.openclaw.canvaswatcher") - self.onChange = onChange + self.watcher = CoalescingFSEventsWatcher( + paths: [url.path], + queueLabel: "ai.openclaw.canvaswatcher", + onChange: onChange) } deinit { @@ -19,76 +15,10 @@ final class CanvasFileWatcher: @unchecked Sendable { } func start() { - guard self.stream == nil else { return } - - let retainedSelf = Unmanaged.passRetained(self) - var context = FSEventStreamContext( - version: 0, - info: retainedSelf.toOpaque(), - retain: nil, - release: { pointer in - guard let pointer else { return } - Unmanaged.fromOpaque(pointer).release() - }, - copyDescription: nil) - - let paths = [self.url.path] as CFArray - let flags = FSEventStreamCreateFlags( - kFSEventStreamCreateFlagFileEvents | - kFSEventStreamCreateFlagUseCFTypes | - kFSEventStreamCreateFlagNoDefer) - - guard let stream = FSEventStreamCreate( - kCFAllocatorDefault, - Self.callback, - &context, - paths, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - 0.05, - flags) - else { - retainedSelf.release() - return - } - - self.stream = stream - FSEventStreamSetDispatchQueue(stream, self.queue) - if FSEventStreamStart(stream) == false { - self.stream = nil - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } + self.watcher.start() } func stop() { - guard let stream = self.stream else { return } - self.stream = nil - FSEventStreamStop(stream) - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } -} - -extension CanvasFileWatcher { - private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in - guard let info else { return } - let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() - watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags) - } - - private func handleEvents(numEvents: Int, eventFlags: UnsafePointer?) { - guard numEvents > 0 else { return } - guard eventFlags != nil else { return } - - // Coalesce rapid changes (common during builds/atomic saves). - if self.pending { return } - self.pending = true - self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in - guard let self else { return } - self.pending = false - self.onChange() - } + self.watcher.stop() } } diff --git a/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift b/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift new file mode 100644 index 00000000000..7999123dbe2 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift @@ -0,0 +1,111 @@ +import CoreServices +import Foundation + +final class CoalescingFSEventsWatcher: @unchecked Sendable { + private let queue: DispatchQueue + private var stream: FSEventStreamRef? + private var pending = false + + private let paths: [String] + private let shouldNotify: (Int, UnsafeMutableRawPointer?) -> Bool + private let onChange: () -> Void + private let coalesceDelay: TimeInterval + + init( + paths: [String], + queueLabel: String, + coalesceDelay: TimeInterval = 0.12, + shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true }, + onChange: @escaping () -> Void + ) { + self.paths = paths + self.queue = DispatchQueue(label: queueLabel) + self.coalesceDelay = coalesceDelay + self.shouldNotify = shouldNotify + self.onChange = onChange + } + + deinit { + self.stop() + } + + func start() { + guard self.stream == nil else { return } + + let retainedSelf = Unmanaged.passRetained(self) + var context = FSEventStreamContext( + version: 0, + info: retainedSelf.toOpaque(), + retain: nil, + release: { pointer in + guard let pointer else { return } + Unmanaged.fromOpaque(pointer).release() + }, + copyDescription: nil) + + let paths = self.paths as CFArray + let flags = FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents | + kFSEventStreamCreateFlagUseCFTypes | + kFSEventStreamCreateFlagNoDefer) + + guard let stream = FSEventStreamCreate( + kCFAllocatorDefault, + Self.callback, + &context, + paths, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.05, + flags) + else { + retainedSelf.release() + return + } + + self.stream = stream + FSEventStreamSetDispatchQueue(stream, self.queue) + if FSEventStreamStart(stream) == false { + self.stream = nil + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + } + + func stop() { + guard let stream = self.stream else { return } + self.stream = nil + FSEventStreamStop(stream) + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } +} + +extension CoalescingFSEventsWatcher { + private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in + guard let info else { return } + let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() + watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags) + } + + private func handleEvents( + numEvents: Int, + eventPaths: UnsafeMutableRawPointer?, + eventFlags: UnsafePointer? + ) { + guard numEvents > 0 else { return } + guard eventFlags != nil else { return } + guard self.shouldNotify(numEvents, eventPaths) else { return } + + // Coalesce rapid changes (common during builds/atomic saves). + if self.pending { return } + self.pending = true + self.queue.asyncAfter(deadline: .now() + self.coalesceDelay) { [weak self] in + guard let self else { return } + self.pending = false + self.onChange() + } + } +} + diff --git a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift index 23689f1fb9d..4434443497e 100644 --- a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift +++ b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift @@ -1,23 +1,34 @@ -import CoreServices import Foundation final class ConfigFileWatcher: @unchecked Sendable { private let url: URL - private let queue: DispatchQueue - private var stream: FSEventStreamRef? - private var pending = false - private let onChange: () -> Void private let watchedDir: URL private let targetPath: String private let targetName: String + private let watcher: CoalescingFSEventsWatcher init(url: URL, onChange: @escaping () -> Void) { self.url = url - self.queue = DispatchQueue(label: "ai.openclaw.configwatcher") - self.onChange = onChange self.watchedDir = url.deletingLastPathComponent() self.targetPath = url.path self.targetName = url.lastPathComponent + let watchedDirPath = self.watchedDir.path + let targetPath = self.targetPath + let targetName = self.targetName + self.watcher = CoalescingFSEventsWatcher( + paths: [watchedDirPath], + queueLabel: "ai.openclaw.configwatcher", + shouldNotify: { _, eventPaths in + guard let eventPaths else { return true } + let paths = unsafeBitCast(eventPaths, to: NSArray.self) + for case let path as String in paths { + if path == targetPath { return true } + if path.hasSuffix("/\(targetName)") { return true } + if path == watchedDirPath { return true } + } + return false + }, + onChange: onChange) } deinit { @@ -25,94 +36,10 @@ final class ConfigFileWatcher: @unchecked Sendable { } func start() { - guard self.stream == nil else { return } - - let retainedSelf = Unmanaged.passRetained(self) - var context = FSEventStreamContext( - version: 0, - info: retainedSelf.toOpaque(), - retain: nil, - release: { pointer in - guard let pointer else { return } - Unmanaged.fromOpaque(pointer).release() - }, - copyDescription: nil) - - let paths = [self.watchedDir.path] as CFArray - let flags = FSEventStreamCreateFlags( - kFSEventStreamCreateFlagFileEvents | - kFSEventStreamCreateFlagUseCFTypes | - kFSEventStreamCreateFlagNoDefer) - - guard let stream = FSEventStreamCreate( - kCFAllocatorDefault, - Self.callback, - &context, - paths, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - 0.05, - flags) - else { - retainedSelf.release() - return - } - - self.stream = stream - FSEventStreamSetDispatchQueue(stream, self.queue) - if FSEventStreamStart(stream) == false { - self.stream = nil - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } + self.watcher.start() } func stop() { - guard let stream = self.stream else { return } - self.stream = nil - FSEventStreamStop(stream) - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } -} - -extension ConfigFileWatcher { - private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in - guard let info else { return } - let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() - watcher.handleEvents( - numEvents: numEvents, - eventPaths: eventPaths, - eventFlags: eventFlags) - } - - private func handleEvents( - numEvents: Int, - eventPaths: UnsafeMutableRawPointer?, - eventFlags: UnsafePointer?) - { - guard numEvents > 0 else { return } - guard eventFlags != nil else { return } - guard self.matchesTarget(eventPaths: eventPaths) else { return } - - if self.pending { return } - self.pending = true - self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in - guard let self else { return } - self.pending = false - self.onChange() - } - } - - private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool { - guard let eventPaths else { return true } - let paths = unsafeBitCast(eventPaths, to: NSArray.self) - for case let path as String in paths { - if path == self.targetPath { return true } - if path.hasSuffix("/\(self.targetName)") { return true } - if path == self.watchedDir.path { return true } - } - return false + self.watcher.stop() } } diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index 43f0fa037d0..cbfbc061d6a 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -21,6 +21,7 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable { enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable { case none case announce + case webhook var id: String { self.rawValue diff --git a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift index 195ab66daf9..f85e8d1a5df 100644 --- a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift @@ -22,16 +22,6 @@ final class DevicePairingApprovalPrompter { private var alertHostWindow: NSWindow? private var resolvedByRequestId: Set = [] - private final class AlertHostWindow: NSWindow { - override var canBecomeKey: Bool { - true - } - - override var canBecomeMain: Bool { - true - } - } - private struct PairingList: Codable { let pending: [PendingRequest] let paired: [PairedDevice]? @@ -238,35 +228,11 @@ final class DevicePairingApprovalPrompter { } private func endActiveAlert() { - guard let alert = self.activeAlert else { return } - if let parent = alert.window.sheetParent { - parent.endSheet(alert.window, returnCode: .abort) - } - self.activeAlert = nil - self.activeRequestId = nil + PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId) } private func requireAlertHostWindow() -> NSWindow { - if let alertHostWindow { - return alertHostWindow - } - - let window = AlertHostWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), - styleMask: [.borderless], - backing: .buffered, - defer: false) - window.title = "" - window.isReleasedWhenClosed = false - window.level = .floating - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.isOpaque = false - window.hasShadow = false - window.backgroundColor = .clear - window.ignoresMouseEvents = true - - self.alertHostWindow = window - return window + PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow) } private func handle(push: GatewayPush) { diff --git a/apps/macos/Sources/OpenClaw/InstancesStore.swift b/apps/macos/Sources/OpenClaw/InstancesStore.swift index 929f12c1699..566340337db 100644 --- a/apps/macos/Sources/OpenClaw/InstancesStore.swift +++ b/apps/macos/Sources/OpenClaw/InstancesStore.swift @@ -158,7 +158,7 @@ final class InstancesStore { private func localFallbackInstance(reason: String) -> InstanceInfo { let host = Host.current().localizedName ?? "this-mac" - let ip = Self.primaryIPv4Address() + let ip = SystemPresenceInfo.primaryIPv4Address() let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String let osVersion = ProcessInfo.processInfo.operatingSystemVersion let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" @@ -172,58 +172,13 @@ final class InstancesStore { platform: platform, deviceFamily: "Mac", modelIdentifier: InstanceIdentity.modelIdentifier, - lastInputSeconds: Self.lastInputSeconds(), + lastInputSeconds: SystemPresenceInfo.lastInputSeconds(), mode: "local", reason: reason, text: text, ts: ts) } - private static func lastInputSeconds() -> Int? { - let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null - let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) - if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } - return Int(seconds.rounded()) - } - - private static func primaryIPv4Address() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - var fallback: String? - var en0: String? - - for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { - let flags = Int32(ptr.pointee.ifa_flags) - let isUp = (flags & IFF_UP) != 0 - let isLoopback = (flags & IFF_LOOPBACK) != 0 - let name = String(cString: ptr.pointee.ifa_name) - let family = ptr.pointee.ifa_addr.pointee.sa_family - if !isUp || isLoopback || family != UInt8(AF_INET) { continue } - - var addr = ptr.pointee.ifa_addr.pointee - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - &addr, - socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard result == 0 else { continue } - let len = buffer.prefix { $0 != 0 } - let bytes = len.map { UInt8(bitPattern: $0) } - guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } - - if name == "en0" { en0 = ip; break } - if fallback == nil { fallback = ip } - } - - return en0 ?? fallback - } - // MARK: - Helpers /// Keep the last raw payload for logging. diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift index 964b340e6b5..ee994b38f65 100644 --- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift @@ -38,16 +38,6 @@ final class NodePairingApprovalPrompter { private var remoteResolutionsByRequestId: [String: PairingResolution] = [:] private var autoApproveAttempts: Set = [] - private final class AlertHostWindow: NSWindow { - override var canBecomeKey: Bool { - true - } - - override var canBecomeMain: Bool { - true - } - } - private struct PairingList: Codable { let pending: [PendingRequest] let paired: [PairedNode]? @@ -242,35 +232,11 @@ final class NodePairingApprovalPrompter { } private func endActiveAlert() { - guard let alert = self.activeAlert else { return } - if let parent = alert.window.sheetParent { - parent.endSheet(alert.window, returnCode: .abort) - } - self.activeAlert = nil - self.activeRequestId = nil + PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId) } private func requireAlertHostWindow() -> NSWindow { - if let alertHostWindow { - return alertHostWindow - } - - let window = AlertHostWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), - styleMask: [.borderless], - backing: .buffered, - defer: false) - window.title = "" - window.isReleasedWhenClosed = false - window.level = .floating - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.isOpaque = false - window.hasShadow = false - window.backgroundColor = .clear - window.ignoresMouseEvents = true - - self.alertHostWindow = window - return window + PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow) } private func handle(push: GatewayPush) { diff --git a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift new file mode 100644 index 00000000000..e8e4428bf3f --- /dev/null +++ b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift @@ -0,0 +1,46 @@ +import AppKit + +final class PairingAlertHostWindow: NSWindow { + override var canBecomeKey: Bool { + true + } + + override var canBecomeMain: Bool { + true + } +} + +@MainActor +enum PairingAlertSupport { + static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) { + guard let alert = activeAlert else { return } + if let parent = alert.window.sheetParent { + parent.endSheet(alert.window, returnCode: .abort) + } + activeAlert = nil + activeRequestId = nil + } + + static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow { + if let alertHostWindow { + return alertHostWindow + } + + let window = PairingAlertHostWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), + styleMask: [.borderless], + backing: .buffered, + defer: false) + window.title = "" + window.isReleasedWhenClosed = false + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.isOpaque = false + window.hasShadow = false + window.backgroundColor = .clear + window.ignoresMouseEvents = true + + alertHostWindow = window + return window + } +} diff --git a/apps/macos/Sources/OpenClaw/PresenceReporter.swift b/apps/macos/Sources/OpenClaw/PresenceReporter.swift index 16d70b8a92c..2e7a1d4c472 100644 --- a/apps/macos/Sources/OpenClaw/PresenceReporter.swift +++ b/apps/macos/Sources/OpenClaw/PresenceReporter.swift @@ -1,5 +1,4 @@ import Cocoa -import Darwin import Foundation import OSLog @@ -33,10 +32,10 @@ final class PresenceReporter { private func push(reason: String) async { let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue } let host = InstanceIdentity.displayName - let ip = Self.primaryIPv4Address() ?? "ip-unknown" + let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown" let version = Self.appVersionString() let platform = Self.platformString() - let lastInput = Self.lastInputSeconds() + let lastInput = SystemPresenceInfo.lastInputSeconds() let text = Self.composePresenceSummary(mode: mode, reason: reason) var params: [String: AnyHashable] = [ "instanceId": AnyHashable(self.instanceId), @@ -64,9 +63,9 @@ final class PresenceReporter { private static func composePresenceSummary(mode: String, reason: String) -> String { let host = InstanceIdentity.displayName - let ip = Self.primaryIPv4Address() ?? "ip-unknown" + let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown" let version = Self.appVersionString() - let lastInput = Self.lastInputSeconds() + let lastInput = SystemPresenceInfo.lastInputSeconds() let lastLabel = lastInput.map { "last input \($0)s ago" } ?? "last input unknown" return "Node: \(host) (\(ip)) Β· app \(version) Β· \(lastLabel) Β· mode \(mode) Β· reason \(reason)" } @@ -87,50 +86,7 @@ final class PresenceReporter { return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" } - private static func lastInputSeconds() -> Int? { - let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null - let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) - if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } - return Int(seconds.rounded()) - } - - private static func primaryIPv4Address() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - var fallback: String? - var en0: String? - - for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { - let flags = Int32(ptr.pointee.ifa_flags) - let isUp = (flags & IFF_UP) != 0 - let isLoopback = (flags & IFF_LOOPBACK) != 0 - let name = String(cString: ptr.pointee.ifa_name) - let family = ptr.pointee.ifa_addr.pointee.sa_family - if !isUp || isLoopback || family != UInt8(AF_INET) { continue } - - var addr = ptr.pointee.ifa_addr.pointee - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - &addr, - socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard result == 0 else { continue } - let len = buffer.prefix { $0 != 0 } - let bytes = len.map { UInt8(bitPattern: $0) } - guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } - - if name == "en0" { en0 = ip; break } - if fallback == nil { fallback = ip } - } - - return en0 ?? fallback - } + // (SystemPresenceInfo) last input + primary IPv4. } #if DEBUG @@ -148,11 +104,11 @@ extension PresenceReporter { } static func _testLastInputSeconds() -> Int? { - self.lastInputSeconds() + SystemPresenceInfo.lastInputSeconds() } static func _testPrimaryIPv4Address() -> String? { - self.primaryIPv4Address() + SystemPresenceInfo.primaryIPv4Address() } } #endif diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index c57ed6ac808..37c85b6f3dd 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.2.15 + 2026.2.16 CFBundleVersion - 202602150 + 202602160 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift b/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift new file mode 100644 index 00000000000..843ed371fb5 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift @@ -0,0 +1,16 @@ +import CoreGraphics +import Foundation +import OpenClawKit + +enum SystemPresenceInfo { + static func lastInputSeconds() -> Int? { + let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null + let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) + if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } + return Int(seconds.rounded()) + } + + static func primaryIPv4Address() -> String? { + NetworkInterfaces.primaryIPv4Address() + } +} diff --git a/apps/macos/Sources/OpenClaw/TailscaleService.swift b/apps/macos/Sources/OpenClaw/TailscaleService.swift index b7f716a4270..2cefa69d59d 100644 --- a/apps/macos/Sources/OpenClaw/TailscaleService.swift +++ b/apps/macos/Sources/OpenClaw/TailscaleService.swift @@ -1,10 +1,8 @@ import AppKit import Foundation import Observation +import OpenClawDiscovery import os -#if canImport(Darwin) -import Darwin -#endif /// Manages Tailscale integration and status checking. @Observable @@ -140,7 +138,7 @@ final class TailscaleService { self.logger.info("Tailscale API not responding; app likely not running") } - if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() { + if self.tailscaleIP == nil, let fallback = TailscaleNetwork.detectTailnetIPv4() { self.tailscaleIP = fallback if !self.isRunning { self.isRunning = true @@ -178,49 +176,7 @@ final class TailscaleService { } } - private nonisolated static func isTailnetIPv4(_ address: String) -> Bool { - let parts = address.split(separator: ".") - guard parts.count == 4 else { return false } - let octets = parts.compactMap { Int($0) } - guard octets.count == 4 else { return false } - let a = octets[0] - let b = octets[1] - return a == 100 && b >= 64 && b <= 127 - } - - private nonisolated static func detectTailnetIPv4() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { - let flags = Int32(ptr.pointee.ifa_flags) - let isUp = (flags & IFF_UP) != 0 - let isLoopback = (flags & IFF_LOOPBACK) != 0 - let family = ptr.pointee.ifa_addr.pointee.sa_family - if !isUp || isLoopback || family != UInt8(AF_INET) { continue } - - var addr = ptr.pointee.ifa_addr.pointee - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - &addr, - socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard result == 0 else { continue } - let len = buffer.prefix { $0 != 0 } - let bytes = len.map { UInt8(bitPattern: $0) } - guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } - if Self.isTailnetIPv4(ip) { return ip } - } - - return nil - } - nonisolated static func fallbackTailnetIPv4() -> String? { - self.detectTailnetIPv4() + TailscaleNetwork.detectTailnetIPv4() } } diff --git a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift index 3c59ea792f1..abd18efaa9a 100644 --- a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift @@ -329,43 +329,9 @@ public final class GatewayDiscoveryModel { } private func updateStatusText() { - let states = Array(self.statesByDomain.values) - if states.isEmpty { - self.statusText = self.browsers.isEmpty ? "Idle" : "Setup" - return - } - - if let failed = states.first(where: { state in - if case .failed = state { return true } - return false - }) { - if case let .failed(err) = failed { - self.statusText = "Failed: \(err)" - return - } - } - - if let waiting = states.first(where: { state in - if case .waiting = state { return true } - return false - }) { - if case let .waiting(err) = waiting { - self.statusText = "Waiting: \(err)" - return - } - } - - if states.contains(where: { if case .ready = $0 { true } else { false } }) { - self.statusText = "Searching…" - return - } - - if states.contains(where: { if case .setup = $0 { true } else { false } }) { - self.statusText = "Setup" - return - } - - self.statusText = "Searching…" + self.statusText = GatewayDiscoveryStatusText.make( + states: Array(self.statesByDomain.values), + hasBrowsers: !self.browsers.isEmpty) } private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] { diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift new file mode 100644 index 00000000000..60b11306d05 --- /dev/null +++ b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift @@ -0,0 +1,47 @@ +import Darwin +import Foundation + +public enum TailscaleNetwork { + public static func isTailnetIPv4(_ address: String) -> Bool { + let parts = address.split(separator: ".") + guard parts.count == 4 else { return false } + let octets = parts.compactMap { Int($0) } + guard octets.count == 4 else { return false } + let a = octets[0] + let b = octets[1] + return a == 100 && b >= 64 && b <= 127 + } + + public static func detectTailnetIPv4() -> String? { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + if self.isTailnetIPv4(ip) { return ip } + } + + return nil + } +} + diff --git a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift index 2933e9242f1..0989164a01e 100644 --- a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift @@ -1,9 +1,7 @@ import Foundation +import OpenClawDiscovery import OpenClawKit import OpenClawProtocol -#if canImport(Darwin) -import Darwin -#endif struct ConnectOptions { var url: String? @@ -301,7 +299,7 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa private func resolveLocalHost(bind: String?) -> String { let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let tailnetIP = detectTailnetIPv4() + let tailnetIP = TailscaleNetwork.detectTailnetIPv4() switch normalized { case "tailnet": return tailnetIP ?? "127.0.0.1" @@ -309,45 +307,3 @@ private func resolveLocalHost(bind: String?) -> String { return "127.0.0.1" } } - -private func detectTailnetIPv4() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { - let flags = Int32(ptr.pointee.ifa_flags) - let isUp = (flags & IFF_UP) != 0 - let isLoopback = (flags & IFF_LOOPBACK) != 0 - let family = ptr.pointee.ifa_addr.pointee.sa_family - if !isUp || isLoopback || family != UInt8(AF_INET) { continue } - - var addr = ptr.pointee.ifa_addr.pointee - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - &addr, - socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard result == 0 else { continue } - let len = buffer.prefix { $0 != 0 } - let bytes = len.map { UInt8(bitPattern: $0) } - guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } - if isTailnetIPv4(ip) { return ip } - } - - return nil -} - -private func isTailnetIPv4(_ address: String) -> Bool { - let parts = address.split(separator: ".") - guard parts.count == 4 else { return false } - let octets = parts.compactMap { Int($0) } - guard octets.count == 4 else { return false } - let a = octets[0] - let b = octets[1] - return a == 100 && b >= 64 && b <= 127 -} diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 29a4059b334..8486e4c4551 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2094,7 +2094,7 @@ public struct CronJob: Codable, Sendable { public let sessiontarget: AnyCodable public let wakemode: AnyCodable public let payload: AnyCodable - public let delivery: [String: AnyCodable]? + public let delivery: AnyCodable? public let state: [String: AnyCodable] public init( @@ -2110,7 +2110,7 @@ public struct CronJob: Codable, Sendable { sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: [String: AnyCodable]?, + delivery: AnyCodable?, state: [String: AnyCodable] ) { self.id = id @@ -2172,7 +2172,7 @@ public struct CronAddParams: Codable, Sendable { public let sessiontarget: AnyCodable public let wakemode: AnyCodable public let payload: AnyCodable - public let delivery: [String: AnyCodable]? + public let delivery: AnyCodable? public init( name: String, @@ -2184,7 +2184,7 @@ public struct CronAddParams: Codable, Sendable { sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: [String: AnyCodable]? + delivery: AnyCodable? ) { self.name = name self.agentid = agentid diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 272fd81c11d..5328a5b692f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -103,18 +103,22 @@ public final class OpenClawChatViewModel { let now = Date().timeIntervalSince1970 * 1000 let cutoff = now - (24 * 60 * 60 * 1000) let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } - var seen = Set() - var recent: [OpenClawChatSessionEntry] = [] - for entry in sorted { - guard !seen.contains(entry.key) else { continue } - seen.insert(entry.key) - guard (entry.updatedAt ?? 0) >= cutoff else { continue } - recent.append(entry) - } var result: [OpenClawChatSessionEntry] = [] var included = Set() - for entry in recent where !included.contains(entry.key) { + + // Always show the main session first, even if it hasn't been updated recently. + if let main = sorted.first(where: { $0.key == "main" }) { + result.append(main) + included.insert(main.key) + } else { + result.append(self.placeholderSession(key: "main")) + included.insert("main") + } + + for entry in sorted { + guard !included.contains(entry.key) else { continue } + guard (entry.updatedAt ?? 0) >= cutoff else { continue } result.append(entry) included.insert(entry.key) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift index ef522447f43..02b53e3c392 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift @@ -1,93 +1,4 @@ -import Foundation +import OpenClawProtocol -/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads. -/// -/// Marked `@unchecked Sendable` because it can hold reference types. -public struct AnyCodable: Codable, @unchecked Sendable, Hashable { - public let value: Any +public typealias AnyCodable = OpenClawProtocol.AnyCodable - public init(_ value: Any) { self.value = value } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let intVal = try? container.decode(Int.self) { self.value = intVal; return } - if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return } - if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return } - if let stringVal = try? container.decode(String.self) { self.value = stringVal; return } - if container.decodeNil() { self.value = NSNull(); return } - if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return } - if let array = try? container.decode([AnyCodable].self) { self.value = array; return } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self.value { - case let intVal as Int: try container.encode(intVal) - case let doubleVal as Double: try container.encode(doubleVal) - case let boolVal as Bool: try container.encode(boolVal) - case let stringVal as String: try container.encode(stringVal) - case is NSNull: try container.encodeNil() - case let dict as [String: AnyCodable]: try container.encode(dict) - case let array as [AnyCodable]: try container.encode(array) - case let dict as [String: Any]: - try container.encode(dict.mapValues { AnyCodable($0) }) - case let array as [Any]: - try container.encode(array.map { AnyCodable($0) }) - case let dict as NSDictionary: - var converted: [String: AnyCodable] = [:] - for (k, v) in dict { - guard let key = k as? String else { continue } - converted[key] = AnyCodable(v) - } - try container.encode(converted) - case let array as NSArray: - try container.encode(array.map { AnyCodable($0) }) - default: - let context = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type") - throw EncodingError.invalidValue(self.value, context) - } - } - - public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { - switch (lhs.value, rhs.value) { - case let (l as Int, r as Int): l == r - case let (l as Double, r as Double): l == r - case let (l as Bool, r as Bool): l == r - case let (l as String, r as String): l == r - case (_ as NSNull, _ as NSNull): true - case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r - case let (l as [AnyCodable], r as [AnyCodable]): l == r - default: - false - } - } - - public func hash(into hasher: inout Hasher) { - switch self.value { - case let v as Int: - hasher.combine(0); hasher.combine(v) - case let v as Double: - hasher.combine(1); hasher.combine(v) - case let v as Bool: - hasher.combine(2); hasher.combine(v) - case let v as String: - hasher.combine(3); hasher.combine(v) - case _ as NSNull: - hasher.combine(4) - case let v as [String: AnyCodable]: - hasher.combine(5) - for (k, val) in v.sorted(by: { $0.key < $1.key }) { - hasher.combine(k) - hasher.combine(val) - } - case let v as [AnyCodable]: - hasher.combine(6) - for item in v { - hasher.combine(item) - } - default: - hasher.combine(999) - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift new file mode 100644 index 00000000000..e15baf17fdb --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift @@ -0,0 +1,39 @@ +import Foundation +import Network + +public enum GatewayDiscoveryStatusText { + public static func make(states: [NWBrowser.State], hasBrowsers: Bool) -> String { + if states.isEmpty { + return hasBrowsers ? "Setup" : "Idle" + } + + if let failed = states.first(where: { state in + if case .failed = state { return true } + return false + }) { + if case let .failed(err) = failed { + return "Failed: \(err)" + } + } + + if let waiting = states.first(where: { state in + if case .waiting = state { return true } + return false + }) { + if case let .waiting(err) = waiting { + return "Waiting: \(err)" + } + } + + if states.contains(where: { if case .ready = $0 { true } else { false } }) { + return "Searching…" + } + + if states.contains(where: { if case .setup = $0 { true } else { false } }) { + return "Setup" + } + + return "Searching…" + } +} + diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift index 8672ab09f68..139aa7d2942 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift @@ -2,14 +2,6 @@ import OpenClawProtocol import Foundation public enum GatewayPayloadDecoding { - public static func decode( - _ payload: OpenClawProtocol.AnyCodable, - as _: T.Type = T.self) throws -> T - { - let data = try JSONEncoder().encode(payload) - return try JSONDecoder().decode(T.self, from: data) - } - public static func decode( _ payload: AnyCodable, as _: T.Type = T.self) throws -> T @@ -18,14 +10,6 @@ public enum GatewayPayloadDecoding { return try JSONDecoder().decode(T.self, from: data) } - public static func decodeIfPresent( - _ payload: OpenClawProtocol.AnyCodable?, - as _: T.Type = T.self) throws -> T? - { - guard let payload else { return nil } - return try self.decode(payload, as: T.self) - } - public static func decodeIfPresent( _ payload: AnyCodable?, as _: T.Type = T.self) throws -> T? diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift new file mode 100644 index 00000000000..3679ef54234 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift @@ -0,0 +1,43 @@ +import Darwin +import Foundation + +public enum NetworkInterfaces { + public static func primaryIPv4Address() -> String? { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + var fallback: String? + var en0: String? + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let name = String(cString: ptr.pointee.ifa_name) + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + + if name == "en0" { en0 = ip; break } + if fallback == nil { fallback = ip } + } + + return en0 ?? fallback + } +} + diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift index b19792ad7b8..5af33d1d35c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift @@ -52,18 +52,26 @@ public enum OpenClawKitResources { for candidate in candidates { guard let baseURL = candidate else { continue } - // Direct path - let directURL = baseURL.appendingPathComponent("\(bundleName).bundle") - if let bundle = Bundle(url: directURL) { - return bundle + // SwiftPM often places the resource bundle next to (or near) the test runner bundle, + // not inside it. Walk up a few levels and check common container paths. + var roots: [URL] = [] + roots.append(baseURL) + roots.append(baseURL.appendingPathComponent("Resources")) + roots.append(baseURL.appendingPathComponent("Contents/Resources")) + + var current = baseURL + for _ in 0 ..< 5 { + current = current.deletingLastPathComponent() + roots.append(current) + roots.append(current.appendingPathComponent("Resources")) + roots.append(current.appendingPathComponent("Contents/Resources")) } - // Inside Resources/ - let resourcesURL = baseURL - .appendingPathComponent("Resources") - .appendingPathComponent("\(bundleName).bundle") - if let bundle = Bundle(url: resourcesURL) { - return bundle + for root in roots { + let bundleURL = root.appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: bundleURL) { + return bundle + } } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift new file mode 100644 index 00000000000..b5f00d34751 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift @@ -0,0 +1,19 @@ +import Foundation + +public enum PhotoCapture { + public static func transcodeJPEGForGateway( + rawData: Data, + maxWidthPx: Int, + quality: Double, + maxPayloadBytes: Int = 5 * 1024 * 1024 + ) throws -> (data: Data, widthPx: Int, heightPx: Int) { + // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit). + let maxEncodedBytes = (maxPayloadBytes / 4) * 3 + return try JPEGTranscoder.transcodeToJPEG( + imageData: rawData, + maxWidthPx: maxWidthPx, + quality: quality, + maxBytes: maxEncodedBytes) + } +} + diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift index ad0c3387296..252e6131e4c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift @@ -1,8 +1,9 @@ import Foundation /// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads. +/// /// Marked `@unchecked Sendable` because it can hold reference types. -public struct AnyCodable: Codable, @unchecked Sendable { +public struct AnyCodable: Codable, @unchecked Sendable, Hashable { public let value: Any public init(_ value: Any) { self.value = value } @@ -16,9 +17,7 @@ public struct AnyCodable: Codable, @unchecked Sendable { if container.decodeNil() { self.value = NSNull(); return } if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return } if let array = try? container.decode([AnyCodable].self) { self.value = array; return } - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Unsupported type") + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") } public func encode(to encoder: Encoder) throws { @@ -51,4 +50,46 @@ public struct AnyCodable: Codable, @unchecked Sendable { throw EncodingError.invalidValue(self.value, context) } } + + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + switch (lhs.value, rhs.value) { + case let (l as Int, r as Int): l == r + case let (l as Double, r as Double): l == r + case let (l as Bool, r as Bool): l == r + case let (l as String, r as String): l == r + case (_ as NSNull, _ as NSNull): true + case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r + case let (l as [AnyCodable], r as [AnyCodable]): l == r + default: + false + } + } + + public func hash(into hasher: inout Hasher) { + switch self.value { + case let v as Int: + hasher.combine(0); hasher.combine(v) + case let v as Double: + hasher.combine(1); hasher.combine(v) + case let v as Bool: + hasher.combine(2); hasher.combine(v) + case let v as String: + hasher.combine(3); hasher.combine(v) + case _ as NSNull: + hasher.combine(4) + case let v as [String: AnyCodable]: + hasher.combine(5) + for (k, val) in v.sorted(by: { $0.key < $1.key }) { + hasher.combine(k) + hasher.combine(val) + } + case let v as [AnyCodable]: + hasher.combine(6) + for item in v { + hasher.combine(item) + } + default: + hasher.combine(999) + } + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 29a4059b334..8486e4c4551 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2094,7 +2094,7 @@ public struct CronJob: Codable, Sendable { public let sessiontarget: AnyCodable public let wakemode: AnyCodable public let payload: AnyCodable - public let delivery: [String: AnyCodable]? + public let delivery: AnyCodable? public let state: [String: AnyCodable] public init( @@ -2110,7 +2110,7 @@ public struct CronJob: Codable, Sendable { sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: [String: AnyCodable]?, + delivery: AnyCodable?, state: [String: AnyCodable] ) { self.id = id @@ -2172,7 +2172,7 @@ public struct CronAddParams: Codable, Sendable { public let sessiontarget: AnyCodable public let wakemode: AnyCodable public let payload: AnyCodable - public let delivery: [String: AnyCodable]? + public let delivery: AnyCodable? public init( name: String, @@ -2184,7 +2184,7 @@ public struct CronAddParams: Codable, Sendable { sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: [String: AnyCodable]? + delivery: AnyCodable? ) { self.name = name self.agentid = agentid diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index b1e5ef9a10c..4ba650aaf78 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -27,6 +27,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - **Main session**: enqueue a system event, then run on the next heartbeat. - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none). - Wakeups are first-class: a job can request β€œwake now” vs β€œnext heartbeat”. +- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`. +- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. ## Quick start (actionable) @@ -99,7 +101,7 @@ A cron job is a stored record with: - a **schedule** (when it should run), - a **payload** (what it should do), -- optional **delivery mode** (announce or none). +- optional **delivery mode** (`announce`, `webhook`, or `none`). - optional **agent binding** (`agentId`): run the job under a specific agent; if missing or unknown, the gateway falls back to the default agent. @@ -140,8 +142,9 @@ Key behaviors: - Prompt is prefixed with `[cron: ]` for traceability. - Each run starts a **fresh session id** (no prior conversation carry-over). - Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`). -- `delivery.mode` (isolated-only) chooses what happens: +- `delivery.mode` chooses what happens: - `announce`: deliver a summary to the target channel and post a brief summary to the main session. + - `webhook`: POST the finished event payload to `delivery.to`. - `none`: internal only (no delivery, no main-session summary). - `wakeMode` controls when the main-session summary posts: - `now`: immediate heartbeat. @@ -163,11 +166,11 @@ Common `agentTurn` fields: - `model` / `thinking`: optional overrides (see below). - `timeoutSeconds`: optional timeout override. -Delivery config (isolated jobs only): +Delivery config: -- `delivery.mode`: `none` | `announce`. +- `delivery.mode`: `none` | `announce` | `webhook`. - `delivery.channel`: `last` or a specific channel. -- `delivery.to`: channel-specific target (phone/chat/channel id). +- `delivery.to`: channel-specific target (announce) or webhook URL (webhook mode). - `delivery.bestEffort`: avoid failing the job if announce delivery fails. Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to` @@ -192,6 +195,18 @@ Behavior details: - The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and `next-heartbeat` waits for the next scheduled heartbeat. +#### Webhook delivery flow + +When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to`. + +Behavior details: + +- The endpoint must be a valid HTTP(S) URL. +- No channel delivery is attempted in webhook mode. +- No main-session summary is posted in webhook mode. +- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. +- Deprecated fallback: stored legacy jobs with `notify: true` still post to `cron.webhook` (if configured), with a warning so you can migrate to `delivery.mode = "webhook"`. + ### Model and thinking overrides Isolated jobs (`agentTurn`) can override the model and thinking level: @@ -213,11 +228,12 @@ Resolution priority: Isolated jobs can deliver output to a channel via the top-level `delivery` config: -- `delivery.mode`: `announce` (deliver a summary) or `none`. +- `delivery.mode`: `announce` (channel delivery), `webhook` (HTTP POST), or `none`. - `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`. - `delivery.to`: channel-specific recipient target. -Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`). +`announce` delivery is only valid for isolated jobs (`sessionTarget: "isolated"`). +`webhook` delivery is valid for both main and isolated jobs. If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s β€œlast route” (the last place the agent replied). @@ -333,10 +349,21 @@ Notes: enabled: true, // default true store: "~/.openclaw/cron/jobs.json", maxConcurrentRuns: 1, // default 1 + webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs + webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode }, } ``` +Webhook behavior: + +- Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job. +- Webhook URLs must be valid `http://` or `https://` URLs. +- Payload is the cron finished event JSON. +- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. +- If `cron.webhookToken` is not set, no `Authorization` header is sent. +- Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present. + Disable cron entirely: - `cron.enabled: false` (config) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 37023da6407..e2328514540 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -87,6 +87,77 @@ Token resolution is account-aware. Config token values win over env fallback. `D - Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`). - Native slash commands run in isolated command sessions (`agent::discord:slash:`), while still carrying `CommandTargetSessionKey` to the routed conversation session. +## Interactive components + +OpenClaw supports Discord components v2 containers for agent messages. Use the message tool with a `components` payload. Interaction results are routed back to the agent as normal inbound messages and follow the existing Discord `replyToMode` settings. + +Supported blocks: + +- `text`, `section`, `separator`, `actions`, `media-gallery`, `file` +- Action rows allow up to 5 buttons or a single select menu +- Select types: `string`, `user`, `role`, `mentionable`, `channel` + +File attachments: + +- `file` blocks must point to an attachment reference (`attachment://`) +- Provide the attachment via `media`/`path`/`filePath` (single file); use `media-gallery` for multiple files +- Use `filename` to override the upload name when it should match the attachment reference + +Modal forms: + +- Add `components.modal` with up to 5 fields +- Field types: `text`, `checkbox`, `radio`, `select`, `role-select`, `user-select` +- OpenClaw adds a trigger button automatically + +Example: + +```json5 +{ + channel: "discord", + action: "send", + to: "channel:123456789012345678", + message: "Optional fallback text", + components: { + text: "Choose a path", + blocks: [ + { + type: "actions", + buttons: [ + { label: "Approve", style: "success" }, + { label: "Decline", style: "danger" }, + ], + }, + { + type: "actions", + select: { + type: "string", + placeholder: "Pick an option", + options: [ + { label: "Option A", value: "a" }, + { label: "Option B", value: "b" }, + ], + }, + }, + ], + modal: { + title: "Details", + triggerLabel: "Open form", + fields: [ + { type: "text", label: "Requester" }, + { + type: "select", + label: "Priority", + options: [ + { label: "Low", value: "low" }, + { label: "High", value: "high" }, + ], + }, + ], + }, + }, +} +``` + ## Access control and routing @@ -313,6 +384,23 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior. + + `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. + + Resolution order: + + - `channels.discord.accounts..ackReaction` + - `channels.discord.ackReaction` + - `messages.ackReaction` + - agent identity emoji fallback (`agents.list[].identity.emoji`, else "πŸ‘€") + + Notes: + + - Discord accepts unicode emoji or custom emoji names. + - Use `""` to disable the reaction for a channel or account. + + + Channel-initiated config writes are enabled by default. @@ -482,6 +570,30 @@ Default gate behavior: | moderation | disabled | | presence | disabled | +## Components v2 UI + +OpenClaw uses Discord components v2 for exec approvals and cross-context markers. Discord message actions can also accept `components` for custom UI (advanced; requires Carbon component instances), while legacy `embeds` remain available but are not recommended. + +- `channels.discord.ui.components.accentColor` sets the accent color used by Discord component containers (hex). +- Set per account with `channels.discord.accounts..ui.components.accentColor`. +- `embeds` are ignored when components v2 are present. + +Example: + +```json5 +{ + channels: { + discord: { + ui: { + components: { + accentColor: "#5865F2", + }, + }, + }, + }, +} +``` + ## Voice messages Discord voice messages show a waveform preview and require OGG/Opus audio plus metadata. OpenClaw generates the waveform automatically, but it needs `ffmpeg` and `ffprobe` available on the gateway host to inspect and convert audio files. @@ -574,6 +686,7 @@ High-signal Discord fields: - media/retry: `mediaMaxMb`, `retry` - actions: `actions.*` - presence: `activity`, `status`, `activityType`, `activityUrl` +- UI: `ui.components.accentColor` - features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix` ## Safety and operations diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 1b3fb0394a3..6bd278846c5 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -105,7 +105,7 @@ Want β€œgroups can only see folder X” instead of β€œno host access”? Keep `w docker: { binds: [ // hostPath:containerPath:mode - "~/FriendsShared:/data:ro", + "/home/user/FriendsShared:/data:ro", ], }, }, diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 243e2f6d044..c4e95c21cf3 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -287,6 +287,22 @@ Available action groups in current Slack tooling: - `channel_id_changed` can migrate channel config keys when `configWrites` is enabled. - Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context. +## Ack reactions + +`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. + +Resolution order: + +- `channels.slack.accounts..ackReaction` +- `channels.slack.ackReaction` +- `messages.ackReaction` +- agent identity emoji fallback (`agents.list[].identity.emoji`, else "πŸ‘€") + +Notes: + +- Slack expects shortcodes (for example `"eyes"`). +- Use `""` to disable the reaction for a channel or account. + ## Manifest and scope checklist diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a919d20b0c1..28a9c227f9d 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -571,6 +571,23 @@ curl "https://api.telegram.org/bot/getUpdates" + + `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. + + Resolution order: + + - `channels.telegram.accounts..ackReaction` + - `channels.telegram.ackReaction` + - `messages.ackReaction` + - agent identity emoji fallback (`agents.list[].identity.emoji`, else "πŸ‘€") + + Notes: + + - Telegram expects unicode emoji (for example "πŸ‘€"). + - Use `""` to disable the reaction for a channel or account. + + + Channel config writes are enabled by default (`configWrites !== false`). diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 945f3883f66..1dc5fb8cca5 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -176,12 +176,24 @@ Behavior: ## Sandbox Session Visibility -Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`. +Session tools can be scoped to reduce cross-session access. + +Default behavior: + +- `tools.sessions.visibility` defaults to `tree` (current session + spawned subagent sessions). +- For sandboxed sessions, `agents.defaults.sandbox.sessionToolsVisibility` can hard-clamp visibility. Config: ```json5 { + tools: { + sessions: { + // "self" | "tree" | "agent" | "all" + // default: "tree" + visibility: "tree", + }, + }, agents: { defaults: { sandbox: { @@ -192,3 +204,11 @@ Config: }, } ``` + +Notes: + +- `self`: only the current session key. +- `tree`: current session + sessions spawned by the current session. +- `agent`: any session belonging to the current agent id. +- `all`: any session (cross-agent access still requires `tools.agentToAgent`). +- When a session is sandboxed and `sessionToolsVisibility="spawned"`, OpenClaw clamps visibility to `tree` even if you set `tools.sessions.visibility="all"`. diff --git a/docs/experiments/plans/pty-process-supervision.md b/docs/experiments/plans/pty-process-supervision.md new file mode 100644 index 00000000000..352850c82f6 --- /dev/null +++ b/docs/experiments/plans/pty-process-supervision.md @@ -0,0 +1,192 @@ +--- +summary: "Production plan for reliable interactive process supervision (PTY + non-PTY) with explicit ownership, unified lifecycle, and deterministic cleanup" +owner: "openclaw" +status: "in-progress" +last_updated: "2026-02-15" +title: "PTY and Process Supervision Plan" +--- + +# PTY and Process Supervision Plan + +## 1. Problem and goal + +We need one reliable lifecycle for long-running command execution across: + +- `exec` foreground runs +- `exec` background runs +- `process` follow up actions (`poll`, `log`, `send-keys`, `paste`, `submit`, `kill`, `remove`) +- CLI agent runner subprocesses + +The goal is not just to support PTY. The goal is predictable ownership, cancellation, timeout, and cleanup with no unsafe process matching heuristics. + +## 2. Scope and boundaries + +- Keep implementation internal in `src/process/supervisor`. +- Do not create a new package for this. +- Keep current behavior compatibility where practical. +- Do not broaden scope to terminal replay or tmux style session persistence. + +## 3. Implemented in this branch + +### Supervisor baseline already present + +- Supervisor module is in place under `src/process/supervisor/*`. +- Exec runtime and CLI runner are already routed through supervisor spawn and wait. +- Registry finalization is idempotent. + +### This pass completed + +1. Explicit PTY command contract + +- `SpawnInput` is now a discriminated union in `src/process/supervisor/types.ts`. +- PTY runs require `ptyCommand` instead of reusing generic `argv`. +- Supervisor no longer rebuilds PTY command strings from argv joins in `src/process/supervisor/supervisor.ts`. +- Exec runtime now passes `ptyCommand` directly in `src/agents/bash-tools.exec-runtime.ts`. + +2. Process layer type decoupling + +- Supervisor types no longer import `SessionStdin` from agents. +- Process local stdin contract lives in `src/process/supervisor/types.ts` (`ManagedRunStdin`). +- Adapters now depend only on process level types: + - `src/process/supervisor/adapters/child.ts` + - `src/process/supervisor/adapters/pty.ts` + +3. Process tool lifecycle ownership improvement + +- `src/agents/bash-tools.process.ts` now requests cancellation through supervisor first. +- `process kill/remove` now use process-tree fallback termination when supervisor lookup misses. +- `remove` keeps deterministic remove behavior by dropping running session entries immediately after termination is requested. + +4. Single source watchdog defaults + +- Added shared defaults in `src/agents/cli-watchdog-defaults.ts`. +- `src/agents/cli-backends.ts` consumes the shared defaults. +- `src/agents/cli-runner/reliability.ts` consumes the same shared defaults. + +5. Dead helper cleanup + +- Removed unused `killSession` helper path from `src/agents/bash-tools.shared.ts`. + +6. Direct supervisor path tests added + +- Added `src/agents/bash-tools.process.supervisor.test.ts` to cover kill and remove routing through supervisor cancellation. + +7. Reliability gap fixes completed + +- `src/agents/bash-tools.process.ts` now falls back to real OS-level process termination when supervisor lookup misses. +- `src/process/supervisor/adapters/child.ts` now uses process-tree termination semantics for default cancel/timeout kill paths. +- Added shared process-tree utility in `src/process/kill-tree.ts`. + +8. PTY contract edge-case coverage added + +- Added `src/process/supervisor/supervisor.pty-command.test.ts` for verbatim PTY command forwarding and empty-command rejection. +- Added `src/process/supervisor/adapters/child.test.ts` for process-tree kill behavior in child adapter cancellation. + +## 4. Remaining gaps and decisions + +### Reliability status + +The two required reliability gaps for this pass are now closed: + +- `process kill/remove` now has a real OS termination fallback when supervisor lookup misses. +- child cancel/timeout now uses process-tree kill semantics for default kill path. +- Regression tests were added for both behaviors. + +### Durability and startup reconciliation + +Restart behavior is now explicitly defined as in-memory lifecycle only. + +- `reconcileOrphans()` remains a no-op in `src/process/supervisor/supervisor.ts` by design. +- Active runs are not recovered after process restart. +- This boundary is intentional for this implementation pass to avoid partial persistence risks. + +### Maintainability follow-ups + +1. `runExecProcess` in `src/agents/bash-tools.exec-runtime.ts` still handles multiple responsibilities and can be split into focused helpers in a follow-up. + +## 5. Implementation plan + +The implementation pass for required reliability and contract items is complete. + +Completed: + +- `process kill/remove` fallback real termination +- process-tree cancellation for child adapter default kill path +- regression tests for fallback kill and child adapter kill path +- PTY command edge-case tests under explicit `ptyCommand` +- explicit in-memory restart boundary with `reconcileOrphans()` no-op by design + +Optional follow-up: + +- split `runExecProcess` into focused helpers with no behavior drift + +## 6. File map + +### Process supervisor + +- `src/process/supervisor/types.ts` updated with discriminated spawn input and process local stdin contract. +- `src/process/supervisor/supervisor.ts` updated to use explicit `ptyCommand`. +- `src/process/supervisor/adapters/child.ts` and `src/process/supervisor/adapters/pty.ts` decoupled from agent types. +- `src/process/supervisor/registry.ts` idempotent finalize unchanged and retained. + +### Exec and process integration + +- `src/agents/bash-tools.exec-runtime.ts` updated to pass PTY command explicitly and keep fallback path. +- `src/agents/bash-tools.process.ts` updated to cancel via supervisor with real process-tree fallback termination. +- `src/agents/bash-tools.shared.ts` removed direct kill helper path. + +### CLI reliability + +- `src/agents/cli-watchdog-defaults.ts` added as shared baseline. +- `src/agents/cli-backends.ts` and `src/agents/cli-runner/reliability.ts` now consume same defaults. + +## 7. Validation run in this pass + +Unit tests: + +- `pnpm vitest src/process/supervisor/registry.test.ts` +- `pnpm vitest src/process/supervisor/supervisor.test.ts` +- `pnpm vitest src/process/supervisor/supervisor.pty-command.test.ts` +- `pnpm vitest src/process/supervisor/adapters/child.test.ts` +- `pnpm vitest src/agents/cli-backends.test.ts` +- `pnpm vitest src/agents/bash-tools.exec.pty-cleanup.test.ts` +- `pnpm vitest src/agents/bash-tools.process.poll-timeout.test.ts` +- `pnpm vitest src/agents/bash-tools.process.supervisor.test.ts` +- `pnpm vitest src/process/exec.test.ts` + +E2E targets: + +- `pnpm test:e2e src/agents/cli-runner.e2e.test.ts` +- `pnpm test:e2e src/agents/bash-tools.exec.pty-fallback.e2e.test.ts src/agents/bash-tools.exec.background-abort.e2e.test.ts src/agents/bash-tools.process.send-keys.e2e.test.ts` + +Typecheck note: + +- `pnpm tsgo` currently fails in this repo due to a pre-existing UI typing dependency issue (`@vitest/browser-playwright` resolution), unrelated to this process supervision work. + +## 8. Operational guarantees preserved + +- Exec env hardening behavior is unchanged. +- Approval and allowlist flow is unchanged. +- Output sanitization and output caps are unchanged. +- PTY adapter still guarantees wait settlement on forced kill and listener disposal. + +## 9. Definition of done + +1. Supervisor is lifecycle owner for managed runs. +2. PTY spawn uses explicit command contract with no argv reconstruction. +3. Process layer has no type dependency on agent layer for supervisor stdin contracts. +4. Watchdog defaults are single source. +5. Targeted unit and e2e tests remain green. +6. Restart durability boundary is explicitly documented or fully implemented. + +## 10. Summary + +The branch now has a coherent and safer supervision shape: + +- explicit PTY contract +- cleaner process layering +- supervisor driven cancellation path for process operations +- real fallback termination when supervisor lookup misses +- process-tree cancellation for child-run default kill paths +- unified watchdog defaults +- explicit in-memory restart boundary (no orphan reconciliation across restart in this pass) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 66124db9b84..a74d3257a75 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -211,6 +211,11 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat textChunkLimit: 2000, chunkMode: "length", // length | newline maxLinesPerMessage: 17, + ui: { + components: { + accentColor: "#5865F2", + }, + }, retry: { attempts: 3, minDelayMs: 500, @@ -227,6 +232,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs. - Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered). - `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars. +- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. **Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds..users` on all messages). @@ -1232,6 +1238,8 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`. ### Ack reaction - Defaults to active agent's `identity.emoji`, otherwise `"πŸ‘€"`. Set `""` to disable. +- Per-channel overrides: `channels..ackReaction`, `channels..accounts..ackReaction`. +- Resolution order: account β†’ channel β†’ `messages.ackReaction` β†’ identity fallback. - Scope: `group-mentions` (default), `group-all`, `direct`, `all`. - `removeAckAfterReply`: removes ack after reply (Slack/Discord/Telegram/Google Chat only). @@ -1500,6 +1508,31 @@ Provider auth follows standard order: auth profiles β†’ env vars β†’ `models.pro } ``` +### `tools.sessions` + +Controls which sessions can be targeted by the session tools (`sessions_list`, `sessions_history`, `sessions_send`). + +Default: `tree` (current session + sessions spawned by it, such as subagents). + +```json5 +{ + tools: { + sessions: { + // "self" | "tree" | "agent" | "all" + visibility: "tree", + }, + }, +} +``` + +Notes: + +- `self`: only the current session key. +- `tree`: current session + sessions spawned by the current session (subagents). +- `agent`: any session belonging to the current agent id (can include other users if you run per-sender sessions under the same agent id). +- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`. +- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`. + ### `tools.subagents` ```json5 @@ -2287,12 +2320,16 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway cron: { enabled: true, maxConcurrentRuns: 2, + webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs + webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth sessionRetention: "24h", // duration string or false }, } ``` - `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`. +- `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent. +- `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`. See [Cron Jobs](/automation/cron-jobs). diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index fe653e82d2a..fe27d2c51ad 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -76,7 +76,7 @@ Global and per-agent binds are **merged** (not replaced). Under `scope: "shared" - When set (including `[]`), it replaces `agents.defaults.sandbox.docker.binds` for the browser container. - When omitted, the browser container falls back to `agents.defaults.sandbox.docker.binds` (backwards compatible). -Example (read-only source + docker socket): +Example (read-only source + an extra data directory): ```json5 { @@ -84,7 +84,7 @@ Example (read-only source + docker socket): defaults: { sandbox: { docker: { - binds: ["/home/user/source:/source:ro", "/var/run/docker.sock:/var/run/docker.sock"], + binds: ["/home/user/source:/source:ro", "/var/data/myapp:/data:ro"], }, }, }, @@ -105,7 +105,8 @@ Example (read-only source + docker socket): Security notes: - Binds bypass the sandbox filesystem: they expose host paths with whatever mode you set (`:ro` or `:rw`). -- Sensitive mounts (e.g., `docker.sock`, secrets, SSH keys) should be `:ro` unless absolutely required. +- OpenClaw blocks dangerous bind sources (for example: `docker.sock`, `/etc`, `/proc`, `/sys`, `/dev`, and parent mounts that would expose them). +- Sensitive mounts (secrets, SSH keys, service credentials) should be `:ro` unless absolutely required. - Combine with `workspaceAccess: "ro"` if you only need read access to the workspace; bind modes stay independent. - See [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) for how binds interact with tool policy and elevated exec. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index b0ea264c4ab..9f7639a6f07 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -710,7 +710,11 @@ Common use cases: scope: "agent", workspaceAccess: "none", }, + // Session tools can reveal sensitive data from transcripts. By default OpenClaw limits these tools + // to the current session + spawned subagent sessions, but you can clamp further if needed. + // See `tools.sessions.visibility` in the configuration reference. tools: { + sessions: { visibility: "tree" }, // self | tree | agent | all allow: [ "sessions_list", "sessions_history", diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index bb493e750c1..e004c9b5864 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -34,17 +34,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.15 \ +APP_VERSION=2026.2.16 \ APP_BUILD="$(git rev-list --count HEAD)" \ 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.2.15.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.16.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.15.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.16.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.15.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.15 \ +APP_VERSION=2026.2.16 \ APP_BUILD="$(git rev-list --count HEAD)" \ 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.2.15.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.16.dSYM.zip ``` ## Appcast entry @@ -67,7 +67,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.2.15.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.2.16.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. @@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.2.15.zip` (and `OpenClaw-2026.2.15.dSYM.zip`) to the GitHub release for tag `v2026.2.15`. +- Upload `OpenClaw-2026.2.16.zip` (and `OpenClaw-2026.2.16.dSYM.zip`) to the GitHub release for tag `v2026.2.16`. - 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/tools/index.md b/docs/tools/index.md index f1496a5982a..71c210bfbbf 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -442,12 +442,14 @@ Notes: - `main` is the canonical direct-chat key; global/unknown are hidden. - `messageLimit > 0` fetches last N messages per session (tool messages filtered). +- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing. - `sessions_send` waits for final completion when `timeoutSeconds > 0`. - Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered. - `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat. - `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately. - `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5). - After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement. +- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`. ### `agents_list` diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md index e7de9caf8d3..dc49d94a29a 100644 --- a/docs/tools/multi-agent-sandbox-tools.md +++ b/docs/tools/multi-agent-sandbox-tools.md @@ -324,6 +324,7 @@ Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defau ```json { "tools": { + "sessions": { "visibility": "tree" }, "allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"], "deny": ["exec", "write", "edit", "apply_patch", "read", "browser"] } diff --git a/docs/tools/web.md b/docs/tools/web.md index 859e6144c51..b0e295cd22a 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -224,6 +224,7 @@ Fetch a URL and extract readable content. enabled: true, maxChars: 50000, maxCharsCap: 50000, + maxResponseBytes: 2000000, timeoutSeconds: 30, cacheTtlMinutes: 15, maxRedirects: 3, @@ -256,6 +257,7 @@ Notes: - `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed. - `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`). - `maxChars` is clamped to `tools.web.fetch.maxCharsCap`. +- `web_fetch` caps the downloaded response body size to `tools.web.fetch.maxResponseBytes` before parsing; oversized responses are truncated and include a warning. - `web_fetch` is best-effort extraction; some sites will need the browser tool. - See [Firecrawl](/tools/firecrawl) for key setup and service details. - Responses are cached (default 15 minutes) to reduce repeated fetches. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 233a67c48b0..2547cc0b469 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -83,6 +83,10 @@ Cron jobs panel notes: - For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs. - Channel/target fields appear when announce is selected. +- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL. +- For main-session jobs, webhook and none delivery modes are available. +- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. +- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated. ## Chat behavior @@ -93,6 +97,10 @@ Cron jobs panel notes: - Click **Stop** (calls `chat.abort`) - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band - `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session +- Abort partial retention: + - When a run is aborted, partial assistant text can still be shown in the UI + - Gateway persists aborted partial assistant text into transcript history when buffered output exists + - Persisted entries include abort metadata so transcript consumers can tell abort partials from normal completion output ## Tailnet access (recommended) diff --git a/docs/web/webchat.md b/docs/web/webchat.md index a765f67598a..657e00ef8b2 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -25,6 +25,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. - The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`. - `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run). +- Aborted runs can keep partial assistant output visible in the UI. +- Gateway persists aborted partial assistant text into transcript history when buffered output exists, and marks those entries with abort metadata. - History is always fetched from the gateway (no local file watching). - If the gateway is unreachable, WebChat is read-only. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 328f2d8289b..b040a6fb29c 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 917079b3ae5..e6d66712e79 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import crypto from "node:crypto"; import path from "node:path"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { postMultipartFormData } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { resolveChatGuidForTarget } from "./send.js"; @@ -219,26 +220,12 @@ export async function sendBlueBubblesAttachment(params: { // Close the multipart body parts.push(encoder.encode(`--${boundary}--\r\n`)); - // Combine all parts into a single buffer - const totalLength = parts.reduce((acc, part) => acc + part.length, 0); - const body = new Uint8Array(totalLength); - let offset = 0; - for (const part of parts) { - body.set(part, offset); - offset += part.length; - } - - const res = await blueBubblesFetchWithTimeout( + const res = await postMultipartFormData({ url, - { - method: "POST", - headers: { - "Content-Type": `multipart/form-data; boundary=${boundary}`, - }, - body, - }, - opts.timeoutMs ?? 60_000, // longer timeout for file uploads - ); + boundary, + parts, + timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads + }); if (!res.ok) { const errorText = await res.text(); diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index bfb37a4ddf8..7e25c2cec88 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import crypto from "node:crypto"; import path from "node:path"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { postMultipartFormData } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; @@ -376,26 +377,12 @@ export async function setGroupIconBlueBubbles( // Close multipart body parts.push(encoder.encode(`--${boundary}--\r\n`)); - // Combine into single buffer - const totalLength = parts.reduce((acc, part) => acc + part.length, 0); - const body = new Uint8Array(totalLength); - let offset = 0; - for (const part of parts) { - body.set(part, offset); - offset += part.length; - } - - const res = await blueBubblesFetchWithTimeout( + const res = await postMultipartFormData({ url, - { - method: "POST", - headers: { - "Content-Type": `multipart/form-data; boundary=${boundary}`, - }, - body, - }, - opts.timeoutMs ?? 60_000, // longer timeout for file uploads - ); + boundary, + parts, + timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads + }); if (!res.ok) { const errorText = await res.text().catch(() => ""); diff --git a/extensions/bluebubbles/src/multipart.ts b/extensions/bluebubbles/src/multipart.ts new file mode 100644 index 00000000000..851cca016b7 --- /dev/null +++ b/extensions/bluebubbles/src/multipart.ts @@ -0,0 +1,32 @@ +import { blueBubblesFetchWithTimeout } from "./types.js"; + +export function concatUint8Arrays(parts: Uint8Array[]): Uint8Array { + const totalLength = parts.reduce((acc, part) => acc + part.length, 0); + const body = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + body.set(part, offset); + offset += part.length; + } + return body; +} + +export async function postMultipartFormData(params: { + url: string; + boundary: string; + parts: Uint8Array[]; + timeoutMs: number; +}): Promise { + const body = Buffer.from(concatUint8Arrays(params.parts)); + return await blueBubblesFetchWithTimeout( + params.url, + { + method: "POST", + headers: { + "Content-Type": `multipart/form-data; boundary=${params.boundary}`, + }, + body, + }, + params.timeoutMs, + ); +} diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index 7b49ae698ed..e60c47dc643 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -1,9 +1,8 @@ +import type { BaseProbeResult } from "openclaw/plugin-sdk"; import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js"; -export type BlueBubblesProbe = { - ok: boolean; +export type BlueBubblesProbe = BaseProbeResult & { status?: number | null; - error?: string | null; }; export type BlueBubblesServerInfo = { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 9ffa4b85a9c..756b6a26849 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.2.15", + "version": "2026.2.16", "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 74ccbc24872..c0098b1a14b 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 4876a771bb9..b68e1223337 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw Discord channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 4119a95e815..f8fc9576e6a 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -158,6 +158,12 @@ export const discordPlugin: ChannelPlugin = { threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", }, + agentPrompt: { + messageToolHints: () => [ + "- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.", + "- Forms: add `components.modal` (title, fields). OpenClaw adds a trigger button and routes submissions as new messages.", + ], + }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, targetResolver: { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 2cf278d2444..c5ae74770da 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 7a1ffd6191e..a0646a86e0c 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1,5 +1,6 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import { + buildAgentMediaPayload, buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, clearHistoryEntriesIfEnabled, @@ -433,27 +434,6 @@ async function resolveFeishuMediaList(params: { * Build media payload for inbound context. * Similar to Discord's buildDiscordMediaPayload(). */ -function buildFeishuMediaPayload(mediaList: FeishuMediaInfo[]): { - MediaPath?: string; - MediaType?: string; - MediaUrl?: string; - MediaPaths?: string[]; - MediaUrls?: string[]; - MediaTypes?: string[]; -} { - const first = mediaList[0]; - const mediaPaths = mediaList.map((media) => media.path); - const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[]; - return { - MediaPath: first?.path, - MediaType: first?.contentType, - MediaUrl: first?.path, - MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, - }; -} - export function parseFeishuMessageEvent( event: FeishuMessageEvent, botOpenId?: string, @@ -766,7 +746,7 @@ export async function handleFeishuMessage(params: { log, accountId: account.accountId, }); - const mediaPayload = buildFeishuMediaPayload(mediaList); + const mediaPayload = buildAgentMediaPayload(mediaList); // Fetch quoted/replied message content if parentId exists let quotedContent: string | undefined; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index bdc3aa04ba9..fb7881f2307 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,5 +1,10 @@ import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk"; -import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk"; +import { + buildBaseChannelStatusSummary, + createDefaultChannelRuntimeState, + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, +} from "openclaw/plugin-sdk"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; import { resolveFeishuAccount, @@ -303,20 +308,9 @@ export const feishuPlugin: ChannelPlugin = { }, outbound: feishuOutbound, status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - port: null, - }, + defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, + ...buildBaseChannelStatusSummary(snapshot), port: snapshot.port ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index dbfde807806..dad248aa9f4 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -1,3 +1,4 @@ +import type { BaseProbeResult } from "openclaw/plugin-sdk"; import type { FeishuConfigSchema, FeishuGroupSchema, @@ -52,9 +53,7 @@ export type FeishuSendResult = { chatId: string; }; -export type FeishuProbeResult = { - ok: boolean; - error?: string; +export type FeishuProbeResult = BaseProbeResult & { appId?: string; botName?: string; botOpenId?: string; diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts index 15f1bf1ee2b..055cb15e00b 100644 --- a/extensions/google-antigravity-auth/index.ts +++ b/extensions/google-antigravity-auth/index.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { + buildOauthProviderAuthResult, emptyPluginConfigSchema, isWSL2Sync, type OpenClawPluginApi, @@ -396,37 +397,19 @@ const antigravityPlugin = { progress: spin, }); - const profileId = `google-antigravity:${result.email ?? "default"}`; - return { - profiles: [ - { - profileId, - credential: { - type: "oauth", - provider: "google-antigravity", - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - projectId: result.projectId, - }, - }, - ], - configPatch: { - agents: { - defaults: { - models: { - [DEFAULT_MODEL]: {}, - }, - }, - }, - }, + return buildOauthProviderAuthResult({ + providerId: "google-antigravity", defaultModel: DEFAULT_MODEL, + access: result.access, + refresh: result.refresh, + expires: result.expires, + email: result.email, + credentialExtra: { projectId: result.projectId }, notes: [ "Antigravity uses Google Cloud project quotas.", "Enable Gemini for Google Cloud on your project if requests fail.", ], - }; + }); } catch (err) { spin.stop("Antigravity OAuth failed"); throw err; diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json index b2afcd50159..7d5bc539f05 100644 --- a/extensions/google-antigravity-auth/package.json +++ b/extensions/google-antigravity-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/google-antigravity-auth", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw Google Antigravity OAuth provider plugin", "type": "module", diff --git a/extensions/google-gemini-cli-auth/index.ts b/extensions/google-gemini-cli-auth/index.ts index ba7913e2d86..89b7c4d1cfb 100644 --- a/extensions/google-gemini-cli-auth/index.ts +++ b/extensions/google-gemini-cli-auth/index.ts @@ -1,4 +1,5 @@ import { + buildOauthProviderAuthResult, emptyPluginConfigSchema, type OpenClawPluginApi, type ProviderAuthContext, @@ -46,34 +47,16 @@ const geminiCliPlugin = { }); spin.stop("Gemini CLI OAuth complete"); - const profileId = `google-gemini-cli:${result.email ?? "default"}`; - return { - profiles: [ - { - profileId, - credential: { - type: "oauth", - provider: PROVIDER_ID, - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - projectId: result.projectId, - }, - }, - ], - configPatch: { - agents: { - defaults: { - models: { - [DEFAULT_MODEL]: {}, - }, - }, - }, - }, + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, defaultModel: DEFAULT_MODEL, + access: result.access, + refresh: result.refresh, + expires: result.expires, + email: result.email, + credentialExtra: { projectId: result.projectId }, notes: ["If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID."], - }; + }); } catch (err) { spin.stop("Gemini CLI OAuth failed"); await ctx.prompter.note( diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 5ac915b720e..51f94113444 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.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 40c93804576..2d8648c3b00 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/googlechat", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 34f62930ae7..d4c9aef4365 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -2,7 +2,9 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { createReplyPrefixOptions, + normalizeWebhookPath, readJsonBodyWithLimit, + resolveWebhookPath, requestBodyErrorToText, resolveMentionGatingWithBypass, } from "openclaw/plugin-sdk"; @@ -86,34 +88,6 @@ function warnDeprecatedUsersEmailEntries( ); } -function normalizeWebhookPath(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return "/"; - } - const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - if (withSlash.length > 1 && withSlash.endsWith("/")) { - return withSlash.slice(0, -1); - } - return withSlash; -} - -function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null { - const trimmedPath = webhookPath?.trim(); - if (trimmedPath) { - return normalizeWebhookPath(trimmedPath); - } - if (webhookUrl?.trim()) { - try { - const parsed = new URL(webhookUrl); - return normalizeWebhookPath(parsed.pathname || "/"); - } catch { - return null; - } - } - return "/googlechat"; -} - export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { const key = normalizeWebhookPath(target.path); const normalizedTarget = { ...target, path: key }; @@ -933,7 +907,11 @@ async function uploadAttachmentForReply(params: { export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void { const core = getGoogleChatRuntime(); - const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl); + const webhookPath = resolveWebhookPath({ + webhookPath: options.webhookPath, + webhookUrl: options.webhookUrl, + defaultPath: "/googlechat", + }); if (!webhookPath) { options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`); return () => {}; @@ -968,8 +946,11 @@ export function resolveGoogleChatWebhookPath(params: { account: ResolvedGoogleChatAccount; }): string { return ( - resolveWebhookPath(params.account.config.webhookPath, params.account.config.webhookUrl) ?? - "/googlechat" + resolveWebhookPath({ + webhookPath: params.account.config.webhookPath, + webhookUrl: params.account.config.webhookUrl, + defaultPath: "/googlechat", + }) ?? "/googlechat" ); } diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index dc9e0d6fdc9..b801b57ba32 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 3e8977f1bd4..88b9d19ee3b 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw IRC channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/irc/src/types.ts b/extensions/irc/src/types.ts index 5446649aad2..ac6a5c9cb7b 100644 --- a/extensions/irc/src/types.ts +++ b/extensions/irc/src/types.ts @@ -1,3 +1,4 @@ +import type { BaseProbeResult } from "openclaw/plugin-sdk"; import type { BlockStreamingCoalesceConfig, DmConfig, @@ -83,12 +84,10 @@ export type IrcInboundMessage = { isGroup: boolean; }; -export type IrcProbe = { - ok: boolean; +export type IrcProbe = BaseProbeResult & { host: string; port: number; tls: boolean; nick: string; latencyMs?: number; - error?: string; }; diff --git a/extensions/line/package.json b/extensions/line/package.json index 9e8550e5184..c03d34cf19b 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts new file mode 100644 index 00000000000..fa04e6ca6d7 --- /dev/null +++ b/extensions/line/src/channel.startup.test.ts @@ -0,0 +1,101 @@ +import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { linePlugin } from "./channel.js"; +import { setLineRuntime } from "./runtime.js"; + +function createRuntime() { + const probeLineBot = vi.fn(async () => ({ ok: false })); + const monitorLineProvider = vi.fn(async () => ({ + account: { accountId: "default" }, + handleWebhook: async () => {}, + stop: () => {}, + })); + + const runtime = { + channel: { + line: { + probeLineBot, + monitorLineProvider, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime; + + return { runtime, probeLineBot, monitorLineProvider }; +} + +function createStartAccountCtx(params: { token: string; secret: string; runtime: unknown }) { + return { + account: { + accountId: "default", + channelAccessToken: params.token, + channelSecret: params.secret, + config: {}, + }, + cfg: {} as OpenClawConfig, + runtime: params.runtime, + abortSignal: undefined, + log: { info: vi.fn(), debug: vi.fn() }, + }; +} + +describe("linePlugin gateway.startAccount", () => { + it("fails startup when channel secret is missing", async () => { + const { runtime, monitorLineProvider } = createRuntime(); + setLineRuntime(runtime); + + await expect( + linePlugin.gateway.startAccount( + createStartAccountCtx({ + token: "token", + secret: " ", + runtime: {}, + }) as never, + ), + ).rejects.toThrow( + 'LINE webhook mode requires a non-empty channel secret for account "default".', + ); + expect(monitorLineProvider).not.toHaveBeenCalled(); + }); + + it("fails startup when channel access token is missing", async () => { + const { runtime, monitorLineProvider } = createRuntime(); + setLineRuntime(runtime); + + await expect( + linePlugin.gateway.startAccount( + createStartAccountCtx({ + token: " ", + secret: "secret", + runtime: {}, + }) as never, + ), + ).rejects.toThrow( + 'LINE webhook mode requires a non-empty channel access token for account "default".', + ); + expect(monitorLineProvider).not.toHaveBeenCalled(); + }); + + it("starts provider when token and secret are present", async () => { + const { runtime, monitorLineProvider } = createRuntime(); + setLineRuntime(runtime); + + await linePlugin.gateway.startAccount( + createStartAccountCtx({ + token: "token", + secret: "secret", + runtime: {}, + }) as never, + ); + + expect(monitorLineProvider).toHaveBeenCalledWith( + expect.objectContaining({ + channelAccessToken: "token", + channelSecret: "secret", + accountId: "default", + }), + ); + }); +}); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 96c0a51d795..cc30264e1e1 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -119,12 +119,13 @@ export const linePlugin: ChannelPlugin = { }, }; }, - isConfigured: (account) => Boolean(account.channelAccessToken?.trim()), + isConfigured: (account) => + Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: Boolean(account.channelAccessToken?.trim()), + configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), tokenSource: account.tokenSource ?? undefined, }), resolveAllowFrom: ({ cfg, accountId }) => @@ -603,7 +604,9 @@ export const linePlugin: ChannelPlugin = { probeAccount: async ({ account, timeoutMs }) => getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs), buildAccountSnapshot: ({ account, runtime, probe }) => { - const configured = Boolean(account.channelAccessToken?.trim()); + const configured = Boolean( + account.channelAccessToken?.trim() && account.channelSecret?.trim(), + ); return { accountId: account.accountId, name: account.name, @@ -626,6 +629,16 @@ export const linePlugin: ChannelPlugin = { const account = ctx.account; const token = account.channelAccessToken.trim(); const secret = account.channelSecret.trim(); + if (!token) { + throw new Error( + `LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`, + ); + } + if (!secret) { + throw new Error( + `LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`, + ); + } let lineBotLabel = ""; try { diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 13c6b256a97..e527185a0e0 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 6bc9674ad1c..3ceb3736da1 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.2.15", + "version": "2026.2.16", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "devDependencies": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 76e12ddc8e2..71dbe303b57 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.16 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.15 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 98eedf802cf..4a9c328ec61 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { diff --git a/extensions/matrix/src/group-mentions.ts b/extensions/matrix/src/group-mentions.ts index dd8c2bb7e71..8f36f6d9542 100644 --- a/extensions/matrix/src/group-mentions.ts +++ b/extensions/matrix/src/group-mentions.ts @@ -3,29 +3,33 @@ import type { CoreConfig } from "./types.js"; import { resolveMatrixAccountConfig } from "./matrix/accounts.js"; import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; -export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean { +function stripLeadingPrefixCaseInsensitive(value: string, prefix: string): string { + return value.toLowerCase().startsWith(prefix.toLowerCase()) + ? value.slice(prefix.length).trim() + : value; +} + +function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) { const rawGroupId = params.groupId?.trim() ?? ""; let roomId = rawGroupId; - const lower = roomId.toLowerCase(); - if (lower.startsWith("matrix:")) { - roomId = roomId.slice("matrix:".length).trim(); - } - if (roomId.toLowerCase().startsWith("channel:")) { - roomId = roomId.slice("channel:".length).trim(); - } - if (roomId.toLowerCase().startsWith("room:")) { - roomId = roomId.slice("room:".length).trim(); - } + roomId = stripLeadingPrefixCaseInsensitive(roomId, "matrix:"); + roomId = stripLeadingPrefixCaseInsensitive(roomId, "channel:"); + roomId = stripLeadingPrefixCaseInsensitive(roomId, "room:"); + const groupChannel = params.groupChannel?.trim() ?? ""; const aliases = groupChannel ? [groupChannel] : []; const cfg = params.cfg as CoreConfig; const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId }); - const resolved = resolveMatrixRoomConfig({ + return resolveMatrixRoomConfig({ rooms: matrixConfig.groups ?? matrixConfig.rooms, roomId, aliases, name: groupChannel || undefined, }).config; +} + +export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean { + const resolved = resolveMatrixRoomConfigForGroup(params); if (resolved) { if (resolved.autoReply === true) { return false; @@ -43,27 +47,6 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b export function resolveMatrixGroupToolPolicy( params: ChannelGroupContext, ): GroupToolPolicyConfig | undefined { - const rawGroupId = params.groupId?.trim() ?? ""; - let roomId = rawGroupId; - const lower = roomId.toLowerCase(); - if (lower.startsWith("matrix:")) { - roomId = roomId.slice("matrix:".length).trim(); - } - if (roomId.toLowerCase().startsWith("channel:")) { - roomId = roomId.slice("channel:".length).trim(); - } - if (roomId.toLowerCase().startsWith("room:")) { - roomId = roomId.slice("room:".length).trim(); - } - const groupChannel = params.groupChannel?.trim() ?? ""; - const aliases = groupChannel ? [groupChannel] : []; - const cfg = params.cfg as CoreConfig; - const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId }); - const resolved = resolveMatrixRoomConfig({ - rooms: matrixConfig.groups ?? matrixConfig.rooms, - roomId, - aliases, - name: groupChannel || undefined, - }).config; + const resolved = resolveMatrixRoomConfigForGroup(params); return resolved?.tools; } diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts index 7bd54bdc400..5681b242c24 100644 --- a/extensions/matrix/src/matrix/probe.ts +++ b/extensions/matrix/src/matrix/probe.ts @@ -1,9 +1,8 @@ +import type { BaseProbeResult } from "openclaw/plugin-sdk"; import { createMatrixClient, isBunRuntime } from "./client.js"; -export type MatrixProbe = { - ok: boolean; +export type MatrixProbe = BaseProbeResult & { status?: number | null; - error?: string | null; elapsedMs: number; userId?: string | null; }; diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index e053f4d43a9..ff4df9f7414 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw Mattermost channel plugin", "type": "module", diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index ddc6dce702b..db31051356a 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -6,6 +6,7 @@ import type { RuntimeEnv, } from "openclaw/plugin-sdk"; import { + buildAgentMediaPayload, createReplyPrefixOptions, createTypingCallbacks, logInboundDrop, @@ -179,27 +180,6 @@ function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): return `${tag} (${mediaList.length} ${suffix})`; } -function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): { - MediaPath?: string; - MediaType?: string; - MediaUrl?: string; - MediaPaths?: string[]; - MediaUrls?: string[]; - MediaTypes?: string[]; -} { - const first = mediaList[0]; - const mediaPaths = mediaList.map((media) => media.path); - const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[]; - return { - MediaPath: first?.path, - MediaType: first?.contentType, - MediaUrl: first?.path, - MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, - }; -} - function buildMattermostWsUrl(baseUrl: string): string { const normalized = normalizeMattermostBaseUrl(baseUrl); if (!normalized) { @@ -650,7 +630,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`; - const mediaPayload = buildMattermostMediaPayload(mediaList); + const mediaPayload = buildAgentMediaPayload(mediaList); const inboundHistory = historyKey && historyLimit > 0 ? (channelHistories.get(historyKey) ?? []).map((entry) => ({ diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index a02ca4935fd..cb468ec14db 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -1,9 +1,8 @@ +import type { BaseProbeResult } from "openclaw/plugin-sdk"; import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js"; -export type MattermostProbe = { - ok: boolean; +export type MattermostProbe = BaseProbeResult & { status?: number | null; - error?: string | null; elapsedMs?: number | null; bot?: MattermostUser; }; diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index 9adaf8da479..99994f54487 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-core", - "version": "2026.2.15", + "version": "2026.2.16", "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 a15dcf1caac..58c97bf228e 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index 0fd990f383f..704b9f6b188 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.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index ce0da7bd476..fc2b72ed9af 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.16 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.15 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 42809fdcd63..10f033b9c9b 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d6fd75abf6c..2958e4c22d0 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,6 +1,8 @@ import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk"; import { + buildBaseChannelStatusSummary, buildChannelConfigSchema, + createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, @@ -415,20 +417,9 @@ export const msteamsPlugin: ChannelPlugin = { }, outbound: msteamsOutbound, status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - port: null, - }, + defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, + ...buildBaseChannelStatusSummary(snapshot), port: snapshot.port ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, diff --git a/extensions/msteams/src/onboarding.ts b/extensions/msteams/src/onboarding.ts index d950bd2db08..191a2631a91 100644 --- a/extensions/msteams/src/onboarding.ts +++ b/extensions/msteams/src/onboarding.ts @@ -63,6 +63,32 @@ function looksLikeGuid(value: string): boolean { return /^[0-9a-fA-F-]{16,}$/.test(value); } +async function promptMSTeamsCredentials(prompter: WizardPrompter): Promise<{ + appId: string; + appPassword: string; + tenantId: string; +}> { + const appId = String( + await prompter.text({ + message: "Enter MS Teams App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const appPassword = String( + await prompter.text({ + message: "Enter MS Teams App Password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const tenantId = String( + await prompter.text({ + message: "Enter MS Teams Tenant ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { appId, appPassword, tenantId }; +} + async function promptMSTeamsAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -251,24 +277,7 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } else { - appId = String( - await prompter.text({ - message: "Enter MS Teams App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appPassword = String( - await prompter.text({ - message: "Enter MS Teams App Password", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - tenantId = String( - await prompter.text({ - message: "Enter MS Teams Tenant ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + ({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter)); } } else if (hasConfigCreds) { const keep = await prompter.confirm({ @@ -276,44 +285,10 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keep) { - appId = String( - await prompter.text({ - message: "Enter MS Teams App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appPassword = String( - await prompter.text({ - message: "Enter MS Teams App Password", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - tenantId = String( - await prompter.text({ - message: "Enter MS Teams Tenant ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + ({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter)); } } else { - appId = String( - await prompter.text({ - message: "Enter MS Teams App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appPassword = String( - await prompter.text({ - message: "Enter MS Teams App Password", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - tenantId = String( - await prompter.text({ - message: "Enter MS Teams Tenant ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + ({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter)); } if (appId && appPassword && tenantId) { diff --git a/extensions/msteams/src/probe.ts b/extensions/msteams/src/probe.ts index 6bbcc0b3c3c..b6732c658c4 100644 --- a/extensions/msteams/src/probe.ts +++ b/extensions/msteams/src/probe.ts @@ -1,11 +1,9 @@ -import type { MSTeamsConfig } from "openclaw/plugin-sdk"; +import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk"; import { formatUnknownError } from "./errors.js"; import { loadMSTeamsSdkWithAuth } from "./sdk.js"; import { resolveMSTeamsCredentials } from "./token.js"; -export type ProbeMSTeamsResult = { - ok: boolean; - error?: string; +export type ProbeMSTeamsResult = BaseProbeResult & { appId?: string; graph?: { ok: boolean; diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index c9e3d2c5861..8861244c04c 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index c61303c1bf2..d5cd6f985f3 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.16 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.15 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 91de4c6a646..469b57eca5c 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 8fa8d58b61f..8fe7ce4ac92 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -1,5 +1,7 @@ import { buildChannelConfigSchema, + collectStatusIssuesFromLastError, + createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, formatPairingApproveHint, type ChannelPlugin, @@ -157,28 +159,8 @@ export const nostrPlugin: ChannelPlugin = { }, status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) { - return []; - } - return [ - { - channel: "nostr", - accountId: account.accountId, - kind: "runtime" as const, - message: `Channel error: ${lastError}`, - }, - ]; - }), + defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), + collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("nostr", accounts), buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, publicKey: snapshot.publicKey ?? null, diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 389076660c6..d7750c954eb 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 2f35b466502..74321565137 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 1b270e89469..18c3bcc2393 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,6 +1,9 @@ import { applyAccountNameToChannelSection, + buildBaseChannelStatusSummary, buildChannelConfigSchema, + collectStatusIssuesFromLastError, + createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, @@ -249,35 +252,11 @@ export const signalPlugin: ChannelPlugin = { }, }, status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) { - return []; - } - return [ - { - channel: "signal", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), + defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), + collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("signal", accounts), buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, + ...buildBaseChannelStatusSummary(snapshot), baseUrl: snapshot.baseUrl ?? null, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), diff --git a/extensions/slack/package.json b/extensions/slack/package.json index f17c978fd04..f0d9bff43b8 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index cc0ed00dcbe..74d289bf702 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index dcddb873d44..4cc32c2e03a 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", diff --git a/extensions/tlon/src/urbit/channel-client.ts b/extensions/tlon/src/urbit/channel-client.ts index 1b38f7d7c80..fb8af656a6f 100644 --- a/extensions/tlon/src/urbit/channel-client.ts +++ b/extensions/tlon/src/urbit/channel-client.ts @@ -1,5 +1,5 @@ import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; -import { ensureUrbitChannelOpen } from "./channel-ops.js"; +import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js"; import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; import { urbitFetch } from "./fetch.js"; @@ -70,69 +70,35 @@ export class UrbitChannelClient { async poke(params: { app: string; mark: string; json: unknown }): Promise { await this.open(); - const pokeId = Date.now(); - const pokeData = { - id: pokeId, - action: "poke", - ship: this.ship, - app: params.app, - mark: params.mark, - json: params.json, - }; - - const { response, release } = await urbitFetch({ - baseUrl: this.baseUrl, - path: this.channelPath, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([pokeData]), - }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, - timeoutMs: 30_000, - auditContext: "tlon-urbit-poke", - }); - - try { - if (!response.ok && response.status !== 204) { - const errorText = await response.text().catch(() => ""); - throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`); - } - return pokeId; - } finally { - await release(); + const channelId = this.channelId; + if (!channelId) { + throw new Error("Channel not opened"); } + return await pokeUrbitChannel( + { + baseUrl: this.baseUrl, + cookie: this.cookie, + ship: this.ship, + channelId, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + }, + { ...params, auditContext: "tlon-urbit-poke" }, + ); } async scry(path: string): Promise { - const scryPath = `/~/scry${path}`; - const { response, release } = await urbitFetch({ - baseUrl: this.baseUrl, - path: scryPath, - init: { - method: "GET", - headers: { Cookie: this.cookie }, + return await scryUrbitPath( + { + baseUrl: this.baseUrl, + cookie: this.cookie, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, - timeoutMs: 30_000, - auditContext: "tlon-urbit-scry", - }); - - try { - if (!response.ok) { - throw new Error(`Scry failed: ${response.status} for path ${path}`); - } - return await response.json(); - } finally { - await release(); - } + { path, auditContext: "tlon-urbit-scry" }, + ); } async getOurName(): Promise { diff --git a/extensions/tlon/src/urbit/channel-ops.ts b/extensions/tlon/src/urbit/channel-ops.ts index f62d870fc45..077e8d01816 100644 --- a/extensions/tlon/src/urbit/channel-ops.ts +++ b/extensions/tlon/src/urbit/channel-ops.ts @@ -12,6 +12,78 @@ export type UrbitChannelDeps = { fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; }; +export async function pokeUrbitChannel( + deps: UrbitChannelDeps, + params: { app: string; mark: string; json: unknown; auditContext: string }, +): Promise { + const pokeId = Date.now(); + const pokeData = { + id: pokeId, + action: "poke", + ship: deps.ship, + app: params.app, + mark: params.mark, + json: params.json, + }; + + const { response, release } = await urbitFetch({ + baseUrl: deps.baseUrl, + path: `/~/channel/${deps.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: deps.cookie, + }, + body: JSON.stringify([pokeData]), + }, + ssrfPolicy: deps.ssrfPolicy, + lookupFn: deps.lookupFn, + fetchImpl: deps.fetchImpl, + timeoutMs: 30_000, + auditContext: params.auditContext, + }); + + try { + if (!response.ok && response.status !== 204) { + const errorText = await response.text().catch(() => ""); + throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`); + } + return pokeId; + } finally { + await release(); + } +} + +export async function scryUrbitPath( + deps: Pick, + params: { path: string; auditContext: string }, +): Promise { + const scryPath = `/~/scry${params.path}`; + const { response, release } = await urbitFetch({ + baseUrl: deps.baseUrl, + path: scryPath, + init: { + method: "GET", + headers: { Cookie: deps.cookie }, + }, + ssrfPolicy: deps.ssrfPolicy, + lookupFn: deps.lookupFn, + fetchImpl: deps.fetchImpl, + timeoutMs: 30_000, + auditContext: params.auditContext, + }); + + try { + if (!response.ok) { + throw new Error(`Scry failed: ${response.status} for path ${params.path}`); + } + return await response.json(); + } finally { + await release(); + } +} + export async function createUrbitChannel( deps: UrbitChannelDeps, params: { body: unknown; auditContext: string }, diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index f4a1b8fdf8c..a379d1680b6 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -1,6 +1,6 @@ import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; import { Readable } from "node:stream"; -import { ensureUrbitChannelOpen } from "./channel-ops.js"; +import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js"; import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; import { urbitFetch } from "./fetch.js"; @@ -290,71 +290,31 @@ export class UrbitSSEClient { } async poke(params: { app: string; mark: string; json: unknown }) { - const pokeId = Date.now(); - const pokeData = { - id: pokeId, - action: "poke", - ship: this.ship, - app: params.app, - mark: params.mark, - json: params.json, - }; - - const { response, release } = await urbitFetch({ - baseUrl: this.url, - path: `/~/channel/${this.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([pokeData]), + return await pokeUrbitChannel( + { + baseUrl: this.url, + cookie: this.cookie, + ship: this.ship, + channelId: this.channelId, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, - timeoutMs: 30_000, - auditContext: "tlon-urbit-poke", - }); - - try { - if (!response.ok && response.status !== 204) { - const errorText = await response.text().catch(() => ""); - throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`); - } - } finally { - await release(); - } - - return pokeId; + { ...params, auditContext: "tlon-urbit-poke" }, + ); } async scry(path: string) { - const { response, release } = await urbitFetch({ - baseUrl: this.url, - path: `/~/scry${path}`, - init: { - method: "GET", - headers: { - Cookie: this.cookie, - }, + return await scryUrbitPath( + { + baseUrl: this.url, + cookie: this.cookie, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, - timeoutMs: 30_000, - auditContext: "tlon-urbit-scry", - }); - - try { - if (!response.ok) { - throw new Error(`Scry failed: ${response.status} for path ${path}`); - } - return await response.json(); - } finally { - await release(); - } + { path, auditContext: "tlon-urbit-scry" }, + ); } async attemptReconnect() { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index b8bdcce37bc..a30f2c78439 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.16 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.15 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index c5b8c470901..490bef10daa 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw Twitch channel plugin", "type": "module", diff --git a/extensions/twitch/src/probe.ts b/extensions/twitch/src/probe.ts index 56ea99146d5..41321103a45 100644 --- a/extensions/twitch/src/probe.ts +++ b/extensions/twitch/src/probe.ts @@ -1,3 +1,4 @@ +import type { BaseProbeResult } from "openclaw/plugin-sdk"; import { StaticAuthProvider } from "@twurple/auth"; import { ChatClient } from "@twurple/chat"; import type { TwitchAccountConfig } from "./types.js"; @@ -6,9 +7,7 @@ import { normalizeToken } from "./utils/twitch.js"; /** * Result of probing a Twitch account */ -export type ProbeTwitchResult = { - ok: boolean; - error?: string; +export type ProbeTwitchResult = BaseProbeResult & { username?: string; elapsedMs: number; connected?: boolean; diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index cb7ab8c8da4..3d3e7738fb1 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.16 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.15 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index c184b58ccf3..161f99e4c4c 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index fcd10985c00..fca5d24aa4a 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.2.15", + "version": "2026.2.16", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index f0f3648235d..3d1a3ee5e9a 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.16 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.15 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 60c4aca0e66..b836a0ee1cf 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 2c41d8262ca..1ee2efb5315 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -2,7 +2,9 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk"; import { createReplyPrefixOptions, + normalizeWebhookPath, readJsonBodyWithLimit, + resolveWebhookPath, requestBodyErrorToText, } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; @@ -80,34 +82,6 @@ type WebhookTarget = { const webhookTargets = new Map(); -function normalizeWebhookPath(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return "/"; - } - const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - if (withSlash.length > 1 && withSlash.endsWith("/")) { - return withSlash.slice(0, -1); - } - return withSlash; -} - -function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null { - const trimmedPath = webhookPath?.trim(); - if (trimmedPath) { - return normalizeWebhookPath(trimmedPath); - } - if (webhookUrl?.trim()) { - try { - const parsed = new URL(webhookUrl); - return normalizeWebhookPath(parsed.pathname || "/"); - } catch { - return null; - } - } - return null; -} - export function registerZaloWebhookTarget(target: WebhookTarget): () => void { const key = normalizeWebhookPath(target.path); const normalizedTarget = { ...target, path: key }; @@ -700,7 +674,7 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< throw new Error("Zalo webhook secret must be 8-256 characters"); } - const path = resolveWebhookPath(webhookPath, webhookUrl); + const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null }); if (!path) { throw new Error("Zalo webhookPath could not be derived"); } diff --git a/extensions/zalo/src/probe.ts b/extensions/zalo/src/probe.ts index ebdb37a34f3..c2d95fa1d28 100644 --- a/extensions/zalo/src/probe.ts +++ b/extensions/zalo/src/probe.ts @@ -1,9 +1,8 @@ +import type { BaseProbeResult } from "openclaw/plugin-sdk"; import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js"; -export type ZaloProbeResult = { - ok: boolean; +export type ZaloProbeResult = BaseProbeResult & { bot?: ZaloBotInfo; - error?: string; elapsedMs: number; }; diff --git a/extensions/zalo/src/token.ts b/extensions/zalo/src/token.ts index 480f66c8fad..b335f57a3c2 100644 --- a/extensions/zalo/src/token.ts +++ b/extensions/zalo/src/token.ts @@ -1,9 +1,8 @@ import { readFileSync } from "node:fs"; -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; +import { type BaseTokenResolution, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; import type { ZaloConfig } from "./types.js"; -export type ZaloTokenResolution = { - token: string; +export type ZaloTokenResolution = BaseTokenResolution & { source: "env" | "config" | "configFile" | "none"; }; diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index 33f2f4f11ba..cdf1581b628 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.16 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.15 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index a2aa258e596..60481ce2ef0 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.2.15", + "version": "2026.2.16", "description": "OpenClaw Zalo Personal Account plugin via zca-cli", "type": "module", "dependencies": { diff --git a/extensions/zalouser/src/probe.ts b/extensions/zalouser/src/probe.ts index bfeb92ec586..6bdc962052f 100644 --- a/extensions/zalouser/src/probe.ts +++ b/extensions/zalouser/src/probe.ts @@ -1,11 +1,10 @@ +import type { BaseProbeResult } from "openclaw/plugin-sdk"; import type { ZcaUserInfo } from "./types.js"; import { runZca, parseJsonOutput } from "./zca.js"; -export interface ZalouserProbeResult { - ok: boolean; +export type ZalouserProbeResult = BaseProbeResult & { user?: ZcaUserInfo; - error?: string; -} +}; export async function probeZalouser( profile: string, diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit index b58a53100d4..919e8507bbe 100755 --- a/git-hooks/pre-commit +++ b/git-hooks/pre-commit @@ -1,9 +1,38 @@ -#!/bin/sh -FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') -[ -z "$FILES" ] && exit 0 +#!/usr/bin/env bash -echo "$FILES" | xargs pnpm lint --fix -echo "$FILES" | xargs pnpm format --no-error-on-unmatched-pattern -echo "$FILES" | xargs git add +set -euo pipefail -exit 0 +ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +RUN_NODE_TOOL="$ROOT_DIR/scripts/pre-commit/run-node-tool.sh" +FILTER_FILES="$ROOT_DIR/scripts/pre-commit/filter-staged-files.mjs" + +if [[ ! -x "$RUN_NODE_TOOL" ]]; then + echo "Missing helper: $RUN_NODE_TOOL" >&2 + exit 1 +fi + +if [[ ! -f "$FILTER_FILES" ]]; then + echo "Missing helper: $FILTER_FILES" >&2 + exit 1 +fi + +# Security: avoid option-injection from malicious file names (e.g. "--all", "--force"). +# Robustness: NUL-delimited file list handles spaces/newlines safely. +mapfile -d '' -t files < <(git diff --cached --name-only --diff-filter=ACMR -z) + +if [ "${#files[@]}" -eq 0 ]; then + exit 0 +fi + +mapfile -d '' -t lint_files < <(node "$FILTER_FILES" lint -- "${files[@]}") +mapfile -d '' -t format_files < <(node "$FILTER_FILES" format -- "${files[@]}") + +if [ "${#lint_files[@]}" -gt 0 ]; then + "$RUN_NODE_TOOL" oxlint --type-aware --fix -- "${lint_files[@]}" +fi + +if [ "${#format_files[@]}" -gt 0 ]; then + "$RUN_NODE_TOOL" oxfmt --write -- "${format_files[@]}" +fi + +git add -- "${files[@]}" diff --git a/package.json b/package.json index c85cc08b2ba..bef3ae67b3f 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,25 @@ { "name": "openclaw", - "version": "2026.2.15", + "version": "2026.2.16", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], + "homepage": "https://github.com/openclaw/openclaw#readme", + "bugs": { + "url": "https://github.com/openclaw/openclaw/issues" + }, "license": "MIT", "author": "", + "repository": { + "type": "git", + "url": "git+https://github.com/openclaw/openclaw.git" + }, "bin": { "openclaw": "openclaw.mjs" }, + "directories": { + "doc": "docs", + "test": "test" + }, "files": [ "CHANGELOG.md", "LICENSE", @@ -160,7 +172,7 @@ "sharp": "^0.34.5", "signal-utils": "^0.21.1", "sqlite-vec": "0.1.7-alpha.2", - "tar": "7.5.7", + "tar": "7.5.9", "tslog": "^4.10.2", "undici": "^7.22.0", "ws": "^8.19.0", @@ -177,13 +189,13 @@ "@types/proper-lockfile": "^4.1.4", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260214.1", + "@typescript/native-preview": "7.0.0-dev.20260215.1", "@vitest/coverage-v8": "^4.0.18", "lit": "^3.3.2", "ollama": "^0.6.3", "oxfmt": "0.32.0", "oxlint": "^1.47.0", - "oxlint-tsgolint": "^0.12.2", + "oxlint-tsgolint": "^0.13.0", "rolldown": "1.0.0-rc.4", "tsdown": "^0.20.3", "tsx": "^4.21.0", @@ -205,7 +217,7 @@ "form-data": "2.5.4", "qs": "6.14.2", "@sinclair/typebox": "0.34.48", - "tar": "7.5.7", + "tar": "7.5.9", "tough-cookie": "4.1.3" }, "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d8eb48939c..4b5b37f7e4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: form-data: 2.5.4 qs: 6.14.2 '@sinclair/typebox': 0.34.48 - tar: 7.5.7 + tar: 7.5.9 tough-cookie: 4.1.3 importers: @@ -161,8 +161,8 @@ importers: specifier: 0.1.7-alpha.2 version: 0.1.7-alpha.2 tar: - specifier: 7.5.7 - version: 7.5.7 + specifier: 7.5.9 + version: 7.5.9 tslog: specifier: ^4.10.2 version: 4.10.2 @@ -207,8 +207,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260214.1 - version: 7.0.0-dev.20260214.1 + specifier: 7.0.0-dev.20260215.1 + version: 7.0.0-dev.20260215.1 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.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) @@ -223,16 +223,16 @@ importers: version: 0.32.0 oxlint: specifier: ^1.47.0 - version: 1.47.0(oxlint-tsgolint@0.12.2) + version: 1.47.0(oxlint-tsgolint@0.13.0) oxlint-tsgolint: - specifier: ^0.12.2 - version: 0.12.2 + specifier: ^0.13.0 + version: 0.13.0 rolldown: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4 tsdown: specifier: ^0.20.3 - version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260214.1)(typescript@5.9.3) + version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260215.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -783,8 +783,8 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@8.0.0-rc.1': - resolution: {integrity: sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw==} + '@babel/helper-string-parser@8.0.0-rc.2': + resolution: {integrity: sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ==} engines: {node: ^20.19.0 || >=22.12.0} '@babel/helper-validator-identifier@7.28.5': @@ -2059,33 +2059,33 @@ packages: cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.12.2': - resolution: {integrity: sha512-XIfavTqkJPGYi/98z7ZCkZvXq2AccMAAB0iwvKDRTQqiweMXVUyeUdx46phCHHH1PgmIVJtVfysThkHq2xCyrw==} + '@oxlint-tsgolint/darwin-arm64@0.13.0': + resolution: {integrity: sha512-OWQ3U+oDjjupmX0WU9oYyKF2iUOKDMLW/+zan0cd0vYIGId80xTRHHA8oXnREmK8dsMMP3nV3VXME3NH/hS0lw==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.12.2': - resolution: {integrity: sha512-tytsvP6zmNShRNDo4GgQartOXmd4GPd+TylCUMdO/iWl9PZVOgRyswWbYVTNgn85Cib/aY2q3Uu+jOw+QlbxvQ==} + '@oxlint-tsgolint/darwin-x64@0.13.0': + resolution: {integrity: sha512-wZvgj+eVqNkCUjSq2ExlMdbGDpZfaw6J+YctQV1pkGFdn7Y9cySWdfwu5v/AW2JPsJbFMXJ8GAr+WoZbRapz2A==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.12.2': - resolution: {integrity: sha512-3W38yJuF7taEquhEuD6mYQyCeWNAlc1pNPjFkspkhLKZVgbrhDA4V6fCxLDDRvrTHde0bXPmFvuPlUq5pSePgA==} + '@oxlint-tsgolint/linux-arm64@0.13.0': + resolution: {integrity: sha512-nwtf5BgHbAWSVwyIF00l6QpfyFcpDMp6D+3cpe6NTgBYMSSSC0Ip1gswUwzVccOPoQK48t+J6vHyURQ96M1KDg==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.12.2': - resolution: {integrity: sha512-EjcEspeeV0NmaopEp4wcN5ntQP9VCJJDrTvzOjMP4W6ajz18M+pni9vkKvmcPIpRa/UmWobeFgKoVd/KGueeuQ==} + '@oxlint-tsgolint/linux-x64@0.13.0': + resolution: {integrity: sha512-Rkzgj38eVoGSBuGDaCrALS4FM19+m1Qlv0hjB4MWvXUej014XkB5ze+svYE3HX+AAm1ey9QYj/CQzfz203FPIg==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.12.2': - resolution: {integrity: sha512-a9L7iA5K/Ht/i8d9+7RTp6hbPa4cyXP0MdySVXAO6vczpL/4ildfY9Hr2m2wqL12uK6xe/uVABpVTrqay/wV+g==} + '@oxlint-tsgolint/win32-arm64@0.13.0': + resolution: {integrity: sha512-Y+0hFqLT5M7UIvGvTR3QFK27l17FqXk6UwwpBFOcyBGJ5bLd1RaAPWjqTmcgPvdolA6FCMeW1pxZuNtKDlYd7A==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.12.2': - resolution: {integrity: sha512-Cvt40UbTf5ib12DjGN+mMGOnjWa4Bc6Y7KEaXXp9qzckvs3HpNk2wSwMV3gnuR8Ipx4hkzkzrgzD0BAUsySAfA==} + '@oxlint-tsgolint/win32-x64@0.13.0': + resolution: {integrity: sha512-mXjTttzyyfl8d/XvxggmZFBq0pbQmRvHbjQEv70YECNaLEHG8j8WYUwLa641uudAnV1VoBI34pc7bmgJM7qhOA==} cpu: [x64] os: [win32] @@ -2995,43 +2995,43 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-Jb2WcLGpTOC6x58e8QPYC/14xmDbnbFIuKqUvYoI77hVtojVyxZi8L5Y4CgYqXYx8vRWmIFk35c1OGdtPip6Sg==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-icVO/hEMXjWlKhmpjIpqDyCzPvtHqfrPB+2rkd6M3rz84Bmw+o8Xgd7JvRxryZhR+D0y55me/bKh9xgvsgzuhA==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-O9l2gVuQFZsb8NIQtu0HN5Tn/Hw2fwylPOPS/0Y4oW+FUMhkqtvetUkb3zZ0qj7capilZ4YnmyGYg3TDqkP4Nw==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-Wz73wf1o9+4KwCLg8wnnIZZDAvv2KRZlDyP4X8GfBNzajfIAwYvI0ANWuIDznUUGeDAcqhBJXNe0Bkf4H9y4mg==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-Hl4e3yxJqzIGgFI8aH/rLGW+a7kSLHJCpAd5JOLG7hHKnamZF4SjlunnoHLV4IcMri+G6UE3W/84i0QvQP5wLA==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-AYyXRxVwLZzfkEYN8FGdV4vqXwbTmv93nAZ6gMLvpDG4ItOybAE1R2obFjlFc+Or/rfQmVvfdkTym3c4bRJ3XQ==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-TaFrVnx3iXtl/oH1hzwvFyqWj9tzkjW8Ufl2m0Vx2/7GXnzZadm2KA6tFpGbzzWbZJznmXxKHL4O3AZRQYyZqQ==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-6WVXFVSp3LBBiBgBMtAHQgTDN72mDhgjrmXH7GoABTxR9asK8oPfmy5cwTp1sPD46pYhqjnSHMrARyg2FaNSeA==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-a/JypIXTc/tdodhYdQm24WH6aTfnJJjDbwxce4BS2g6IzYSc2GFcZBvlq1CJYS2FAVLpiSxj0OFAZmgjpCDAKg==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-Ui6qbTO+nE7fwh5OGTGfL4ndaT+SpiUiv0F1m3+nMaiAKysY5GbgXUfzWzkSrOODsT8F/4jZ4wCzEzJordt8sQ==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-MJGPEDvdXj8olcWH0P+cWYcaN4r/0J4aSbcaISlen3MZ/2hrrgNl46PV4eGJKKCDniY2pH2fJzrMyJWZOcdb0w==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-dBFyAH9h3bMUaIp/84c3gKwyQ6jQmtzVoIBamSrYNw0xinJ56A/Ln5igdNOYrH8+/Aofmeh7pAWaa8U456XMjw==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-BtF48TRUyiCKznlOcQ7r7EXhonGSanm9X2eu7d8Yq1vaWO5SDgB0e+ISQXSoIfs3a1S3d5S5QV/vTE4+vocPxA==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-bEMSwX71OGGvfsfHEa/aX7ZUWbPSI2oKEmeWcDQVY8vH1VK1ZwcFzMhKfgVJPt5pKH2bK3EO3xYnAyKkDO/Ung==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-BDM0ZLf2v6ilR0tDi8OMEr4X08lFCToPk3/p1SSE4GhagzmlU/5b+9slR0kKtaKMrds01FhvaKx6U9+NmAWgbQ==} + '@typescript/native-preview@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-grs0BbJyPR7VLNerBVteEToPku1InMKVKVKBUTJi19LfK+LU3+pkU6/fsTfZhH3xmIzIxD/sNRQHLt4x/Yb9yg==} hasBin: true '@typespec/ts-http-runtime@0.3.3': @@ -4728,8 +4728,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-tsgolint@0.12.2: - resolution: {integrity: sha512-IFiOhYZfSgiHbBznTZOhFpEHpsZFSP0j7fVRake03HEkgH0YljnTFDNoRkGWsTrnrHr7nRIomSsF4TnCI/O+kQ==} + oxlint-tsgolint@0.13.0: + resolution: {integrity: sha512-VUOWP5T9R9RwuPLKvNgvhsjdPFVhr2k8no8ea84+KhDtYPmk9L/3StNP3WClyPOKJOT8bFlO3eyhTKxXK9+Oog==} hasBin: true oxlint@1.47.0: @@ -5364,8 +5364,8 @@ packages: resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} engines: {node: '>=12.17'} - tar@7.5.7: - resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} + tar@7.5.9: + resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} engines: {node: '>=18'} thenify-all@1.6.0: @@ -6285,7 +6285,7 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@8.0.0-rc.1': {} + '@babel/helper-string-parser@8.0.0-rc.2': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -6308,7 +6308,7 @@ snapshots: '@babel/types@8.0.0-rc.1': dependencies: - '@babel/helper-string-parser': 8.0.0-rc.1 + '@babel/helper-string-parser': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.1 '@bcoe/v8-coverage@1.0.2': {} @@ -7546,22 +7546,22 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.32.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.12.2': + '@oxlint-tsgolint/darwin-arm64@0.13.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.12.2': + '@oxlint-tsgolint/darwin-x64@0.13.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.12.2': + '@oxlint-tsgolint/linux-arm64@0.13.0': optional: true - '@oxlint-tsgolint/linux-x64@0.12.2': + '@oxlint-tsgolint/linux-x64@0.13.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.12.2': + '@oxlint-tsgolint/win32-arm64@0.13.0': optional: true - '@oxlint-tsgolint/win32-x64@0.12.2': + '@oxlint-tsgolint/win32-x64@0.13.0': optional: true '@oxlint/binding-android-arm-eabi@1.47.0': @@ -8473,36 +8473,36 @@ snapshots: dependencies: '@types/node': 25.2.3 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260214.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260214.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260214.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260214.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260214.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260214.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260214.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260214.1': + '@typescript/native-preview@7.0.0-dev.20260215.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260214.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260214.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260215.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260215.1 '@typespec/ts-http-runtime@0.3.3': dependencies: @@ -8990,7 +8990,7 @@ snapshots: npmlog: 6.0.2 rc: 1.2.8 semver: 7.7.4 - tar: 7.5.7 + tar: 7.5.9 url-join: 4.0.1 which: 2.0.2 yargs: 17.7.2 @@ -10385,16 +10385,16 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.32.0 '@oxfmt/binding-win32-x64-msvc': 0.32.0 - oxlint-tsgolint@0.12.2: + oxlint-tsgolint@0.13.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.12.2 - '@oxlint-tsgolint/darwin-x64': 0.12.2 - '@oxlint-tsgolint/linux-arm64': 0.12.2 - '@oxlint-tsgolint/linux-x64': 0.12.2 - '@oxlint-tsgolint/win32-arm64': 0.12.2 - '@oxlint-tsgolint/win32-x64': 0.12.2 + '@oxlint-tsgolint/darwin-arm64': 0.13.0 + '@oxlint-tsgolint/darwin-x64': 0.13.0 + '@oxlint-tsgolint/linux-arm64': 0.13.0 + '@oxlint-tsgolint/linux-x64': 0.13.0 + '@oxlint-tsgolint/win32-arm64': 0.13.0 + '@oxlint-tsgolint/win32-x64': 0.13.0 - oxlint@1.47.0(oxlint-tsgolint@0.12.2): + oxlint@1.47.0(oxlint-tsgolint@0.13.0): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.47.0 '@oxlint/binding-android-arm64': 1.47.0 @@ -10415,7 +10415,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.47.0 '@oxlint/binding-win32-ia32-msvc': 1.47.0 '@oxlint/binding-win32-x64-msvc': 1.47.0 - oxlint-tsgolint: 0.12.2 + oxlint-tsgolint: 0.13.0 p-finally@1.0.0: {} @@ -10791,7 +10791,7 @@ snapshots: dependencies: glob: 10.5.0 - rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260214.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260215.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -10804,7 +10804,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.0-rc.3 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260214.1 + '@typescript/native-preview': 7.0.0-dev.20260215.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -11211,7 +11211,7 @@ snapshots: array-back: 6.2.2 wordwrapjs: 5.1.1 - tar@7.5.7: + tar@7.5.9: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -11269,7 +11269,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260214.1)(typescript@5.9.3): + tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260215.1)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -11280,7 +11280,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.3 rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260214.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260215.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 9e293c1abdf..fcad225beda 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -15,6 +15,8 @@ COPY skills ./skills COPY patches ./patches COPY ui ./ui COPY extensions/memory-core ./extensions/memory-core +COPY vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit +COPY apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI RUN pnpm install --frozen-lockfile RUN pnpm build diff --git a/scripts/pre-commit/filter-staged-files.mjs b/scripts/pre-commit/filter-staged-files.mjs new file mode 100644 index 00000000000..7e3dcfd7abc --- /dev/null +++ b/scripts/pre-commit/filter-staged-files.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import path from "node:path"; + +/** + * Prints selected files as NUL-delimited tokens to stdout. + * + * Usage: + * node scripts/pre-commit/filter-staged-files.mjs lint -- + * node scripts/pre-commit/filter-staged-files.mjs format -- + * + * Keep this dependency-free: the pre-commit hook runs in many environments. + */ + +const mode = process.argv[2]; +const rawArgs = process.argv.slice(3); +const files = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + +if (mode !== "lint" && mode !== "format") { + process.stderr.write("usage: filter-staged-files.mjs -- \n"); + process.exit(2); +} + +const lintExts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); +const formatExts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".md", ".mdx"]); + +const shouldSelect = (filePath) => { + const ext = path.extname(filePath).toLowerCase(); + if (mode === "lint") { + return lintExts.has(ext); + } + return formatExts.has(ext); +}; + +for (const file of files) { + if (shouldSelect(file)) { + process.stdout.write(file); + process.stdout.write("\0"); + } +} diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 04b60c35565..6ea080444c3 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -27,10 +27,18 @@ const unitIsolatedFilesRaw = [ "src/browser/server.agent-contract-form-layout-act-commands.test.ts", "src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts", "src/browser/server.auth-token-gates-http.test.ts", - "src/browser/server-context.remote-tab-ops.test.ts", - "src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts", + // Keep this high-variance heavy file off the unit-fast critical path. + "src/auto-reply/reply.block-streaming.test.ts", + // Archive extraction/fixture-heavy suite; keep off unit-fast critical path. + "src/hooks/install.test.ts", + // Setup-heavy bot bootstrap suite. + "src/telegram/bot.create-telegram-bot.test.ts", + // Medium-heavy bot behavior suite; move off unit-fast critical path. + "src/telegram/bot.test.ts", + // Slack slash registration tests are setup-heavy and can bottleneck unit-fast. + "src/slack/monitor/slash.test.ts", // Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage. - "src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts", + "src/imessage/monitor.shutdown.unhandled-rejection.test.ts", ]; const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file)); @@ -165,7 +173,7 @@ const defaultWorkerBudget = unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), unitIsolated: 1, extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: 1, + gateway: 2, }; // Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM. diff --git a/skills/discord/SKILL.md b/skills/discord/SKILL.md index 18411486488..dfedea1d88b 100644 --- a/skills/discord/SKILL.md +++ b/skills/discord/SKILL.md @@ -16,6 +16,12 @@ Use the `message` tool. No provider-specific `discord` tool exposed to the agent - Prefer explicit ids: `guildId`, `channelId`, `messageId`, `userId`. - Multi-account: optional `accountId`. +## Guidelines + +- Avoid Markdown tables in outbound Discord messages. +- Mention users as `<@USER_ID>`. +- Prefer Discord components v2 (`components`) for rich UI; use legacy `embeds` only when you must. + ## Targets - Send-like actions: `to: "channel:"` or `to: "user:"`. @@ -47,6 +53,37 @@ Send with media: } ``` +- Optional `silent: true` to suppress Discord notifications. + +Send with components v2 (recommended for rich UI): + +```json +{ + "action": "send", + "channel": "discord", + "to": "channel:123", + "message": "Status update", + "components": "[Carbon v2 components]" +} +``` + +- `components` expects Carbon component instances (Container, TextDisplay, etc.) from JS/TS integrations. +- Do not combine `components` with `embeds` (Discord rejects v2 + embeds). + +Legacy embeds (not recommended): + +```json +{ + "action": "send", + "channel": "discord", + "to": "channel:123", + "message": "Status update", + "embeds": [{ "title": "Legacy", "description": "Embeds are legacy." }] +} +``` + +- `embeds` are ignored when components v2 are present. + React: ```json @@ -157,4 +194,4 @@ Presence (often gated): - Short, conversational, low ceremony. - No markdown tables. -- Prefer multiple small replies over one wall of text. +- Mention users as `<@USER_ID>`. diff --git a/skills/sherpa-onnx-tts/SKILL.md b/skills/sherpa-onnx-tts/SKILL.md index ee5daeae97b..1628660637b 100644 --- a/skills/sherpa-onnx-tts/SKILL.md +++ b/skills/sherpa-onnx-tts/SKILL.md @@ -18,7 +18,7 @@ metadata: "archive": "tar.bz2", "extract": true, "stripComponents": 1, - "targetDir": "~/.openclaw/tools/sherpa-onnx-tts/runtime", + "targetDir": "runtime", "label": "Download sherpa-onnx runtime (macOS)", }, { @@ -29,7 +29,7 @@ metadata: "archive": "tar.bz2", "extract": true, "stripComponents": 1, - "targetDir": "~/.openclaw/tools/sherpa-onnx-tts/runtime", + "targetDir": "runtime", "label": "Download sherpa-onnx runtime (Linux x64)", }, { @@ -40,7 +40,7 @@ metadata: "archive": "tar.bz2", "extract": true, "stripComponents": 1, - "targetDir": "~/.openclaw/tools/sherpa-onnx-tts/runtime", + "targetDir": "runtime", "label": "Download sherpa-onnx runtime (Windows x64)", }, { @@ -49,7 +49,7 @@ metadata: "url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2", "archive": "tar.bz2", "extract": true, - "targetDir": "~/.openclaw/tools/sherpa-onnx-tts/models", + "targetDir": "models", "label": "Download Piper en_US lessac (high)", }, ], diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index 7b266b606fc..78292b4e3ed 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -1,6 +1,7 @@ import type { RequestPermissionRequest } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; import { resolvePermissionRequest } from "./client.js"; +import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js"; function makePermissionRequest( overrides: Partial = {}, @@ -139,3 +140,32 @@ describe("resolvePermissionRequest", () => { expect(res).toEqual({ outcome: { outcome: "cancelled" } }); }); }); + +describe("acp event mapper", () => { + it("extracts text and resource blocks into prompt text", () => { + const text = extractTextFromPrompt([ + { type: "text", text: "Hello" }, + { type: "resource", resource: { text: "File contents" } }, + { type: "resource_link", uri: "https://example.com", title: "Spec" }, + { type: "image", data: "abc", mimeType: "image/png" }, + ]); + + expect(text).toBe("Hello\nFile contents\n[Resource link (Spec)] https://example.com"); + }); + + it("extracts image blocks into gateway attachments", () => { + const attachments = extractAttachmentsFromPrompt([ + { type: "image", data: "abc", mimeType: "image/png" }, + { type: "image", data: "", mimeType: "image/png" }, + { type: "text", text: "ignored" }, + ]); + + expect(attachments).toEqual([ + { + type: "image", + mimeType: "image/png", + content: "abc", + }, + ]); + }); +}); diff --git a/src/acp/event-mapper.test.ts b/src/acp/event-mapper.test.ts deleted file mode 100644 index 0b7682ef358..00000000000 --- a/src/acp/event-mapper.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js"; - -describe("acp event mapper", () => { - it("extracts text and resource blocks into prompt text", () => { - const text = extractTextFromPrompt([ - { type: "text", text: "Hello" }, - { type: "resource", resource: { text: "File contents" } }, - { type: "resource_link", uri: "https://example.com", title: "Spec" }, - { type: "image", data: "abc", mimeType: "image/png" }, - ]); - - expect(text).toBe("Hello\nFile contents\n[Resource link (Spec)] https://example.com"); - }); - - it("extracts image blocks into gateway attachments", () => { - const attachments = extractAttachmentsFromPrompt([ - { type: "image", data: "abc", mimeType: "image/png" }, - { type: "image", data: "", mimeType: "image/png" }, - { type: "text", text: "ignored" }, - ]); - - expect(attachments).toEqual([ - { - type: "image", - mimeType: "image/png", - content: "abc", - }, - ]); - }); -}); diff --git a/src/acp/session-mapper.test.ts b/src/acp/session-mapper.test.ts index 859b1da7380..ac06dcf4b89 100644 --- a/src/acp/session-mapper.test.ts +++ b/src/acp/session-mapper.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js"; +import { createInMemorySessionStore } from "./session.js"; function createGateway(resolveLabelKey = "agent:main:label"): { gateway: GatewayClient; @@ -54,3 +55,26 @@ describe("acp session mapper", () => { expect(request).not.toHaveBeenCalled(); }); }); + +describe("acp session manager", () => { + const store = createInMemorySessionStore(); + + afterEach(() => { + store.clearAllSessionsForTest(); + }); + + it("tracks active runs and clears on cancel", () => { + const session = store.createSession({ + sessionKey: "acp:test", + cwd: "/tmp", + }); + const controller = new AbortController(); + store.setActiveRun(session.sessionId, "run-1", controller); + + expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId); + + const cancelled = store.cancelActiveRun(session.sessionId); + expect(cancelled).toBe(true); + expect(store.getSessionByRunId("run-1")).toBeUndefined(); + }); +}); diff --git a/src/acp/session.test.ts b/src/acp/session.test.ts deleted file mode 100644 index a38b58f1703..00000000000 --- a/src/acp/session.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, afterEach } from "vitest"; -import { createInMemorySessionStore } from "./session.js"; - -describe("acp session manager", () => { - const store = createInMemorySessionStore(); - - afterEach(() => { - store.clearAllSessionsForTest(); - }); - - it("tracks active runs and clears on cancel", () => { - const session = store.createSession({ - sessionKey: "acp:test", - cwd: "/tmp", - }); - const controller = new AbortController(); - store.setActiveRun(session.sessionId, "run-1", controller); - - expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId); - - const cancelled = store.cancelActiveRun(session.sessionId); - expect(cancelled).toBe(true); - expect(store.getSessionByRunId("run-1")).toBeUndefined(); - }); -}); diff --git a/src/agents/agent-paths.e2e.test.ts b/src/agents/agent-paths.e2e.test.ts index f455f82862c..f0df2cbbdbc 100644 --- a/src/agents/agent-paths.e2e.test.ts +++ b/src/agents/agent-paths.e2e.test.ts @@ -2,12 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; describe("resolveOpenClawAgentDir", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const env = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR", "PI_CODING_AGENT_DIR"]); let tempStateDir: string | null = null; afterEach(async () => { @@ -15,21 +14,7 @@ describe("resolveOpenClawAgentDir", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + env.restore(); }); it("defaults to the multi-agent path when no overrides are set", async () => { diff --git a/src/agents/agent-scope.e2e.test.ts b/src/agents/agent-scope.e2e.test.ts index 8720d54d4c4..d1d3c900a49 100644 --- a/src/agents/agent-scope.e2e.test.ts +++ b/src/agents/agent-scope.e2e.test.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentConfig, resolveAgentDir, + resolveEffectiveModelFallbacks, resolveAgentModelFallbacksOverride, resolveAgentModelPrimary, resolveAgentWorkspaceDir, @@ -112,6 +113,60 @@ describe("resolveAgentConfig", () => { }, }; expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]); + + expect( + resolveEffectiveModelFallbacks({ + cfg, + agentId: "linus", + hasSessionModelOverride: false, + }), + ).toEqual(["openai/gpt-5.2"]); + expect( + resolveEffectiveModelFallbacks({ + cfg, + agentId: "linus", + hasSessionModelOverride: true, + }), + ).toEqual(["openai/gpt-5.2"]); + expect( + resolveEffectiveModelFallbacks({ + cfg: cfgNoOverride, + agentId: "linus", + hasSessionModelOverride: true, + }), + ).toEqual([]); + + const cfgInheritDefaults: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-4.1"], + }, + }, + list: [ + { + id: "linus", + model: { + primary: "anthropic/claude-opus-4", + }, + }, + ], + }, + }; + expect( + resolveEffectiveModelFallbacks({ + cfg: cfgInheritDefaults, + agentId: "linus", + hasSessionModelOverride: true, + }), + ).toEqual(["openai/gpt-4.1"]); + expect( + resolveEffectiveModelFallbacks({ + cfg: cfgDisable, + agentId: "linus", + hasSessionModelOverride: true, + }), + ).toEqual([]); }); it("should return agent-specific sandbox config", () => { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index fe7f0f6a508..178bd1ec7e4 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -7,6 +7,7 @@ import { parseAgentSessionKey, } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; +import { normalizeSkillFilter } from "./skills/filter.js"; import { resolveDefaultAgentWorkspaceDir } from "./workspace.js"; export { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; @@ -128,12 +129,7 @@ export function resolveAgentSkillsFilter( cfg: OpenClawConfig, agentId: string, ): string[] | undefined { - const raw = resolveAgentConfig(cfg, agentId)?.skills; - if (!raw) { - return undefined; - } - const normalized = raw.map((entry) => String(entry).trim()).filter(Boolean); - return normalized.length > 0 ? normalized : []; + return normalizeSkillFilter(resolveAgentConfig(cfg, agentId)?.skills); } export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined { @@ -163,6 +159,22 @@ export function resolveAgentModelFallbacksOverride( return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined; } +export function resolveEffectiveModelFallbacks(params: { + cfg: OpenClawConfig; + agentId: string; + hasSessionModelOverride: boolean; +}): string[] | undefined { + const agentFallbacksOverride = resolveAgentModelFallbacksOverride(params.cfg, params.agentId); + if (!params.hasSessionModelOverride) { + return agentFallbacksOverride; + } + const defaultFallbacks = + typeof params.cfg.agents?.defaults?.model === "object" + ? (params.cfg.agents.defaults.model.fallbacks ?? []) + : []; + return agentFallbacksOverride ?? defaultFallbacks; +} + export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) { const id = normalizeAgentId(agentId); const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); diff --git a/src/agents/auth-profiles.auth-profile-cooldowns.e2e.test.ts b/src/agents/auth-profiles.auth-profile-cooldowns.e2e.test.ts deleted file mode 100644 index e5fe3900ad0..00000000000 --- a/src/agents/auth-profiles.auth-profile-cooldowns.e2e.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { calculateAuthProfileCooldownMs } from "./auth-profiles.js"; - -describe("auth profile cooldowns", () => { - it("applies exponential backoff with a 1h cap", () => { - expect(calculateAuthProfileCooldownMs(1)).toBe(60_000); - expect(calculateAuthProfileCooldownMs(2)).toBe(5 * 60_000); - expect(calculateAuthProfileCooldownMs(3)).toBe(25 * 60_000); - expect(calculateAuthProfileCooldownMs(4)).toBe(60 * 60_000); - expect(calculateAuthProfileCooldownMs(5)).toBe(60 * 60_000); - }); -}); diff --git a/src/agents/auth-profiles.chutes.e2e.test.ts b/src/agents/auth-profiles.chutes.e2e.test.ts index 317ce9c771a..c21f37ed1ca 100644 --- a/src/agents/auth-profiles.chutes.e2e.test.ts +++ b/src/agents/auth-profiles.chutes.e2e.test.ts @@ -2,6 +2,7 @@ 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 { captureEnv } from "../test-utils/env.js"; import { type AuthProfileStore, ensureAuthProfileStore, @@ -10,10 +11,7 @@ import { import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth } from "./chutes-oauth.js"; describe("auth-profiles (chutes)", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousChutesClientId = process.env.CHUTES_CLIENT_ID; + let envSnapshot: ReturnType | undefined; let tempDir: string | null = null; afterEach(async () => { @@ -22,29 +20,17 @@ describe("auth-profiles (chutes)", () => { await fs.rm(tempDir, { recursive: true, force: true }); tempDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - if (previousChutesClientId === undefined) { - delete process.env.CHUTES_CLIENT_ID; - } else { - process.env.CHUTES_CLIENT_ID = previousChutesClientId; - } + envSnapshot?.restore(); + envSnapshot = undefined; }); it("refreshes expired Chutes OAuth credentials", async () => { + envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "CHUTES_CLIENT_ID", + ]); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chutes-")); process.env.OPENCLAW_STATE_DIR = tempDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempDir, "agents", "main", "agent"); diff --git a/src/agents/auth-profiles.markauthprofilefailure.e2e.test.ts b/src/agents/auth-profiles.markauthprofilefailure.e2e.test.ts index 0fc86907d1c..1a8cfb16ebd 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.e2e.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.e2e.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { ensureAuthProfileStore, markAuthProfileFailure } from "./auth-profiles.js"; +import { + calculateAuthProfileCooldownMs, + ensureAuthProfileStore, + markAuthProfileFailure, +} from "./auth-profiles.js"; describe("markAuthProfileFailure", () => { it("disables billing failures for ~5 hours by default", async () => { @@ -129,3 +133,13 @@ describe("markAuthProfileFailure", () => { } }); }); + +describe("calculateAuthProfileCooldownMs", () => { + it("applies exponential backoff with a 1h cap", () => { + expect(calculateAuthProfileCooldownMs(1)).toBe(60_000); + expect(calculateAuthProfileCooldownMs(2)).toBe(5 * 60_000); + expect(calculateAuthProfileCooldownMs(3)).toBe(25 * 60_000); + expect(calculateAuthProfileCooldownMs(4)).toBe(60 * 60_000); + expect(calculateAuthProfileCooldownMs(5)).toBe(60 * 60_000); + }); +}); diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts index 692b67a01cf..79f22798949 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts @@ -1,30 +1,13 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; +import { + ANTHROPIC_CFG, + ANTHROPIC_STORE, +} from "./auth-profiles.resolve-auth-profile-order.fixtures.js"; describe("resolveAuthProfileOrder", () => { - const store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; + const store = ANTHROPIC_STORE; + const cfg = ANTHROPIC_CFG; it("does not prioritize lastGood over round-robin ordering", () => { const order = resolveAuthProfileOrder({ diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts b/src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts new file mode 100644 index 00000000000..bc7b5cf983d --- /dev/null +++ b/src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts @@ -0,0 +1,26 @@ +import type { AuthProfileStore } from "./auth-profiles.js"; + +export const ANTHROPIC_STORE: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + "anthropic:work": { + type: "api_key", + provider: "anthropic", + key: "sk-work", + }, + }, +}; + +export const ANTHROPIC_CFG = { + auth: { + profiles: { + "anthropic:default": { provider: "anthropic", mode: "api_key" }, + "anthropic:work": { provider: "anthropic", mode: "api_key" }, + }, + }, +}; diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts index a6bd59b3bb6..0817f2280ea 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts @@ -2,30 +2,6 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; describe("resolveAuthProfileOrder", () => { - const _store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const _cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; - it("normalizes z.ai aliases in auth.order", () => { const order = resolveAuthProfileOrder({ cfg: { diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts index 55816522c27..2842fb48e15 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts @@ -2,30 +2,6 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; describe("resolveAuthProfileOrder", () => { - const _store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const _cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; - it("orders by lastUsed when no explicit order exists", () => { const order = resolveAuthProfileOrder({ store: { diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts index 0a4344bb6b1..c5ec9826e36 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts @@ -1,30 +1,13 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; +import { + ANTHROPIC_CFG, + ANTHROPIC_STORE, +} from "./auth-profiles.resolve-auth-profile-order.fixtures.js"; describe("resolveAuthProfileOrder", () => { - const store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; + const store = ANTHROPIC_STORE; + const cfg = ANTHROPIC_CFG; it("uses stored profiles when no config exists", () => { const order = resolveAuthProfileOrder({ diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts index 9379d387913..ea15d462f01 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts @@ -3,13 +3,16 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "./types.js"; +import { captureEnv } from "../../test-utils/env.js"; import { resolveApiKeyForProfile } from "./oauth.js"; import { ensureAuthProfileStore } from "./store.js"; describe("resolveApiKeyForProfile fallback to main agent", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); let tmpDir: string; let mainAgentDir: string; let secondaryAgentDir: string; @@ -30,22 +33,7 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { afterEach(async () => { vi.unstubAllGlobals(); - // Restore original environment - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); await fs.rm(tmpDir, { recursive: true, force: true }); }); diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 2af4e4a7f6a..d458df01d1e 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -1,17 +1,17 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { Type } from "@sinclair/typebox"; import path from "node:path"; import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; -import type { ProcessSession, SessionStdin } from "./bash-process-registry.js"; +import type { ProcessSession } from "./bash-process-registry.js"; import type { ExecToolDetails } from "./bash-tools.exec.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { mergePathPrepend } from "../infra/path-prepend.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; export { applyPathPrepend, normalizePathPrepend } from "../infra/path-prepend.js"; +import type { ManagedRun } from "../process/supervisor/index.js"; import { logWarn } from "../logger.js"; -import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js"; +import { getProcessSupervisor } from "../process/supervisor/index.js"; import { addSession, appendOutput, @@ -23,7 +23,6 @@ import { buildDockerExecArgs, chunkString, clampWithDefault, - killSession, readEnvInt, } from "./bash-tools.shared.js"; import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; @@ -147,26 +146,6 @@ export const execSchema = Type.Object({ ), }); -type PtyExitEvent = { exitCode: number; signal?: number }; -type PtyListener = (event: T) => void; -type PtyHandle = { - pid: number; - write: (data: string | Buffer) => void; - onData: (listener: PtyListener) => void; - onExit: (listener: PtyListener) => void; -}; -type PtySpawn = ( - file: string, - args: string[] | string, - options: { - name?: string; - cols?: number; - rows?: number; - cwd?: string; - env?: Record; - }, -) => PtyHandle; - export type ExecProcessOutcome = { status: "completed" | "failed"; exitCode: number | null; @@ -319,138 +298,10 @@ export async function runExecProcess(opts: { }): Promise { const startedAt = Date.now(); const sessionId = createSessionSlug(); - let child: ChildProcessWithoutNullStreams | null = null; - let pty: PtyHandle | null = null; - let stdin: SessionStdin | undefined; const execCommand = opts.execCommand ?? opts.command; + const supervisor = getProcessSupervisor(); - const spawnFallbacks = [ - { - label: "no-detach", - options: { detached: false }, - }, - ]; - - const handleSpawnFallback = (err: unknown, fallback: { label: string }) => { - const errText = formatSpawnError(err); - const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`; - logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`); - opts.warnings.push(warning); - }; - - const spawnShellChild = async ( - shell: string, - shellArgs: string[], - ): Promise => { - const { child: spawned } = await spawnWithFallback({ - argv: [shell, ...shellArgs, execCommand], - options: { - cwd: opts.workdir, - env: opts.env, - detached: process.platform !== "win32", - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }, - fallbacks: spawnFallbacks, - onFallback: handleSpawnFallback, - }); - return spawned as ChildProcessWithoutNullStreams; - }; - - // `exec` does not currently accept tool-provided stdin content. For non-PTY runs, - // keeping stdin open can cause commands like `wc -l` (or safeBins-hardened segments) - // to block forever waiting for input, leading to accidental backgrounding. - // For interactive flows, callers should use `pty: true` (stdin kept open). - const maybeCloseNonPtyStdin = () => { - if (opts.usePty) { - return; - } - try { - // Signal EOF immediately so stdin-only commands can terminate. - child?.stdin?.end(); - } catch { - // ignore stdin close errors - } - }; - - if (opts.sandbox) { - const { child: spawned } = await spawnWithFallback({ - argv: [ - "docker", - ...buildDockerExecArgs({ - containerName: opts.sandbox.containerName, - command: execCommand, - workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, - env: opts.env, - tty: opts.usePty, - }), - ], - options: { - cwd: opts.workdir, - env: process.env, - detached: process.platform !== "win32", - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }, - fallbacks: spawnFallbacks, - onFallback: handleSpawnFallback, - }); - child = spawned as ChildProcessWithoutNullStreams; - stdin = child.stdin; - maybeCloseNonPtyStdin(); - } else if (opts.usePty) { - const { shell, args: shellArgs } = getShellConfig(); - try { - const ptyModule = (await import("@lydell/node-pty")) as unknown as { - spawn?: PtySpawn; - default?: { spawn?: PtySpawn }; - }; - const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn; - if (!spawnPty) { - throw new Error("PTY support is unavailable (node-pty spawn not found)."); - } - pty = spawnPty(shell, [...shellArgs, execCommand], { - cwd: opts.workdir, - env: opts.env, - name: process.env.TERM ?? "xterm-256color", - cols: 120, - rows: 30, - }); - stdin = { - destroyed: false, - write: (data, cb) => { - try { - pty?.write(data); - cb?.(null); - } catch (err) { - cb?.(err as Error); - } - }, - end: () => { - try { - const eof = process.platform === "win32" ? "\x1a" : "\x04"; - pty?.write(eof); - } catch { - // ignore EOF errors - } - }, - }; - } catch (err) { - const errText = String(err); - const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`; - logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`); - opts.warnings.push(warning); - child = await spawnShellChild(shell, shellArgs); - stdin = child.stdin; - } - } else { - const { shell, args: shellArgs } = getShellConfig(); - child = await spawnShellChild(shell, shellArgs); - stdin = child.stdin; - maybeCloseNonPtyStdin(); - } - - const session = { + const session: ProcessSession = { id: sessionId, command: opts.command, scopeKey: opts.scopeKey, @@ -458,9 +309,9 @@ export async function runExecProcess(opts: { notifyOnExit: opts.notifyOnExit, notifyOnExitEmptySuccess: opts.notifyOnExitEmptySuccess === true, exitNotified: false, - child: child ?? undefined, - stdin, - pid: child?.pid ?? pty?.pid, + child: undefined, + stdin: undefined, + pid: undefined, startedAt, cwd: opts.workdir, maxOutputChars: opts.maxOutput, @@ -477,59 +328,9 @@ export async function runExecProcess(opts: { exitSignal: undefined as NodeJS.Signals | number | null | undefined, truncated: false, backgrounded: false, - } satisfies ProcessSession; + }; addSession(session); - let settled = false; - let timeoutTimer: NodeJS.Timeout | null = null; - let timeoutFinalizeTimer: NodeJS.Timeout | null = null; - let timedOut = false; - const timeoutFinalizeMs = 1000; - let resolveFn: ((outcome: ExecProcessOutcome) => void) | null = null; - - const settle = (outcome: ExecProcessOutcome) => { - if (settled) { - return; - } - settled = true; - resolveFn?.(outcome); - }; - - const finalizeTimeout = () => { - if (session.exited) { - return; - } - markExited(session, null, "SIGKILL", "failed"); - maybeNotifyOnExit(session, "failed"); - const aggregated = session.aggregated.trim(); - const reason = `Command timed out after ${opts.timeoutSec} seconds`; - settle({ - status: "failed", - exitCode: null, - exitSignal: "SIGKILL", - durationMs: Date.now() - startedAt, - aggregated, - timedOut: true, - reason: aggregated ? `${aggregated}\n\n${reason}` : reason, - }); - }; - - const onTimeout = () => { - timedOut = true; - killSession(session); - if (!timeoutFinalizeTimer) { - timeoutFinalizeTimer = setTimeout(() => { - finalizeTimeout(); - }, timeoutFinalizeMs); - } - }; - - if (opts.timeoutSec > 0) { - timeoutTimer = setTimeout(() => { - onTimeout(); - }, opts.timeoutSec * 1000); - } - const emitUpdate = () => { if (!opts.onUpdate) { return; @@ -565,116 +366,208 @@ export async function runExecProcess(opts: { } }; - if (pty) { - const cursorResponse = buildCursorPositionResponse(); - pty.onData((data) => { - const raw = data.toString(); - const { cleaned, requests } = stripDsrRequests(raw); - if (requests > 0) { + const timeoutMs = + typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 + ? Math.floor(opts.timeoutSec * 1000) + : undefined; + + const spawnSpec: + | { + mode: "child"; + argv: string[]; + env: NodeJS.ProcessEnv; + stdinMode: "pipe-open" | "pipe-closed"; + } + | { + mode: "pty"; + ptyCommand: string; + childFallbackArgv: string[]; + env: NodeJS.ProcessEnv; + stdinMode: "pipe-open"; + } = (() => { + if (opts.sandbox) { + return { + mode: "child" as const, + argv: [ + "docker", + ...buildDockerExecArgs({ + containerName: opts.sandbox.containerName, + command: execCommand, + workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, + env: opts.env, + tty: opts.usePty, + }), + ], + env: process.env, + stdinMode: opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const), + }; + } + const { shell, args: shellArgs } = getShellConfig(); + const childArgv = [shell, ...shellArgs, execCommand]; + if (opts.usePty) { + return { + mode: "pty" as const, + ptyCommand: execCommand, + childFallbackArgv: childArgv, + env: opts.env, + stdinMode: "pipe-open" as const, + }; + } + return { + mode: "child" as const, + argv: childArgv, + env: opts.env, + stdinMode: "pipe-closed" as const, + }; + })(); + + let managedRun: ManagedRun | null = null; + let usingPty = spawnSpec.mode === "pty"; + const cursorResponse = buildCursorPositionResponse(); + + const onSupervisorStdout = (chunk: string) => { + if (usingPty) { + const { cleaned, requests } = stripDsrRequests(chunk); + if (requests > 0 && managedRun?.stdin) { for (let i = 0; i < requests; i += 1) { - pty.write(cursorResponse); + managedRun.stdin.write(cursorResponse); } } handleStdout(cleaned); - }); - } else if (child) { - child.stdout.on("data", handleStdout); - child.stderr.on("data", handleStderr); - } + return; + } + handleStdout(chunk); + }; - const promise = new Promise((resolve) => { - resolveFn = resolve; - const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => { - if (timeoutTimer) { - clearTimeout(timeoutTimer); - } - if (timeoutFinalizeTimer) { - clearTimeout(timeoutFinalizeTimer); + try { + const spawnBase = { + runId: sessionId, + sessionId: opts.sessionKey?.trim() || sessionId, + backendId: opts.sandbox ? "exec-sandbox" : "exec-host", + scopeKey: opts.scopeKey, + cwd: opts.workdir, + env: spawnSpec.env, + timeoutMs, + captureOutput: false, + onStdout: onSupervisorStdout, + onStderr: handleStderr, + }; + managedRun = + spawnSpec.mode === "pty" + ? await supervisor.spawn({ + ...spawnBase, + mode: "pty", + ptyCommand: spawnSpec.ptyCommand, + }) + : await supervisor.spawn({ + ...spawnBase, + mode: "child", + argv: spawnSpec.argv, + stdinMode: spawnSpec.stdinMode, + }); + } catch (err) { + if (spawnSpec.mode === "pty") { + const warning = `Warning: PTY spawn failed (${String(err)}); retrying without PTY for \`${opts.command}\`.`; + logWarn( + `exec: PTY spawn failed (${String(err)}); retrying without PTY for "${opts.command}".`, + ); + opts.warnings.push(warning); + usingPty = false; + try { + managedRun = await supervisor.spawn({ + runId: sessionId, + sessionId: opts.sessionKey?.trim() || sessionId, + backendId: "exec-host", + scopeKey: opts.scopeKey, + mode: "child", + argv: spawnSpec.childFallbackArgv, + cwd: opts.workdir, + env: spawnSpec.env, + stdinMode: "pipe-open", + timeoutMs, + captureOutput: false, + onStdout: handleStdout, + onStderr: handleStderr, + }); + } catch (retryErr) { + markExited(session, null, null, "failed"); + maybeNotifyOnExit(session, "failed"); + throw retryErr; } + } else { + markExited(session, null, null, "failed"); + maybeNotifyOnExit(session, "failed"); + throw err; + } + } + session.stdin = managedRun.stdin; + session.pid = managedRun.pid; + + const promise = managedRun + .wait() + .then((exit): ExecProcessOutcome => { const durationMs = Date.now() - startedAt; - const wasSignal = exitSignal != null; - const isSuccess = code === 0 && !wasSignal && !timedOut; - const status: "completed" | "failed" = isSuccess ? "completed" : "failed"; - markExited(session, code, exitSignal, status); + const status: "completed" | "failed" = + exit.exitCode === 0 && exit.reason === "exit" ? "completed" : "failed"; + markExited(session, exit.exitCode, exit.exitSignal, status); maybeNotifyOnExit(session, status); if (!session.child && session.stdin) { session.stdin.destroyed = true; } - - if (settled) { - return; - } const aggregated = session.aggregated.trim(); - if (!isSuccess) { - const reason = timedOut - ? `Command timed out after ${opts.timeoutSec} seconds` - : wasSignal && exitSignal - ? `Command aborted by signal ${exitSignal}` - : code === null - ? "Command aborted before exit code was captured" - : `Command exited with code ${code}`; - const message = aggregated ? `${aggregated}\n\n${reason}` : reason; - settle({ - status: "failed", - exitCode: code ?? null, - exitSignal: exitSignal ?? null, + if (status === "completed") { + return { + status: "completed", + exitCode: exit.exitCode ?? 0, + exitSignal: exit.exitSignal, durationMs, aggregated, - timedOut, - reason: message, - }); - return; + timedOut: false, + }; } - settle({ - status: "completed", - exitCode: code ?? 0, - exitSignal: exitSignal ?? null, + const reason = + exit.reason === "overall-timeout" + ? `Command timed out after ${opts.timeoutSec} seconds` + : exit.reason === "no-output-timeout" + ? "Command timed out waiting for output" + : exit.exitSignal != null + ? `Command aborted by signal ${exit.exitSignal}` + : exit.exitCode == null + ? "Command aborted before exit code was captured" + : `Command exited with code ${exit.exitCode}`; + return { + status: "failed", + exitCode: exit.exitCode, + exitSignal: exit.exitSignal, durationMs, aggregated, + timedOut: exit.timedOut, + reason: aggregated ? `${aggregated}\n\n${reason}` : reason, + }; + }) + .catch((err): ExecProcessOutcome => { + markExited(session, null, null, "failed"); + maybeNotifyOnExit(session, "failed"); + const aggregated = session.aggregated.trim(); + const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err); + return { + status: "failed", + exitCode: null, + exitSignal: null, + durationMs: Date.now() - startedAt, + aggregated, timedOut: false, - }); - }; - - if (pty) { - pty.onExit((event) => { - const rawSignal = event.signal ?? null; - const normalizedSignal = rawSignal === 0 ? null : rawSignal; - handleExit(event.exitCode ?? null, normalizedSignal); - }); - } else if (child) { - child.once("close", (code, exitSignal) => { - handleExit(code, exitSignal); - }); - - child.once("error", (err) => { - if (timeoutTimer) { - clearTimeout(timeoutTimer); - } - if (timeoutFinalizeTimer) { - clearTimeout(timeoutFinalizeTimer); - } - markExited(session, null, null, "failed"); - maybeNotifyOnExit(session, "failed"); - const aggregated = session.aggregated.trim(); - const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err); - settle({ - status: "failed", - exitCode: null, - exitSignal: null, - durationMs: Date.now() - startedAt, - aggregated, - timedOut, - reason: message, - }); - }); - } - }); + reason: message, + }; + }); return { session, startedAt, pid: session.pid ?? undefined, promise, - kill: () => killSession(session), + kill: () => { + managedRun?.cancel("manual-cancel"); + }, }; } diff --git a/src/agents/bash-tools.exec.pty-cleanup.test.ts b/src/agents/bash-tools.exec.pty-cleanup.test.ts new file mode 100644 index 00000000000..efe6f01d606 --- /dev/null +++ b/src/agents/bash-tools.exec.pty-cleanup.test.ts @@ -0,0 +1,71 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { resetProcessRegistryForTests } from "./bash-process-registry"; +import { createExecTool } from "./bash-tools.exec"; + +const { ptySpawnMock } = vi.hoisted(() => ({ + ptySpawnMock: vi.fn(), +})); + +vi.mock("@lydell/node-pty", () => ({ + spawn: (...args: unknown[]) => ptySpawnMock(...args), +})); + +afterEach(() => { + resetProcessRegistryForTests(); + vi.clearAllMocks(); +}); + +test("exec disposes PTY listeners after normal exit", async () => { + const disposeData = vi.fn(); + const disposeExit = vi.fn(); + + ptySpawnMock.mockImplementation(() => ({ + pid: 0, + write: vi.fn(), + onData: (listener: (value: string) => void) => { + listener("ok"); + return { dispose: disposeData }; + }, + onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => { + listener({ exitCode: 0 }); + return { dispose: disposeExit }; + }, + kill: vi.fn(), + })); + + const tool = createExecTool({ allowBackground: false }); + const result = await tool.execute("toolcall", { + command: "echo ok", + pty: true, + }); + + expect(result.details.status).toBe("completed"); + expect(disposeData).toHaveBeenCalledTimes(1); + expect(disposeExit).toHaveBeenCalledTimes(1); +}); + +test("exec tears down PTY resources on timeout", async () => { + const disposeData = vi.fn(); + const disposeExit = vi.fn(); + const kill = vi.fn(); + + ptySpawnMock.mockImplementation(() => ({ + pid: 0, + write: vi.fn(), + onData: () => ({ dispose: disposeData }), + onExit: () => ({ dispose: disposeExit }), + kill, + })); + + const tool = createExecTool({ allowBackground: false }); + await expect( + tool.execute("toolcall", { + command: "sleep 5", + pty: true, + timeout: 0.01, + }), + ).rejects.toThrow("Command timed out"); + expect(kill).toHaveBeenCalledTimes(1); + expect(disposeData).toHaveBeenCalledTimes(1); + expect(disposeExit).toHaveBeenCalledTimes(1); +}); diff --git a/src/agents/bash-tools.exec.pty-fallback-failure.test.ts b/src/agents/bash-tools.exec.pty-fallback-failure.test.ts new file mode 100644 index 00000000000..2caad66a83f --- /dev/null +++ b/src/agents/bash-tools.exec.pty-fallback-failure.test.ts @@ -0,0 +1,39 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { listRunningSessions, resetProcessRegistryForTests } from "./bash-process-registry"; +import { createExecTool } from "./bash-tools.exec"; + +const { supervisorSpawnMock } = vi.hoisted(() => ({ + supervisorSpawnMock: vi.fn(), +})); + +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => ({ + spawn: (...args: unknown[]) => supervisorSpawnMock(...args), + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), +})); + +afterEach(() => { + resetProcessRegistryForTests(); + vi.clearAllMocks(); +}); + +test("exec cleans session state when PTY fallback spawn also fails", async () => { + supervisorSpawnMock + .mockRejectedValueOnce(new Error("pty spawn failed")) + .mockRejectedValueOnce(new Error("child fallback failed")); + + const tool = createExecTool({ allowBackground: false }); + + await expect( + tool.execute("toolcall", { + command: "echo ok", + pty: true, + }), + ).rejects.toThrow("child fallback failed"); + + expect(listRunningSessions()).toHaveLength(0); +}); diff --git a/src/agents/bash-tools.process.poll-timeout.test.ts b/src/agents/bash-tools.process.poll-timeout.test.ts index e72d95a3426..44e3bb74153 100644 --- a/src/agents/bash-tools.process.poll-timeout.test.ts +++ b/src/agents/bash-tools.process.poll-timeout.test.ts @@ -1,56 +1,100 @@ -import { afterEach, expect, test } from "vitest"; -import { resetProcessRegistryForTests } from "./bash-process-registry.js"; -import { createExecTool } from "./bash-tools.exec.js"; +import { afterEach, expect, test, vi } from "vitest"; +import type { ProcessSession } from "./bash-process-registry.js"; +import { + addSession, + appendOutput, + markExited, + resetProcessRegistryForTests, +} from "./bash-process-registry.js"; import { createProcessTool } from "./bash-tools.process.js"; afterEach(() => { resetProcessRegistryForTests(); }); -const sleepAndEcho = - process.platform === "win32" - ? "Start-Sleep -Milliseconds 300; Write-Output done" - : "sleep 0.3; echo done"; +function createBackgroundSession(id: string): ProcessSession { + return { + id, + command: "test", + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 10_000, + pendingMaxOutputChars: 30_000, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + pendingStdoutChars: 0, + pendingStderrChars: 0, + aggregated: "", + tail: "", + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: true, + }; +} test("process poll waits for completion when timeout is provided", async () => { - const execTool = createExecTool(); - const processTool = createProcessTool(); - const started = Date.now(); - const run = await execTool.execute("toolcall", { - command: sleepAndEcho, - background: true, - }); - expect(run.details.status).toBe("running"); - const sessionId = run.details.sessionId; + vi.useFakeTimers(); + try { + const processTool = createProcessTool(); + const sessionId = "sess"; + const session = createBackgroundSession(sessionId); + addSession(session); - const poll = await processTool.execute("toolcall", { - action: "poll", - sessionId, - timeout: 2000, - }); - const elapsedMs = Date.now() - started; - const details = poll.details as { status?: string; aggregated?: string }; - expect(details.status).toBe("completed"); - expect(details.aggregated ?? "").toContain("done"); - expect(elapsedMs).toBeGreaterThanOrEqual(200); + setTimeout(() => { + appendOutput(session, "stdout", "done\n"); + markExited(session, 0, null, "completed"); + }, 10); + + const pollPromise = processTool.execute("toolcall", { + action: "poll", + sessionId, + timeout: 2000, + }); + + let resolved = false; + void pollPromise.finally(() => { + resolved = true; + }); + + await vi.advanceTimersByTimeAsync(200); + expect(resolved).toBe(false); + + await vi.advanceTimersByTimeAsync(100); + const poll = await pollPromise; + const details = poll.details as { status?: string; aggregated?: string }; + expect(details.status).toBe("completed"); + expect(details.aggregated ?? "").toContain("done"); + } finally { + vi.useRealTimers(); + } }); test("process poll accepts string timeout values", async () => { - const execTool = createExecTool(); - const processTool = createProcessTool(); - const run = await execTool.execute("toolcall", { - command: sleepAndEcho, - background: true, - }); - expect(run.details.status).toBe("running"); - const sessionId = run.details.sessionId; + vi.useFakeTimers(); + try { + const processTool = createProcessTool(); + const sessionId = "sess-2"; + const session = createBackgroundSession(sessionId); + addSession(session); + setTimeout(() => { + appendOutput(session, "stdout", "done\n"); + markExited(session, 0, null, "completed"); + }, 10); - const poll = await processTool.execute("toolcall", { - action: "poll", - sessionId, - timeout: "2000", - }); - const details = poll.details as { status?: string; aggregated?: string }; - expect(details.status).toBe("completed"); - expect(details.aggregated ?? "").toContain("done"); + const pollPromise = processTool.execute("toolcall", { + action: "poll", + sessionId, + timeout: "2000", + }); + await vi.advanceTimersByTimeAsync(350); + const poll = await pollPromise; + const details = poll.details as { status?: string; aggregated?: string }; + expect(details.status).toBe("completed"); + expect(details.aggregated ?? "").toContain("done"); + } finally { + vi.useRealTimers(); + } }); diff --git a/src/agents/bash-tools.process.supervisor.test.ts b/src/agents/bash-tools.process.supervisor.test.ts new file mode 100644 index 00000000000..e6d026595f4 --- /dev/null +++ b/src/agents/bash-tools.process.supervisor.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProcessSession } from "./bash-process-registry.js"; +import { + addSession, + getFinishedSession, + getSession, + resetProcessRegistryForTests, +} from "./bash-process-registry.js"; +import { createProcessTool } from "./bash-tools.process.js"; + +const { supervisorMock } = vi.hoisted(() => ({ + supervisorMock: { + spawn: vi.fn(), + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }, +})); + +const { killProcessTreeMock } = vi.hoisted(() => ({ + killProcessTreeMock: vi.fn(), +})); + +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => supervisorMock, +})); + +vi.mock("../process/kill-tree.js", () => ({ + killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args), +})); + +function createBackgroundSession(id: string, pid?: number): ProcessSession { + return { + id, + command: "sleep 999", + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 10_000, + pendingMaxOutputChars: 30_000, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + pendingStdoutChars: 0, + pendingStderrChars: 0, + aggregated: "", + tail: "", + pid, + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: true, + }; +} + +describe("process tool supervisor cancellation", () => { + beforeEach(() => { + supervisorMock.spawn.mockReset(); + supervisorMock.cancel.mockReset(); + supervisorMock.cancelScope.mockReset(); + supervisorMock.reconcileOrphans.mockReset(); + supervisorMock.getRecord.mockReset(); + killProcessTreeMock.mockReset(); + }); + + afterEach(() => { + resetProcessRegistryForTests(); + }); + + it("routes kill through supervisor when run is managed", async () => { + supervisorMock.getRecord.mockReturnValue({ + runId: "sess", + state: "running", + }); + addSession(createBackgroundSession("sess")); + const processTool = createProcessTool(); + + const result = await processTool.execute("toolcall", { + action: "kill", + sessionId: "sess", + }); + + expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel"); + expect(getSession("sess")).toBeDefined(); + expect(getSession("sess")?.exited).toBe(false); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Termination requested for session sess.", + }); + }); + + it("remove drops running session immediately when cancellation is requested", async () => { + supervisorMock.getRecord.mockReturnValue({ + runId: "sess", + state: "running", + }); + addSession(createBackgroundSession("sess")); + const processTool = createProcessTool(); + + const result = await processTool.execute("toolcall", { + action: "remove", + sessionId: "sess", + }); + + expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel"); + expect(getSession("sess")).toBeUndefined(); + expect(getFinishedSession("sess")).toBeUndefined(); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Removed session sess (termination requested).", + }); + }); + + it("falls back to process-tree kill when supervisor record is missing", async () => { + supervisorMock.getRecord.mockReturnValue(undefined); + addSession(createBackgroundSession("sess-fallback", 4242)); + const processTool = createProcessTool(); + + const result = await processTool.execute("toolcall", { + action: "kill", + sessionId: "sess-fallback", + }); + + expect(killProcessTreeMock).toHaveBeenCalledWith(4242); + expect(getSession("sess-fallback")).toBeUndefined(); + expect(getFinishedSession("sess-fallback")).toBeDefined(); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Killed session sess-fallback.", + }); + }); + + it("fails remove when no supervisor record and no pid is available", async () => { + supervisorMock.getRecord.mockReturnValue(undefined); + addSession(createBackgroundSession("sess-no-pid")); + const processTool = createProcessTool(); + + const result = await processTool.execute("toolcall", { + action: "remove", + sessionId: "sess-no-pid", + }); + + expect(killProcessTreeMock).not.toHaveBeenCalled(); + expect(getSession("sess-no-pid")).toBeDefined(); + expect(result.details).toMatchObject({ status: "failed" }); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Unable to remove session sess-no-pid: no active supervisor run or process id.", + }); + }); +}); diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index b5966ab79b0..38a8ac357ab 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -1,7 +1,10 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; +import { killProcessTree } from "../process/kill-tree.js"; +import { getProcessSupervisor } from "../process/supervisor/index.js"; import { + type ProcessSession, deleteSession, drainSession, getFinishedSession, @@ -11,13 +14,7 @@ import { markExited, setJobTtlMs, } from "./bash-process-registry.js"; -import { - deriveSessionName, - killSession, - pad, - sliceLogLines, - truncateMiddle, -} from "./bash-tools.shared.js"; +import { deriveSessionName, pad, sliceLogLines, truncateMiddle } from "./bash-tools.shared.js"; import { encodeKeySequence, encodePaste } from "./pty-keys.js"; export type ProcessToolDefaults = { @@ -65,8 +62,9 @@ const processSchema = Type.Object({ offset: Type.Optional(Type.Number({ description: "Log offset" })), limit: Type.Optional(Type.Number({ description: "Log length" })), timeout: Type.Optional( - Type.Union([Type.Number(), Type.String()], { + Type.Number({ description: "For poll: wait up to this many milliseconds before returning", + minimum: 0, }), ), }); @@ -106,9 +104,28 @@ export function createProcessTool( setJobTtlMs(defaults.cleanupMs); } const scopeKey = defaults?.scopeKey; + const supervisor = getProcessSupervisor(); const isInScope = (session?: { scopeKey?: string } | null) => !scopeKey || session?.scopeKey === scopeKey; + const cancelManagedSession = (sessionId: string) => { + const record = supervisor.getRecord(sessionId); + if (!record || record.state === "exited") { + return false; + } + supervisor.cancel(sessionId, "manual-cancel"); + return true; + }; + + const terminateSessionFallback = (session: ProcessSession) => { + const pid = session.pid ?? session.child?.pid; + if (typeof pid !== "number" || !Number.isFinite(pid) || pid <= 0) { + return false; + } + killProcessTree(pid); + return true; + }; + return { name: "process", label: "process", @@ -138,7 +155,7 @@ export function createProcessTool( eof?: boolean; offset?: number; limit?: number; - timeout?: number | string; + timeout?: unknown; }; if (params.action === "list") { @@ -522,10 +539,25 @@ export function createProcessTool( if (!scopedSession.backgrounded) { return failText(`Session ${params.sessionId} is not backgrounded.`); } - killSession(scopedSession); - markExited(scopedSession, null, "SIGKILL", "failed"); + const canceled = cancelManagedSession(scopedSession.id); + if (!canceled) { + const terminated = terminateSessionFallback(scopedSession); + if (!terminated) { + return failText( + `Unable to terminate session ${params.sessionId}: no active supervisor run or process id.`, + ); + } + markExited(scopedSession, null, "SIGKILL", "failed"); + } return { - content: [{ type: "text", text: `Killed session ${params.sessionId}.` }], + content: [ + { + type: "text", + text: canceled + ? `Termination requested for session ${params.sessionId}.` + : `Killed session ${params.sessionId}.`, + }, + ], details: { status: "failed", name: scopedSession ? deriveSessionName(scopedSession.command) : undefined, @@ -554,10 +586,30 @@ export function createProcessTool( case "remove": { if (scopedSession) { - killSession(scopedSession); - markExited(scopedSession, null, "SIGKILL", "failed"); + const canceled = cancelManagedSession(scopedSession.id); + if (canceled) { + // Keep remove semantics deterministic: drop from process registry now. + scopedSession.backgrounded = false; + deleteSession(params.sessionId); + } else { + const terminated = terminateSessionFallback(scopedSession); + if (!terminated) { + return failText( + `Unable to remove session ${params.sessionId}: no active supervisor run or process id.`, + ); + } + markExited(scopedSession, null, "SIGKILL", "failed"); + deleteSession(params.sessionId); + } return { - content: [{ type: "text", text: `Removed session ${params.sessionId}.` }], + content: [ + { + type: "text", + text: canceled + ? `Removed session ${params.sessionId} (termination requested).` + : `Removed session ${params.sessionId}.`, + }, + ], details: { status: "failed", name: scopedSession ? deriveSessionName(scopedSession.command) : undefined, diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index 99a7a4b792f..07b12266006 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -1,11 +1,9 @@ -import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync, statSync } from "node:fs"; import fs from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { sliceUtf16Safe } from "../utils.js"; import { assertSandboxPath } from "./sandbox-paths.js"; -import { killProcessTree } from "./shell-utils.js"; const CHUNK_LIMIT = 8 * 1024; @@ -115,13 +113,6 @@ export async function resolveSandboxWorkdir(params: { } } -export function killSession(session: { pid?: number; child?: ChildProcessWithoutNullStreams }) { - const pid = session.pid ?? session.child?.pid; - if (pid) { - killProcessTree(pid); - } -} - export function resolveWorkdir(workdir: string, warnings: string[]) { const current = safeCwd(); const fallback = current ?? homedir(); diff --git a/src/agents/claude-cli-runner.e2e.test.ts b/src/agents/claude-cli-runner.e2e.test.ts index 587a13ff2dd..afa353daba3 100644 --- a/src/agents/claude-cli-runner.e2e.test.ts +++ b/src/agents/claude-cli-runner.e2e.test.ts @@ -2,7 +2,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { sleep } from "../utils.js"; import { runClaudeCliAgent } from "./claude-cli-runner.js"; -const runCommandWithTimeoutMock = vi.fn(); +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), +})); + +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => ({ + spawn: (...args: unknown[]) => mocks.spawn(...args), + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: async () => {}, + getRecord: vi.fn(), + }), +})); function createDeferred() { let resolve: (value: T) => void; @@ -18,6 +30,40 @@ function createDeferred() { }; } +function createManagedRun( + exit: Promise<{ + reason: "exit" | "overall-timeout" | "no-output-timeout" | "signal" | "manual-cancel"; + exitCode: number | null; + exitSignal: NodeJS.Signals | null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: boolean; + noOutputTimedOut: boolean; + }>, +) { + return { + runId: "run-test", + pid: 12345, + startedAtMs: Date.now(), + wait: async () => await exit, + cancel: vi.fn(), + }; +} + +function successExit(payload: { message: string; session_id: string }) { + return { + reason: "exit" as const, + exitCode: 0, + exitSignal: null, + durationMs: 1, + stdout: JSON.stringify(payload), + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }; +} + async function waitForCalls(mockFn: { mock: { calls: unknown[][] } }, count: number) { for (let i = 0; i < 50; i += 1) { if (mockFn.mock.calls.length >= count) { @@ -28,23 +74,15 @@ async function waitForCalls(mockFn: { mock: { calls: unknown[][] } }, count: num throw new Error(`Expected ${count} calls, got ${mockFn.mock.calls.length}`); } -vi.mock("../process/exec.js", () => ({ - runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), -})); - describe("runClaudeCliAgent", () => { beforeEach(() => { - runCommandWithTimeoutMock.mockReset(); + mocks.spawn.mockReset(); }); it("starts a new session with --session-id when none is provided", async () => { - runCommandWithTimeoutMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ message: "ok", session_id: "sid-1" }), - stderr: "", - code: 0, - signal: null, - killed: false, - }); + mocks.spawn.mockResolvedValueOnce( + createManagedRun(Promise.resolve(successExit({ message: "ok", session_id: "sid-1" }))), + ); await runClaudeCliAgent({ sessionId: "openclaw-session", @@ -56,21 +94,18 @@ describe("runClaudeCliAgent", () => { runId: "run-1", }); - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); - const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[]; - expect(argv).toContain("claude"); - expect(argv).toContain("--session-id"); - expect(argv).toContain("hi"); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + const spawnInput = mocks.spawn.mock.calls[0]?.[0] as { argv: string[]; mode: string }; + expect(spawnInput.mode).toBe("child"); + expect(spawnInput.argv).toContain("claude"); + expect(spawnInput.argv).toContain("--session-id"); + expect(spawnInput.argv).toContain("hi"); }); it("uses --resume when a claude session id is provided", async () => { - runCommandWithTimeoutMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }), - stderr: "", - code: 0, - signal: null, - killed: false, - }); + mocks.spawn.mockResolvedValueOnce( + createManagedRun(Promise.resolve(successExit({ message: "ok", session_id: "sid-2" }))), + ); await runClaudeCliAgent({ sessionId: "openclaw-session", @@ -83,32 +118,21 @@ describe("runClaudeCliAgent", () => { claudeSessionId: "c9d7b831-1c31-4d22-80b9-1e50ca207d4b", }); - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); - const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[]; - expect(argv).toContain("--resume"); - expect(argv).toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b"); - expect(argv).toContain("hi"); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + const spawnInput = mocks.spawn.mock.calls[0]?.[0] as { argv: string[] }; + expect(spawnInput.argv).toContain("--resume"); + expect(spawnInput.argv).toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b"); + expect(spawnInput.argv).not.toContain("--session-id"); + expect(spawnInput.argv).toContain("hi"); }); it("serializes concurrent claude-cli runs", async () => { - const firstDeferred = createDeferred<{ - stdout: string; - stderr: string; - code: number | null; - signal: NodeJS.Signals | null; - killed: boolean; - }>(); - const secondDeferred = createDeferred<{ - stdout: string; - stderr: string; - code: number | null; - signal: NodeJS.Signals | null; - killed: boolean; - }>(); + const firstDeferred = createDeferred>(); + const secondDeferred = createDeferred>(); - runCommandWithTimeoutMock - .mockImplementationOnce(() => firstDeferred.promise) - .mockImplementationOnce(() => secondDeferred.promise); + mocks.spawn + .mockResolvedValueOnce(createManagedRun(firstDeferred.promise)) + .mockResolvedValueOnce(createManagedRun(secondDeferred.promise)); const firstRun = runClaudeCliAgent({ sessionId: "s1", @@ -130,25 +154,13 @@ describe("runClaudeCliAgent", () => { runId: "run-2", }); - await waitForCalls(runCommandWithTimeoutMock, 1); + await waitForCalls(mocks.spawn, 1); - firstDeferred.resolve({ - stdout: JSON.stringify({ message: "ok", session_id: "sid-1" }), - stderr: "", - code: 0, - signal: null, - killed: false, - }); + firstDeferred.resolve(successExit({ message: "ok", session_id: "sid-1" })); - await waitForCalls(runCommandWithTimeoutMock, 2); + await waitForCalls(mocks.spawn, 2); - secondDeferred.resolve({ - stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }), - stderr: "", - code: 0, - signal: null, - killed: false, - }); + secondDeferred.resolve(successExit({ message: "ok", session_id: "sid-2" })); await Promise.all([firstRun, secondRun]); }); diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts new file mode 100644 index 00000000000..c78dfdb87fc --- /dev/null +++ b/src/agents/cli-backends.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveCliBackendConfig } from "./cli-backends.js"; + +describe("resolveCliBackendConfig reliability merge", () => { + it("deep-merges reliability watchdog overrides for codex", () => { + const cfg = { + agents: { + defaults: { + cliBackends: { + "codex-cli": { + command: "codex", + reliability: { + watchdog: { + resume: { + noOutputTimeoutMs: 42_000, + }, + }, + }, + }, + }, + }, + }, + } satisfies OpenClawConfig; + + const resolved = resolveCliBackendConfig("codex-cli", cfg); + + expect(resolved).not.toBeNull(); + expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutMs).toBe(42_000); + // Ensure defaults are retained when only one field is overridden. + expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutRatio).toBe(0.3); + expect(resolved?.config.reliability?.watchdog?.resume?.minMs).toBe(60_000); + expect(resolved?.config.reliability?.watchdog?.resume?.maxMs).toBe(180_000); + expect(resolved?.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8); + }); +}); diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 5f6b2253fb2..2f1db0f87a6 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -1,5 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig } from "../config/types.js"; +import { + CLI_FRESH_WATCHDOG_DEFAULTS, + CLI_RESUME_WATCHDOG_DEFAULTS, +} from "./cli-watchdog-defaults.js"; import { normalizeProviderId } from "./model-selection.js"; export type ResolvedCliBackend = { @@ -49,6 +53,12 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = { systemPromptMode: "append", systemPromptWhen: "first", clearEnv: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"], + reliability: { + watchdog: { + fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS }, + resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS }, + }, + }, serialize: true, }; @@ -73,6 +83,12 @@ const DEFAULT_CODEX_BACKEND: CliBackendConfig = { sessionMode: "existing", imageArg: "--image", imageMode: "repeat", + reliability: { + watchdog: { + fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS }, + resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS }, + }, + }, serialize: true, }; @@ -96,6 +112,10 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig) if (!override) { return { ...base }; } + const baseFresh = base.reliability?.watchdog?.fresh ?? {}; + const baseResume = base.reliability?.watchdog?.resume ?? {}; + const overrideFresh = override.reliability?.watchdog?.fresh ?? {}; + const overrideResume = override.reliability?.watchdog?.resume ?? {}; return { ...base, ...override, @@ -106,6 +126,22 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig) sessionIdFields: override.sessionIdFields ?? base.sessionIdFields, sessionArgs: override.sessionArgs ?? base.sessionArgs, resumeArgs: override.resumeArgs ?? base.resumeArgs, + reliability: { + ...base.reliability, + ...override.reliability, + watchdog: { + ...base.reliability?.watchdog, + ...override.reliability?.watchdog, + fresh: { + ...baseFresh, + ...overrideFresh, + }, + resume: { + ...baseResume, + ...overrideResume, + }, + }, + }, }; } diff --git a/src/agents/cli-runner.e2e.test.ts b/src/agents/cli-runner.e2e.test.ts index 1383be1edb3..16f563d9e7c 100644 --- a/src/agents/cli-runner.e2e.test.ts +++ b/src/agents/cli-runner.e2e.test.ts @@ -3,50 +3,69 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { CliBackendConfig } from "../config/types.js"; import { runCliAgent } from "./cli-runner.js"; -import { cleanupResumeProcesses, cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js"; +import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js"; -const runCommandWithTimeoutMock = vi.fn(); -const runExecMock = vi.fn(); +const supervisorSpawnMock = vi.fn(); -vi.mock("../process/exec.js", () => ({ - runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), - runExec: (...args: unknown[]) => runExecMock(...args), +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => ({ + spawn: (...args: unknown[]) => supervisorSpawnMock(...args), + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), })); -describe("runCliAgent resume cleanup", () => { +type MockRunExit = { + reason: + | "manual-cancel" + | "overall-timeout" + | "no-output-timeout" + | "spawn-error" + | "signal" + | "exit"; + exitCode: number | null; + exitSignal: NodeJS.Signals | number | null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: boolean; + noOutputTimedOut: boolean; +}; + +function createManagedRun(exit: MockRunExit, pid = 1234) { + return { + runId: "run-supervisor", + pid, + startedAtMs: Date.now(), + stdin: undefined, + wait: vi.fn().mockResolvedValue(exit), + cancel: vi.fn(), + }; +} + +describe("runCliAgent with process supervisor", () => { beforeEach(() => { - runCommandWithTimeoutMock.mockReset(); - runExecMock.mockReset(); + supervisorSpawnMock.mockReset(); }); - it("kills stale resume processes for codex sessions", async () => { - const selfPid = process.pid; - - runExecMock - .mockResolvedValueOnce({ - stdout: " 1 999 S /bin/launchd\n", + it("runs CLI through supervisor and returns payload", async () => { + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 50, + stdout: "ok", stderr: "", - }) // cleanupSuspendedCliProcesses (ps) β€” ppid 999 != selfPid, no match - .mockResolvedValueOnce({ - stdout: [ - ` ${selfPid + 1} ${selfPid} codex exec resume thread-123 --color never --sandbox read-only --skip-git-repo-check`, - ` ${selfPid + 2} 999 codex exec resume thread-123 --color never --sandbox read-only --skip-git-repo-check`, - ].join("\n"), - stderr: "", - }) // cleanupResumeProcesses (ps) - .mockResolvedValueOnce({ stdout: "", stderr: "" }) // cleanupResumeProcesses (kill -TERM) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); // cleanupResumeProcesses (kill -9) - runCommandWithTimeoutMock.mockResolvedValueOnce({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); + timedOut: false, + noOutputTimedOut: false, + }), + ); - await runCliAgent({ + const result = await runCliAgent({ sessionId: "s1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", @@ -58,28 +77,80 @@ describe("runCliAgent resume cleanup", () => { cliSessionId: "thread-123", }); - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } + expect(result.payloads?.[0]?.text).toBe("ok"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { + argv?: string[]; + mode?: string; + timeoutMs?: number; + noOutputTimeoutMs?: number; + replaceExistingScope?: boolean; + scopeKey?: string; + }; + expect(input.mode).toBe("child"); + expect(input.argv?.[0]).toBe("codex"); + expect(input.timeoutMs).toBe(1_000); + expect(input.noOutputTimeoutMs).toBeGreaterThanOrEqual(1_000); + expect(input.replaceExistingScope).toBe(true); + expect(input.scopeKey).toContain("thread-123"); + }); - expect(runExecMock).toHaveBeenCalledTimes(4); + it("fails with timeout when no-output watchdog trips", async () => { + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "no-output-timeout", + exitCode: null, + exitSignal: "SIGKILL", + durationMs: 200, + stdout: "", + stderr: "", + timedOut: true, + noOutputTimedOut: true, + }), + ); - // Second call: cleanupResumeProcesses ps - const psCall = runExecMock.mock.calls[1] ?? []; - expect(psCall[0]).toBe("ps"); + await expect( + runCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.2-codex", + timeoutMs: 1_000, + runId: "run-2", + cliSessionId: "thread-123", + }), + ).rejects.toThrow("produced no output"); + }); - // Third call: TERM, only the child PID - const termCall = runExecMock.mock.calls[2] ?? []; - expect(termCall[0]).toBe("kill"); - const termArgs = termCall[1] as string[]; - expect(termArgs).toEqual(["-TERM", String(selfPid + 1)]); + it("fails with timeout when overall timeout trips", async () => { + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "overall-timeout", + exitCode: null, + exitSignal: "SIGKILL", + durationMs: 200, + stdout: "", + stderr: "", + timedOut: true, + noOutputTimedOut: false, + }), + ); - // Fourth call: KILL, only the child PID - const killCall = runExecMock.mock.calls[3] ?? []; - expect(killCall[0]).toBe("kill"); - const killArgs = killCall[1] as string[]; - expect(killArgs).toEqual(["-9", String(selfPid + 1)]); + await expect( + runCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.2-codex", + timeoutMs: 1_000, + runId: "run-3", + cliSessionId: "thread-123", + }), + ).rejects.toThrow("exceeded timeout"); }); it("falls back to per-agent workspace when workspaceDir is missing", async () => { @@ -94,14 +165,18 @@ describe("runCliAgent resume cleanup", () => { }, } satisfies OpenClawConfig; - runExecMock.mockResolvedValue({ stdout: "", stderr: "" }); - runCommandWithTimeoutMock.mockResolvedValueOnce({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 25, + stdout: "ok", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }), + ); try { await runCliAgent({ @@ -114,264 +189,33 @@ describe("runCliAgent resume cleanup", () => { provider: "codex-cli", model: "gpt-5.2-codex", timeoutMs: 1_000, - runId: "run-1", + runId: "run-4", }); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } - const options = runCommandWithTimeoutMock.mock.calls[0]?.[1] as { cwd?: string }; - expect(options.cwd).toBe(path.resolve(fallbackWorkspace)); + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { cwd?: string }; + expect(input.cwd).toBe(path.resolve(fallbackWorkspace)); }); +}); - it("throws when sessionKey is malformed", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-runner-")); - const mainWorkspace = path.join(tempDir, "workspace-main"); - const researchWorkspace = path.join(tempDir, "workspace-research"); - await fs.mkdir(mainWorkspace, { recursive: true }); - await fs.mkdir(researchWorkspace, { recursive: true }); - const cfg = { - agents: { - defaults: { - workspace: mainWorkspace, +describe("resolveCliNoOutputTimeoutMs", () => { + it("uses backend-configured resume watchdog override", () => { + const timeoutMs = resolveCliNoOutputTimeoutMs({ + backend: { + command: "codex", + reliability: { + watchdog: { + resume: { + noOutputTimeoutMs: 42_000, + }, + }, }, - list: [{ id: "research", workspace: researchWorkspace }], }, - } satisfies OpenClawConfig; - - try { - await expect( - runCliAgent({ - sessionId: "s1", - sessionKey: "agent::broken", - agentId: "research", - sessionFile: "/tmp/session.jsonl", - workspaceDir: undefined as unknown as string, - config: cfg, - prompt: "hi", - provider: "codex-cli", - model: "gpt-5.2-codex", - timeoutMs: 1_000, - runId: "run-2", - }), - ).rejects.toThrow("Malformed agent session key"); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); - }); -}); - -describe("cleanupSuspendedCliProcesses", () => { - beforeEach(() => { - runExecMock.mockReset(); - }); - - it("skips when no session tokens are configured", async () => { - await cleanupSuspendedCliProcesses( - { - command: "tool", - } as CliBackendConfig, - 0, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).not.toHaveBeenCalled(); - }); - - it("matches sessionArg-based commands", async () => { - const selfPid = process.pid; - runExecMock - .mockResolvedValueOnce({ - stdout: [ - ` 40 ${selfPid} T+ claude --session-id thread-1 -p`, - ` 41 ${selfPid} S claude --session-id thread-2 -p`, - ].join("\n"), - stderr: "", - }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await cleanupSuspendedCliProcesses( - { - command: "claude", - sessionArg: "--session-id", - } as CliBackendConfig, - 0, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).toHaveBeenCalledTimes(2); - const killCall = runExecMock.mock.calls[1] ?? []; - expect(killCall[0]).toBe("kill"); - expect(killCall[1]).toEqual(["-9", "40"]); - }); - - it("matches resumeArgs with positional session id", async () => { - const selfPid = process.pid; - runExecMock - .mockResolvedValueOnce({ - stdout: [ - ` 50 ${selfPid} T codex exec resume thread-99 --color never --sandbox read-only`, - ` 51 ${selfPid} T codex exec resume other --color never --sandbox read-only`, - ].join("\n"), - stderr: "", - }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await cleanupSuspendedCliProcesses( - { - command: "codex", - resumeArgs: ["exec", "resume", "{sessionId}", "--color", "never", "--sandbox", "read-only"], - } as CliBackendConfig, - 1, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).toHaveBeenCalledTimes(2); - const killCall = runExecMock.mock.calls[1] ?? []; - expect(killCall[0]).toBe("kill"); - expect(killCall[1]).toEqual(["-9", "50", "51"]); - }); - - it("only kills child processes of current process (ppid validation)", async () => { - const selfPid = process.pid; - const childPid = selfPid + 1; - const unrelatedPid = 9999; - - runExecMock - .mockResolvedValueOnce({ - stdout: [ - ` ${childPid} ${selfPid} T claude --session-id thread-1 -p`, - ` ${unrelatedPid} 100 T claude --session-id thread-2 -p`, - ].join("\n"), - stderr: "", - }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await cleanupSuspendedCliProcesses( - { - command: "claude", - sessionArg: "--session-id", - } as CliBackendConfig, - 0, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).toHaveBeenCalledTimes(2); - const killCall = runExecMock.mock.calls[1] ?? []; - expect(killCall[0]).toBe("kill"); - // Only childPid killed; unrelatedPid (ppid=100) excluded - expect(killCall[1]).toEqual(["-9", String(childPid)]); - }); - - it("skips all processes when none are children of current process", async () => { - runExecMock.mockResolvedValueOnce({ - stdout: [ - " 200 100 T claude --session-id thread-1 -p", - " 201 100 T claude --session-id thread-2 -p", - ].join("\n"), - stderr: "", + timeoutMs: 120_000, + useResume: true, }); - - await cleanupSuspendedCliProcesses( - { - command: "claude", - sessionArg: "--session-id", - } as CliBackendConfig, - 0, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - // Only ps called β€” no kill because no matching ppid - expect(runExecMock).toHaveBeenCalledTimes(1); - }); -}); - -describe("cleanupResumeProcesses", () => { - beforeEach(() => { - runExecMock.mockReset(); - }); - - it("only kills resume processes owned by current process", async () => { - const selfPid = process.pid; - - runExecMock - .mockResolvedValueOnce({ - stdout: [ - ` ${selfPid + 1} ${selfPid} codex exec resume abc-123`, - ` ${selfPid + 2} 999 codex exec resume abc-123`, - ].join("\n"), - stderr: "", - }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await cleanupResumeProcesses( - { - command: "codex", - resumeArgs: ["exec", "resume", "{sessionId}"], - } as CliBackendConfig, - "abc-123", - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).toHaveBeenCalledTimes(3); - - const termCall = runExecMock.mock.calls[1] ?? []; - expect(termCall[0]).toBe("kill"); - expect(termCall[1]).toEqual(["-TERM", String(selfPid + 1)]); - - const killCall = runExecMock.mock.calls[2] ?? []; - expect(killCall[0]).toBe("kill"); - expect(killCall[1]).toEqual(["-9", String(selfPid + 1)]); - }); - - it("skips kill when no resume processes match ppid", async () => { - runExecMock.mockResolvedValueOnce({ - stdout: [" 300 100 codex exec resume abc-123", " 301 200 codex exec resume abc-123"].join( - "\n", - ), - stderr: "", - }); - - await cleanupResumeProcesses( - { - command: "codex", - resumeArgs: ["exec", "resume", "{sessionId}"], - } as CliBackendConfig, - "abc-123", - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - // Only ps called β€” no kill because no matching ppid - expect(runExecMock).toHaveBeenCalledTimes(1); + expect(timeoutMs).toBe(42_000); }); }); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 68dbf0d5c22..5160611e8e5 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -6,20 +6,20 @@ import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js"; import { shouldLogVerbose } from "../globals.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { runCommandWithTimeout } from "../process/exec.js"; +import { getProcessSupervisor } from "../process/supervisor/index.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; import { appendImagePathsToPrompt, + buildCliSupervisorScopeKey, buildCliArgs, buildSystemPrompt, - cleanupResumeProcesses, - cleanupSuspendedCliProcesses, enqueueCliRun, normalizeCliModel, parseCliJson, parseCliJsonl, + resolveCliNoOutputTimeoutMs, resolvePromptInput, resolveSessionIdToSend, resolveSystemPromptUsage, @@ -226,19 +226,32 @@ export async function runCliAgent(params: { } return next; })(); - - // Cleanup suspended processes that have accumulated (regardless of sessionId) - await cleanupSuspendedCliProcesses(backend); - if (useResume && cliSessionIdToSend) { - await cleanupResumeProcesses(backend, cliSessionIdToSend); - } - - const result = await runCommandWithTimeout([backend.command, ...args], { + const noOutputTimeoutMs = resolveCliNoOutputTimeoutMs({ + backend, timeoutMs: params.timeoutMs, + useResume, + }); + const supervisor = getProcessSupervisor(); + const scopeKey = buildCliSupervisorScopeKey({ + backend, + backendId: backendResolved.id, + cliSessionId: useResume ? cliSessionIdToSend : undefined, + }); + + const managedRun = await supervisor.spawn({ + sessionId: params.sessionId, + backendId: backendResolved.id, + scopeKey, + replaceExistingScope: Boolean(useResume && scopeKey), + mode: "child", + argv: [backend.command, ...args], + timeoutMs: params.timeoutMs, + noOutputTimeoutMs, cwd: workspaceDir, env, input: stdinPayload, }); + const result = await managedRun.wait(); const stdout = result.stdout.trim(); const stderr = result.stderr.trim(); @@ -259,7 +272,28 @@ export async function runCliAgent(params: { } } - if (result.code !== 0) { + if (result.exitCode !== 0 || result.reason !== "exit") { + if (result.reason === "no-output-timeout" || result.noOutputTimedOut) { + const timeoutReason = `CLI produced no output for ${Math.round(noOutputTimeoutMs / 1000)}s and was terminated.`; + log.warn( + `cli watchdog timeout: provider=${params.provider} model=${modelId} session=${cliSessionIdToSend ?? params.sessionId} noOutputTimeoutMs=${noOutputTimeoutMs} pid=${managedRun.pid ?? "unknown"}`, + ); + throw new FailoverError(timeoutReason, { + reason: "timeout", + provider: params.provider, + model: modelId, + status: resolveFailoverStatus("timeout"), + }); + } + if (result.reason === "overall-timeout") { + const timeoutReason = `CLI exceeded timeout (${Math.round(params.timeoutMs / 1000)}s) and was terminated.`; + throw new FailoverError(timeoutReason, { + reason: "timeout", + provider: params.provider, + model: modelId, + status: resolveFailoverStatus("timeout"), + }); + } const err = stderr || stdout || "CLI failed."; const reason = classifyFailoverReason(err) ?? "unknown"; const status = resolveFailoverStatus(reason); diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 572c3c1dea7..f11c3d0aa76 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -8,232 +8,27 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { CliBackendConfig } from "../../config/types.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; -import { runExec } from "../../process/exec.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; -import { escapeRegExp, isRecord } from "../../utils.js"; +import { isRecord } from "../../utils.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; import { resolveDefaultModelForAgent } from "../model-selection.js"; import { detectRuntimeShell } from "../shell-utils.js"; import { buildSystemPromptParams } from "../system-prompt-params.js"; import { buildAgentSystemPrompt } from "../system-prompt.js"; +export { buildCliSupervisorScopeKey, resolveCliNoOutputTimeoutMs } from "./reliability.js"; const CLI_RUN_QUEUE = new Map>(); - -function buildLooseArgOrderRegex(tokens: string[]): RegExp { - // Scan `ps` output lines. Keep matching flexible, but require whitespace arg boundaries - // to avoid substring matches like `codexx` or `/path/to/codexx`. - const [head, ...rest] = tokens.map((t) => String(t ?? "").trim()).filter(Boolean); - if (!head) { - return /$^/; - } - - const headEscaped = escapeRegExp(head); - const headFragment = `(?:^|\\s)(?:${headEscaped}|\\S+\\/${headEscaped})(?=\\s|$)`; - const restFragments = rest.map((t) => `(?:^|\\s)${escapeRegExp(t)}(?=\\s|$)`); - return new RegExp([headFragment, ...restFragments].join(".*")); -} - -async function psWithFallback(argsA: string[], argsB: string[]): Promise { - try { - const { stdout } = await runExec("ps", argsA); - return stdout; - } catch { - // fallthrough - } - const { stdout } = await runExec("ps", argsB); - return stdout; -} - -export async function cleanupResumeProcesses( - backend: CliBackendConfig, - sessionId: string, -): Promise { - if (process.platform === "win32") { - return; - } - const resumeArgs = backend.resumeArgs ?? []; - if (resumeArgs.length === 0) { - return; - } - if (!resumeArgs.some((arg) => arg.includes("{sessionId}"))) { - return; - } - const commandToken = path.basename(backend.command ?? "").trim(); - if (!commandToken) { - return; - } - - const resumeTokens = resumeArgs.map((arg) => arg.replaceAll("{sessionId}", sessionId)); - const pattern = [commandToken, ...resumeTokens] - .filter(Boolean) - .map((token) => escapeRegExp(token)) - .join(".*"); - if (!pattern) { - return; - } - - try { - const stdout = await psWithFallback( - ["-axww", "-o", "pid=,ppid=,command="], - ["-ax", "-o", "pid=,ppid=,command="], - ); - const patternRegex = buildLooseArgOrderRegex([commandToken, ...resumeTokens]); - const toKill: number[] = []; - - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed); - if (!match) { - continue; - } - const pid = Number(match[1]); - const ppid = Number(match[2]); - const cmd = match[3] ?? ""; - if (!Number.isFinite(pid)) { - continue; - } - if (ppid !== process.pid) { - continue; - } - if (!patternRegex.test(cmd)) { - continue; - } - toKill.push(pid); - } - - if (toKill.length > 0) { - const pidArgs = toKill.map((pid) => String(pid)); - try { - await runExec("kill", ["-TERM", ...pidArgs]); - } catch { - // ignore - } - await new Promise((resolve) => setTimeout(resolve, 250)); - try { - await runExec("kill", ["-9", ...pidArgs]); - } catch { - // ignore - } - } - } catch { - // ignore errors - best effort cleanup - } -} - -function buildSessionMatchers(backend: CliBackendConfig): RegExp[] { - const commandToken = path.basename(backend.command ?? "").trim(); - if (!commandToken) { - return []; - } - const matchers: RegExp[] = []; - const sessionArg = backend.sessionArg?.trim(); - const sessionArgs = backend.sessionArgs ?? []; - const resumeArgs = backend.resumeArgs ?? []; - - const addMatcher = (args: string[]) => { - if (args.length === 0) { - return; - } - const tokens = [commandToken, ...args]; - const pattern = tokens - .map((token, index) => { - const tokenPattern = tokenToRegex(token); - return index === 0 ? `(?:^|\\s)${tokenPattern}` : `\\s+${tokenPattern}`; - }) - .join(""); - matchers.push(new RegExp(pattern)); - }; - - if (sessionArgs.some((arg) => arg.includes("{sessionId}"))) { - addMatcher(sessionArgs); - } else if (sessionArg) { - addMatcher([sessionArg, "{sessionId}"]); - } - - if (resumeArgs.some((arg) => arg.includes("{sessionId}"))) { - addMatcher(resumeArgs); - } - - return matchers; -} - -function tokenToRegex(token: string): string { - if (!token.includes("{sessionId}")) { - return escapeRegExp(token); - } - const parts = token.split("{sessionId}").map((part) => escapeRegExp(part)); - return parts.join("\\S+"); -} - -/** - * Cleanup suspended OpenClaw CLI processes that have accumulated. - * Only cleans up if there are more than the threshold (default: 10). - */ -export async function cleanupSuspendedCliProcesses( - backend: CliBackendConfig, - threshold = 10, -): Promise { - if (process.platform === "win32") { - return; - } - const matchers = buildSessionMatchers(backend); - if (matchers.length === 0) { - return; - } - - try { - const stdout = await psWithFallback( - ["-axww", "-o", "pid=,ppid=,stat=,command="], - ["-ax", "-o", "pid=,ppid=,stat=,command="], - ); - const suspended: number[] = []; - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const match = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed); - if (!match) { - continue; - } - const pid = Number(match[1]); - const ppid = Number(match[2]); - const stat = match[3] ?? ""; - const command = match[4] ?? ""; - if (!Number.isFinite(pid)) { - continue; - } - if (ppid !== process.pid) { - continue; - } - if (!stat.includes("T")) { - continue; - } - if (!matchers.some((matcher) => matcher.test(command))) { - continue; - } - suspended.push(pid); - } - - if (suspended.length > threshold) { - // Verified locally: stopped (T) processes ignore SIGTERM, so use SIGKILL. - await runExec("kill", ["-9", ...suspended.map((pid) => String(pid))]); - } - } catch { - // ignore errors - best effort cleanup - } -} export function enqueueCliRun(key: string, task: () => Promise): Promise { const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve(); const chained = prior.catch(() => undefined).then(task); - const tracked = chained.finally(() => { - if (CLI_RUN_QUEUE.get(key) === tracked) { - CLI_RUN_QUEUE.delete(key); - } - }); + // Keep queue continuity even when a run rejects, without emitting unhandled rejections. + const tracked = chained + .catch(() => undefined) + .finally(() => { + if (CLI_RUN_QUEUE.get(key) === tracked) { + CLI_RUN_QUEUE.delete(key); + } + }); CLI_RUN_QUEUE.set(key, tracked); return chained; } diff --git a/src/agents/cli-runner/reliability.ts b/src/agents/cli-runner/reliability.ts new file mode 100644 index 00000000000..cd1fefa9378 --- /dev/null +++ b/src/agents/cli-runner/reliability.ts @@ -0,0 +1,88 @@ +import path from "node:path"; +import type { CliBackendConfig } from "../../config/types.js"; +import { + CLI_FRESH_WATCHDOG_DEFAULTS, + CLI_RESUME_WATCHDOG_DEFAULTS, + CLI_WATCHDOG_MIN_TIMEOUT_MS, +} from "../cli-watchdog-defaults.js"; + +function pickWatchdogProfile( + backend: CliBackendConfig, + useResume: boolean, +): { + noOutputTimeoutMs?: number; + noOutputTimeoutRatio: number; + minMs: number; + maxMs: number; +} { + const defaults = useResume ? CLI_RESUME_WATCHDOG_DEFAULTS : CLI_FRESH_WATCHDOG_DEFAULTS; + const configured = useResume + ? backend.reliability?.watchdog?.resume + : backend.reliability?.watchdog?.fresh; + + const ratio = (() => { + const value = configured?.noOutputTimeoutRatio; + if (typeof value !== "number" || !Number.isFinite(value)) { + return defaults.noOutputTimeoutRatio; + } + return Math.max(0.05, Math.min(0.95, value)); + })(); + const minMs = (() => { + const value = configured?.minMs; + if (typeof value !== "number" || !Number.isFinite(value)) { + return defaults.minMs; + } + return Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, Math.floor(value)); + })(); + const maxMs = (() => { + const value = configured?.maxMs; + if (typeof value !== "number" || !Number.isFinite(value)) { + return defaults.maxMs; + } + return Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, Math.floor(value)); + })(); + + return { + noOutputTimeoutMs: + typeof configured?.noOutputTimeoutMs === "number" && + Number.isFinite(configured.noOutputTimeoutMs) + ? Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, Math.floor(configured.noOutputTimeoutMs)) + : undefined, + noOutputTimeoutRatio: ratio, + minMs: Math.min(minMs, maxMs), + maxMs: Math.max(minMs, maxMs), + }; +} + +export function resolveCliNoOutputTimeoutMs(params: { + backend: CliBackendConfig; + timeoutMs: number; + useResume: boolean; +}): number { + const profile = pickWatchdogProfile(params.backend, params.useResume); + // Keep watchdog below global timeout in normal cases. + const cap = Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, params.timeoutMs - 1_000); + if (profile.noOutputTimeoutMs !== undefined) { + return Math.min(profile.noOutputTimeoutMs, cap); + } + const computed = Math.floor(params.timeoutMs * profile.noOutputTimeoutRatio); + const bounded = Math.min(profile.maxMs, Math.max(profile.minMs, computed)); + return Math.min(bounded, cap); +} + +export function buildCliSupervisorScopeKey(params: { + backend: CliBackendConfig; + backendId: string; + cliSessionId?: string; +}): string | undefined { + const commandToken = path + .basename(params.backend.command ?? "") + .trim() + .toLowerCase(); + const backendToken = params.backendId.trim().toLowerCase(); + const sessionToken = params.cliSessionId?.trim(); + if (!sessionToken) { + return undefined; + } + return `cli:${backendToken}:${commandToken}:${sessionToken}`; +} diff --git a/src/agents/cli-watchdog-defaults.ts b/src/agents/cli-watchdog-defaults.ts new file mode 100644 index 00000000000..c96f87e30b0 --- /dev/null +++ b/src/agents/cli-watchdog-defaults.ts @@ -0,0 +1,13 @@ +export const CLI_WATCHDOG_MIN_TIMEOUT_MS = 1_000; + +export const CLI_FRESH_WATCHDOG_DEFAULTS = { + noOutputTimeoutRatio: 0.8, + minMs: 180_000, + maxMs: 600_000, +} as const; + +export const CLI_RESUME_WATCHDOG_DEFAULTS = { + noOutputTimeoutRatio: 0.3, + minMs: 60_000, + maxMs: 180_000, +} as const; diff --git a/src/agents/context.test.ts b/src/agents/context.test.ts new file mode 100644 index 00000000000..34354fc85cd --- /dev/null +++ b/src/agents/context.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { applyConfiguredContextWindows, applyDiscoveredContextWindows } from "./context.js"; +import { createSessionManagerRuntimeRegistry } from "./pi-extensions/session-manager-runtime-registry.js"; + +describe("applyDiscoveredContextWindows", () => { + it("keeps the smallest context window when duplicate model ids are discovered", () => { + const cache = new Map(); + applyDiscoveredContextWindows({ + cache, + models: [ + { id: "claude-sonnet-4-5", contextWindow: 1_000_000 }, + { id: "claude-sonnet-4-5", contextWindow: 200_000 }, + ], + }); + + expect(cache.get("claude-sonnet-4-5")).toBe(200_000); + }); +}); + +describe("applyConfiguredContextWindows", () => { + it("overrides discovered cache values with explicit models.providers contextWindow", () => { + const cache = new Map([["anthropic/claude-opus-4-6", 1_000_000]]); + applyConfiguredContextWindows({ + cache, + modelsConfig: { + providers: { + openrouter: { + models: [{ id: "anthropic/claude-opus-4-6", contextWindow: 200_000 }], + }, + }, + }, + }); + + expect(cache.get("anthropic/claude-opus-4-6")).toBe(200_000); + }); + + it("adds config-only model context windows and ignores invalid entries", () => { + const cache = new Map(); + applyConfiguredContextWindows({ + cache, + modelsConfig: { + providers: { + openrouter: { + models: [ + { id: "custom/model", contextWindow: 150_000 }, + { id: "bad/model", contextWindow: 0 }, + { id: "", contextWindow: 300_000 }, + ], + }, + }, + }, + }); + + expect(cache.get("custom/model")).toBe(150_000); + expect(cache.has("bad/model")).toBe(false); + }); +}); + +describe("createSessionManagerRuntimeRegistry", () => { + it("stores, reads, and clears values by object identity", () => { + const registry = createSessionManagerRuntimeRegistry<{ value: number }>(); + const key = {}; + expect(registry.get(key)).toBeNull(); + registry.set(key, { value: 1 }); + expect(registry.get(key)).toEqual({ value: 1 }); + registry.set(key, null); + expect(registry.get(key)).toBeNull(); + }); + + it("ignores non-object keys", () => { + const registry = createSessionManagerRuntimeRegistry<{ value: number }>(); + registry.set(null, { value: 1 }); + registry.set(123, { value: 1 }); + expect(registry.get(null)).toBeNull(); + expect(registry.get(123)).toBeNull(); + }); +}); diff --git a/src/agents/context.ts b/src/agents/context.ts index b3683e235f2..ddfeb512e48 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -6,29 +6,100 @@ import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; type ModelEntry = { id: string; contextWindow?: number }; +type ModelRegistryLike = { + getAvailable?: () => ModelEntry[]; + getAll: () => ModelEntry[]; +}; +type ConfigModelEntry = { id?: string; contextWindow?: number }; +type ProviderConfigEntry = { models?: ConfigModelEntry[] }; +type ModelsConfig = { providers?: Record }; + +export function applyDiscoveredContextWindows(params: { + cache: Map; + models: ModelEntry[]; +}) { + for (const model of params.models) { + if (!model?.id) { + continue; + } + const contextWindow = + typeof model.contextWindow === "number" ? Math.trunc(model.contextWindow) : undefined; + if (!contextWindow || contextWindow <= 0) { + continue; + } + const existing = params.cache.get(model.id); + // When multiple providers expose the same model id with different limits, + // prefer the smaller window so token budgeting is fail-safe (no overestimation). + if (existing === undefined || contextWindow < existing) { + params.cache.set(model.id, contextWindow); + } + } +} + +export function applyConfiguredContextWindows(params: { + cache: Map; + modelsConfig: ModelsConfig | undefined; +}) { + const providers = params.modelsConfig?.providers; + if (!providers || typeof providers !== "object") { + return; + } + for (const provider of Object.values(providers)) { + if (!Array.isArray(provider?.models)) { + continue; + } + for (const model of provider.models) { + const modelId = typeof model?.id === "string" ? model.id : undefined; + const contextWindow = + typeof model?.contextWindow === "number" ? model.contextWindow : undefined; + if (!modelId || !contextWindow || contextWindow <= 0) { + continue; + } + params.cache.set(modelId, contextWindow); + } + } +} const MODEL_CACHE = new Map(); const loadPromise = (async () => { + let cfg: ReturnType | undefined; + try { + cfg = loadConfig(); + } catch { + // If config can't be loaded, leave cache empty. + return; + } + + try { + await ensureOpenClawModelsJson(cfg); + } catch { + // Continue with best-effort discovery/overrides. + } + try { const { discoverAuthStorage, discoverModels } = await import("./pi-model-discovery.js"); - const cfg = loadConfig(); - await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(agentDir); - const modelRegistry = discoverModels(authStorage, agentDir); - const models = modelRegistry.getAll() as ModelEntry[]; - for (const m of models) { - if (!m?.id) { - continue; - } - if (typeof m.contextWindow === "number" && m.contextWindow > 0) { - MODEL_CACHE.set(m.id, m.contextWindow); - } - } + const modelRegistry = discoverModels(authStorage, agentDir) as unknown as ModelRegistryLike; + const models = + typeof modelRegistry.getAvailable === "function" + ? modelRegistry.getAvailable() + : modelRegistry.getAll(); + applyDiscoveredContextWindows({ + cache: MODEL_CACHE, + models, + }); } catch { - // If pi-ai isn't available, leave cache empty; lookup will fall back. + // If model discovery fails, continue with config overrides only. } -})(); + + applyConfiguredContextWindows({ + cache: MODEL_CACHE, + modelsConfig: cfg.models as ModelsConfig | undefined, + }); +})().catch(() => { + // Keep lookup best-effort. +}); export function lookupContextTokens(modelId?: string): number | undefined { if (!modelId) { diff --git a/src/agents/identity.test.ts b/src/agents/identity.test.ts new file mode 100644 index 00000000000..7ff865fe148 --- /dev/null +++ b/src/agents/identity.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAckReaction } from "./identity.js"; + +describe("resolveAckReaction", () => { + it("prefers account-level overrides", () => { + const cfg: OpenClawConfig = { + messages: { ackReaction: "πŸ‘€" }, + agents: { list: [{ id: "main", identity: { emoji: "βœ…" } }] }, + channels: { + slack: { + ackReaction: "eyes", + accounts: { + acct1: { ackReaction: " party_parrot " }, + }, + }, + }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "slack", accountId: "acct1" })).toBe( + "party_parrot", + ); + }); + + it("falls back to channel-level overrides", () => { + const cfg: OpenClawConfig = { + messages: { ackReaction: "πŸ‘€" }, + agents: { list: [{ id: "main", identity: { emoji: "βœ…" } }] }, + channels: { + slack: { + ackReaction: "eyes", + accounts: { + acct1: { ackReaction: "party_parrot" }, + }, + }, + }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "slack", accountId: "missing" })).toBe( + "eyes", + ); + }); + + it("uses the global ackReaction when channel overrides are missing", () => { + const cfg: OpenClawConfig = { + messages: { ackReaction: "βœ…" }, + agents: { list: [{ id: "main", identity: { emoji: "😺" } }] }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "discord" })).toBe("βœ…"); + }); + + it("falls back to the agent identity emoji when global config is unset", () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", identity: { emoji: "πŸ”₯" } }] }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "discord" })).toBe("πŸ”₯"); + }); + + it("returns the default emoji when no config is present", () => { + const cfg: OpenClawConfig = {}; + + expect(resolveAckReaction(cfg, "main")).toBe("πŸ‘€"); + }); + + it("allows empty strings to disable reactions", () => { + const cfg: OpenClawConfig = { + messages: { ackReaction: "πŸ‘€" }, + channels: { + telegram: { + ackReaction: "", + }, + }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "telegram" })).toBe(""); + }); +}); diff --git a/src/agents/identity.ts b/src/agents/identity.ts index 1ce3831ad98..ae27c88149e 100644 --- a/src/agents/identity.ts +++ b/src/agents/identity.ts @@ -10,11 +10,37 @@ export function resolveAgentIdentity( return resolveAgentConfig(cfg, agentId)?.identity; } -export function resolveAckReaction(cfg: OpenClawConfig, agentId: string): string { +export function resolveAckReaction( + cfg: OpenClawConfig, + agentId: string, + opts?: { channel?: string; accountId?: string }, +): string { + // L1: Channel account level + if (opts?.channel && opts?.accountId) { + const channelCfg = getChannelConfig(cfg, opts.channel); + const accounts = channelCfg?.accounts as Record> | undefined; + const accountReaction = accounts?.[opts.accountId]?.ackReaction as string | undefined; + if (accountReaction !== undefined) { + return accountReaction.trim(); + } + } + + // L2: Channel level + if (opts?.channel) { + const channelCfg = getChannelConfig(cfg, opts.channel); + const channelReaction = channelCfg?.ackReaction as string | undefined; + if (channelReaction !== undefined) { + return channelReaction.trim(); + } + } + + // L3: Global messages level const configured = cfg.messages?.ackReaction; if (configured !== undefined) { return configured.trim(); } + + // L4: Agent identity emoji fallback const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim(); return emoji || DEFAULT_ACK_REACTION; } diff --git a/src/agents/model-auth.e2e.test.ts b/src/agents/model-auth.e2e.test.ts index 7385f18ee3c..f3439c6feb9 100644 --- a/src/agents/model-auth.e2e.test.ts +++ b/src/agents/model-auth.e2e.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { ensureAuthProfileStore } from "./auth-profiles.js"; import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js"; @@ -15,9 +16,11 @@ const oauthFixture = { describe("getApiKeyForModel", () => { it("migrates legacy oauth.json into auth-profiles.json", async () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-")); try { @@ -73,30 +76,18 @@ describe("getApiKeyForModel", () => { }, }); } finally { - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("suggests openai-codex when only Codex OAuth is configured", async () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousOpenAiKey = process.env.OPENAI_API_KEY; + const envSnapshot = captureEnv([ + "OPENAI_API_KEY", + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); try { @@ -137,26 +128,7 @@ describe("getApiKeyForModel", () => { } expect(String(error)).toContain("openai-codex/gpt-5.3-codex"); } finally { - if (previousOpenAiKey === undefined) { - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = previousOpenAiKey; - } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); await fs.rm(tempDir, { recursive: true, force: true }); } }); diff --git a/src/agents/model-scan.e2e.test.ts b/src/agents/model-scan.e2e.test.ts index 574ad51224a..59f50861ad6 100644 --- a/src/agents/model-scan.e2e.test.ts +++ b/src/agents/model-scan.e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { scanOpenRouterModels } from "./model-scan.js"; function createFetchFixture(payload: unknown): typeof fetch { @@ -66,7 +67,7 @@ describe("scanOpenRouterModels", () => { it("requires an API key when probing", async () => { const fetchImpl = createFetchFixture({ data: [] }); - const previousKey = process.env.OPENROUTER_API_KEY; + const envSnapshot = captureEnv(["OPENROUTER_API_KEY"]); try { delete process.env.OPENROUTER_API_KEY; await expect( @@ -77,11 +78,7 @@ describe("scanOpenRouterModels", () => { }), ).rejects.toThrow(/Missing OpenRouter API key/); } finally { - if (previousKey === undefined) { - delete process.env.OPENROUTER_API_KEY; - } else { - process.env.OPENROUTER_API_KEY = previousKey; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.e2e.test.ts b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.e2e.test.ts index 72309c3e5b4..c5e9ac64369 100644 --- a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.e2e.test.ts +++ b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.e2e.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { installModelsConfigTestHooks, withModelsTempHome as withTempHome, @@ -12,7 +13,7 @@ installModelsConfigTestHooks({ restoreFetch: true }); describe("models-config", () => { it("auto-injects github-copilot provider when token is present", async () => { await withTempHome(async (home) => { - const previous = process.env.COPILOT_GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN"]); process.env.COPILOT_GITHUB_TOKEN = "gh-token"; const fetchMock = vi.fn().mockResolvedValue({ ok: true, @@ -36,20 +37,14 @@ describe("models-config", () => { expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example"); expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } + envSnapshot.restore(); } }); }); it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => { await withTempHome(async () => { - const previous = process.env.COPILOT_GITHUB_TOKEN; - const previousGh = process.env.GH_TOKEN; - const previousGithub = process.env.GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); process.env.COPILOT_GITHUB_TOKEN = "copilot-token"; process.env.GH_TOKEN = "gh-token"; process.env.GITHUB_TOKEN = "github-token"; @@ -70,9 +65,7 @@ describe("models-config", () => { const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record }]; expect(opts?.headers?.Authorization).toBe("Bearer copilot-token"); } finally { - process.env.COPILOT_GITHUB_TOKEN = previous; - process.env.GH_TOKEN = previousGh; - process.env.GITHUB_TOKEN = previousGithub; + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts index ee0e4580de7..8458f492f18 100644 --- a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts +++ b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { DEFAULT_COPILOT_API_BASE_URL } from "../providers/github-copilot-token.js"; +import { captureEnv } from "../test-utils/env.js"; import { installModelsConfigTestHooks, withModelsTempHome as withTempHome, @@ -13,7 +14,7 @@ installModelsConfigTestHooks({ restoreFetch: true }); describe("models-config", () => { it("falls back to default baseUrl when token exchange fails", async () => { await withTempHome(async () => { - const previous = process.env.COPILOT_GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN"]); process.env.COPILOT_GITHUB_TOKEN = "gh-token"; const fetchMock = vi.fn().mockResolvedValue({ ok: false, @@ -33,20 +34,14 @@ describe("models-config", () => { expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_COPILOT_API_BASE_URL); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } + envSnapshot.restore(); } }); }); it("uses agentDir override auth profiles for copilot injection", async () => { await withTempHome(async (home) => { - const previous = process.env.COPILOT_GITHUB_TOKEN; - const previousGh = process.env.GH_TOKEN; - const previousGithub = process.env.GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; @@ -91,21 +86,7 @@ describe("models-config", () => { expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example"); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } - if (previousGh === undefined) { - delete process.env.GH_TOKEN; - } else { - process.env.GH_TOKEN = previousGh; - } - if (previousGithub === undefined) { - delete process.env.GITHUB_TOKEN; - } else { - process.env.GITHUB_TOKEN = previousGithub; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.providers.minimax.test.ts b/src/agents/models-config.providers.minimax.test.ts deleted file mode 100644 index 7832e483bce..00000000000 --- a/src/agents/models-config.providers.minimax.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { resolveImplicitProviders } from "./models-config.providers.js"; - -describe("MiniMax implicit provider (#15275)", () => { - it("should use anthropic-messages API for API-key provider", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.MINIMAX_API_KEY; - process.env.MINIMAX_API_KEY = "test-key"; - - try { - const providers = await resolveImplicitProviders({ agentDir }); - expect(providers?.minimax).toBeDefined(); - expect(providers?.minimax?.api).toBe("anthropic-messages"); - expect(providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); - } finally { - if (previous === undefined) { - delete process.env.MINIMAX_API_KEY; - } else { - process.env.MINIMAX_API_KEY = previous; - } - } - }); -}); diff --git a/src/agents/models-config.providers.nvidia.test.ts b/src/agents/models-config.providers.nvidia.test.ts index 42a46ebe4a1..3a2f86e9829 100644 --- a/src/agents/models-config.providers.nvidia.test.ts +++ b/src/agents/models-config.providers.nvidia.test.ts @@ -2,13 +2,14 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveApiKeyForProvider } from "./model-auth.js"; import { buildNvidiaProvider, resolveImplicitProviders } from "./models-config.providers.js"; describe("NVIDIA provider", () => { it("should include nvidia when NVIDIA_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.NVIDIA_API_KEY; + const envSnapshot = captureEnv(["NVIDIA_API_KEY"]); process.env.NVIDIA_API_KEY = "test-key"; try { @@ -16,17 +17,13 @@ describe("NVIDIA provider", () => { expect(providers?.nvidia).toBeDefined(); expect(providers?.nvidia?.models?.length).toBeGreaterThan(0); } finally { - if (previous === undefined) { - delete process.env.NVIDIA_API_KEY; - } else { - process.env.NVIDIA_API_KEY = previous; - } + envSnapshot.restore(); } }); it("resolves the nvidia api key value from env", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.NVIDIA_API_KEY; + const envSnapshot = captureEnv(["NVIDIA_API_KEY"]); process.env.NVIDIA_API_KEY = "nvidia-test-api-key"; try { @@ -39,11 +36,7 @@ describe("NVIDIA provider", () => { expect(auth.mode).toBe("api-key"); expect(auth.source).toContain("NVIDIA_API_KEY"); } finally { - if (previous === undefined) { - delete process.env.NVIDIA_API_KEY; - } else { - process.env.NVIDIA_API_KEY = previous; - } + envSnapshot.restore(); } }); @@ -63,3 +56,55 @@ describe("NVIDIA provider", () => { expect(modelIds).toContain("nvidia/mistral-nemo-minitron-8b-8k-instruct"); }); }); + +describe("MiniMax implicit provider (#15275)", () => { + it("should use anthropic-messages API for API-key provider", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["MINIMAX_API_KEY"]); + process.env.MINIMAX_API_KEY = "test-key"; + + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.minimax).toBeDefined(); + expect(providers?.minimax?.api).toBe("anthropic-messages"); + expect(providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); + } finally { + envSnapshot.restore(); + } + }); +}); + +describe("vLLM provider", () => { + it("should not include vllm when no API key is configured", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["VLLM_API_KEY"]); + delete process.env.VLLM_API_KEY; + + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.vllm).toBeUndefined(); + } finally { + envSnapshot.restore(); + } + }); + + it("should include vllm when VLLM_API_KEY is set", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["VLLM_API_KEY"]); + process.env.VLLM_API_KEY = "test-key"; + + try { + const providers = await resolveImplicitProviders({ agentDir }); + + expect(providers?.vllm).toBeDefined(); + expect(providers?.vllm?.apiKey).toBe("VLLM_API_KEY"); + expect(providers?.vllm?.baseUrl).toBe("http://127.0.0.1:8000/v1"); + expect(providers?.vllm?.api).toBe("openai-completions"); + + // Note: discovery is disabled in test environments (VITEST check) + expect(providers?.vllm?.models).toEqual([]); + } finally { + envSnapshot.restore(); + } + }); +}); diff --git a/src/agents/models-config.providers.qianfan.e2e.test.ts b/src/agents/models-config.providers.qianfan.e2e.test.ts index 17527262897..06f47787464 100644 --- a/src/agents/models-config.providers.qianfan.e2e.test.ts +++ b/src/agents/models-config.providers.qianfan.e2e.test.ts @@ -2,12 +2,13 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveImplicitProviders } from "./models-config.providers.js"; describe("Qianfan provider", () => { it("should include qianfan when QIANFAN_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.QIANFAN_API_KEY; + const envSnapshot = captureEnv(["QIANFAN_API_KEY"]); process.env.QIANFAN_API_KEY = "test-key"; try { @@ -15,11 +16,7 @@ describe("Qianfan provider", () => { expect(providers?.qianfan).toBeDefined(); expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY"); } finally { - if (previous === undefined) { - delete process.env.QIANFAN_API_KEY; - } else { - process.env.QIANFAN_API_KEY = previous; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 393b333e250..84b0c4303e5 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -47,6 +47,33 @@ const MINIMAX_API_COST = { cacheWrite: 10, }; +type ProviderModelConfig = NonNullable[number]; + +function buildMinimaxModel(params: { + id: string; + name: string; + reasoning: boolean; + input: ProviderModelConfig["input"]; +}): ProviderModelConfig { + return { + id: params.id, + name: params.name, + reasoning: params.reasoning, + input: params.input, + cost: MINIMAX_API_COST, + contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, + maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, + }; +} + +function buildMinimaxTextModel(params: { + id: string; + name: string; + reasoning: boolean; +}): ProviderModelConfig { + return buildMinimaxModel({ ...params, input: ["text"] }); +} + const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic"; export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash"; const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144; @@ -389,51 +416,32 @@ function buildMinimaxProvider(): ProviderConfig { baseUrl: MINIMAX_PORTAL_BASE_URL, api: "anthropic-messages", models: [ - { + buildMinimaxTextModel({ id: MINIMAX_DEFAULT_MODEL_ID, name: "MiniMax M2.1", reasoning: false, - input: ["text"], - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }, - { + }), + buildMinimaxTextModel({ id: "MiniMax-M2.1-lightning", name: "MiniMax M2.1 Lightning", reasoning: false, - input: ["text"], - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }, - { + }), + buildMinimaxModel({ id: MINIMAX_DEFAULT_VISION_MODEL_ID, name: "MiniMax VL 01", reasoning: false, input: ["text", "image"], - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }, - { + }), + buildMinimaxTextModel({ id: "MiniMax-M2.5", name: "MiniMax M2.5", reasoning: true, - input: ["text"], - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }, - { + }), + buildMinimaxTextModel({ id: "MiniMax-M2.5-Lightning", name: "MiniMax M2.5 Lightning", reasoning: true, - input: ["text"], - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }, + }), ], }; } @@ -443,24 +451,16 @@ function buildMinimaxPortalProvider(): ProviderConfig { baseUrl: MINIMAX_PORTAL_BASE_URL, api: "anthropic-messages", models: [ - { + buildMinimaxTextModel({ id: MINIMAX_DEFAULT_MODEL_ID, name: "MiniMax M2.1", reasoning: false, - input: ["text"], - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }, - { + }), + buildMinimaxTextModel({ id: "MiniMax-M2.5", name: "MiniMax M2.5", reasoning: true, - input: ["text"], - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }, + }), ], }; } diff --git a/src/agents/models-config.providers.vllm.test.ts b/src/agents/models-config.providers.vllm.test.ts deleted file mode 100644 index 441b4155ec7..00000000000 --- a/src/agents/models-config.providers.vllm.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { resolveImplicitProviders } from "./models-config.providers.js"; - -describe("vLLM provider", () => { - it("should not include vllm when no API key is configured", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const providers = await resolveImplicitProviders({ agentDir }); - - expect(providers?.vllm).toBeUndefined(); - }); - - it("should include vllm when VLLM_API_KEY is set", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - process.env.VLLM_API_KEY = "test-key"; - - try { - const providers = await resolveImplicitProviders({ agentDir }); - - expect(providers?.vllm).toBeDefined(); - expect(providers?.vllm?.apiKey).toBe("VLLM_API_KEY"); - expect(providers?.vllm?.baseUrl).toBe("http://127.0.0.1:8000/v1"); - expect(providers?.vllm?.api).toBe("openai-completions"); - - // Note: discovery is disabled in test environments (VITEST check) - expect(providers?.vllm?.models).toEqual([]); - } finally { - delete process.env.VLLM_API_KEY; - } - }); -}); diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts index b858a234a24..92b5d19dddf 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { installModelsConfigTestHooks, @@ -13,9 +14,7 @@ installModelsConfigTestHooks({ restoreFetch: true }); describe("models-config", () => { it("uses the first github-copilot profile when env tokens are missing", async () => { await withTempHome(async (home) => { - const previous = process.env.COPILOT_GITHUB_TOKEN; - const previousGh = process.env.GH_TOKEN; - const previousGithub = process.env.GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; @@ -61,28 +60,14 @@ describe("models-config", () => { const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record }]; expect(opts?.headers?.Authorization).toBe("Bearer alpha-token"); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } - if (previousGh === undefined) { - delete process.env.GH_TOKEN; - } else { - process.env.GH_TOKEN = previousGh; - } - if (previousGithub === undefined) { - delete process.env.GITHUB_TOKEN; - } else { - process.env.GITHUB_TOKEN = previousGithub; - } + envSnapshot.restore(); } }); }); it("does not override explicit github-copilot provider config", async () => { await withTempHome(async () => { - const previous = process.env.COPILOT_GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN"]); process.env.COPILOT_GITHUB_TOKEN = "gh-token"; const fetchMock = vi.fn().mockResolvedValue({ ok: true, @@ -115,11 +100,7 @@ describe("models-config", () => { expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://copilot.local"); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/openai-responses.reasoning-replay.test.ts b/src/agents/openai-responses.reasoning-replay.test.ts index 2a94db7e3fd..0ef535d92d7 100644 --- a/src/agents/openai-responses.reasoning-replay.test.ts +++ b/src/agents/openai-responses.reasoning-replay.test.ts @@ -115,6 +115,15 @@ describe("openai-responses reasoning replay", () => { expect(types).toContain("reasoning"); expect(types).toContain("function_call"); expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call")); + + const functionCall = input.find( + (item) => + item && + typeof item === "object" && + (item as Record).type === "function_call", + ) as Record | undefined; + expect(functionCall?.call_id).toBe("call_123"); + expect(functionCall?.id).toBe("fc_123"); }); it("still replays reasoning when paired with an assistant message", async () => { diff --git a/src/agents/openclaw-gateway-tool.e2e.test.ts b/src/agents/openclaw-gateway-tool.e2e.test.ts index 716d7ee0ad2..66dfb9483e9 100644 --- a/src/agents/openclaw-gateway-tool.e2e.test.ts +++ b/src/agents/openclaw-gateway-tool.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import "./test-helpers/fast-core-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; @@ -18,8 +19,7 @@ describe("gateway tool", () => { it("schedules SIGUSR1 restart", async () => { vi.useFakeTimers(); const kill = vi.spyOn(process, "kill").mockImplementation(() => true); - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousProfile = process.env.OPENCLAW_PROFILE; + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_PROFILE"]); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); process.env.OPENCLAW_STATE_DIR = stateDir; process.env.OPENCLAW_PROFILE = "isolated"; @@ -60,16 +60,8 @@ describe("gateway tool", () => { } finally { kill.mockRestore(); vi.useRealTimers(); - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousProfile === undefined) { - delete process.env.OPENCLAW_PROFILE; - } else { - process.env.OPENCLAW_PROFILE = previousProfile; - } + envSnapshot.restore(); + await fs.rm(stateDir, { recursive: true, force: true }); } }); diff --git a/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts b/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts new file mode 100644 index 00000000000..6f4cfdd03b3 --- /dev/null +++ b/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +let mockConfig: Record = { + session: { mainKey: "main", scope: "per-sender" }, +}; +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => mockConfig, + resolveGatewayPort: () => 18789, + }; +}); + +import "./test-helpers/fast-core-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; + +describe("sessions tools visibility", () => { + it("defaults to tree visibility (self + spawned) for sessions_history", async () => { + mockConfig = { + session: { mainKey: "main", scope: "per-sender" }, + tools: { agentToAgent: { enabled: false } }, + }; + callGatewayMock.mockReset(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const req = opts as { method?: string; params?: Record }; + if (req.method === "sessions.list" && req.params?.spawnedBy === "main") { + return { sessions: [{ key: "subagent:child-1" }] }; + } + if (req.method === "sessions.resolve") { + const key = typeof req.params?.key === "string" ? String(req.params?.key) : ""; + return { key }; + } + if (req.method === "chat.history") { + return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] }; + } + return {}; + }); + + const tool = createOpenClawTools({ agentSessionKey: "main" }).find( + (candidate) => candidate.name === "sessions_history", + ); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing sessions_history tool"); + } + + const denied = await tool.execute("call1", { + sessionKey: "agent:main:discord:direct:someone-else", + }); + expect(denied.details).toMatchObject({ status: "forbidden" }); + + const allowed = await tool.execute("call2", { sessionKey: "subagent:child-1" }); + expect(allowed.details).toMatchObject({ + sessionKey: "subagent:child-1", + }); + }); + + it("allows broader access when tools.sessions.visibility=all", async () => { + mockConfig = { + session: { mainKey: "main", scope: "per-sender" }, + tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: false } }, + }; + callGatewayMock.mockReset(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const req = opts as { method?: string; params?: Record }; + if (req.method === "chat.history") { + return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] }; + } + return {}; + }); + + const tool = createOpenClawTools({ agentSessionKey: "main" }).find( + (candidate) => candidate.name === "sessions_history", + ); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing sessions_history tool"); + } + + const result = await tool.execute("call3", { + sessionKey: "agent:main:discord:direct:someone-else", + }); + expect(result.details).toMatchObject({ + sessionKey: "agent:main:discord:direct:someone-else", + }); + }); + + it("clamps sandboxed sessions to tree when agents.defaults.sandbox.sessionToolsVisibility=spawned", async () => { + mockConfig = { + session: { mainKey: "main", scope: "per-sender" }, + tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: true, allow: ["*"] } }, + agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } }, + }; + callGatewayMock.mockReset(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const req = opts as { method?: string; params?: Record }; + if (req.method === "sessions.list" && req.params?.spawnedBy === "main") { + return { sessions: [] }; + } + if (req.method === "chat.history") { + return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] }; + } + return {}; + }); + + const tool = createOpenClawTools({ agentSessionKey: "main", sandboxed: true }).find( + (candidate) => candidate.name === "sessions_history", + ); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing sessions_history tool"); + } + + const denied = await tool.execute("call4", { + sessionKey: "agent:other:main", + }); + expect(denied.details).toMatchObject({ status: "forbidden" }); + }); +}); diff --git a/src/agents/openclaw-tools.sessions.e2e.test.ts b/src/agents/openclaw-tools.sessions.e2e.test.ts index df8e1bb7186..14e0ffc1e98 100644 --- a/src/agents/openclaw-tools.sessions.e2e.test.ts +++ b/src/agents/openclaw-tools.sessions.e2e.test.ts @@ -20,6 +20,10 @@ vi.mock("../config/config.js", async (importOriginal) => { scope: "per-sender", agentToAgent: { maxPingPongTurns: 2 }, }, + tools: { + // Keep sessions tools permissive in this suite; dedicated visibility tests cover defaults. + sessions: { visibility: "all" }, + }, }), resolveGatewayPort: () => 18789, }; @@ -783,7 +787,7 @@ describe("sessions tools", () => { text?: string; }; expect(details.status).toBe("ok"); - expect(details.text).toContain("tokens 1k (in 12 / out 1k)"); + expect(details.text).toMatch(/tokens 1(\.0)?k \(in 12 \/ out 1(\.0)?k\)/); expect(details.text).toContain("prompt/cache 197k"); expect(details.text).not.toContain("1.0k io"); } finally { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts index c9b7175717a..ecd32cab749 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts @@ -33,21 +33,37 @@ vi.mock("../gateway/call.js", () => { }; }); +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + describe("sessions_spawn thinking defaults", () => { it("applies agents.defaults.subagents.thinking when thinking is omitted", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); const result = await tool.execute("call-1", { task: "hello" }); expect(result.details).toMatchObject({ status: "accepted" }); - const { callGateway } = await import("../gateway/call.js"); - const calls = (callGateway as unknown as ReturnType).mock.calls; - - const agentCall = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "agent"); - const thinkingPatch = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel); + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + const thinkingPatch = findLastCall( + calls, + (call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined, + ); expect(agentCall?.params?.thinking).toBe("high"); expect(thinkingPatch?.params?.thinkingLevel).toBe("high"); @@ -58,15 +74,12 @@ describe("sessions_spawn thinking defaults", () => { const result = await tool.execute("call-2", { task: "hello", thinking: "low" }); expect(result.details).toMatchObject({ status: "accepted" }); - const { callGateway } = await import("../gateway/call.js"); - const calls = (callGateway as unknown as ReturnType).mock.calls; - - const agentCall = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "agent"); - const thinkingPatch = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel); + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + const thinkingPatch = findLastCall( + calls, + (call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined, + ); expect(agentCall?.params?.thinking).toBe("low"); expect(thinkingPatch?.params?.thinkingLevel).toBe("low"); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts index b1c697064f5..2e568714b71 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { createOpenClawTools } from "./openclaw-tools.js"; import "./test-helpers/fast-core-tools.js"; import { getCallGatewayMock, @@ -10,6 +9,19 @@ import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +type CreateOpenClawToolsOpts = Parameters[0]; + +async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -19,13 +31,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call6", { task: "do thing", @@ -57,13 +66,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call9", { task: "do thing", @@ -78,7 +84,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn allows cross-agent spawning when configured", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -109,13 +115,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call7", { task: "do thing", @@ -132,7 +135,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn allows any agent when allowlist is *", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -163,13 +166,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call8", { task: "do thing", @@ -186,7 +186,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn normalizes allowlisted agent ids", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -217,13 +217,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call10", { task: "do thing", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index 002683386be..e82d4e2dc6a 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -1,16 +1,140 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; -import { sleep } from "../utils.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; import "./test-helpers/fast-core-tools.js"; +import { sleep } from "../utils.js"; import { getCallGatewayMock, resetSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; +vi.mock("./pi-embedded.js", () => ({ + isEmbeddedPiRunActive: () => false, + isEmbeddedPiRunStreaming: () => false, + queueEmbeddedPiMessage: () => false, + waitForEmbeddedPiRunEnd: async () => true, +})); + const callGatewayMock = getCallGatewayMock(); +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +type CreateOpenClawToolsOpts = Parameters[0]; + +async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + +type GatewayRequest = { method?: string; params?: unknown }; +type AgentWaitCall = { runId?: string; timeoutMs?: number }; + +function setupSessionsSpawnGatewayMock(opts: { + includeSessionsList?: boolean; + includeChatHistory?: boolean; + onAgentSubagentSpawn?: (params: unknown) => void; + onSessionsPatch?: (params: unknown) => void; + onSessionsDelete?: (params: unknown) => void; + agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number }; +}): { + calls: Array; + waitCalls: Array; + getChild: () => { runId?: string; sessionKey?: string }; +} { + const calls: Array = []; + const waitCalls: Array = []; + let agentCallCount = 0; + let childRunId: string | undefined; + let childSessionKey: string | undefined; + + callGatewayMock.mockImplementation(async (optsUnknown: unknown) => { + const request = optsUnknown as GatewayRequest; + calls.push(request); + + if (request.method === "sessions.list" && opts.includeSessionsList) { + return { + sessions: [ + { + key: "main", + lastChannel: "whatsapp", + lastTo: "+123", + }, + ], + }; + } + + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as { lane?: string; sessionKey?: string } | undefined; + // Only capture the first agent call (subagent spawn, not main agent trigger) + if (params?.lane === "subagent") { + childRunId = runId; + childSessionKey = params?.sessionKey ?? ""; + opts.onAgentSubagentSpawn?.(params); + } + return { + runId, + status: "accepted", + acceptedAt: 1000 + agentCallCount, + }; + } + + if (request.method === "agent.wait") { + const params = request.params as AgentWaitCall | undefined; + waitCalls.push(params ?? {}); + const res = opts.agentWaitResult ?? { status: "ok", startedAt: 1000, endedAt: 2000 }; + return { + runId: params?.runId ?? "run-1", + ...res, + }; + } + + if (request.method === "sessions.patch") { + opts.onSessionsPatch?.(request.params); + return { ok: true }; + } + + if (request.method === "sessions.delete") { + opts.onSessionsDelete?.(request.params); + return { ok: true }; + } + + if (request.method === "chat.history" && opts.includeChatHistory) { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ], + }; + } + + return {}; + }); + + return { + calls, + waitCalls, + getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }), + }; +} + +const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`timed out waiting for condition (timeoutMs=${timeoutMs})`); + } + await sleep(10); + } +}; + describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -19,84 +143,21 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { it("sessions_spawn runs cleanup flow after subagent completion", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - let patchParams: { key?: string; label?: string } = {}; + const patchCalls: Array<{ key?: string; label?: string }> = []; - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.list") { - return { - sessions: [ - { - key: "main", - lastChannel: "whatsapp", - lastTo: "+123", - }, - ], - }; - } - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - lane?: string; - }; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - } - return { - runId, - status: "accepted", - acceptedAt: 2000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 1000, - endedAt: 2000, - }; - } - if (request.method === "sessions.patch") { - const params = request.params as { key?: string; label?: string } | undefined; - patchParams = { key: params?.key, label: params?.label }; - return { ok: true }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - if (request.method === "sessions.delete") { - return { ok: true }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + includeSessionsList: true, + includeChatHistory: true, + onSessionsPatch: (params) => { + const rec = params as { key?: string; label?: string } | undefined; + patchCalls.push({ key: rec?.key, label: rec?.label }); + }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call2", { task: "do thing", @@ -108,11 +169,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - if (!childRunId) { + const child = ctx.getChild(); + if (!child.runId) { throw new Error("missing child runId"); } emitAgentEvent({ - runId: childRunId, + runId: child.runId, stream: "lifecycle", data: { phase: "end", @@ -121,18 +183,19 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }, }); - await sleep(0); - await sleep(0); - await sleep(0); + await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); + await waitFor(() => patchCalls.some((call) => call.label === "my-task")); + await waitFor(() => ctx.calls.filter((c) => c.method === "agent").length >= 2); - const childWait = waitCalls.find((call) => call.runId === childRunId); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); // Cleanup should patch the label - expect(patchParams.key).toBe(childSessionKey); - expect(patchParams.label).toBe("my-task"); + const labelPatch = patchCalls.find((call) => call.label === "my-task"); + expect(labelPatch?.key).toBe(child.sessionKey); + expect(labelPatch?.label).toBe("my-task"); // Two agent calls: subagent spawn + main agent trigger - const agentCalls = calls.filter((c) => c.method === "agent"); + const agentCalls = ctx.calls.filter((c) => c.method === "agent"); expect(agentCalls).toHaveLength(2); // First call: subagent spawn @@ -145,71 +208,31 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(second?.message).toContain("subagent task"); // No direct send to external channel (main agent handles delivery) - const sendCalls = calls.filter((c) => c.method === "send"); + const sendCalls = ctx.calls.filter((c) => c.method === "send"); expect(sendCalls.length).toBe(0); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); }); it("sessions_spawn runs cleanup via lifecycle events", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; let deletedKey: string | undefined; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - channel?: string; - timeout?: number; - lane?: string; - }; - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - expect(params?.channel).toBe("discord"); - expect(params?.timeout).toBe(1); - } - return { - runId, - status: "accepted", - acceptedAt: 1000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 1000, - endedAt: 2000, - }; - } - if (request.method === "sessions.delete") { - const params = request.params as { key?: string } | undefined; - deletedKey = params?.key; - return { ok: true }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + onAgentSubagentSpawn: (params) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params) => { + const rec = params as { key?: string } | undefined; + deletedKey = rec?.key; + }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call1", { task: "do thing", @@ -221,13 +244,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - if (!childRunId) { + const child = ctx.getChild(); + if (!child.runId) { throw new Error("missing child runId"); } vi.useFakeTimers(); try { emitAgentEvent({ - runId: childRunId, + runId: child.runId, stream: "lifecycle", data: { phase: "end", @@ -241,10 +265,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { vi.useRealTimers(); } - const childWait = waitCalls.find((call) => call.runId === childRunId); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); - const agentCalls = calls.filter((call) => call.method === "agent"); + const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); const first = agentCalls[0]?.params as @@ -259,7 +283,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(first?.deliver).toBe(false); expect(first?.channel).toBe("discord"); expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); const second = agentCalls[1]?.params as | { @@ -272,7 +296,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(second?.deliver).toBe(true); expect(second?.message).toContain("subagent task"); - const sendCalls = calls.filter((c) => c.method === "send"); + const sendCalls = ctx.calls.filter((c) => c.method === "send"); expect(sendCalls.length).toBe(0); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); @@ -281,74 +305,25 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { it("sessions_spawn deletes session when cleanup=delete via agent.wait", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; let deletedKey: string | undefined; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - channel?: string; - timeout?: number; - lane?: string; - }; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - expect(params?.channel).toBe("discord"); - expect(params?.timeout).toBe(1); - } - return { - runId, - status: "accepted", - acceptedAt: 2000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 3000, - endedAt: 4000, - }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - if (request.method === "sessions.delete") { - const params = request.params as { key?: string } | undefined; - deletedKey = params?.key; - return { ok: true }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + includeChatHistory: true, + onAgentSubagentSpawn: (params) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params) => { + const rec = params as { key?: string } | undefined; + deletedKey = rec?.key; + }, + agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call1b", { task: "do thing", @@ -360,16 +335,20 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - await sleep(0); - await sleep(0); - await sleep(0); + const child = ctx.getChild(); + if (!child.runId) { + throw new Error("missing child runId"); + } + await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); + await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); + await waitFor(() => Boolean(deletedKey)); - const childWait = waitCalls.find((call) => call.runId === childRunId); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); // Two agent calls: subagent spawn + main agent trigger - const agentCalls = calls.filter((call) => call.method === "agent"); + const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); // First call: subagent spawn @@ -382,7 +361,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(second?.deliver).toBe(true); // No direct send to external channel (main agent handles delivery) - const sendCalls = calls.filter((c) => c.method === "send"); + const sendCalls = ctx.calls.filter((c) => c.method === "send"); expect(sendCalls.length).toBe(0); // Session should be deleted @@ -428,13 +407,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-timeout", { task: "do thing", @@ -446,9 +422,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - await sleep(0); - await sleep(0); - await sleep(0); + await waitFor(() => calls.filter((call) => call.method === "agent").length >= 2); const mainAgentCall = calls .filter((call) => call.method === "agent") @@ -500,14 +474,11 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", agentAccountId: "kev", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-announce-account", { task: "do thing", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 7d3cd00d62d..288f3b44611 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import "./test-helpers/fast-core-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; import { getCallGatewayMock, resetSessionsSpawnConfigOverride, @@ -11,6 +10,19 @@ import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +type CreateOpenClawToolsOpts = Parameters[0]; + +async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -46,13 +58,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call3", { task: "do thing", @@ -93,13 +102,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-thinking", { task: "do thing", @@ -126,13 +132,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-thinking-invalid", { task: "do thing", @@ -166,13 +169,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:main:main", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-default-model", { task: "do thing", @@ -207,13 +207,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:main:main", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-runtime-default-model", { task: "do thing", @@ -255,13 +252,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:research:main", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-agent-model", { task: "do thing", @@ -271,7 +265,9 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { modelApplied: true, }); - const patchCall = calls.find((call) => call.method === "sessions.patch"); + const patchCall = calls.find( + (call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model, + ); expect(patchCall?.params).toMatchObject({ model: "opencode/claude", }); @@ -287,7 +283,11 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { const request = opts as { method?: string; params?: unknown }; calls.push(request); if (request.method === "sessions.patch") { - throw new Error("invalid model: bad-model"); + const model = (request.params as { model?: unknown } | undefined)?.model; + if (model === "bad-model") { + throw new Error("invalid model: bad-model"); + } + return { ok: true }; } if (request.method === "agent") { agentCallCount += 1; @@ -307,13 +307,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call4", { task: "do thing", @@ -345,13 +342,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call5", { task: "do thing", diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts index 9cd60cb59e0..e4e852e69ef 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { buildBootstrapContextFiles, DEFAULT_BOOTSTRAP_MAX_CHARS, DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS, + resolveBootstrapMaxChars, + resolveBootstrapTotalMaxChars, } from "./pi-embedded-helpers.js"; import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; @@ -100,3 +103,39 @@ describe("buildBootstrapContextFiles", () => { expect(result[0]?.content.startsWith("[MISSING]")).toBe(true); }); }); + +describe("resolveBootstrapMaxChars", () => { + it("returns default when unset", () => { + expect(resolveBootstrapMaxChars()).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS); + }); + it("uses configured value when valid", () => { + const cfg = { + agents: { defaults: { bootstrapMaxChars: 12345 } }, + } as OpenClawConfig; + expect(resolveBootstrapMaxChars(cfg)).toBe(12345); + }); + it("falls back when invalid", () => { + const cfg = { + agents: { defaults: { bootstrapMaxChars: -1 } }, + } as OpenClawConfig; + expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS); + }); +}); + +describe("resolveBootstrapTotalMaxChars", () => { + it("returns default when unset", () => { + expect(resolveBootstrapTotalMaxChars()).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS); + }); + it("uses configured value when valid", () => { + const cfg = { + agents: { defaults: { bootstrapTotalMaxChars: 12345 } }, + } as OpenClawConfig; + expect(resolveBootstrapTotalMaxChars(cfg)).toBe(12345); + }); + it("falls back when invalid", () => { + const cfg = { + agents: { defaults: { bootstrapTotalMaxChars: -1 } }, + } as OpenClawConfig; + expect(resolveBootstrapTotalMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS); + }); +}); diff --git a/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts b/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts deleted file mode 100644 index daf9d9cf586..00000000000 --- a/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { classifyFailoverReason } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("classifyFailoverReason", () => { - it("returns a stable reason", () => { - expect(classifyFailoverReason("invalid api key")).toBe("auth"); - expect(classifyFailoverReason("no credentials found")).toBe("auth"); - expect(classifyFailoverReason("no api key found")).toBe("auth"); - expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); - expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit"); - expect( - classifyFailoverReason( - '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', - ), - ).toBe("rate_limit"); - expect(classifyFailoverReason("invalid request format")).toBe("format"); - expect(classifyFailoverReason("credit balance too low")).toBe("billing"); - expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); - expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout"); - expect( - classifyFailoverReason( - "521 Web server is downCloudflare", - ), - ).toBe("timeout"); - expect(classifyFailoverReason("string should match pattern")).toBe("format"); - expect(classifyFailoverReason("bad request")).toBeNull(); - expect( - classifyFailoverReason( - "messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels", - ), - ).toBeNull(); - expect(classifyFailoverReason("image exceeds 5 MB maximum")).toBeNull(); - }); - it("classifies OpenAI usage limit errors as rate_limit", () => { - expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe( - "rate_limit", - ); - }); -}); diff --git a/src/agents/pi-embedded-helpers.downgradeopenai-reasoning.e2e.test.ts b/src/agents/pi-embedded-helpers.downgradeopenai-reasoning.e2e.test.ts deleted file mode 100644 index ee156e5a70a..00000000000 --- a/src/agents/pi-embedded-helpers.downgradeopenai-reasoning.e2e.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js"; - -describe("downgradeOpenAIReasoningBlocks", () => { - it("keeps reasoning signatures when followed by content", () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), - }, - { type: "text", text: "answer" }, - ], - }, - ]; - - // oxlint-disable-next-line typescript/no-explicit-any - expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); - }); - - it("drops orphaned reasoning blocks without following content", () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }), - }, - ], - }, - { role: "user", content: "next" }, - ]; - - // oxlint-disable-next-line typescript/no-explicit-any - expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([ - { role: "user", content: "next" }, - ]); - }); - - it("drops object-form orphaned signatures", () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinkingSignature: { id: "rs_obj", type: "reasoning" }, - }, - ], - }, - ]; - - // oxlint-disable-next-line typescript/no-explicit-any - expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]); - }); - - it("keeps non-reasoning thinking signatures", () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinking: "t", - thinkingSignature: "reasoning_content", - }, - ], - }, - ]; - - // oxlint-disable-next-line typescript/no-explicit-any - expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); - }); -}); diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts index 9ba67b6a147..c563ac948f3 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts @@ -4,6 +4,7 @@ import { BILLING_ERROR_USER_MESSAGE, formatBillingErrorMessage, formatAssistantErrorText, + formatRawAssistantErrorForUi, } from "./pi-embedded-helpers.js"; describe("formatAssistantErrorText", () => { @@ -114,3 +115,38 @@ describe("formatAssistantErrorText", () => { expect(formatAssistantErrorText(msg)).toBe("LLM request timed out."); }); }); + +describe("formatRawAssistantErrorForUi", () => { + it("renders HTTP code + type + message from Anthropic payloads", () => { + const text = formatRawAssistantErrorForUi( + '429 {"type":"error","error":{"type":"rate_limit_error","message":"Rate limited."},"request_id":"req_123"}', + ); + + expect(text).toContain("HTTP 429"); + expect(text).toContain("rate_limit_error"); + expect(text).toContain("Rate limited."); + expect(text).toContain("req_123"); + }); + + it("renders a generic unknown error message when raw is empty", () => { + expect(formatRawAssistantErrorForUi("")).toContain("unknown error"); + }); + + it("formats plain HTTP status lines", () => { + expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe( + "HTTP 500: Internal Server Error", + ); + }); + + it("sanitizes HTML error pages into a clean unavailable message", () => { + const htmlError = `521 + + Web server is down | example.com | Cloudflare + Ray ID: abc123 +`; + + expect(formatRawAssistantErrorForUi(htmlError)).toBe( + "The AI service is temporarily unavailable (HTTP 521). Please try again in a moment.", + ); + }); +}); diff --git a/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.e2e.test.ts b/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.e2e.test.ts deleted file mode 100644 index 8fd0ed1aff8..00000000000 --- a/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.e2e.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { formatRawAssistantErrorForUi } from "./pi-embedded-helpers.js"; - -describe("formatRawAssistantErrorForUi", () => { - it("renders HTTP code + type + message from Anthropic payloads", () => { - const text = formatRawAssistantErrorForUi( - '429 {"type":"error","error":{"type":"rate_limit_error","message":"Rate limited."},"request_id":"req_123"}', - ); - - expect(text).toContain("HTTP 429"); - expect(text).toContain("rate_limit_error"); - expect(text).toContain("Rate limited."); - expect(text).toContain("req_123"); - }); - - it("renders a generic unknown error message when raw is empty", () => { - expect(formatRawAssistantErrorForUi("")).toContain("unknown error"); - }); - - it("formats plain HTTP status lines", () => { - expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe( - "HTTP 500: Internal Server Error", - ); - }); - - it("sanitizes HTML error pages into a clean unavailable message", () => { - const htmlError = `521 - - Web server is down | example.com | Cloudflare - Ray ID: abc123 -`; - - expect(formatRawAssistantErrorForUi(htmlError)).toBe( - "The AI service is temporarily unavailable (HTTP 521). Please try again in a moment.", - ); - }); -}); diff --git a/src/agents/pi-embedded-helpers.image-dimension-error.e2e.test.ts b/src/agents/pi-embedded-helpers.image-dimension-error.e2e.test.ts deleted file mode 100644 index 2c92ed68125..00000000000 --- a/src/agents/pi-embedded-helpers.image-dimension-error.e2e.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-embedded-helpers.js"; - -describe("image dimension errors", () => { - it("parses anthropic image dimension errors", () => { - const raw = - '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}'; - const parsed = parseImageDimensionError(raw); - expect(parsed).not.toBeNull(); - expect(parsed?.maxDimensionPx).toBe(2000); - expect(parsed?.messageIndex).toBe(84); - expect(parsed?.contentIndex).toBe(1); - expect(isImageDimensionErrorMessage(raw)).toBe(true); - }); -}); diff --git a/src/agents/pi-embedded-helpers.image-size-error.e2e.test.ts b/src/agents/pi-embedded-helpers.image-size-error.e2e.test.ts deleted file mode 100644 index d69a3c381ae..00000000000 --- a/src/agents/pi-embedded-helpers.image-size-error.e2e.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseImageSizeError } from "./pi-embedded-helpers.js"; - -describe("parseImageSizeError", () => { - it("parses max MB values from error text", () => { - expect(parseImageSizeError("image exceeds 5 MB maximum")?.maxMb).toBe(5); - expect(parseImageSizeError("Image exceeds 5.5 MB limit")?.maxMb).toBe(5.5); - }); - - it("returns null for unrelated errors", () => { - expect(parseImageSizeError("context overflow")).toBeNull(); - }); -}); diff --git a/src/agents/pi-embedded-helpers.isautherrormessage.e2e.test.ts b/src/agents/pi-embedded-helpers.isautherrormessage.e2e.test.ts deleted file mode 100644 index 2c8fd65d099..00000000000 --- a/src/agents/pi-embedded-helpers.isautherrormessage.e2e.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isAuthErrorMessage } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("isAuthErrorMessage", () => { - it("matches credential validation errors", () => { - const samples = [ - 'No credentials found for profile "anthropic:default".', - "No API key found for profile openai.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } - }); - it("matches OAuth refresh failures", () => { - const samples = [ - "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", - "Please re-authenticate to continue.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } - }); - it("ignores unrelated errors", () => { - expect(isAuthErrorMessage("rate limit exceeded")).toBe(false); - expect(isAuthErrorMessage("billing issue detected")).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.e2e.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.e2e.test.ts index 69b04e8bb37..4f72364de1f 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.e2e.test.ts @@ -1,14 +1,45 @@ import { describe, expect, it } from "vitest"; -import { isBillingErrorMessage } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; +import { + classifyFailoverReason, + isAuthErrorMessage, + isBillingErrorMessage, + isCloudCodeAssistFormatError, + isCloudflareOrHtmlErrorPage, + isCompactionFailureError, + isContextOverflowError, + isFailoverErrorMessage, + isImageDimensionErrorMessage, + isLikelyContextOverflowError, + isTransientHttpError, + parseImageDimensionError, + parseImageSizeError, +} from "./pi-embedded-helpers.js"; -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, +describe("isAuthErrorMessage", () => { + it("matches credential validation errors", () => { + const samples = [ + 'No credentials found for profile "anthropic:default".', + "No API key found for profile openai.", + ]; + for (const sample of samples) { + expect(isAuthErrorMessage(sample)).toBe(true); + } + }); + it("matches OAuth refresh failures", () => { + const samples = [ + "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", + "Please re-authenticate to continue.", + ]; + for (const sample of samples) { + expect(isAuthErrorMessage(sample)).toBe(true); + } + }); + it("ignores unrelated errors", () => { + expect(isAuthErrorMessage("rate limit exceeded")).toBe(false); + expect(isAuthErrorMessage("billing issue detected")).toBe(false); + }); }); + describe("isBillingErrorMessage", () => { it("matches credit / payment failures", () => { const samples = [ @@ -65,3 +96,255 @@ describe("isBillingErrorMessage", () => { } }); }); + +describe("isCloudCodeAssistFormatError", () => { + it("matches format errors", () => { + const samples = [ + "INVALID_REQUEST_ERROR: string should match pattern", + "messages.1.content.1.tool_use.id", + "tool_use.id should match pattern", + "invalid request format", + ]; + for (const sample of samples) { + expect(isCloudCodeAssistFormatError(sample)).toBe(true); + } + }); + it("ignores unrelated errors", () => { + expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false); + expect( + isCloudCodeAssistFormatError( + '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}', + ), + ).toBe(false); + }); +}); + +describe("isCloudflareOrHtmlErrorPage", () => { + it("detects Cloudflare 521 HTML pages", () => { + const htmlError = `521 + + Web server is down | example.com | Cloudflare +

Web server is down

+`; + + expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); + }); + + it("detects generic 5xx HTML pages", () => { + const htmlError = `503 Service Unavailabledown`; + expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); + }); + + it("does not flag non-HTML status lines", () => { + expect(isCloudflareOrHtmlErrorPage("500 Internal Server Error")).toBe(false); + expect(isCloudflareOrHtmlErrorPage("429 Too Many Requests")).toBe(false); + }); + + it("does not flag quoted HTML without a closing html tag", () => { + const plainTextWithHtmlPrefix = "500 upstream responded with partial HTML text"; + expect(isCloudflareOrHtmlErrorPage(plainTextWithHtmlPrefix)).toBe(false); + }); +}); + +describe("isCompactionFailureError", () => { + it("matches compaction overflow failures", () => { + const samples = [ + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + "auto-compaction failed due to context overflow", + "Compaction failed: prompt is too long", + "Summarization failed: context window exceeded for this request", + ]; + for (const sample of samples) { + expect(isCompactionFailureError(sample)).toBe(true); + } + }); + it("ignores non-compaction overflow errors", () => { + expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false); + expect(isCompactionFailureError("rate limit exceeded")).toBe(false); + }); +}); + +describe("isContextOverflowError", () => { + it("matches known overflow hints", () => { + const samples = [ + "request_too_large", + "Request exceeds the maximum size", + "context length exceeded", + "Maximum context length", + "prompt is too long: 208423 tokens > 200000 maximum", + "Context overflow: Summarization failed", + "413 Request Entity Too Large", + ]; + for (const sample of samples) { + expect(isContextOverflowError(sample)).toBe(true); + } + }); + + it("matches Anthropic 'Request size exceeds model context window' error", () => { + // Anthropic returns this error format when the prompt exceeds the context window. + // Without this fix, auto-compaction is NOT triggered because neither + // isContextOverflowError nor pi-ai's isContextOverflow recognizes this pattern. + // The user sees: "LLM request rejected: Request size exceeds model context window" + // instead of automatic compaction + retry. + const anthropicRawError = + '{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}'; + expect(isContextOverflowError(anthropicRawError)).toBe(true); + }); + + it("matches 'exceeds model context window' in various formats", () => { + const samples = [ + "Request size exceeds model context window", + "request size exceeds model context window", + '400 {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}', + "The request size exceeds model context window limit", + ]; + for (const sample of samples) { + expect(isContextOverflowError(sample)).toBe(true); + } + }); + + it("ignores unrelated errors", () => { + expect(isContextOverflowError("rate limit exceeded")).toBe(false); + expect(isContextOverflowError("request size exceeds upload limit")).toBe(false); + expect(isContextOverflowError("model not found")).toBe(false); + expect(isContextOverflowError("authentication failed")).toBe(false); + }); + + it("ignores normal conversation text mentioning context overflow", () => { + // These are legitimate conversation snippets, not error messages + expect(isContextOverflowError("Let's investigate the context overflow bug")).toBe(false); + expect(isContextOverflowError("The mystery context overflow errors are strange")).toBe(false); + expect(isContextOverflowError("We're debugging context overflow issues")).toBe(false); + expect(isContextOverflowError("Something is causing context overflow messages")).toBe(false); + }); +}); + +describe("isLikelyContextOverflowError", () => { + it("matches context overflow hints", () => { + const samples = [ + "Model context window is 128k tokens, you requested 256k tokens", + "Context window exceeded: requested 12000 tokens", + "Prompt too large for this model", + ]; + for (const sample of samples) { + expect(isLikelyContextOverflowError(sample)).toBe(true); + } + }); + + it("excludes context window too small errors", () => { + const samples = [ + "Model context window too small (minimum is 128k tokens)", + "Context window too small: minimum is 1000 tokens", + ]; + for (const sample of samples) { + expect(isLikelyContextOverflowError(sample)).toBe(false); + } + }); + + it("excludes rate limit errors that match the broad hint regex", () => { + const samples = [ + "request reached organization TPD rate limit, current: 1506556, limit: 1500000", + "rate limit exceeded", + "too many requests", + "429 Too Many Requests", + "exceeded your current quota", + "This request would exceed your account's rate limit", + "429 Too Many Requests: request exceeds rate limit", + ]; + for (const sample of samples) { + expect(isLikelyContextOverflowError(sample)).toBe(false); + } + }); +}); + +describe("isTransientHttpError", () => { + it("returns true for retryable 5xx status codes", () => { + expect(isTransientHttpError("500 Internal Server Error")).toBe(true); + expect(isTransientHttpError("502 Bad Gateway")).toBe(true); + expect(isTransientHttpError("503 Service Unavailable")).toBe(true); + expect(isTransientHttpError("521 ")).toBe(true); + expect(isTransientHttpError("529 Overloaded")).toBe(true); + }); + + it("returns false for non-retryable or non-http text", () => { + expect(isTransientHttpError("504 Gateway Timeout")).toBe(false); + expect(isTransientHttpError("429 Too Many Requests")).toBe(false); + expect(isTransientHttpError("network timeout")).toBe(false); + }); +}); + +describe("isFailoverErrorMessage", () => { + it("matches auth/rate/billing/timeout", () => { + const samples = [ + "invalid api key", + "429 rate limit exceeded", + "Your credit balance is too low", + "request timed out", + "invalid request format", + ]; + for (const sample of samples) { + expect(isFailoverErrorMessage(sample)).toBe(true); + } + }); +}); + +describe("parseImageSizeError", () => { + it("parses max MB values from error text", () => { + expect(parseImageSizeError("image exceeds 5 MB maximum")?.maxMb).toBe(5); + expect(parseImageSizeError("Image exceeds 5.5 MB limit")?.maxMb).toBe(5.5); + }); + + it("returns null for unrelated errors", () => { + expect(parseImageSizeError("context overflow")).toBeNull(); + }); +}); + +describe("image dimension errors", () => { + it("parses anthropic image dimension errors", () => { + const raw = + '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}'; + const parsed = parseImageDimensionError(raw); + expect(parsed).not.toBeNull(); + expect(parsed?.maxDimensionPx).toBe(2000); + expect(parsed?.messageIndex).toBe(84); + expect(parsed?.contentIndex).toBe(1); + expect(isImageDimensionErrorMessage(raw)).toBe(true); + }); +}); + +describe("classifyFailoverReason", () => { + it("returns a stable reason", () => { + expect(classifyFailoverReason("invalid api key")).toBe("auth"); + expect(classifyFailoverReason("no credentials found")).toBe("auth"); + expect(classifyFailoverReason("no api key found")).toBe("auth"); + expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); + expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit"); + expect( + classifyFailoverReason( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + ), + ).toBe("rate_limit"); + expect(classifyFailoverReason("invalid request format")).toBe("format"); + expect(classifyFailoverReason("credit balance too low")).toBe("billing"); + expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); + expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout"); + expect( + classifyFailoverReason( + "521 Web server is downCloudflare", + ), + ).toBe("timeout"); + expect(classifyFailoverReason("string should match pattern")).toBe("format"); + expect(classifyFailoverReason("bad request")).toBeNull(); + expect( + classifyFailoverReason( + "messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels", + ), + ).toBeNull(); + expect(classifyFailoverReason("image exceeds 5 MB maximum")).toBeNull(); + }); + it("classifies OpenAI usage limit errors as rate_limit", () => { + expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe( + "rate_limit", + ); + }); +}); diff --git a/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.e2e.test.ts b/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.e2e.test.ts deleted file mode 100644 index 2433642e46d..00000000000 --- a/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.e2e.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isCloudCodeAssistFormatError } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("isCloudCodeAssistFormatError", () => { - it("matches format errors", () => { - const samples = [ - "INVALID_REQUEST_ERROR: string should match pattern", - "messages.1.content.1.tool_use.id", - "tool_use.id should match pattern", - "invalid request format", - ]; - for (const sample of samples) { - expect(isCloudCodeAssistFormatError(sample)).toBe(true); - } - }); - it("ignores unrelated errors", () => { - expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false); - expect( - isCloudCodeAssistFormatError( - '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}', - ), - ).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.iscloudflareorhtmlerrorpage.e2e.test.ts b/src/agents/pi-embedded-helpers.iscloudflareorhtmlerrorpage.e2e.test.ts deleted file mode 100644 index ebdb22c6c5d..00000000000 --- a/src/agents/pi-embedded-helpers.iscloudflareorhtmlerrorpage.e2e.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isCloudflareOrHtmlErrorPage } from "./pi-embedded-helpers.js"; - -describe("isCloudflareOrHtmlErrorPage", () => { - it("detects Cloudflare 521 HTML pages", () => { - const htmlError = `521 - - Web server is down | example.com | Cloudflare -

Web server is down

-`; - - expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); - }); - - it("detects generic 5xx HTML pages", () => { - const htmlError = `503 Service Unavailabledown`; - expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); - }); - - it("does not flag non-HTML status lines", () => { - expect(isCloudflareOrHtmlErrorPage("500 Internal Server Error")).toBe(false); - expect(isCloudflareOrHtmlErrorPage("429 Too Many Requests")).toBe(false); - }); - - it("does not flag quoted HTML without a closing html tag", () => { - const plainTextWithHtmlPrefix = "500 upstream responded with partial HTML text"; - expect(isCloudflareOrHtmlErrorPage(plainTextWithHtmlPrefix)).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.iscompactionfailureerror.e2e.test.ts b/src/agents/pi-embedded-helpers.iscompactionfailureerror.e2e.test.ts deleted file mode 100644 index 6abcabba5bd..00000000000 --- a/src/agents/pi-embedded-helpers.iscompactionfailureerror.e2e.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isCompactionFailureError } from "./pi-embedded-helpers/errors.js"; -describe("isCompactionFailureError", () => { - it("matches compaction overflow failures", () => { - const samples = [ - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - "auto-compaction failed due to context overflow", - "Compaction failed: prompt is too long", - "Summarization failed: context window exceeded for this request", - ]; - for (const sample of samples) { - expect(isCompactionFailureError(sample)).toBe(true); - } - }); - it("ignores non-compaction overflow errors", () => { - expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false); - expect(isCompactionFailureError("rate limit exceeded")).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.iscontextoverflowerror.e2e.test.ts b/src/agents/pi-embedded-helpers.iscontextoverflowerror.e2e.test.ts deleted file mode 100644 index 79a19732640..00000000000 --- a/src/agents/pi-embedded-helpers.iscontextoverflowerror.e2e.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isContextOverflowError } from "./pi-embedded-helpers.js"; - -describe("isContextOverflowError", () => { - it("matches known overflow hints", () => { - const samples = [ - "request_too_large", - "Request exceeds the maximum size", - "context length exceeded", - "Maximum context length", - "prompt is too long: 208423 tokens > 200000 maximum", - "Context overflow: Summarization failed", - "413 Request Entity Too Large", - ]; - for (const sample of samples) { - expect(isContextOverflowError(sample)).toBe(true); - } - }); - - it("matches Anthropic 'Request size exceeds model context window' error", () => { - // Anthropic returns this error format when the prompt exceeds the context window. - // Without this fix, auto-compaction is NOT triggered because neither - // isContextOverflowError nor pi-ai's isContextOverflow recognizes this pattern. - // The user sees: "LLM request rejected: Request size exceeds model context window" - // instead of automatic compaction + retry. - const anthropicRawError = - '{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}'; - expect(isContextOverflowError(anthropicRawError)).toBe(true); - }); - - it("matches 'exceeds model context window' in various formats", () => { - const samples = [ - "Request size exceeds model context window", - "request size exceeds model context window", - '400 {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}', - "The request size exceeds model context window limit", - ]; - for (const sample of samples) { - expect(isContextOverflowError(sample)).toBe(true); - } - }); - - it("ignores unrelated errors", () => { - expect(isContextOverflowError("rate limit exceeded")).toBe(false); - expect(isContextOverflowError("request size exceeds upload limit")).toBe(false); - expect(isContextOverflowError("model not found")).toBe(false); - expect(isContextOverflowError("authentication failed")).toBe(false); - }); - - it("ignores normal conversation text mentioning context overflow", () => { - // These are legitimate conversation snippets, not error messages - expect(isContextOverflowError("Let's investigate the context overflow bug")).toBe(false); - expect(isContextOverflowError("The mystery context overflow errors are strange")).toBe(false); - expect(isContextOverflowError("We're debugging context overflow issues")).toBe(false); - expect(isContextOverflowError("Something is causing context overflow messages")).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.isfailovererrormessage.e2e.test.ts b/src/agents/pi-embedded-helpers.isfailovererrormessage.e2e.test.ts deleted file mode 100644 index 2afb8557b2e..00000000000 --- a/src/agents/pi-embedded-helpers.isfailovererrormessage.e2e.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isFailoverErrorMessage } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("isFailoverErrorMessage", () => { - it("matches auth/rate/billing/timeout", () => { - const samples = [ - "invalid api key", - "429 rate limit exceeded", - "Your credit balance is too low", - "request timed out", - "invalid request format", - ]; - for (const sample of samples) { - expect(isFailoverErrorMessage(sample)).toBe(true); - } - }); -}); diff --git a/src/agents/pi-embedded-helpers.islikelycontextoverflowerror.e2e.test.ts b/src/agents/pi-embedded-helpers.islikelycontextoverflowerror.e2e.test.ts deleted file mode 100644 index e9ff9e457c3..00000000000 --- a/src/agents/pi-embedded-helpers.islikelycontextoverflowerror.e2e.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js"; - -describe("isLikelyContextOverflowError", () => { - it("matches context overflow hints", () => { - const samples = [ - "Model context window is 128k tokens, you requested 256k tokens", - "Context window exceeded: requested 12000 tokens", - "Prompt too large for this model", - ]; - for (const sample of samples) { - expect(isLikelyContextOverflowError(sample)).toBe(true); - } - }); - - it("excludes context window too small errors", () => { - const samples = [ - "Model context window too small (minimum is 128k tokens)", - "Context window too small: minimum is 1000 tokens", - ]; - for (const sample of samples) { - expect(isLikelyContextOverflowError(sample)).toBe(false); - } - }); - - it("excludes rate limit errors that match the broad hint regex", () => { - const samples = [ - "request reached organization TPD rate limit, current: 1506556, limit: 1500000", - "rate limit exceeded", - "too many requests", - "429 Too Many Requests", - "exceeded your current quota", - "This request would exceed your account's rate limit", - "429 Too Many Requests: request exceeds rate limit", - ]; - for (const sample of samples) { - expect(isLikelyContextOverflowError(sample)).toBe(false); - } - }); -}); diff --git a/src/agents/pi-embedded-helpers.istransienthttperror.e2e.test.ts b/src/agents/pi-embedded-helpers.istransienthttperror.e2e.test.ts deleted file mode 100644 index faaf4a20139..00000000000 --- a/src/agents/pi-embedded-helpers.istransienthttperror.e2e.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isTransientHttpError } from "./pi-embedded-helpers.js"; - -describe("isTransientHttpError", () => { - it("returns true for retryable 5xx status codes", () => { - expect(isTransientHttpError("500 Internal Server Error")).toBe(true); - expect(isTransientHttpError("502 Bad Gateway")).toBe(true); - expect(isTransientHttpError("503 Service Unavailable")).toBe(true); - expect(isTransientHttpError("521 ")).toBe(true); - expect(isTransientHttpError("529 Overloaded")).toBe(true); - }); - - it("returns false for non-retryable or non-http text", () => { - expect(isTransientHttpError("504 Gateway Timeout")).toBe(false); - expect(isTransientHttpError("429 Too Many Requests")).toBe(false); - expect(isTransientHttpError("network timeout")).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.messaging-duplicate.e2e.test.ts b/src/agents/pi-embedded-helpers.messaging-duplicate.e2e.test.ts deleted file mode 100644 index 04f88d023f2..00000000000 --- a/src/agents/pi-embedded-helpers.messaging-duplicate.e2e.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isMessagingToolDuplicate, normalizeTextForComparison } from "./pi-embedded-helpers.js"; - -describe("normalizeTextForComparison", () => { - it("lowercases text", () => { - expect(normalizeTextForComparison("Hello World")).toBe("hello world"); - }); - - it("trims whitespace", () => { - expect(normalizeTextForComparison(" hello ")).toBe("hello"); - }); - - it("collapses multiple spaces", () => { - expect(normalizeTextForComparison("hello world")).toBe("hello world"); - }); - - it("strips emoji", () => { - expect(normalizeTextForComparison("Hello πŸ‘‹ World 🌍")).toBe("hello world"); - }); - - it("handles mixed normalization", () => { - expect(normalizeTextForComparison(" Hello πŸ‘‹ WORLD 🌍 ")).toBe("hello world"); - }); -}); - -describe("isMessagingToolDuplicate", () => { - it("returns false for empty sentTexts", () => { - expect(isMessagingToolDuplicate("hello world", [])).toBe(false); - }); - - it("returns false for short texts", () => { - expect(isMessagingToolDuplicate("short", ["short"])).toBe(false); - }); - - it("detects exact duplicates", () => { - expect( - isMessagingToolDuplicate("Hello, this is a test message!", [ - "Hello, this is a test message!", - ]), - ).toBe(true); - }); - - it("detects duplicates with different casing", () => { - expect( - isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [ - "hello, this is a test message!", - ]), - ).toBe(true); - }); - - it("detects duplicates with emoji variations", () => { - expect( - isMessagingToolDuplicate("Hello! πŸ‘‹ This is a test message!", [ - "Hello! This is a test message!", - ]), - ).toBe(true); - }); - - it("detects substring duplicates (LLM elaboration)", () => { - expect( - isMessagingToolDuplicate('I sent the message: "Hello, this is a test message!"', [ - "Hello, this is a test message!", - ]), - ).toBe(true); - }); - - it("detects when sent text contains block reply (reverse substring)", () => { - expect( - isMessagingToolDuplicate("Hello, this is a test message!", [ - 'I sent the message: "Hello, this is a test message!"', - ]), - ).toBe(true); - }); - - it("returns false for non-matching texts", () => { - expect( - isMessagingToolDuplicate("This is completely different content.", [ - "Hello, this is a test message!", - ]), - ).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.normalizetextforcomparison.e2e.test.ts b/src/agents/pi-embedded-helpers.normalizetextforcomparison.e2e.test.ts deleted file mode 100644 index 300dd234b36..00000000000 --- a/src/agents/pi-embedded-helpers.normalizetextforcomparison.e2e.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeTextForComparison } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("normalizeTextForComparison", () => { - it("lowercases text", () => { - expect(normalizeTextForComparison("Hello World")).toBe("hello world"); - }); - it("trims whitespace", () => { - expect(normalizeTextForComparison(" hello ")).toBe("hello"); - }); - it("collapses multiple spaces", () => { - expect(normalizeTextForComparison("hello world")).toBe("hello world"); - }); - it("strips emoji", () => { - expect(normalizeTextForComparison("Hello πŸ‘‹ World 🌍")).toBe("hello world"); - }); - it("handles mixed normalization", () => { - expect(normalizeTextForComparison(" Hello πŸ‘‹ WORLD 🌍 ")).toBe("hello world"); - }); -}); diff --git a/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts b/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts deleted file mode 100644 index c4a0e7471c2..00000000000 --- a/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { - DEFAULT_BOOTSTRAP_MAX_CHARS, - DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS, - resolveBootstrapMaxChars, - resolveBootstrapTotalMaxChars, -} from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("resolveBootstrapMaxChars", () => { - it("returns default when unset", () => { - expect(resolveBootstrapMaxChars()).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS); - }); - it("uses configured value when valid", () => { - const cfg = { - agents: { defaults: { bootstrapMaxChars: 12345 } }, - } as OpenClawConfig; - expect(resolveBootstrapMaxChars(cfg)).toBe(12345); - }); - it("falls back when invalid", () => { - const cfg = { - agents: { defaults: { bootstrapMaxChars: -1 } }, - } as OpenClawConfig; - expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS); - }); -}); - -describe("resolveBootstrapTotalMaxChars", () => { - it("returns default when unset", () => { - expect(resolveBootstrapTotalMaxChars()).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS); - }); - it("uses configured value when valid", () => { - const cfg = { - agents: { defaults: { bootstrapTotalMaxChars: 12345 } }, - } as OpenClawConfig; - expect(resolveBootstrapTotalMaxChars(cfg)).toBe(12345); - }); - it("falls back when invalid", () => { - const cfg = { - agents: { defaults: { bootstrapTotalMaxChars: -1 } }, - } as OpenClawConfig; - expect(resolveBootstrapTotalMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.e2e.test.ts deleted file mode 100644 index 1b3210790cc..00000000000 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.e2e.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { describe, expect, it } from "vitest"; -import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js"; - -describe("sanitizeSessionMessagesImages", () => { - it("keeps tool call + tool result IDs unchanged by default", async () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_123|fc_456", - name: "read", - arguments: { path: "package.json" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_456", - toolName: "read", - content: [{ type: "text", text: "ok" }], - isError: false, - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test"); - - const assistant = out[0] as unknown as { role?: string; content?: unknown }; - expect(assistant.role).toBe("assistant"); - expect(Array.isArray(assistant.content)).toBe(true); - const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( - (b) => b.type === "toolCall", - ); - expect(toolCall?.id).toBe("call_123|fc_456"); - - const toolResult = out[1] as unknown as { - role?: string; - toolCallId?: string; - }; - expect(toolResult.role).toBe("toolResult"); - expect(toolResult.toolCallId).toBe("call_123|fc_456"); - }); - - it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_123|fc_456", - name: "read", - arguments: { path: "package.json" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_456", - toolName: "read", - content: [{ type: "text", text: "ok" }], - isError: false, - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test", { - sanitizeToolCallIds: true, - toolCallIdMode: "strict", - }); - - const assistant = out[0] as unknown as { role?: string; content?: unknown }; - expect(assistant.role).toBe("assistant"); - expect(Array.isArray(assistant.content)).toBe(true); - const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( - (b) => b.type === "toolCall", - ); - // Strict mode strips all non-alphanumeric characters - expect(toolCall?.id).toBe("call123fc456"); - - const toolResult = out[1] as unknown as { - role?: string; - toolCallId?: string; - }; - expect(toolResult.role).toBe("toolResult"); - expect(toolResult.toolCallId).toBe("call123fc456"); - }); - it("does not synthesize tool call input when missing", async () => { - const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "read" }], - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test"); - const assistant = out[0] as { content?: Array> }; - const toolCall = assistant.content?.find((b) => b.type === "toolCall"); - expect(toolCall).toBeTruthy(); - expect("input" in (toolCall ?? {})).toBe(false); - expect("arguments" in (toolCall ?? {})).toBe(false); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts index 4d03c3ffe7f..4770a4b4d34 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts @@ -1,8 +1,111 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js"; +import { + sanitizeGoogleTurnOrdering, + sanitizeSessionMessagesImages, +} from "./pi-embedded-helpers.js"; describe("sanitizeSessionMessagesImages", () => { + it("keeps tool call + tool result IDs unchanged by default", async () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_123|fc_456", + name: "read", + arguments: { path: "package.json" }, + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_123|fc_456", + toolName: "read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + + const assistant = out[0] as unknown as { role?: string; content?: unknown }; + expect(assistant.role).toBe("assistant"); + expect(Array.isArray(assistant.content)).toBe(true); + const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( + (b) => b.type === "toolCall", + ); + expect(toolCall?.id).toBe("call_123|fc_456"); + + const toolResult = out[1] as unknown as { + role?: string; + toolCallId?: string; + }; + expect(toolResult.role).toBe("toolResult"); + expect(toolResult.toolCallId).toBe("call_123|fc_456"); + }); + + it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_123|fc_456", + name: "read", + arguments: { path: "package.json" }, + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_123|fc_456", + toolName: "read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test", { + sanitizeToolCallIds: true, + toolCallIdMode: "strict", + }); + + const assistant = out[0] as unknown as { role?: string; content?: unknown }; + expect(assistant.role).toBe("assistant"); + expect(Array.isArray(assistant.content)).toBe(true); + const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( + (b) => b.type === "toolCall", + ); + // Strict mode strips all non-alphanumeric characters + expect(toolCall?.id).toBe("call123fc456"); + + const toolResult = out[1] as unknown as { + role?: string; + toolCallId?: string; + }; + expect(toolResult.role).toBe("toolResult"); + expect(toolResult.toolCallId).toBe("call123fc456"); + }); + + it("does not synthesize tool call input when missing", async () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read" }], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + const assistant = out[0] as { content?: Array> }; + const toolCall = assistant.content?.find((b) => b.type === "toolCall"); + expect(toolCall).toBeTruthy(); + expect("input" in (toolCall ?? {})).toBe(false); + expect("arguments" in (toolCall ?? {})).toBe(false); + }); + it("removes empty assistant text blocks but preserves tool calls", async () => { const input = [ { @@ -57,6 +160,35 @@ describe("sanitizeSessionMessagesImages", () => { const toolResult = out[1] as { toolUseId?: string }; expect(toolResult.toolUseId).toBe("callabcitem123"); }); + + it("does not sanitize tool IDs in images-only mode", async () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_123|fc_456", + toolName: "read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test", { + sanitizeMode: "images-only", + sanitizeToolCallIds: true, + toolCallIdMode: "strict", + }); + + const assistant = out[0] as unknown as { content?: Array<{ type?: string; id?: string }> }; + const toolCall = assistant.content?.find((b) => b.type === "toolCall"); + expect(toolCall?.id).toBe("call_123|fc_456"); + + const toolResult = out[1] as unknown as { toolCallId?: string }; + expect(toolResult.toolCallId).toBe("call_123|fc_456"); + }); it("filters whitespace-only assistant text blocks", async () => { const input = [ { @@ -117,4 +249,50 @@ describe("sanitizeSessionMessagesImages", () => { expect(out[0]?.role).toBe("user"); expect(out[1]?.role).toBe("toolResult"); }); + + describe("thought_signature stripping", () => { + it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => { + const input = [ + { + role: "assistant", + content: [ + { type: "text", text: "hello", thought_signature: "msg_abc123" }, + { + type: "thinking", + thinking: "reasoning", + thought_signature: "AQID", + }, + ], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + + expect(out).toHaveLength(1); + const content = (out[0] as { content?: unknown[] }).content; + expect(content).toHaveLength(2); + expect("thought_signature" in ((content?.[0] ?? {}) as object)).toBe(false); + expect((content?.[1] as { thought_signature?: unknown })?.thought_signature).toBe("AQID"); + }); + }); +}); + +describe("sanitizeGoogleTurnOrdering", () => { + it("prepends a synthetic user turn when history starts with assistant", () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }], + }, + ] satisfies AgentMessage[]; + + const out = sanitizeGoogleTurnOrdering(input); + expect(out[0]?.role).toBe("user"); + expect(out[1]?.role).toBe("assistant"); + }); + it("is a no-op when history starts with user", () => { + const input = [{ role: "user", content: "hi" }] satisfies AgentMessage[]; + const out = sanitizeGoogleTurnOrdering(input); + expect(out).toBe(input); + }); }); diff --git a/src/agents/pi-embedded-helpers.sanitizegoogleturnordering.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizegoogleturnordering.e2e.test.ts deleted file mode 100644 index a12f82367c9..00000000000 --- a/src/agents/pi-embedded-helpers.sanitizegoogleturnordering.e2e.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { describe, expect, it } from "vitest"; -import { sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("sanitizeGoogleTurnOrdering", () => { - it("prepends a synthetic user turn when history starts with assistant", () => { - const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }], - }, - ] satisfies AgentMessage[]; - - const out = sanitizeGoogleTurnOrdering(input); - expect(out[0]?.role).toBe("user"); - expect(out[1]?.role).toBe("assistant"); - }); - it("is a no-op when history starts with user", () => { - const input = [{ role: "user", content: "hi" }] satisfies AgentMessage[]; - const out = sanitizeGoogleTurnOrdering(input); - expect(out).toBe(input); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitizesessionmessagesimages-thought-signature-stripping.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizesessionmessagesimages-thought-signature-stripping.e2e.test.ts deleted file mode 100644 index 977002ce9a6..00000000000 --- a/src/agents/pi-embedded-helpers.sanitizesessionmessagesimages-thought-signature-stripping.e2e.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { describe, expect, it } from "vitest"; -import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("sanitizeSessionMessagesImages - thought_signature stripping", () => { - it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => { - const input = [ - { - role: "assistant", - content: [ - { type: "text", text: "hello", thought_signature: "msg_abc123" }, - { - type: "thinking", - thinking: "reasoning", - thought_signature: "AQID", - }, - ], - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test"); - - expect(out).toHaveLength(1); - const content = (out[0] as { content?: unknown[] }).content; - expect(content).toHaveLength(2); - expect("thought_signature" in ((content?.[0] ?? {}) as object)).toBe(false); - expect((content?.[1] as { thought_signature?: unknown })?.thought_signature).toBe("AQID"); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitizetoolcallid.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizetoolcallid.e2e.test.ts deleted file mode 100644 index 71256a71dc6..00000000000 --- a/src/agents/pi-embedded-helpers.sanitizetoolcallid.e2e.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { sanitizeToolCallId } from "./pi-embedded-helpers.js"; - -describe("sanitizeToolCallId", () => { - describe("strict mode (default)", () => { - it("keeps valid alphanumeric tool call IDs", () => { - expect(sanitizeToolCallId("callabc123")).toBe("callabc123"); - }); - it("strips underscores and hyphens", () => { - expect(sanitizeToolCallId("call_abc-123")).toBe("callabc123"); - expect(sanitizeToolCallId("call_abc_def")).toBe("callabcdef"); - }); - it("strips invalid characters", () => { - expect(sanitizeToolCallId("call_abc|item:456")).toBe("callabcitem456"); - }); - it("returns default for empty IDs", () => { - expect(sanitizeToolCallId("")).toBe("defaulttoolid"); - }); - }); - - describe("strict mode (alphanumeric only)", () => { - it("strips all non-alphanumeric characters", () => { - expect(sanitizeToolCallId("call_abc-123", "strict")).toBe("callabc123"); - expect(sanitizeToolCallId("call_abc|item:456", "strict")).toBe("callabcitem456"); - expect(sanitizeToolCallId("whatsapp_login_1768799841527_1", "strict")).toBe( - "whatsapplogin17687998415271", - ); - }); - it("returns default for empty IDs", () => { - expect(sanitizeToolCallId("", "strict")).toBe("defaulttoolid"); - }); - }); - - describe("strict9 mode (Mistral tool call IDs)", () => { - it("returns alphanumeric IDs with length 9", () => { - const out = sanitizeToolCallId("call_abc|item:456", "strict9"); - expect(out).toMatch(/^[a-zA-Z0-9]{9}$/); - }); - it("returns default for empty IDs", () => { - expect(sanitizeToolCallId("", "strict9")).toMatch(/^[a-zA-Z0-9]{9}$/); - }); - }); -}); diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts index 318bb3ce6d2..4d9817f59f6 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { sanitizeUserFacingText } from "./pi-embedded-helpers.js"; +import { + downgradeOpenAIReasoningBlocks, + isMessagingToolDuplicate, + normalizeTextForComparison, + sanitizeToolCallId, + sanitizeUserFacingText, + stripThoughtSignatures, +} from "./pi-embedded-helpers.js"; describe("sanitizeUserFacingText", () => { it("strips final tags", () => { @@ -114,3 +121,278 @@ describe("sanitizeUserFacingText", () => { expect(sanitizeUserFacingText(" \n ")).toBe(""); }); }); + +describe("stripThoughtSignatures", () => { + it("returns non-array content unchanged", () => { + expect(stripThoughtSignatures("hello")).toBe("hello"); + expect(stripThoughtSignatures(null)).toBe(null); + expect(stripThoughtSignatures(undefined)).toBe(undefined); + expect(stripThoughtSignatures(123)).toBe(123); + }); + it("removes msg_-prefixed thought_signature from content blocks", () => { + const input = [ + { type: "text", text: "hello", thought_signature: "msg_abc123" }, + { type: "thinking", thinking: "test", thought_signature: "AQID" }, + ]; + const result = stripThoughtSignatures(input); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ type: "text", text: "hello" }); + expect(result[1]).toEqual({ + type: "thinking", + thinking: "test", + thought_signature: "AQID", + }); + expect("thought_signature" in result[0]).toBe(false); + expect("thought_signature" in result[1]).toBe(true); + }); + it("preserves blocks without thought_signature", () => { + const input = [ + { type: "text", text: "hello" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + ]; + const result = stripThoughtSignatures(input); + + expect(result).toEqual(input); + }); + it("handles mixed blocks with and without thought_signature", () => { + const input = [ + { type: "text", text: "hello", thought_signature: "msg_abc" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "thinking", thinking: "hmm", thought_signature: "msg_xyz" }, + ]; + const result = stripThoughtSignatures(input); + + expect(result).toEqual([ + { type: "text", text: "hello" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "thinking", thinking: "hmm" }, + ]); + }); + it("handles empty array", () => { + expect(stripThoughtSignatures([])).toEqual([]); + }); + it("handles null/undefined blocks in array", () => { + const input = [null, undefined, { type: "text", text: "hello" }]; + const result = stripThoughtSignatures(input); + expect(result).toEqual([null, undefined, { type: "text", text: "hello" }]); + }); +}); + +describe("sanitizeToolCallId", () => { + describe("strict mode (default)", () => { + it("keeps valid alphanumeric tool call IDs", () => { + expect(sanitizeToolCallId("callabc123")).toBe("callabc123"); + }); + it("strips underscores and hyphens", () => { + expect(sanitizeToolCallId("call_abc-123")).toBe("callabc123"); + expect(sanitizeToolCallId("call_abc_def")).toBe("callabcdef"); + }); + it("strips invalid characters", () => { + expect(sanitizeToolCallId("call_abc|item:456")).toBe("callabcitem456"); + }); + it("returns default for empty IDs", () => { + expect(sanitizeToolCallId("")).toBe("defaulttoolid"); + }); + }); + + describe("strict mode (alphanumeric only)", () => { + it("strips all non-alphanumeric characters", () => { + expect(sanitizeToolCallId("call_abc-123", "strict")).toBe("callabc123"); + expect(sanitizeToolCallId("call_abc|item:456", "strict")).toBe("callabcitem456"); + expect(sanitizeToolCallId("whatsapp_login_1768799841527_1", "strict")).toBe( + "whatsapplogin17687998415271", + ); + }); + it("returns default for empty IDs", () => { + expect(sanitizeToolCallId("", "strict")).toBe("defaulttoolid"); + }); + }); + + describe("strict9 mode (Mistral tool call IDs)", () => { + it("returns alphanumeric IDs with length 9", () => { + const out = sanitizeToolCallId("call_abc|item:456", "strict9"); + expect(out).toMatch(/^[a-zA-Z0-9]{9}$/); + }); + it("returns default for empty IDs", () => { + expect(sanitizeToolCallId("", "strict9")).toMatch(/^[a-zA-Z0-9]{9}$/); + }); + }); +}); + +describe("downgradeOpenAIReasoningBlocks", () => { + it("keeps reasoning signatures when followed by content", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), + }, + { type: "text", text: "answer" }, + ], + }, + ]; + + // oxlint-disable-next-line typescript/no-explicit-any + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); + }); + + it("drops orphaned reasoning blocks without following content", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }), + }, + ], + }, + { role: "user", content: "next" }, + ]; + + // oxlint-disable-next-line typescript/no-explicit-any + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([ + { role: "user", content: "next" }, + ]); + }); + + it("drops object-form orphaned signatures", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinkingSignature: { id: "rs_obj", type: "reasoning" }, + }, + ], + }, + ]; + + // oxlint-disable-next-line typescript/no-explicit-any + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]); + }); + + it("keeps non-reasoning thinking signatures", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "t", + thinkingSignature: "reasoning_content", + }, + ], + }, + ]; + + // oxlint-disable-next-line typescript/no-explicit-any + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); + }); + + it("is idempotent for orphaned reasoning cleanup", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinkingSignature: JSON.stringify({ id: "rs_orphan", type: "reasoning" }), + }, + ], + }, + { role: "user", content: "next" }, + ]; + + // oxlint-disable-next-line typescript/no-explicit-any + const once = downgradeOpenAIReasoningBlocks(input as any); + // oxlint-disable-next-line typescript/no-explicit-any + const twice = downgradeOpenAIReasoningBlocks(once as any); + expect(twice).toEqual(once); + }); +}); + +describe("normalizeTextForComparison", () => { + it("lowercases text", () => { + expect(normalizeTextForComparison("Hello World")).toBe("hello world"); + }); + + it("trims whitespace", () => { + expect(normalizeTextForComparison(" hello ")).toBe("hello"); + }); + + it("collapses multiple spaces", () => { + expect(normalizeTextForComparison("hello world")).toBe("hello world"); + }); + + it("strips emoji", () => { + expect(normalizeTextForComparison("Hello πŸ‘‹ World 🌍")).toBe("hello world"); + }); + + it("handles mixed normalization", () => { + expect(normalizeTextForComparison(" Hello πŸ‘‹ WORLD 🌍 ")).toBe("hello world"); + }); +}); + +describe("isMessagingToolDuplicate", () => { + it("returns false for empty sentTexts", () => { + expect(isMessagingToolDuplicate("hello world", [])).toBe(false); + }); + + it("returns false for short texts", () => { + expect(isMessagingToolDuplicate("short", ["short"])).toBe(false); + }); + + it("detects exact duplicates", () => { + expect( + isMessagingToolDuplicate("Hello, this is a test message!", [ + "Hello, this is a test message!", + ]), + ).toBe(true); + }); + + it("detects duplicates with different casing", () => { + expect( + isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [ + "hello, this is a test message!", + ]), + ).toBe(true); + }); + + it("detects duplicates with emoji variations", () => { + expect( + isMessagingToolDuplicate("Hello! πŸ‘‹ This is a test message!", [ + "Hello! This is a test message!", + ]), + ).toBe(true); + }); + + it("detects substring duplicates (LLM elaboration)", () => { + expect( + isMessagingToolDuplicate('I sent the message: "Hello, this is a test message!"', [ + "Hello, this is a test message!", + ]), + ).toBe(true); + }); + + it("detects when sent text contains block reply (reverse substring)", () => { + expect( + isMessagingToolDuplicate("Hello, this is a test message!", [ + 'I sent the message: "Hello, this is a test message!"', + ]), + ).toBe(true); + }); + + it("returns false for non-matching texts", () => { + expect( + isMessagingToolDuplicate("This is completely different content.", [ + "Hello, this is a test message!", + ]), + ).toBe(false); + }); +}); diff --git a/src/agents/pi-embedded-helpers.stripthoughtsignatures.e2e.test.ts b/src/agents/pi-embedded-helpers.stripthoughtsignatures.e2e.test.ts deleted file mode 100644 index 84ac4274fe4..00000000000 --- a/src/agents/pi-embedded-helpers.stripthoughtsignatures.e2e.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { stripThoughtSignatures } from "./pi-embedded-helpers.js"; -import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; - -const _makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "", - missing: false, - ...overrides, -}); -describe("stripThoughtSignatures", () => { - it("returns non-array content unchanged", () => { - expect(stripThoughtSignatures("hello")).toBe("hello"); - expect(stripThoughtSignatures(null)).toBe(null); - expect(stripThoughtSignatures(undefined)).toBe(undefined); - expect(stripThoughtSignatures(123)).toBe(123); - }); - it("removes msg_-prefixed thought_signature from content blocks", () => { - const input = [ - { type: "text", text: "hello", thought_signature: "msg_abc123" }, - { type: "thinking", thinking: "test", thought_signature: "AQID" }, - ]; - const result = stripThoughtSignatures(input); - - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ type: "text", text: "hello" }); - expect(result[1]).toEqual({ - type: "thinking", - thinking: "test", - thought_signature: "AQID", - }); - expect("thought_signature" in result[0]).toBe(false); - expect("thought_signature" in result[1]).toBe(true); - }); - it("preserves blocks without thought_signature", () => { - const input = [ - { type: "text", text: "hello" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - ]; - const result = stripThoughtSignatures(input); - - expect(result).toEqual(input); - }); - it("handles mixed blocks with and without thought_signature", () => { - const input = [ - { type: "text", text: "hello", thought_signature: "msg_abc" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - { type: "thinking", thinking: "hmm", thought_signature: "msg_xyz" }, - ]; - const result = stripThoughtSignatures(input); - - expect(result).toEqual([ - { type: "text", text: "hello" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - { type: "thinking", thinking: "hmm" }, - ]); - }); - it("handles empty array", () => { - expect(stripThoughtSignatures([])).toEqual([]); - }); - it("handles null/undefined blocks in array", () => { - const input = [null, undefined, { type: "text", text: "hello" }]; - const result = stripThoughtSignatures(input); - expect(result).toEqual([null, undefined, { type: "text", text: "hello" }]); - }); -}); diff --git a/src/agents/pi-embedded-helpers/images.ts b/src/agents/pi-embedded-helpers/images.ts index 3af4dd0a677..9162bb812b4 100644 --- a/src/agents/pi-embedded-helpers/images.ts +++ b/src/agents/pi-embedded-helpers/images.ts @@ -51,9 +51,10 @@ export async function sanitizeSessionMessagesImages( const allowNonImageSanitization = sanitizeMode === "full"; // We sanitize historical session messages because Anthropic can reject a request // if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX). - const sanitizedIds = options?.sanitizeToolCallIds - ? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode) - : messages; + const sanitizedIds = + allowNonImageSanitization && options?.sanitizeToolCallIds + ? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode) + : messages; const out: AgentMessage[] = []; for (const msg of sanitizedIds) { if (!msg || typeof msg !== "object") { diff --git a/src/agents/pi-embedded-runner.openai-tool-id-preservation.e2e.test.ts b/src/agents/pi-embedded-runner.openai-tool-id-preservation.e2e.test.ts new file mode 100644 index 00000000000..115d0a22b67 --- /dev/null +++ b/src/agents/pi-embedded-runner.openai-tool-id-preservation.e2e.test.ts @@ -0,0 +1,50 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { describe, expect, it } from "vitest"; +import { + makeInMemorySessionManager, + makeModelSnapshotEntry, +} from "./pi-embedded-runner.sanitize-session-history.test-harness.js"; +import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js"; + +describe("sanitizeSessionHistory openai tool id preservation", () => { + it("keeps canonical call_id|fc_id pairings for same-model openai replay", async () => { + const sessionEntries = [ + makeModelSnapshotEntry({ + provider: "openai", + modelApi: "openai-responses", + modelId: "gpt-5.2-codex", + }), + ]; + const sessionManager = makeInMemorySessionManager(sessionEntries); + + const messages: AgentMessage[] = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_123|fc_123", + toolName: "noop", + content: [{ type: "text", text: "ok" }], + isError: false, + } as unknown as AgentMessage, + ]; + + const result = await sanitizeSessionHistory({ + messages, + modelApi: "openai-responses", + provider: "openai", + modelId: "gpt-5.2-codex", + sessionManager, + sessionId: "test-session", + }); + + const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> }; + const toolCall = assistant.content?.find((block) => block.type === "toolCall"); + expect(toolCall?.id).toBe("call_123|fc_123"); + + const toolResult = result[1] as { toolCallId?: string }; + expect(toolResult.toolCallId).toBe("call_123|fc_123"); + }); +}); diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts index c3f58100662..d4f4488b6dc 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts @@ -52,7 +52,7 @@ describe("sanitizeSessionHistory e2e smoke", () => { ); }); - it("applies strict tool-call sanitization for openai-responses", async () => { + it("keeps images-only sanitize policy without tool-call id rewriting for openai-responses", async () => { vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); await sanitizeSessionHistory({ @@ -68,8 +68,7 @@ describe("sanitizeSessionHistory e2e smoke", () => { "session:history", expect.objectContaining({ sanitizeMode: "images-only", - sanitizeToolCallIds: true, - toolCallIdMode: "strict", + sanitizeToolCallIds: false, }), ); }); 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 a36c5ba0b44..ca463a2e358 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -98,7 +98,7 @@ describe("sanitizeSessionHistory", () => { ); }); - it("sanitizes tool call ids for openai-responses while keeping images-only mode", async () => { + it("does not sanitize tool call ids for openai-responses", async () => { vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); await sanitizeSessionHistory({ @@ -112,11 +112,7 @@ describe("sanitizeSessionHistory", () => { expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( mockMessages, "session:history", - expect.objectContaining({ - sanitizeMode: "images-only", - sanitizeToolCallIds: true, - toolCallIdMode: "strict", - }), + expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }), ); }); @@ -243,7 +239,7 @@ describe("sanitizeSessionHistory", () => { expect(result).toEqual(messages); }); - it("downgrades openai reasoning only when the model changes", async () => { + it("downgrades openai reasoning when the model changes", async () => { const sessionEntries = [ makeModelSnapshotEntry({ provider: "anthropic", diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 48cf6a69de0..05f8cd4581e 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -252,16 +252,7 @@ export async function compactEmbeddedPiSessionDirect( const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; - const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); - await ensureOpenClawModelsJson(params.config, agentDir); - const { model, error, authStorage, modelRegistry } = resolveModel( - provider, - modelId, - agentDir, - params.config, - ); - if (!model) { - const reason = error ?? `Unknown model: ${provider}/${modelId}`; + const fail = (reason: string): EmbeddedPiCompactResult => { log.warn( `[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` + `diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` + @@ -273,6 +264,18 @@ export async function compactEmbeddedPiSessionDirect( compacted: false, reason, }; + }; + const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); + await ensureOpenClawModelsJson(params.config, agentDir); + const { model, error, authStorage, modelRegistry } = resolveModel( + provider, + modelId, + agentDir, + params.config, + ); + if (!model) { + const reason = error ?? `Unknown model: ${provider}/${modelId}`; + return fail(reason); } try { const apiKeyInfo = await getApiKeyForModel({ @@ -299,17 +302,7 @@ export async function compactEmbeddedPiSessionDirect( } } catch (err) { const reason = describeUnknownError(err); - log.warn( - `[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` + - `diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` + - `attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` + - `durationMs=${Date.now() - startedAt}`, - ); - return { - ok: false, - compacted: false, - reason, - }; + return fail(reason); } await fs.mkdir(resolvedWorkspace, { recursive: true }); @@ -711,17 +704,7 @@ export async function compactEmbeddedPiSessionDirect( } } catch (err) { const reason = describeUnknownError(err); - log.warn( - `[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` + - `diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` + - `attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` + - `durationMs=${Date.now() - startedAt}`, - ); - return { - ok: false, - compacted: false, - reason, - }; + return fail(reason); } finally { restoreSkillEnv?.(); process.chdir(prevCwd); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts index 20097404db5..2e51e8a2952 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts @@ -5,31 +5,6 @@ vi.mock("../../utils.js", () => ({ resolveUserPath: vi.fn((p: string) => p), })); -vi.mock("../auth-profiles.js", () => ({ - markAuthProfileFailure: vi.fn(async () => {}), - markAuthProfileGood: vi.fn(async () => {}), - markAuthProfileUsed: vi.fn(async () => {}), -})); - -vi.mock("../usage.js", () => ({ - normalizeUsage: vi.fn((usage?: unknown) => - usage && typeof usage === "object" ? usage : undefined, - ), - derivePromptTokens: vi.fn( - (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { - if (!usage) { - return undefined; - } - const input = usage.input ?? 0; - const cacheRead = usage.cacheRead ?? 0; - const cacheWrite = usage.cacheWrite ?? 0; - const sum = input + cacheRead + cacheWrite; - return sum > 0 ? sum : undefined; - }, - ), - hasNonzeroUsage: vi.fn(() => false), -})); - vi.mock("../pi-embedded-helpers.js", async () => { return { isCompactionFailureError: (msg?: string) => { diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index 407788564ab..6a872721859 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -1,5 +1,31 @@ import { vi } from "vitest"; +vi.mock("../auth-profiles.js", () => ({ + isProfileInCooldown: vi.fn(() => false), + markAuthProfileFailure: vi.fn(async () => {}), + markAuthProfileGood: vi.fn(async () => {}), + markAuthProfileUsed: vi.fn(async () => {}), +})); + +vi.mock("../usage.js", () => ({ + normalizeUsage: vi.fn((usage?: unknown) => + usage && typeof usage === "object" ? usage : undefined, + ), + derivePromptTokens: vi.fn( + (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { + if (!usage) { + return undefined; + } + const input = usage.input ?? 0; + const cacheRead = usage.cacheRead ?? 0; + const cacheWrite = usage.cacheWrite ?? 0; + const sum = input + cacheRead + cacheWrite; + return sum > 0 ? sum : undefined; + }, + ), + hasNonzeroUsage: vi.fn(() => false), +})); + vi.mock("./run/attempt.js", () => ({ runEmbeddedAttempt: vi.fn(), })); 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 ded9da42c02..20944a29bad 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1,31 +1,6 @@ import "./run.overflow-compaction.mocks.shared.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("../auth-profiles.js", () => ({ - isProfileInCooldown: vi.fn(() => false), - markAuthProfileFailure: vi.fn(async () => {}), - markAuthProfileGood: vi.fn(async () => {}), - markAuthProfileUsed: vi.fn(async () => {}), -})); - -vi.mock("../usage.js", () => ({ - normalizeUsage: vi.fn((usage?: unknown) => - usage && typeof usage === "object" ? usage : undefined, - ), - derivePromptTokens: vi.fn( - (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { - if (!usage) { - return undefined; - } - const input = usage.input ?? 0; - const cacheRead = usage.cacheRead ?? 0; - const cacheWrite = usage.cacheWrite ?? 0; - const sum = input + cacheRead + cacheWrite; - return sum > 0 ? sum : undefined; - }, - ), -})); - vi.mock("../workspace-run.js", () => ({ resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ workspaceDir: params.workspaceDir, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 9fafd965c7c..dc648e44280 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -770,7 +770,7 @@ export async function runEmbeddedAttempt( isCompacting: () => subscription.isCompacting(), abort: abortRun, }; - setActiveEmbeddedRun(params.sessionId, queueHandle); + setActiveEmbeddedRun(params.sessionId, queueHandle, params.sessionKey); let abortWarnTimer: NodeJS.Timeout | undefined; const isProbeSession = params.sessionId?.startsWith("probe-") ?? false; @@ -954,6 +954,32 @@ export async function runEmbeddedAttempt( ); } + if (hookRunner?.hasHooks("llm_input")) { + hookRunner + .runLlmInput( + { + runId: params.runId, + sessionId: params.sessionId, + provider: params.provider, + model: params.modelId, + systemPrompt: systemPromptText, + prompt: effectivePrompt, + historyMessages: activeSession.messages, + imagesCount: imageResult.images.length, + }, + { + agentId: hookAgentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider ?? undefined, + }, + ) + .catch((err) => { + log.warn(`llm_input hook failed: ${String(err)}`); + }); + } + // Only pass images option if there are actually images to pass // This avoids potential issues with models that don't expect the images parameter if (imageResult.images.length > 0) { @@ -1087,7 +1113,7 @@ export async function runEmbeddedAttempt( `CRITICAL: unsubscribe failed, possible resource leak: runId=${params.runId} ${String(err)}`, ); } - clearActiveEmbeddedRun(params.sessionId, queueHandle); + clearActiveEmbeddedRun(params.sessionId, queueHandle, params.sessionKey); params.abortSignal?.removeEventListener?.("abort", onAbort); } @@ -1103,6 +1129,31 @@ export async function runEmbeddedAttempt( ) .map((entry) => ({ toolName: entry.toolName, meta: entry.meta })); + if (hookRunner?.hasHooks("llm_output")) { + hookRunner + .runLlmOutput( + { + runId: params.runId, + sessionId: params.sessionId, + provider: params.provider, + model: params.modelId, + assistantTexts, + lastAssistant, + usage: getUsageTotals(), + }, + { + agentId: hookAgentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider ?? undefined, + }, + ) + .catch((err) => { + log.warn(`llm_output hook failed: ${String(err)}`); + }); + } + return { aborted, timedOut, diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index e0155874028..41dad4df582 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -115,11 +115,16 @@ function notifyEmbeddedRunEnded(sessionId: string) { } } -export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) { +export function setActiveEmbeddedRun( + sessionId: string, + handle: EmbeddedPiQueueHandle, + sessionKey?: string, +) { const wasActive = ACTIVE_EMBEDDED_RUNS.has(sessionId); ACTIVE_EMBEDDED_RUNS.set(sessionId, handle); logSessionStateChange({ sessionId, + sessionKey, state: "processing", reason: wasActive ? "run_replaced" : "run_started", }); @@ -128,10 +133,14 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH } } -export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) { +export function clearActiveEmbeddedRun( + sessionId: string, + handle: EmbeddedPiQueueHandle, + sessionKey?: string, +) { if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) { ACTIVE_EMBEDDED_RUNS.delete(sessionId); - logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" }); + logSessionStateChange({ sessionId, sessionKey, state: "idle", reason: "run_completed" }); if (!sessionId.startsWith("probe-")) { diag.debug(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); } diff --git a/src/agents/pi-embedded-subscribe.e2e-harness.ts b/src/agents/pi-embedded-subscribe.e2e-harness.ts new file mode 100644 index 00000000000..64975e8c72c --- /dev/null +++ b/src/agents/pi-embedded-subscribe.e2e-harness.ts @@ -0,0 +1,28 @@ +type SubscribeEmbeddedPiSession = + typeof import("./pi-embedded-subscribe.js").subscribeEmbeddedPiSession; +type PiSession = Parameters[0]["session"]; + +export function createStubSessionHarness(): { + session: PiSession; + emit: (evt: unknown) => void; +} { + let handler: ((evt: unknown) => void) | undefined; + const session = { + subscribe: (fn: (evt: unknown) => void) => { + handler = fn; + return () => {}; + }, + } as unknown as PiSession; + + return { session, emit: (evt: unknown) => handler?.(evt) }; +} + +export function extractAgentEventPayloads(calls: Array): Array> { + return calls + .map((call) => { + const first = call?.[0] as { data?: unknown } | undefined; + const data = first?.data; + return data && typeof data === "object" ? (data as Record) : undefined; + }) + .filter((value): value is Record => Boolean(value)); +} diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts index 30336ed38ec..020d7e939d4 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts @@ -8,13 +8,6 @@ type StubSession = { type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("calls onBlockReplyFlush before tool_execution_start to preserve message boundaries", () => { let handler: SessionEventHandler | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts index 690a1d7abf4..c268c11ff86 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts @@ -6,13 +6,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - function setupTextEndSubscription() { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts index 60460571309..1a909ae2746 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts @@ -8,13 +8,6 @@ type StubSession = { type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("does not call onBlockReplyFlush when callback is not provided", () => { let handler: SessionEventHandler | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts index 00138a7f9ab..a68984b272d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts @@ -6,13 +6,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("does not duplicate when text_end repeats full content", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts index 827c58193fd..ee7037a24c0 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts @@ -1,40 +1,22 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - -type SessionEventHandler = (evt: unknown) => void; - describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("does not emit duplicate block replies when text_end repeats", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "text_end", }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -43,7 +25,7 @@ describe("subscribeEmbeddedPiSession", () => { }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -51,7 +33,7 @@ describe("subscribeEmbeddedPiSession", () => { }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -63,16 +45,10 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["Hello block"]); }); it("does not duplicate assistantTexts when message_end repeats", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", }); @@ -81,22 +57,16 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(subscription.assistantTexts).toEqual(["Hello world"]); }); it("does not duplicate assistantTexts when message_end repeats with trailing whitespace changes", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", }); @@ -110,22 +80,16 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessageWithNewline }); - handler?.({ type: "message_end", message: assistantMessageTrimmed }); + emit({ type: "message_end", message: assistantMessageWithNewline }); + emit({ type: "message_end", message: assistantMessageTrimmed }); expect(subscription.assistantTexts).toEqual(["Hello world"]); }); it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", reasoningMode: "on", }); @@ -138,37 +102,31 @@ describe("subscribeEmbeddedPiSession", () => { ], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(subscription.assistantTexts).toEqual(["Hello world"]); }); it("populates assistantTexts for non-streaming models with chunking enabled", () => { // Non-streaming models (e.g. zai/glm-4.7): no text_delta events; message_end // must still populate assistantTexts so providers can deliver a final reply. - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", blockReplyChunking: { minChars: 50, maxChars: 200 }, // Chunking enabled }); // Simulate non-streaming model: only message_start and message_end, no text_delta - handler?.({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_start", message: { role: "assistant" } }); const assistantMessage = { role: "assistant", content: [{ type: "text", text: "Response from non-streaming model" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(subscription.assistantTexts).toEqual(["Response from non-streaming model"]); }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts index d8fcf94c91e..7ce844c55a9 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("emits block replies on text_end and does not duplicate on message_end", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts index ad7bdfd81cb..76a51a89197 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts @@ -1,41 +1,28 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { + createStubSessionHarness, + extractAgentEventPayloads, +} from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("filters to and suppresses output without a start tag", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onPartialReply = vi.fn(); const onAgentEvent = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", enforceFinalTag: true, onPartialReply, onAgentEvent, }); - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -50,8 +37,8 @@ describe("subscribeEmbeddedPiSession", () => { onPartialReply.mockReset(); - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -63,18 +50,12 @@ describe("subscribeEmbeddedPiSession", () => { expect(onPartialReply).not.toHaveBeenCalled(); }); it("emits agent events on message_end even without tags", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", enforceFinalTag: true, onAgentEvent, @@ -85,12 +66,10 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_start", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_start", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe("Hello world"); expect(payloads[0]?.delta).toBe("Hello world"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts index 37532c48a86..3b04100219b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts @@ -6,13 +6,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("includes canvas action metadata in tool summaries", async () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts index 8b4d539465c..0bb70f3d8b5 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("keeps assistantTexts to the final answer when block replies are disabled", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts index d8d868541ad..507ca49da7b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("keeps indented fenced blocks intact", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts index f786b104f1f..b3d800af04b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("reopens fenced blocks when splitting inside them", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts index 19cbeaa2a40..f6eeb24a27d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("splits long single-line fenced blocks with reopen/close", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts index 59973be7e21..6c1bd3f0b13 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts @@ -1,32 +1,16 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("streams soft chunks with paragraph preference", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "message_end", @@ -39,7 +23,7 @@ describe("subscribeEmbeddedPiSession", () => { const text = "First block line\n\nSecond block line"; - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -53,7 +37,7 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(2); expect(onBlockReply.mock.calls[0][0].text).toBe("First block line"); @@ -61,18 +45,12 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["First block line", "Second block line"]); }); it("avoids splitting inside fenced code blocks", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "message_end", @@ -85,7 +63,7 @@ describe("subscribeEmbeddedPiSession", () => { const text = "Intro\n\n```bash\nline1\nline2\n```\n\nOutro"; - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -99,7 +77,7 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(3); expect(onBlockReply.mock.calls[0][0].text).toBe("Intro"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts index b53ffa62e53..1371a697d75 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts @@ -1,5 +1,9 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { + createStubSessionHarness, + extractAgentEventPayloads, +} from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; type StubSession = { @@ -186,18 +190,12 @@ describe("subscribeEmbeddedPiSession", () => { }); it("emits agent events on message_end for non-streaming assistant text", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onAgentEvent, }); @@ -207,12 +205,10 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_start", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_start", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe("Hello world"); expect(payloads[0]?.delta).toBe("Hello world"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts index a28d55358b4..bb0fff53264 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("suppresses message_end block replies when the message tool already sent", async () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts index 2f961082555..319baf58bf8 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("waits for multiple compaction retries before resolving", async () => { const listeners: SessionEventHandler[] = []; const session = { diff --git a/src/agents/pi-extensions/compaction-safeguard-runtime.ts b/src/agents/pi-extensions/compaction-safeguard-runtime.ts index bda1b1de638..df3919cf815 100644 --- a/src/agents/pi-extensions/compaction-safeguard-runtime.ts +++ b/src/agents/pi-extensions/compaction-safeguard-runtime.ts @@ -1,35 +1,12 @@ +import { createSessionManagerRuntimeRegistry } from "./session-manager-runtime-registry.js"; + export type CompactionSafeguardRuntimeValue = { maxHistoryShare?: number; contextWindowTokens?: number; }; -// Session-scoped runtime registry keyed by object identity. -// Follows the same WeakMap pattern as context-pruning/runtime.ts. -const REGISTRY = new WeakMap(); +const registry = createSessionManagerRuntimeRegistry(); -export function setCompactionSafeguardRuntime( - sessionManager: unknown, - value: CompactionSafeguardRuntimeValue | null, -): void { - if (!sessionManager || typeof sessionManager !== "object") { - return; - } +export const setCompactionSafeguardRuntime = registry.set; - const key = sessionManager; - if (value === null) { - REGISTRY.delete(key); - return; - } - - REGISTRY.set(key, value); -} - -export function getCompactionSafeguardRuntime( - sessionManager: unknown, -): CompactionSafeguardRuntimeValue | null { - if (!sessionManager || typeof sessionManager !== "object") { - return null; - } - - return REGISTRY.get(sessionManager) ?? null; -} +export const getCompactionSafeguardRuntime = registry.get; diff --git a/src/agents/pi-extensions/context-pruning/runtime.ts b/src/agents/pi-extensions/context-pruning/runtime.ts index 7780464d1da..6d4fd07a2fb 100644 --- a/src/agents/pi-extensions/context-pruning/runtime.ts +++ b/src/agents/pi-extensions/context-pruning/runtime.ts @@ -1,4 +1,5 @@ import type { EffectiveContextPruningSettings } from "./settings.js"; +import { createSessionManagerRuntimeRegistry } from "../session-manager-runtime-registry.js"; export type ContextPruningRuntimeValue = { settings: EffectiveContextPruningSettings; @@ -7,34 +8,10 @@ export type ContextPruningRuntimeValue = { lastCacheTouchAt?: number | null; }; -// Session-scoped runtime registry keyed by object identity. // Important: this relies on Pi passing the same SessionManager object instance into // ExtensionContext (ctx.sessionManager) that we used when calling setContextPruningRuntime. -const REGISTRY = new WeakMap(); +const registry = createSessionManagerRuntimeRegistry(); -export function setContextPruningRuntime( - sessionManager: unknown, - value: ContextPruningRuntimeValue | null, -): void { - if (!sessionManager || typeof sessionManager !== "object") { - return; - } +export const setContextPruningRuntime = registry.set; - const key = sessionManager; - if (value === null) { - REGISTRY.delete(key); - return; - } - - REGISTRY.set(key, value); -} - -export function getContextPruningRuntime( - sessionManager: unknown, -): ContextPruningRuntimeValue | null { - if (!sessionManager || typeof sessionManager !== "object") { - return null; - } - - return REGISTRY.get(sessionManager) ?? null; -} +export const getContextPruningRuntime = registry.get; diff --git a/src/agents/pi-extensions/session-manager-runtime-registry.ts b/src/agents/pi-extensions/session-manager-runtime-registry.ts new file mode 100644 index 00000000000..a23a7385d6a --- /dev/null +++ b/src/agents/pi-extensions/session-manager-runtime-registry.ts @@ -0,0 +1,29 @@ +export function createSessionManagerRuntimeRegistry() { + // Session-scoped runtime registry keyed by object identity. + // The SessionManager instance must stay stable across set/get calls. + const registry = new WeakMap(); + + const set = (sessionManager: unknown, value: TValue | null): void => { + if (!sessionManager || typeof sessionManager !== "object") { + return; + } + + const key = sessionManager; + if (value === null) { + registry.delete(key); + return; + } + + registry.set(key, value); + }; + + const get = (sessionManager: unknown): TValue | null => { + if (!sessionManager || typeof sessionManager !== "object") { + return null; + } + + return registry.get(sessionManager) ?? null; + }; + + return { set, get }; +} diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts index 6104fc16936..51ccca68c42 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts @@ -102,7 +102,10 @@ describe("createOpenClawCodingTools", () => { execute, }; - const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]); + const wrapped = __testing.wrapToolParamNormalization(tool, [ + { keys: ["path", "file_path"], label: "path (path or file_path)" }, + { keys: ["content"], label: "content" }, + ]); await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" }); expect(execute).toHaveBeenCalledWith( @@ -115,9 +118,21 @@ describe("createOpenClawCodingTools", () => { await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( /Missing required parameter/, ); + await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( /Missing required parameter/, ); + await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); + await expect(wrapped.execute("tool-4", {})).rejects.toThrow( + /Missing required parameters: path \(path or file_path\), content/, + ); + await expect(wrapped.execute("tool-4", {})).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); }); }); diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 3798c6dd8b1..71e9bb72348 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -87,6 +87,12 @@ type RequiredParamGroup = { label?: string; }; +const RETRY_GUIDANCE_SUFFIX = " Supply correct parameters before retrying."; + +function parameterValidationError(message: string): Error { + return new Error(`${message}.${RETRY_GUIDANCE_SUFFIX}`); +} + export const CLAUDE_PARAM_GROUPS = { read: [{ keys: ["path", "file_path"], label: "path (path or file_path)" }], write: [ @@ -245,9 +251,10 @@ export function assertRequiredParams( toolName: string, ): void { if (!record || typeof record !== "object") { - throw new Error(`Missing parameters for ${toolName}`); + throw parameterValidationError(`Missing parameters for ${toolName}`); } + const missingLabels: string[] = []; for (const group of groups) { const satisfied = group.keys.some((key) => { if (!(key in record)) { @@ -265,9 +272,15 @@ export function assertRequiredParams( if (!satisfied) { const label = group.label ?? group.keys.join(" or "); - throw new Error(`Missing required parameter: ${label}`); + missingLabels.push(label); } } + + if (missingLabels.length > 0) { + const joined = missingLabels.join(", "); + const noun = missingLabels.length === 1 ? "parameter" : "parameters"; + throw parameterValidationError(`Missing required ${noun}: ${joined}`); + } } // Generic wrapper to normalize parameters for any tool diff --git a/src/agents/pi-tools.safe-bins.e2e.test.ts b/src/agents/pi-tools.safe-bins.e2e.test.ts index 665059035d2..f022a84abc1 100644 --- a/src/agents/pi-tools.safe-bins.e2e.test.ts +++ b/src/agents/pi-tools.safe-bins.e2e.test.ts @@ -4,8 +4,9 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; +import { captureEnv } from "../test-utils/env.js"; -const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const bundledPluginsDirSnapshot = captureEnv(["OPENCLAW_BUNDLED_PLUGINS_DIR"]); beforeAll(() => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join( @@ -15,32 +16,18 @@ beforeAll(() => { }); afterAll(() => { - if (previousBundledPluginsDir === undefined) { - delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - } else { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; - } + bundledPluginsDirSnapshot.restore(); }); vi.mock("../infra/shell-env.js", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - getShellPathFromLoginShell: vi.fn(() => "/usr/bin:/bin"), + getShellPathFromLoginShell: vi.fn(() => null), resolveShellEnvFallbackTimeoutMs: vi.fn(() => 500), }; }); -vi.mock("../plugins/tools.js", () => ({ - getPluginToolMeta: () => undefined, - resolvePluginTools: () => [], -})); - -vi.mock("../infra/shell-env.js", async (importOriginal) => { - const mod = await importOriginal(); - return { ...mod, getShellPathFromLoginShell: () => null }; -}); - vi.mock("../plugins/tools.js", () => ({ resolvePluginTools: () => [], getPluginToolMeta: () => undefined, @@ -109,20 +96,16 @@ describe("createOpenClawCodingTools safeBins", () => { expect(execTool).toBeDefined(); const marker = `safe-bins-${Date.now()}`; - const prevShellEnvTimeoutMs = process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS; - process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000"; + const envSnapshot = captureEnv(["OPENCLAW_SHELL_ENV_TIMEOUT_MS"]); const result = await (async () => { try { + process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000"; return await execTool!.execute("call1", { command: `echo ${marker}`, workdir: tmpDir, }); } finally { - if (prevShellEnvTimeoutMs === undefined) { - delete process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS; - } else { - process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = prevShellEnvTimeoutMs; - } + envSnapshot.restore(); } })(); const text = result.content.find((content) => content.type === "text")?.text ?? ""; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 5e72cdb5a19..7ba93ab3810 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -52,6 +52,7 @@ import { import { applyOwnerOnlyToolPolicy, collectExplicitAllowlist, + mergeAlsoAllowPolicy, resolveToolProfilePolicy, } from "./tool-policy.js"; import { resolveWorkspaceRoot } from "./workspace-dir.js"; @@ -219,15 +220,8 @@ export function createOpenClawCodingTools(options?: { const profilePolicy = resolveToolProfilePolicy(profile); const providerProfilePolicy = resolveToolProfilePolicy(providerProfile); - const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => { - if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) { - return policy; - } - return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) }; - }; - - const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow); - const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow( + const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow); + const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy( providerProfilePolicy, providerProfileAlsoAllow, ); diff --git a/src/agents/pty-dsr.e2e.test.ts b/src/agents/pty-dsr.e2e.test.ts deleted file mode 100644 index a71f95c0265..00000000000 --- a/src/agents/pty-dsr.e2e.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from "vitest"; -import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; - -test("stripDsrRequests removes cursor queries and counts them", () => { - const input = "hi\x1b[6nthere\x1b[?6n"; - const { cleaned, requests } = stripDsrRequests(input); - expect(cleaned).toBe("hithere"); - expect(requests).toBe(2); -}); - -test("buildCursorPositionResponse returns CPR sequence", () => { - expect(buildCursorPositionResponse()).toBe("\x1b[1;1R"); - expect(buildCursorPositionResponse(12, 34)).toBe("\x1b[12;34R"); -}); diff --git a/src/agents/pty-keys.e2e.test.ts b/src/agents/pty-keys.e2e.test.ts index a295a11b8b5..36fe6bcdf80 100644 --- a/src/agents/pty-keys.e2e.test.ts +++ b/src/agents/pty-keys.e2e.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "vitest"; +import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, @@ -38,3 +39,15 @@ test("encodePaste wraps bracketed sequences by default", () => { expect(payload.startsWith(BRACKETED_PASTE_START)).toBe(true); expect(payload.endsWith(BRACKETED_PASTE_END)).toBe(true); }); + +test("stripDsrRequests removes cursor queries and counts them", () => { + const input = "hi\x1b[6nthere\x1b[?6n"; + const { cleaned, requests } = stripDsrRequests(input); + expect(cleaned).toBe("hithere"); + expect(requests).toBe(2); +}); + +test("buildCursorPositionResponse returns CPR sequence", () => { + expect(buildCursorPositionResponse()).toBe("\x1b[1;1R"); + expect(buildCursorPositionResponse(12, 34)).toBe("\x1b[12;34R"); +}); diff --git a/src/agents/sandbox-create-args.e2e.test.ts b/src/agents/sandbox-create-args.e2e.test.ts index 5200572c86e..ccb9b3395ad 100644 --- a/src/agents/sandbox-create-args.e2e.test.ts +++ b/src/agents/sandbox-create-args.e2e.test.ts @@ -94,7 +94,7 @@ describe("buildSandboxCreateArgs", () => { ); }); - it("emits -v flags for custom binds", () => { + it("emits -v flags for safe custom binds", () => { const cfg: SandboxDockerConfig = { image: "openclaw-sandbox:bookworm-slim", containerPrefix: "openclaw-sbx-", @@ -103,7 +103,7 @@ describe("buildSandboxCreateArgs", () => { tmpfs: [], network: "none", capDrop: [], - binds: ["/home/user/source:/source:rw", "/var/run/docker.sock:/var/run/docker.sock"], + binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"], }; const args = buildSandboxCreateArgs({ @@ -124,7 +124,116 @@ describe("buildSandboxCreateArgs", () => { } } expect(vFlags).toContain("/home/user/source:/source:rw"); - expect(vFlags).toContain("/var/run/docker.sock:/var/run/docker.sock"); + expect(vFlags).toContain("/var/data/myapp:/data:ro"); + }); + + it("throws on dangerous bind mounts (Docker socket)", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: ["/var/run/docker.sock:/var/run/docker.sock"], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-dangerous", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/blocked path/); + }); + + it("throws on dangerous bind mounts (parent path)", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: ["/run:/run"], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-dangerous-parent", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/blocked path/); + }); + + it("throws on network host mode", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "host", + capDrop: [], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-host", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/network mode "host" is blocked/); + }); + + it("throws on seccomp unconfined", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + seccompProfile: "unconfined", + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-seccomp", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/seccomp profile "unconfined" is blocked/); + }); + + it("throws on apparmor unconfined", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + apparmorProfile: "unconfined", + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-apparmor", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/apparmor profile "unconfined" is blocked/); }); it("omits -v flags when binds is empty or undefined", () => { diff --git a/src/agents/sandbox-skills.e2e.test.ts b/src/agents/sandbox-skills.e2e.test.ts index ae37f2a9fe9..0280c5d529a 100644 --- a/src/agents/sandbox-skills.e2e.test.ts +++ b/src/agents/sandbox-skills.e2e.test.ts @@ -3,6 +3,7 @@ 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 { captureFullEnv } from "../test-utils/env.js"; import { resolveSandboxContext } from "./sandbox.js"; vi.mock("./sandbox/docker.js", () => ({ @@ -27,30 +28,15 @@ async function writeSkill(params: { dir: string; name: string; description: stri ); } -function restoreEnv(snapshot: Record) { - for (const key of Object.keys(process.env)) { - if (!(key in snapshot)) { - delete process.env[key]; - } - } - for (const [key, value] of Object.entries(snapshot)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - describe("sandbox skill mirroring", () => { - let envSnapshot: Record; + let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = { ...process.env }; + envSnapshot = captureFullEnv(); }); afterEach(() => { - restoreEnv(envSnapshot); + envSnapshot.restore(); }); const runContext = async (workspaceAccess: "none" | "ro") => { diff --git a/src/agents/sandbox/config-hash.test.ts b/src/agents/sandbox/config-hash.test.ts new file mode 100644 index 00000000000..b00e42821c2 --- /dev/null +++ b/src/agents/sandbox/config-hash.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import type { SandboxDockerConfig } from "./types.js"; +import { computeSandboxBrowserConfigHash, computeSandboxConfigHash } from "./config-hash.js"; + +function createDockerConfig(overrides?: Partial): SandboxDockerConfig { + return { + image: "openclaw-sandbox:test", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp", "/var/tmp", "/run"], + network: "none", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + dns: ["1.1.1.1", "8.8.8.8"], + extraHosts: ["host.docker.internal:host-gateway"], + binds: ["/tmp/workspace:/workspace:rw", "/tmp/cache:/cache:ro"], + ...overrides, + }; +} + +type DockerArrayField = "tmpfs" | "capDrop" | "dns" | "extraHosts" | "binds"; + +const ORDER_SENSITIVE_ARRAY_CASES: ReadonlyArray<{ + field: DockerArrayField; + before: string[]; + after: string[]; +}> = [ + { + field: "tmpfs", + before: ["/tmp", "/var/tmp", "/run"], + after: ["/run", "/var/tmp", "/tmp"], + }, + { + field: "capDrop", + before: ["ALL", "CHOWN"], + after: ["CHOWN", "ALL"], + }, + { + field: "dns", + before: ["1.1.1.1", "8.8.8.8"], + after: ["8.8.8.8", "1.1.1.1"], + }, + { + field: "extraHosts", + before: ["host.docker.internal:host-gateway", "db.local:10.0.0.5"], + after: ["db.local:10.0.0.5", "host.docker.internal:host-gateway"], + }, + { + field: "binds", + before: ["/tmp/workspace:/workspace:rw", "/tmp/cache:/cache:ro"], + after: ["/tmp/cache:/cache:ro", "/tmp/workspace:/workspace:rw"], + }, +]; + +describe("computeSandboxConfigHash", () => { + it("ignores object key order", () => { + const shared = { + workspaceAccess: "rw" as const, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + }; + const left = computeSandboxConfigHash({ + ...shared, + docker: createDockerConfig({ + env: { + LANG: "C.UTF-8", + B: "2", + A: "1", + }, + }), + }); + const right = computeSandboxConfigHash({ + ...shared, + docker: createDockerConfig({ + env: { + A: "1", + B: "2", + LANG: "C.UTF-8", + }, + }), + }); + expect(left).toBe(right); + }); + + it.each(ORDER_SENSITIVE_ARRAY_CASES)("treats $field order as significant", (testCase) => { + const shared = { + workspaceAccess: "rw" as const, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + }; + const left = computeSandboxConfigHash({ + ...shared, + docker: createDockerConfig({ + [testCase.field]: testCase.before, + } as Partial), + }); + const right = computeSandboxConfigHash({ + ...shared, + docker: createDockerConfig({ + [testCase.field]: testCase.after, + } as Partial), + }); + expect(left).not.toBe(right); + }); +}); + +describe("computeSandboxBrowserConfigHash", () => { + it("treats docker bind order as significant", () => { + const shared = { + browser: { + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: false, + enableNoVnc: true, + }, + workspaceAccess: "rw" as const, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + }; + const left = computeSandboxBrowserConfigHash({ + ...shared, + docker: createDockerConfig({ + binds: ["/tmp/workspace:/workspace:rw", "/tmp/cache:/cache:ro"], + }), + }); + const right = computeSandboxBrowserConfigHash({ + ...shared, + docker: createDockerConfig({ + binds: ["/tmp/cache:/cache:ro", "/tmp/workspace:/workspace:rw"], + }), + }); + expect(left).not.toBe(right); + }); +}); diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts index 3b7b580ef60..ca99dbf4ddb 100644 --- a/src/agents/sandbox/config-hash.ts +++ b/src/agents/sandbox/config-hash.ts @@ -1,5 +1,5 @@ -import crypto from "node:crypto"; import type { SandboxBrowserConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; +import { hashTextSha256 } from "./hash.js"; type SandboxHashInput = { docker: SandboxDockerConfig; @@ -19,24 +19,12 @@ type SandboxBrowserHashInput = { agentWorkspaceDir: string; }; -function isPrimitive(value: unknown): value is string | number | boolean | bigint | symbol | null { - return value === null || (typeof value !== "object" && typeof value !== "function"); -} function normalizeForHash(value: unknown): unknown { if (value === undefined) { return undefined; } if (Array.isArray(value)) { - const normalized = value - .map(normalizeForHash) - .filter((item): item is unknown => item !== undefined); - const primitives = normalized.filter(isPrimitive); - if (primitives.length === normalized.length) { - return [...primitives].toSorted((a, b) => - primitiveToString(a).localeCompare(primitiveToString(b)), - ); - } - return normalized; + return value.map(normalizeForHash).filter((item): item is unknown => item !== undefined); } if (value && typeof value === "object") { const entries = Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b)); @@ -52,22 +40,6 @@ function normalizeForHash(value: unknown): unknown { return value; } -function primitiveToString(value: unknown): string { - if (value === null) { - return "null"; - } - if (typeof value === "string") { - return value; - } - if (typeof value === "number") { - return String(value); - } - if (typeof value === "boolean") { - return value ? "true" : "false"; - } - return JSON.stringify(value); -} - export function computeSandboxConfigHash(input: SandboxHashInput): string { return computeHash(input); } @@ -79,5 +51,5 @@ export function computeSandboxBrowserConfigHash(input: SandboxBrowserHashInput): function computeHash(input: unknown): string { const payload = normalizeForHash(input); const raw = JSON.stringify(payload); - return crypto.createHash("sha1").update(raw).digest("hex"); + return hashTextSha256(raw); } diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 1d365210807..b0eb0ffd9e7 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -64,11 +64,7 @@ async function ensureSandboxWorkspaceLayout(params: { return { agentWorkspaceDir, scopeKey, sandboxWorkspaceDir, workspaceDir }; } -export async function resolveSandboxContext(params: { - config?: OpenClawConfig; - sessionKey?: string; - workspaceDir?: string; -}): Promise { +function resolveSandboxSession(params: { config?: OpenClawConfig; sessionKey?: string }) { const rawSessionKey = params.sessionKey?.trim(); if (!rawSessionKey) { return null; @@ -83,6 +79,19 @@ export async function resolveSandboxContext(params: { } const cfg = resolveSandboxConfigForAgent(params.config, runtime.agentId); + return { rawSessionKey, runtime, cfg }; +} + +export async function resolveSandboxContext(params: { + config?: OpenClawConfig; + sessionKey?: string; + workspaceDir?: string; +}): Promise { + const resolved = resolveSandboxSession(params); + if (!resolved) { + return null; + } + const { rawSessionKey, cfg } = resolved; await maybePruneSandboxes(cfg); @@ -152,20 +161,11 @@ export async function ensureSandboxWorkspaceForSession(params: { sessionKey?: string; workspaceDir?: string; }): Promise { - const rawSessionKey = params.sessionKey?.trim(); - if (!rawSessionKey) { + const resolved = resolveSandboxSession(params); + if (!resolved) { return null; } - - const runtime = resolveSandboxRuntimeStatus({ - cfg: params.config, - sessionKey: rawSessionKey, - }); - if (!runtime.sandboxed) { - return null; - } - - const cfg = resolveSandboxConfigForAgent(params.config, runtime.agentId); + const { rawSessionKey, cfg } = resolved; const { workspaceDir } = await ensureSandboxWorkspaceLayout({ cfg, diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts new file mode 100644 index 00000000000..ae8706a6b7f --- /dev/null +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -0,0 +1,191 @@ +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SandboxConfig } from "./types.js"; +import { computeSandboxConfigHash } from "./config-hash.js"; +import { ensureSandboxContainer } from "./docker.js"; + +type SpawnCall = { + command: string; + args: string[]; +}; + +const spawnState = vi.hoisted(() => ({ + calls: [] as SpawnCall[], + inspectRunning: true, + labelHash: "", +})); + +const registryMocks = vi.hoisted(() => ({ + readRegistry: vi.fn(), + updateRegistry: vi.fn(), +})); + +vi.mock("./registry.js", () => ({ + readRegistry: registryMocks.readRegistry, + updateRegistry: registryMocks.updateRegistry, +})); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (command: string, args: string[]) => { + spawnState.calls.push({ command, args }); + const child = new EventEmitter() as EventEmitter & { + stdout: Readable; + stderr: Readable; + stdin: { end: (input?: string | Buffer) => void }; + kill: (signal?: NodeJS.Signals) => void; + }; + child.stdout = new Readable({ read() {} }); + child.stderr = new Readable({ read() {} }); + child.stdin = { end: () => undefined }; + child.kill = () => undefined; + + let code = 0; + let stdout = ""; + let stderr = ""; + if (command !== "docker") { + code = 1; + stderr = `unexpected command: ${command}`; + } else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") { + stdout = spawnState.inspectRunning ? "true\n" : "false\n"; + } else if ( + args[0] === "inspect" && + args[1] === "-f" && + args[2]?.includes('index .Config.Labels "openclaw.configHash"') + ) { + stdout = `${spawnState.labelHash}\n`; + } else if ( + (args[0] === "rm" && args[1] === "-f") || + (args[0] === "image" && args[1] === "inspect") || + args[0] === "create" || + args[0] === "start" + ) { + code = 0; + } else { + code = 1; + stderr = `unexpected docker args: ${args.join(" ")}`; + } + + queueMicrotask(() => { + if (stdout) { + child.stdout.emit("data", Buffer.from(stdout)); + } + if (stderr) { + child.stderr.emit("data", Buffer.from(stderr)); + } + child.emit("close", code); + }); + return child; + }, + }; +}); + +function createSandboxConfig(dns: string[]): SandboxConfig { + return { + mode: "all", + scope: "shared", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "openclaw-sandbox:test", + containerPrefix: "oc-test-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp", "/var/tmp", "/run"], + network: "none", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + dns, + extraHosts: ["host.docker.internal:host-gateway"], + binds: ["/tmp/workspace:/workspace:rw"], + }, + browser: { + enabled: false, + image: "openclaw-browser:test", + containerPrefix: "oc-browser-", + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 5000, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }; +} + +describe("ensureSandboxContainer config-hash recreation", () => { + beforeEach(() => { + spawnState.calls.length = 0; + spawnState.inspectRunning = true; + spawnState.labelHash = ""; + registryMocks.readRegistry.mockReset(); + registryMocks.updateRegistry.mockReset(); + registryMocks.updateRegistry.mockResolvedValue(undefined); + }); + + it("recreates shared container when array-order change alters hash", async () => { + const workspaceDir = "/tmp/workspace"; + const oldCfg = createSandboxConfig(["1.1.1.1", "8.8.8.8"]); + const newCfg = createSandboxConfig(["8.8.8.8", "1.1.1.1"]); + + const oldHash = computeSandboxConfigHash({ + docker: oldCfg.docker, + workspaceAccess: oldCfg.workspaceAccess, + workspaceDir, + agentWorkspaceDir: workspaceDir, + }); + const newHash = computeSandboxConfigHash({ + docker: newCfg.docker, + workspaceAccess: newCfg.workspaceAccess, + workspaceDir, + agentWorkspaceDir: workspaceDir, + }); + expect(newHash).not.toBe(oldHash); + + spawnState.labelHash = oldHash; + registryMocks.readRegistry.mockResolvedValue({ + entries: [ + { + containerName: "oc-test-shared", + sessionKey: "shared", + createdAtMs: 1, + lastUsedAtMs: 0, + image: newCfg.docker.image, + configHash: oldHash, + }, + ], + }); + + const containerName = await ensureSandboxContainer({ + sessionKey: "agent:main:session-1", + workspaceDir, + agentWorkspaceDir: workspaceDir, + cfg: newCfg, + }); + + expect(containerName).toBe("oc-test-shared"); + const dockerCalls = spawnState.calls.filter((call) => call.command === "docker"); + expect( + dockerCalls.some( + (call) => + call.args[0] === "rm" && call.args[1] === "-f" && call.args[2] === "oc-test-shared", + ), + ).toBe(true); + const createCall = dockerCalls.find((call) => call.args[0] === "create"); + expect(createCall).toBeDefined(); + expect(createCall?.args).toContain(`openclaw.configHash=${newHash}`); + expect(registryMocks.updateRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + containerName: "oc-test-shared", + configHash: newHash, + }), + ); + }); +}); diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index f79885d8a13..f87f7d5f5b4 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -111,6 +111,7 @@ import { computeSandboxConfigHash } from "./config-hash.js"; import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { readRegistry, updateRegistry } from "./registry.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; +import { validateSandboxSecurity } from "./validate-sandbox-security.js"; const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; @@ -240,6 +241,9 @@ export function buildSandboxCreateArgs(params: { labels?: Record; configHash?: string; }) { + // Runtime security validation: blocks dangerous bind mounts, network modes, and profiles. + validateSandboxSecurity(params.cfg); + const createdAtMs = params.createdAtMs ?? Date.now(); const args = ["create", "--name", params.name]; args.push("--label", "openclaw.sandbox=1"); diff --git a/src/agents/sandbox/hash.ts b/src/agents/sandbox/hash.ts new file mode 100644 index 00000000000..d1d0e8dc430 --- /dev/null +++ b/src/agents/sandbox/hash.ts @@ -0,0 +1,5 @@ +import crypto from "node:crypto"; + +export function hashTextSha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index 89c80f95bd8..f6988146e90 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -23,14 +23,18 @@ export type SandboxBrowserInfo = SandboxBrowserRegistryEntry & { imageMatch: boolean; }; -export async function listSandboxContainers(): Promise { - const config = loadConfig(); - const registry = await readRegistry(); - const results: SandboxContainerInfo[] = []; +async function listSandboxRegistryItems< + TEntry extends { containerName: string; image: string; sessionKey: string }, +>(params: { + read: () => Promise<{ entries: TEntry[] }>; + resolveConfiguredImage: (agentId?: string) => string; +}): Promise> { + const registry = await params.read(); + const results: Array = []; for (const entry of registry.entries) { const state = await dockerContainerState(entry.containerName); - // Get actual image from container + // Get actual image from container. let actualImage = entry.image; if (state.exists) { try { @@ -46,7 +50,7 @@ export async function listSandboxContainers(): Promise { } } const agentId = resolveSandboxAgentId(entry.sessionKey); - const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker.image; + const configuredImage = params.resolveConfiguredImage(agentId); results.push({ ...entry, image: actualImage, @@ -58,38 +62,21 @@ export async function listSandboxContainers(): Promise { return results; } +export async function listSandboxContainers(): Promise { + const config = loadConfig(); + return listSandboxRegistryItems({ + read: readRegistry, + resolveConfiguredImage: (agentId) => resolveSandboxConfigForAgent(config, agentId).docker.image, + }); +} + export async function listSandboxBrowsers(): Promise { const config = loadConfig(); - const registry = await readBrowserRegistry(); - const results: SandboxBrowserInfo[] = []; - - for (const entry of registry.entries) { - const state = await dockerContainerState(entry.containerName); - let actualImage = entry.image; - if (state.exists) { - try { - const result = await execDocker( - ["inspect", "-f", "{{.Config.Image}}", entry.containerName], - { allowFailure: true }, - ); - if (result.code === 0) { - actualImage = result.stdout.trim(); - } - } catch { - // ignore - } - } - const agentId = resolveSandboxAgentId(entry.sessionKey); - const configuredImage = resolveSandboxConfigForAgent(config, agentId).browser.image; - results.push({ - ...entry, - image: actualImage, - running: state.running, - imageMatch: actualImage === configuredImage, - }); - } - - return results; + return listSandboxRegistryItems({ + read: readBrowserRegistry, + resolveConfiguredImage: (agentId) => + resolveSandboxConfigForAgent(config, agentId).browser.image, + }); } export async function removeSandboxContainer(containerName: string): Promise { diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index de3616f7e49..c3b37534e36 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -8,69 +8,80 @@ import { readRegistry, removeBrowserRegistryEntry, removeRegistryEntry, + type SandboxBrowserRegistryEntry, + type SandboxRegistryEntry, } from "./registry.js"; let lastPruneAtMs = 0; -async function pruneSandboxContainers(cfg: SandboxConfig) { - const now = Date.now(); +type PruneableRegistryEntry = Pick< + SandboxRegistryEntry, + "containerName" | "createdAtMs" | "lastUsedAtMs" +>; + +function shouldPruneSandboxEntry(cfg: SandboxConfig, now: number, entry: PruneableRegistryEntry) { const idleHours = cfg.prune.idleHours; const maxAgeDays = cfg.prune.maxAgeDays; if (idleHours === 0 && maxAgeDays === 0) { + return false; + } + const idleMs = now - entry.lastUsedAtMs; + const ageMs = now - entry.createdAtMs; + return ( + (idleHours > 0 && idleMs > idleHours * 60 * 60 * 1000) || + (maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1000) + ); +} + +async function pruneSandboxRegistryEntries(params: { + cfg: SandboxConfig; + read: () => Promise<{ entries: TEntry[] }>; + remove: (containerName: string) => Promise; + onRemoved?: (entry: TEntry) => Promise; +}) { + const now = Date.now(); + if (params.cfg.prune.idleHours === 0 && params.cfg.prune.maxAgeDays === 0) { return; } - const registry = await readRegistry(); + const registry = await params.read(); for (const entry of registry.entries) { - const idleMs = now - entry.lastUsedAtMs; - const ageMs = now - entry.createdAtMs; - if ( - (idleHours > 0 && idleMs > idleHours * 60 * 60 * 1000) || - (maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1000) - ) { - try { - await execDocker(["rm", "-f", entry.containerName], { - allowFailure: true, - }); - } catch { - // ignore prune failures - } finally { - await removeRegistryEntry(entry.containerName); - } + if (!shouldPruneSandboxEntry(params.cfg, now, entry)) { + continue; + } + try { + await execDocker(["rm", "-f", entry.containerName], { + allowFailure: true, + }); + } catch { + // ignore prune failures + } finally { + await params.remove(entry.containerName); + await params.onRemoved?.(entry); } } } +async function pruneSandboxContainers(cfg: SandboxConfig) { + await pruneSandboxRegistryEntries({ + cfg, + read: readRegistry, + remove: removeRegistryEntry, + }); +} + async function pruneSandboxBrowsers(cfg: SandboxConfig) { - const now = Date.now(); - const idleHours = cfg.prune.idleHours; - const maxAgeDays = cfg.prune.maxAgeDays; - if (idleHours === 0 && maxAgeDays === 0) { - return; - } - const registry = await readBrowserRegistry(); - for (const entry of registry.entries) { - const idleMs = now - entry.lastUsedAtMs; - const ageMs = now - entry.createdAtMs; - if ( - (idleHours > 0 && idleMs > idleHours * 60 * 60 * 1000) || - (maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1000) - ) { - try { - await execDocker(["rm", "-f", entry.containerName], { - allowFailure: true, - }); - } catch { - // ignore prune failures - } finally { - await removeBrowserRegistryEntry(entry.containerName); - const bridge = BROWSER_BRIDGES.get(entry.sessionKey); - if (bridge?.containerName === entry.containerName) { - await stopBrowserBridgeServer(bridge.bridge.server).catch(() => undefined); - BROWSER_BRIDGES.delete(entry.sessionKey); - } + await pruneSandboxRegistryEntries({ + cfg, + read: readBrowserRegistry, + remove: removeBrowserRegistryEntry, + onRemoved: async (entry) => { + const bridge = BROWSER_BRIDGES.get(entry.sessionKey); + if (bridge?.containerName === entry.containerName) { + await stopBrowserBridgeServer(bridge.bridge.server).catch(() => undefined); + BROWSER_BRIDGES.delete(entry.sessionKey); } - } - } + }, + }); } export async function maybePruneSandboxes(cfg: SandboxConfig) { diff --git a/src/agents/sandbox/shared.ts b/src/agents/sandbox/shared.ts index 0c9bc849c4d..cb3585aad77 100644 --- a/src/agents/sandbox/shared.ts +++ b/src/agents/sandbox/shared.ts @@ -1,12 +1,12 @@ -import crypto from "node:crypto"; import path from "node:path"; import { normalizeAgentId } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; import { resolveAgentIdFromSessionKey } from "../agent-scope.js"; +import { hashTextSha256 } from "./hash.js"; export function slugifySessionKey(value: string) { const trimmed = value.trim() || "session"; - const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 8); + const hash = hashTextSha256(trimmed).slice(0, 8); const safe = trimmed .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") diff --git a/src/agents/sandbox/tool-policy.e2e.test.ts b/src/agents/sandbox/tool-policy.e2e.test.ts deleted file mode 100644 index 319a84a9749..00000000000 --- a/src/agents/sandbox/tool-policy.e2e.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SandboxToolPolicy } from "./types.js"; -import { isToolAllowed } from "./tool-policy.js"; - -describe("sandbox tool policy", () => { - it("allows all tools with * allow", () => { - const policy: SandboxToolPolicy = { allow: ["*"], deny: [] }; - expect(isToolAllowed(policy, "browser")).toBe(true); - }); - - it("denies all tools with * deny", () => { - const policy: SandboxToolPolicy = { allow: [], deny: ["*"] }; - expect(isToolAllowed(policy, "read")).toBe(false); - }); - - it("supports wildcard patterns", () => { - const policy: SandboxToolPolicy = { allow: ["web_*"] }; - expect(isToolAllowed(policy, "web_fetch")).toBe(true); - expect(isToolAllowed(policy, "read")).toBe(false); - }); -}); diff --git a/src/agents/sandbox/tool-policy.ts b/src/agents/sandbox/tool-policy.ts index b50a363846b..083cfcac4af 100644 --- a/src/agents/sandbox/tool-policy.ts +++ b/src/agents/sandbox/tool-policy.ts @@ -89,6 +89,9 @@ export function resolveSandboxToolPolicyForAgent( // `image` is essential for multimodal workflows; always include it in sandboxed // sessions unless explicitly denied. if ( + // Empty allowlist means "allow all" for `isToolAllowed`, so don't inject a + // single tool that would accidentally turn it into an explicit allowlist. + expandedAllow.length > 0 && !expandedDeny.map((v) => v.toLowerCase()).includes("image") && !expandedAllow.map((v) => v.toLowerCase()).includes("image") ) { diff --git a/src/agents/sandbox/validate-sandbox-security.test.ts b/src/agents/sandbox/validate-sandbox-security.test.ts new file mode 100644 index 00000000000..4b3ff9d698c --- /dev/null +++ b/src/agents/sandbox/validate-sandbox-security.test.ts @@ -0,0 +1,153 @@ +import { mkdtempSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + getBlockedBindReason, + validateBindMounts, + validateNetworkMode, + validateSeccompProfile, + validateApparmorProfile, + validateSandboxSecurity, +} from "./validate-sandbox-security.js"; + +describe("getBlockedBindReason", () => { + it("blocks common Docker socket directories", () => { + expect(getBlockedBindReason("/run:/run")).toEqual(expect.objectContaining({ kind: "targets" })); + expect(getBlockedBindReason("/var/run:/var/run:ro")).toEqual( + expect.objectContaining({ kind: "targets" }), + ); + }); + + it("does not block /var by default", () => { + expect(getBlockedBindReason("/var:/var")).toBeNull(); + }); +}); + +describe("validateBindMounts", () => { + it("allows legitimate project directory mounts", () => { + expect(() => + validateBindMounts([ + "/home/user/source:/source:rw", + "/home/user/projects:/projects:ro", + "/var/data/myapp:/data", + "/opt/myapp/config:/config:ro", + ]), + ).not.toThrow(); + }); + + it("allows undefined or empty binds", () => { + expect(() => validateBindMounts(undefined)).not.toThrow(); + expect(() => validateBindMounts([])).not.toThrow(); + }); + + it("blocks /etc mount", () => { + expect(() => validateBindMounts(["/etc/passwd:/mnt/passwd:ro"])).toThrow( + /blocked path "\/etc"/, + ); + }); + + it("blocks /proc mount", () => { + expect(() => validateBindMounts(["/proc:/proc:ro"])).toThrow(/blocked path "\/proc"/); + }); + + it("blocks Docker socket mounts (/var/run + /run)", () => { + expect(() => validateBindMounts(["/var/run/docker.sock:/var/run/docker.sock"])).toThrow( + /docker\.sock/, + ); + expect(() => validateBindMounts(["/run/docker.sock:/run/docker.sock"])).toThrow(/docker\.sock/); + }); + + it("blocks parent mounts that would expose the Docker socket", () => { + expect(() => validateBindMounts(["/run:/run"])).toThrow(/blocked path/); + expect(() => validateBindMounts(["/var/run:/var/run"])).toThrow(/blocked path/); + expect(() => validateBindMounts(["/var:/var"])).not.toThrow(); + }); + + it("blocks paths with .. traversal to dangerous directories", () => { + expect(() => validateBindMounts(["/home/user/../../etc/shadow:/mnt/shadow"])).toThrow( + /blocked path "\/etc"/, + ); + }); + + it("blocks paths with double slashes normalizing to dangerous dirs", () => { + expect(() => validateBindMounts(["//etc//passwd:/mnt/passwd"])).toThrow(/blocked path "\/etc"/); + }); + + it("blocks symlink escapes into blocked directories", () => { + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const link = join(dir, "etc-link"); + symlinkSync("/etc", link); + const run = () => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`]); + + if (process.platform === "win32") { + // Windows source paths (e.g. C:\...) are intentionally rejected as non-POSIX. + expect(run).toThrow(/non-absolute source path/); + return; + } + + expect(run).toThrow(/blocked path/); + }); + + it("rejects non-absolute source paths (relative or named volumes)", () => { + expect(() => validateBindMounts(["../etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/); + expect(() => validateBindMounts(["etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/); + expect(() => validateBindMounts(["myvol:/mnt"])).toThrow(/non-absolute/); + }); +}); + +describe("validateNetworkMode", () => { + it("allows bridge/none/custom/undefined", () => { + expect(() => validateNetworkMode("bridge")).not.toThrow(); + expect(() => validateNetworkMode("none")).not.toThrow(); + expect(() => validateNetworkMode("my-custom-network")).not.toThrow(); + expect(() => validateNetworkMode(undefined)).not.toThrow(); + }); + + it("blocks host mode (case-insensitive)", () => { + expect(() => validateNetworkMode("host")).toThrow(/network mode "host" is blocked/); + expect(() => validateNetworkMode("HOST")).toThrow(/network mode "HOST" is blocked/); + }); +}); + +describe("validateSeccompProfile", () => { + it("allows custom profile paths/undefined", () => { + expect(() => validateSeccompProfile("/tmp/seccomp.json")).not.toThrow(); + expect(() => validateSeccompProfile(undefined)).not.toThrow(); + }); + + it("blocks unconfined (case-insensitive)", () => { + expect(() => validateSeccompProfile("unconfined")).toThrow( + /seccomp profile "unconfined" is blocked/, + ); + expect(() => validateSeccompProfile("Unconfined")).toThrow( + /seccomp profile "Unconfined" is blocked/, + ); + }); +}); + +describe("validateApparmorProfile", () => { + it("allows named profile/undefined", () => { + expect(() => validateApparmorProfile("openclaw-sandbox")).not.toThrow(); + expect(() => validateApparmorProfile(undefined)).not.toThrow(); + }); + + it("blocks unconfined (case-insensitive)", () => { + expect(() => validateApparmorProfile("unconfined")).toThrow( + /apparmor profile "unconfined" is blocked/, + ); + }); +}); + +describe("validateSandboxSecurity", () => { + it("passes with safe config", () => { + expect(() => + validateSandboxSecurity({ + binds: ["/home/user/src:/src:rw"], + network: "none", + seccompProfile: "/tmp/seccomp.json", + apparmorProfile: "openclaw-sandbox", + }), + ).not.toThrow(); + }); +}); diff --git a/src/agents/sandbox/validate-sandbox-security.ts b/src/agents/sandbox/validate-sandbox-security.ts new file mode 100644 index 00000000000..2ed84e9c93d --- /dev/null +++ b/src/agents/sandbox/validate-sandbox-security.ts @@ -0,0 +1,195 @@ +/** + * Sandbox security validation β€” blocks dangerous Docker configurations. + * + * Threat model: local-trusted config, but protect against foot-guns and config injection. + * Enforced at runtime when creating sandbox containers. + */ + +import { existsSync, realpathSync } from "node:fs"; +import { posix } from "node:path"; + +// Targeted denylist: host paths that should never be exposed inside sandbox containers. +// Exported for reuse in security audit collectors. +export const BLOCKED_HOST_PATHS = [ + "/etc", + "/private/etc", + "/proc", + "/sys", + "/dev", + "/root", + "/boot", + // Directories that commonly contain (or alias) the Docker socket. + "/run", + "/var/run", + "/private/var/run", + "/var/run/docker.sock", + "/private/var/run/docker.sock", + "/run/docker.sock", +]; + +const BLOCKED_NETWORK_MODES = new Set(["host"]); +const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]); +const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]); + +export type BlockedBindReason = + | { kind: "targets"; blockedPath: string } + | { kind: "covers"; blockedPath: string } + | { kind: "non_absolute"; sourcePath: string }; + +/** + * Parse the host/source path from a Docker bind mount string. + * Format: `source:target[:mode]` + */ +export function parseBindSourcePath(bind: string): string { + const trimmed = bind.trim(); + const firstColon = trimmed.indexOf(":"); + if (firstColon <= 0) { + // No colon or starts with colon β€” treat as source. + return trimmed; + } + return trimmed.slice(0, firstColon); +} + +/** + * Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`. + */ +export function normalizeHostPath(raw: string): string { + const trimmed = raw.trim(); + return posix.normalize(trimmed).replace(/\/+$/, "") || "/"; +} + +/** + * String-only blocked-path check (no filesystem I/O). + * Blocks: + * - binds that target blocked paths (equal or under) + * - binds that cover the system root (mounting "/" is never safe) + * - non-absolute source paths (relative / volume names) because they are hard to validate safely + */ +export function getBlockedBindReason(bind: string): BlockedBindReason | null { + const sourceRaw = parseBindSourcePath(bind); + if (!sourceRaw.startsWith("/")) { + return { kind: "non_absolute", sourcePath: sourceRaw }; + } + + const normalized = normalizeHostPath(sourceRaw); + return getBlockedReasonForSourcePath(normalized); +} + +export function getBlockedReasonForSourcePath(sourceNormalized: string): BlockedBindReason | null { + if (sourceNormalized === "/") { + return { kind: "covers", blockedPath: "/" }; + } + for (const blocked of BLOCKED_HOST_PATHS) { + if (sourceNormalized === blocked || sourceNormalized.startsWith(blocked + "/")) { + return { kind: "targets", blockedPath: blocked }; + } + } + + return null; +} + +function tryRealpathAbsolute(path: string): string { + if (!path.startsWith("/")) { + return path; + } + if (!existsSync(path)) { + return path; + } + try { + // Use native when available (keeps platform semantics); normalize for prefix checks. + return normalizeHostPath(realpathSync.native(path)); + } catch { + return path; + } +} + +function formatBindBlockedError(params: { bind: string; reason: BlockedBindReason }): Error { + if (params.reason.kind === "non_absolute") { + return new Error( + `Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` + + `"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`, + ); + } + const verb = params.reason.kind === "covers" ? "covers" : "targets"; + return new Error( + `Sandbox security: bind mount "${params.bind}" ${verb} blocked path "${params.reason.blockedPath}". ` + + "Mounting system directories (or Docker socket paths) into sandbox containers is not allowed. " + + "Use project-specific paths instead (e.g. /home/user/myproject).", + ); +} + +/** + * Validate bind mounts β€” throws if any source path is dangerous. + * Includes a symlink/realpath pass when the source path exists. + */ +export function validateBindMounts(binds: string[] | undefined): void { + if (!binds?.length) { + return; + } + + for (const rawBind of binds) { + const bind = rawBind.trim(); + if (!bind) { + continue; + } + + // Fast string-only check (covers .., //, ancestor/descendant logic). + const blocked = getBlockedBindReason(bind); + if (blocked) { + throw formatBindBlockedError({ bind, reason: blocked }); + } + + // Symlink escape hardening: resolve existing absolute paths and re-check. + const sourceRaw = parseBindSourcePath(bind); + const sourceNormalized = normalizeHostPath(sourceRaw); + const sourceReal = tryRealpathAbsolute(sourceNormalized); + if (sourceReal !== sourceNormalized) { + const reason = getBlockedReasonForSourcePath(sourceReal); + if (reason) { + throw formatBindBlockedError({ bind, reason }); + } + } + } +} + +export function validateNetworkMode(network: string | undefined): void { + if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) { + throw new Error( + `Sandbox security: network mode "${network}" is blocked. ` + + 'Network "host" mode bypasses container network isolation. ' + + 'Use "bridge" or "none" instead.', + ); + } +} + +export function validateSeccompProfile(profile: string | undefined): void { + if (profile && BLOCKED_SECCOMP_PROFILES.has(profile.trim().toLowerCase())) { + throw new Error( + `Sandbox security: seccomp profile "${profile}" is blocked. ` + + "Disabling seccomp removes syscall filtering and weakens sandbox isolation. " + + "Use a custom seccomp profile file or omit this setting.", + ); + } +} + +export function validateApparmorProfile(profile: string | undefined): void { + if (profile && BLOCKED_APPARMOR_PROFILES.has(profile.trim().toLowerCase())) { + throw new Error( + `Sandbox security: apparmor profile "${profile}" is blocked. ` + + "Disabling AppArmor removes mandatory access controls and weakens sandbox isolation. " + + "Use a named AppArmor profile or omit this setting.", + ); + } +} + +export function validateSandboxSecurity(cfg: { + binds?: string[]; + network?: string; + seccompProfile?: string; + apparmorProfile?: string; +}): void { + validateBindMounts(cfg.binds); + validateNetworkMode(cfg.network); + validateSeccompProfile(cfg.seccompProfile); + validateApparmorProfile(cfg.apparmorProfile); +} diff --git a/src/agents/sanitize-for-prompt.test.ts b/src/agents/sanitize-for-prompt.test.ts new file mode 100644 index 00000000000..32a4ce3d86e --- /dev/null +++ b/src/agents/sanitize-for-prompt.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; +import { buildAgentSystemPrompt } from "./system-prompt.js"; + +describe("sanitizeForPromptLiteral (OC-19 hardening)", () => { + it("strips ASCII control chars (CR/LF/NUL/tab)", () => { + expect(sanitizeForPromptLiteral("/tmp/a\nb\rc\x00d\te")).toBe("/tmp/abcde"); + }); + + it("strips Unicode line/paragraph separators", () => { + expect(sanitizeForPromptLiteral(`/tmp/a\u2028b\u2029c`)).toBe("/tmp/abc"); + }); + + it("strips Unicode format chars (bidi override)", () => { + // U+202E RIGHT-TO-LEFT OVERRIDE (Cf) can spoof rendered text. + expect(sanitizeForPromptLiteral(`/tmp/a\u202Eb`)).toBe("/tmp/ab"); + }); + + it("preserves ordinary Unicode + spaces", () => { + const value = "/tmp/my project/ζ—₯本θͺž-folder.v2"; + expect(sanitizeForPromptLiteral(value)).toBe(value); + }); +}); + +describe("buildAgentSystemPrompt uses sanitized workspace/sandbox strings", () => { + it("sanitizes workspaceDir (no newlines / separators)", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/project\nINJECT\u2028MORE", + }); + expect(prompt).toContain("Your working directory is: /tmp/projectINJECTMORE"); + expect(prompt).not.toContain("Your working directory is: /tmp/project\n"); + expect(prompt).not.toContain("\u2028"); + }); + + it("sanitizes sandbox workspace/mount/url strings", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/test", + sandboxInfo: { + enabled: true, + containerWorkspaceDir: "/work\u2029space", + workspaceDir: "/host\nspace", + workspaceAccess: "read-write", + agentWorkspaceMount: "/mnt\u2028mount", + browserNoVncUrl: "http://example.test/\nui", + }, + }); + expect(prompt).toContain("Sandbox container workdir: /workspace"); + expect(prompt).toContain( + "Sandbox host mount source (file tools bridge only; not valid inside sandbox exec): /hostspace", + ); + expect(prompt).toContain("(mounted at /mntmount)"); + expect(prompt).toContain("Sandbox browser observer (noVNC): http://example.test/ui"); + expect(prompt).not.toContain("\nui"); + }); +}); diff --git a/src/agents/sanitize-for-prompt.ts b/src/agents/sanitize-for-prompt.ts new file mode 100644 index 00000000000..7692cf306da --- /dev/null +++ b/src/agents/sanitize-for-prompt.ts @@ -0,0 +1,18 @@ +/** + * Sanitize untrusted strings before embedding them into an LLM prompt. + * + * Threat model (OC-19): attacker-controlled directory names (or other runtime strings) + * that contain newline/control characters can break prompt structure and inject + * arbitrary instructions. + * + * Strategy (Option 3 hardening): + * - Strip Unicode "control" (Cc) + "format" (Cf) characters (includes CR/LF/NUL, bidi marks, zero-width chars). + * - Strip explicit line/paragraph separators (Zl/Zp): U+2028/U+2029. + * + * Notes: + * - This is intentionally lossy; it trades edge-case path fidelity for prompt integrity. + * - If you need lossless representation, escape instead of stripping. + */ +export function sanitizeForPromptLiteral(value: string): string { + return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, ""); +} diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 1164598b774..94d43d5ac8d 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -1,6 +1,7 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { isPidAlive } from "../shared/pid-alive.js"; type LockFilePayload = { pid: number; @@ -48,18 +49,6 @@ function resolveCleanupState(): CleanupState { return proc[CLEANUP_STATE_KEY]; } -function isAlive(pid: number): boolean { - if (!Number.isFinite(pid) || pid <= 0) { - return false; - } - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - /** * Synchronously release all held locks. * Used during process exit when async operations aren't reliable. @@ -162,24 +151,25 @@ export async function acquireSessionWriteLock(params: { } const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile)); const lockPath = `${normalizedSessionFile}.lock`; + const release = async () => { + const current = HELD_LOCKS.get(normalizedSessionFile); + if (!current) { + return; + } + current.count -= 1; + if (current.count > 0) { + return; + } + HELD_LOCKS.delete(normalizedSessionFile); + await current.handle.close(); + await fs.rm(current.lockPath, { force: true }); + }; const held = HELD_LOCKS.get(normalizedSessionFile); if (held) { held.count += 1; return { - release: async () => { - const current = HELD_LOCKS.get(normalizedSessionFile); - if (!current) { - return; - } - current.count -= 1; - if (current.count > 0) { - return; - } - HELD_LOCKS.delete(normalizedSessionFile); - await current.handle.close(); - await fs.rm(current.lockPath, { force: true }); - }, + release, }; } @@ -195,19 +185,7 @@ export async function acquireSessionWriteLock(params: { ); HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath }); return { - release: async () => { - const current = HELD_LOCKS.get(normalizedSessionFile); - if (!current) { - return; - } - current.count -= 1; - if (current.count > 0) { - return; - } - HELD_LOCKS.delete(normalizedSessionFile); - await current.handle.close(); - await fs.rm(current.lockPath, { force: true }); - }, + release, }; } catch (err) { const code = (err as { code?: unknown }).code; @@ -217,7 +195,7 @@ export async function acquireSessionWriteLock(params: { const payload = await readLockPayload(lockPath); const createdAt = payload?.createdAt ? Date.parse(payload.createdAt) : NaN; const stale = !Number.isFinite(createdAt) || Date.now() - createdAt > staleMs; - const alive = payload?.pid ? isAlive(payload.pid) : false; + const alive = payload?.pid ? isPidAlive(payload.pid) : false; if (stale || !alive) { await fs.rm(lockPath, { force: true }); continue; diff --git a/src/agents/skills-install-download.ts b/src/agents/skills-install-download.ts new file mode 100644 index 00000000000..f9b2ff2837c --- /dev/null +++ b/src/agents/skills-install-download.ts @@ -0,0 +1,376 @@ +import type { ReadableStream as NodeReadableStream } from "node:stream/web"; +import fs from "node:fs"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import type { SkillInstallResult } from "./skills-install.js"; +import type { SkillEntry, SkillInstallSpec } from "./skills.js"; +import { extractArchive as extractArchiveSafe } from "../infra/archive.js"; +import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +import { isWithinDir, resolveSafeBaseDir } from "../infra/path-safety.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { ensureDir, resolveUserPath } from "../utils.js"; +import { hasBinary } from "./skills.js"; +import { resolveSkillToolsRootDir } from "./skills/tools-dir.js"; + +function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream { + return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function"); +} + +function summarizeInstallOutput(text: string): string | undefined { + const raw = text.trim(); + if (!raw) { + return undefined; + } + const lines = raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length === 0) { + return undefined; + } + + const preferred = + lines.find((line) => /^error\b/i.test(line)) ?? + lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ?? + lines.at(-1); + + if (!preferred) { + return undefined; + } + const normalized = preferred.replace(/\s+/g, " ").trim(); + const maxLen = 200; + return normalized.length > maxLen ? `${normalized.slice(0, maxLen - 1)}…` : normalized; +} + +function formatInstallFailureMessage(result: { + code: number | null; + stdout: string; + stderr: string; +}): string { + const code = typeof result.code === "number" ? `exit ${result.code}` : "unknown exit"; + const summary = summarizeInstallOutput(result.stderr) ?? summarizeInstallOutput(result.stdout); + if (!summary) { + return `Install failed (${code})`; + } + return `Install failed (${code}): ${summary}`; +} + +function isWindowsDrivePath(p: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(p); +} + +function resolveDownloadTargetDir(entry: SkillEntry, spec: SkillInstallSpec): string { + const safeRoot = resolveSkillToolsRootDir(entry); + const raw = spec.targetDir?.trim(); + if (!raw) { + return safeRoot; + } + + // Treat non-absolute paths as relative to the per-skill tools root. + const resolved = + raw.startsWith("~") || path.isAbsolute(raw) || isWindowsDrivePath(raw) + ? resolveUserPath(raw) + : path.resolve(safeRoot, raw); + + if (!isWithinDir(safeRoot, resolved)) { + throw new Error( + `Refusing to install outside the skill tools directory. targetDir="${raw}" resolves to "${resolved}". Allowed root: "${safeRoot}".`, + ); + } + return resolved; +} + +function resolveArchiveType(spec: SkillInstallSpec, filename: string): string | undefined { + const explicit = spec.archive?.trim().toLowerCase(); + if (explicit) { + return explicit; + } + const lower = filename.toLowerCase(); + if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) { + return "tar.gz"; + } + if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) { + return "tar.bz2"; + } + if (lower.endsWith(".zip")) { + return "zip"; + } + return undefined; +} + +function normalizeArchiveEntryPath(raw: string): string { + return raw.replaceAll("\\", "/"); +} + +function validateArchiveEntryPath(entryPath: string): void { + if (!entryPath || entryPath === "." || entryPath === "./") { + return; + } + if (isWindowsDrivePath(entryPath)) { + throw new Error(`archive entry uses a drive path: ${entryPath}`); + } + const normalized = path.posix.normalize(normalizeArchiveEntryPath(entryPath)); + if (normalized === ".." || normalized.startsWith("../")) { + throw new Error(`archive entry escapes targetDir: ${entryPath}`); + } + if (path.posix.isAbsolute(normalized) || normalized.startsWith("//")) { + throw new Error(`archive entry is absolute: ${entryPath}`); + } +} + +function stripArchivePath(entryPath: string, stripComponents: number): string | null { + const raw = normalizeArchiveEntryPath(entryPath); + if (!raw || raw === "." || raw === "./") { + return null; + } + + // Important: tar's --strip-components semantics operate on raw path segments, + // before any normalization that would collapse "..". We mimic that so we + // can detect strip-induced escapes like "a/../b" with stripComponents=1. + const parts = raw.split("/").filter((part) => part.length > 0 && part !== "."); + const strip = Math.max(0, Math.floor(stripComponents)); + const stripped = strip === 0 ? parts.join("/") : parts.slice(strip).join("/"); + const result = path.posix.normalize(stripped); + if (!result || result === "." || result === "./") { + return null; + } + return result; +} + +function validateExtractedPathWithinRoot(params: { + rootDir: string; + relPath: string; + originalPath: string; +}): void { + const safeBase = resolveSafeBaseDir(params.rootDir); + const outPath = path.resolve(params.rootDir, params.relPath); + if (!outPath.startsWith(safeBase)) { + throw new Error(`archive entry escapes targetDir: ${params.originalPath}`); + } +} + +async function downloadFile( + url: string, + destPath: string, + timeoutMs: number, +): Promise<{ bytes: number }> { + const { response, release } = await fetchWithSsrFGuard({ + url, + timeoutMs: Math.max(1_000, timeoutMs), + }); + try { + if (!response.ok || !response.body) { + throw new Error(`Download failed (${response.status} ${response.statusText})`); + } + await ensureDir(path.dirname(destPath)); + const file = fs.createWriteStream(destPath); + const body = response.body as unknown; + const readable = isNodeReadableStream(body) + ? body + : Readable.fromWeb(body as NodeReadableStream); + await pipeline(readable, file); + const stat = await fs.promises.stat(destPath); + return { bytes: stat.size }; + } finally { + await release(); + } +} + +async function extractArchive(params: { + archivePath: string; + archiveType: string; + targetDir: string; + stripComponents?: number; + timeoutMs: number; +}): Promise<{ stdout: string; stderr: string; code: number | null }> { + const { archivePath, archiveType, targetDir, stripComponents, timeoutMs } = params; + const strip = + typeof stripComponents === "number" && Number.isFinite(stripComponents) + ? Math.max(0, Math.floor(stripComponents)) + : 0; + + try { + if (archiveType === "zip") { + await extractArchiveSafe({ + archivePath, + destDir: targetDir, + timeoutMs, + kind: "zip", + stripComponents: strip, + }); + return { stdout: "", stderr: "", code: 0 }; + } + + if (archiveType === "tar.gz") { + await extractArchiveSafe({ + archivePath, + destDir: targetDir, + timeoutMs, + kind: "tar", + stripComponents: strip, + tarGzip: true, + }); + return { stdout: "", stderr: "", code: 0 }; + } + + if (archiveType === "tar.bz2") { + if (!hasBinary("tar")) { + return { stdout: "", stderr: "tar not found on PATH", code: null }; + } + + // Preflight list to prevent zip-slip style traversal before extraction. + const listResult = await runCommandWithTimeout(["tar", "tf", archivePath], { timeoutMs }); + if (listResult.code !== 0) { + return { + stdout: listResult.stdout, + stderr: listResult.stderr || "tar list failed", + code: listResult.code, + }; + } + const entries = listResult.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const verboseResult = await runCommandWithTimeout(["tar", "tvf", archivePath], { timeoutMs }); + if (verboseResult.code !== 0) { + return { + stdout: verboseResult.stdout, + stderr: verboseResult.stderr || "tar verbose list failed", + code: verboseResult.code, + }; + } + for (const line of verboseResult.stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const typeChar = trimmed[0]; + if (typeChar === "l" || typeChar === "h" || trimmed.includes(" -> ")) { + return { + stdout: verboseResult.stdout, + stderr: "tar archive contains link entries; refusing to extract for safety", + code: 1, + }; + } + } + + for (const entry of entries) { + validateArchiveEntryPath(entry); + const relPath = stripArchivePath(entry, strip); + if (!relPath) { + continue; + } + validateArchiveEntryPath(relPath); + validateExtractedPathWithinRoot({ rootDir: targetDir, relPath, originalPath: entry }); + } + + const argv = ["tar", "xf", archivePath, "-C", targetDir]; + if (strip > 0) { + argv.push("--strip-components", String(strip)); + } + return await runCommandWithTimeout(argv, { timeoutMs }); + } + + return { stdout: "", stderr: `unsupported archive type: ${archiveType}`, code: null }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { stdout: "", stderr: message, code: 1 }; + } +} + +export async function installDownloadSpec(params: { + entry: SkillEntry; + spec: SkillInstallSpec; + timeoutMs: number; +}): Promise { + const { entry, spec, timeoutMs } = params; + const url = spec.url?.trim(); + if (!url) { + return { + ok: false, + message: "missing download url", + stdout: "", + stderr: "", + code: null, + }; + } + + let filename = ""; + try { + const parsed = new URL(url); + filename = path.basename(parsed.pathname); + } catch { + filename = path.basename(url); + } + if (!filename) { + filename = "download"; + } + + let targetDir = ""; + try { + targetDir = resolveDownloadTargetDir(entry, spec); + await ensureDir(targetDir); + const stat = await fs.promises.lstat(targetDir); + if (stat.isSymbolicLink()) { + throw new Error(`targetDir is a symlink: ${targetDir}`); + } + if (!stat.isDirectory()) { + throw new Error(`targetDir is not a directory: ${targetDir}`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, message, stdout: "", stderr: message, code: null }; + } + + const archivePath = path.join(targetDir, filename); + let downloaded = 0; + try { + const result = await downloadFile(url, archivePath, timeoutMs); + downloaded = result.bytes; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, message, stdout: "", stderr: message, code: null }; + } + + const archiveType = resolveArchiveType(spec, filename); + const shouldExtract = spec.extract ?? Boolean(archiveType); + if (!shouldExtract) { + return { + ok: true, + message: `Downloaded to ${archivePath}`, + stdout: `downloaded=${downloaded}`, + stderr: "", + code: 0, + }; + } + + if (!archiveType) { + return { + ok: false, + message: "extract requested but archive type could not be detected", + stdout: "", + stderr: "", + code: null, + }; + } + + const extractResult = await extractArchive({ + archivePath, + archiveType, + targetDir, + stripComponents: spec.stripComponents, + timeoutMs, + }); + const success = extractResult.code === 0; + return { + ok: success, + message: success + ? `Downloaded and extracted to ${targetDir}` + : formatInstallFailureMessage(extractResult), + stdout: extractResult.stdout.trim(), + stderr: extractResult.stderr.trim(), + code: extractResult.code, + }; +} diff --git a/src/agents/skills-install-fallback.e2e.test.ts b/src/agents/skills-install-fallback.e2e.test.ts new file mode 100644 index 00000000000..70c6a9270d4 --- /dev/null +++ b/src/agents/skills-install-fallback.e2e.test.ts @@ -0,0 +1,240 @@ +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 { installSkill } from "./skills-install.js"; +import { buildWorkspaceSkillStatus } from "./skills-status.js"; + +const runCommandWithTimeoutMock = vi.fn(); +const scanDirectoryWithSummaryMock = vi.fn(); +const hasBinaryMock = vi.fn(); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + +vi.mock("../infra/net/fetch-guard.js", () => ({ + fetchWithSsrFGuard: vi.fn(), +})); + +vi.mock("../security/skill-scanner.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), + }; +}); + +vi.mock("../shared/config-eval.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasBinary: (...args: unknown[]) => hasBinaryMock(...args), + }; +}); + +vi.mock("../infra/brew.js", () => ({ + resolveBrewExecutable: () => undefined, +})); + +async function writeSkillWithInstallers( + workspaceDir: string, + name: string, + installSpecs: Array>, +): Promise { + const skillDir = path.join(workspaceDir, "skills", name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: ${name} +description: test skill +metadata: ${JSON.stringify({ openclaw: { install: installSpecs } })} +--- + +# ${name} +`, + "utf-8", + ); + await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8"); + return skillDir; +} + +async function writeSkillWithInstaller( + workspaceDir: string, + name: string, + kind: string, + extra: Record, +): Promise { + return writeSkillWithInstallers(workspaceDir, name, [{ id: "deps", kind, ...extra }]); +} + +describe("skills-install fallback edge cases", () => { + let workspaceDir: string; + + beforeAll(async () => { + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fallback-test-")); + await writeSkillWithInstaller(workspaceDir, "go-tool-single", "go", { + module: "example.com/tool@latest", + }); + await writeSkillWithInstallers(workspaceDir, "go-tool-multi", [ + { id: "brew", kind: "brew", formula: "go" }, + { id: "go", kind: "go", module: "example.com/tool@latest" }, + ]); + await writeSkillWithInstaller(workspaceDir, "py-tool", "uv", { + package: "example-package", + }); + }); + + beforeEach(async () => { + runCommandWithTimeoutMock.mockReset(); + scanDirectoryWithSummaryMock.mockReset(); + hasBinaryMock.mockReset(); + scanDirectoryWithSummaryMock.mockResolvedValue({ critical: 0, warn: 0, findings: [] }); + }); + + afterAll(async () => { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + }); + + it("apt-get available but sudo missing/unusable returns helpful error for go install", async () => { + // go not available, brew not available, apt-get + sudo are available, sudo check fails + hasBinaryMock.mockImplementation((bin: string) => { + if (bin === "go") { + return false; + } + if (bin === "brew") { + return false; + } + if (bin === "apt-get" || bin === "sudo") { + return true; + } + return false; + }); + + // sudo -n true fails (no passwordless sudo) + runCommandWithTimeoutMock.mockResolvedValueOnce({ + code: 1, + stdout: "", + stderr: "sudo: a password is required", + }); + + const result = await installSkill({ + workspaceDir, + skillName: "go-tool-single", + installId: "deps", + }); + + expect(result.ok).toBe(false); + expect(result.message).toContain("sudo"); + expect(result.message).toContain("https://go.dev/doc/install"); + + // Verify sudo -n true was called + expect(runCommandWithTimeoutMock).toHaveBeenCalledWith( + ["sudo", "-n", "true"], + expect.objectContaining({ timeoutMs: 5_000 }), + ); + + // Verify apt-get install was NOT called + const aptCalls = runCommandWithTimeoutMock.mock.calls.filter( + (call) => Array.isArray(call[0]) && (call[0] as string[]).includes("apt-get"), + ); + expect(aptCalls).toHaveLength(0); + }); + + it("status-selected go installer fails gracefully when apt fallback needs sudo", async () => { + // no go/brew, but apt and sudo are present + hasBinaryMock.mockImplementation((bin: string) => { + if (bin === "go" || bin === "brew") { + return false; + } + if (bin === "apt-get" || bin === "sudo") { + return true; + } + return false; + }); + + runCommandWithTimeoutMock.mockResolvedValueOnce({ + code: 1, + stdout: "", + stderr: "sudo: a password is required", + }); + + const status = buildWorkspaceSkillStatus(workspaceDir); + const skill = status.skills.find((entry) => entry.name === "go-tool-multi"); + expect(skill?.install[0]?.id).toBe("go"); + + const result = await installSkill({ + workspaceDir, + skillName: "go-tool-multi", + installId: skill?.install[0]?.id ?? "", + }); + + expect(result.ok).toBe(false); + expect(result.message).toContain("sudo is not usable"); + }); + + it("handles sudo probe spawn failures without throwing", async () => { + // go not available, brew not available, apt-get + sudo appear available + hasBinaryMock.mockImplementation((bin: string) => { + if (bin === "go") { + return false; + } + if (bin === "brew") { + return false; + } + if (bin === "apt-get" || bin === "sudo") { + return true; + } + return false; + }); + + runCommandWithTimeoutMock.mockRejectedValueOnce( + new Error('Executable not found in $PATH: "sudo"'), + ); + + const result = await installSkill({ + workspaceDir, + skillName: "go-tool-single", + installId: "deps", + }); + + expect(result.ok).toBe(false); + expect(result.message).toContain("sudo is not usable"); + expect(result.stderr).toContain("Executable not found"); + + // Verify apt-get install was NOT called + const aptCalls = runCommandWithTimeoutMock.mock.calls.filter( + (call) => Array.isArray(call[0]) && (call[0] as string[]).includes("apt-get"), + ); + expect(aptCalls).toHaveLength(0); + }); + + it("uv not installed and no brew returns helpful error without curl auto-install", async () => { + // uv not available, brew not available, curl IS available + hasBinaryMock.mockImplementation((bin: string) => { + if (bin === "uv") { + return false; + } + if (bin === "brew") { + return false; + } + if (bin === "curl") { + return true; + } + return false; + }); + + const result = await installSkill({ + workspaceDir, + skillName: "py-tool", + installId: "deps", + }); + + expect(result.ok).toBe(false); + expect(result.message).toContain("https://docs.astral.sh/uv/getting-started/installation/"); + + // Verify NO curl command was attempted (no auto-install) + expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/skills-install.download-tarbz2.e2e.test.ts b/src/agents/skills-install.download-tarbz2.e2e.test.ts new file mode 100644 index 00000000000..1cadeb621e6 --- /dev/null +++ b/src/agents/skills-install.download-tarbz2.e2e.test.ts @@ -0,0 +1,317 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { installSkill } from "./skills-install.js"; + +const runCommandWithTimeoutMock = vi.fn(); +const scanDirectoryWithSummaryMock = vi.fn(); +const fetchWithSsrFGuardMock = vi.fn(); + +const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR; + +afterEach(() => { + if (originalOpenClawStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir; + } +}); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + +vi.mock("../infra/net/fetch-guard.js", () => ({ + fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), +})); + +vi.mock("../security/skill-scanner.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), + }; +}); + +async function writeDownloadSkill(params: { + workspaceDir: string; + name: string; + installId: string; + url: string; + stripComponents?: number; + targetDir: string; +}): Promise { + const skillDir = path.join(params.workspaceDir, "skills", params.name); + await fs.mkdir(skillDir, { recursive: true }); + const meta = { + openclaw: { + install: [ + { + id: params.installId, + kind: "download", + url: params.url, + archive: "tar.bz2", + extract: true, + stripComponents: params.stripComponents, + targetDir: params.targetDir, + }, + ], + }, + }; + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: ${params.name} +description: test skill +metadata: ${JSON.stringify(meta)} +--- + +# ${params.name} +`, + "utf-8", + ); + await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8"); + return skillDir; +} + +function setTempStateDir(workspaceDir: string): string { + const stateDir = path.join(workspaceDir, "state"); + process.env.OPENCLAW_STATE_DIR = stateDir; + return stateDir; +} + +describe("installSkill download extraction safety (tar.bz2)", () => { + beforeEach(() => { + runCommandWithTimeoutMock.mockReset(); + scanDirectoryWithSummaryMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + scanDirectoryWithSummaryMock.mockResolvedValue({ + scannedFiles: 0, + critical: 0, + warn: 0, + info: 0, + findings: [], + }); + }); + + it("rejects tar.bz2 traversal before extraction", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + const targetDir = path.join(stateDir, "tools", "tbz2-slip", "target"); + const url = "https://example.invalid/evil.tbz2"; + + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), + release: async () => undefined, + }); + + runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { + const cmd = argv as string[]; + if (cmd[0] === "tar" && cmd[1] === "tf") { + return { code: 0, stdout: "../outside.txt\n", stderr: "", signal: null, killed: false }; + } + if (cmd[0] === "tar" && cmd[1] === "tvf") { + return { + code: 0, + stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 ../outside.txt\n", + stderr: "", + signal: null, + killed: false, + }; + } + if (cmd[0] === "tar" && cmd[1] === "xf") { + throw new Error("should not extract"); + } + return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; + }); + + await writeDownloadSkill({ + workspaceDir, + name: "tbz2-slip", + installId: "dl", + url, + targetDir, + }); + + const result = await installSkill({ workspaceDir, skillName: "tbz2-slip", installId: "dl" }); + expect(result.ok).toBe(false); + expect( + runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"), + ).toBe(false); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("rejects tar.bz2 archives containing symlinks", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + const targetDir = path.join(stateDir, "tools", "tbz2-symlink", "target"); + const url = "https://example.invalid/evil.tbz2"; + + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), + release: async () => undefined, + }); + + runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { + const cmd = argv as string[]; + if (cmd[0] === "tar" && cmd[1] === "tf") { + return { + code: 0, + stdout: "link\nlink/pwned.txt\n", + stderr: "", + signal: null, + killed: false, + }; + } + if (cmd[0] === "tar" && cmd[1] === "tvf") { + return { + code: 0, + stdout: "lrwxr-xr-x 0 0 0 0 Jan 1 00:00 link -> ../outside\n", + stderr: "", + signal: null, + killed: false, + }; + } + if (cmd[0] === "tar" && cmd[1] === "xf") { + throw new Error("should not extract"); + } + return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; + }); + + await writeDownloadSkill({ + workspaceDir, + name: "tbz2-symlink", + installId: "dl", + url, + targetDir, + }); + + const result = await installSkill({ + workspaceDir, + skillName: "tbz2-symlink", + installId: "dl", + }); + expect(result.ok).toBe(false); + expect(result.stderr.toLowerCase()).toContain("link"); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("extracts tar.bz2 with stripComponents safely (preflight only)", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + const targetDir = path.join(stateDir, "tools", "tbz2-ok", "target"); + const url = "https://example.invalid/good.tbz2"; + + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), + release: async () => undefined, + }); + + runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { + const cmd = argv as string[]; + if (cmd[0] === "tar" && cmd[1] === "tf") { + return { + code: 0, + stdout: "package/hello.txt\n", + stderr: "", + signal: null, + killed: false, + }; + } + if (cmd[0] === "tar" && cmd[1] === "tvf") { + return { + code: 0, + stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 package/hello.txt\n", + stderr: "", + signal: null, + killed: false, + }; + } + if (cmd[0] === "tar" && cmd[1] === "xf") { + return { code: 0, stdout: "ok", stderr: "", signal: null, killed: false }; + } + return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; + }); + + await writeDownloadSkill({ + workspaceDir, + name: "tbz2-ok", + installId: "dl", + url, + stripComponents: 1, + targetDir, + }); + + const result = await installSkill({ workspaceDir, skillName: "tbz2-ok", installId: "dl" }); + expect(result.ok).toBe(true); + expect( + runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"), + ).toBe(true); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("rejects tar.bz2 stripComponents escape", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + const targetDir = path.join(stateDir, "tools", "tbz2-strip-escape", "target"); + const url = "https://example.invalid/evil.tbz2"; + + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), + release: async () => undefined, + }); + + runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { + const cmd = argv as string[]; + if (cmd[0] === "tar" && cmd[1] === "tf") { + return { code: 0, stdout: "a/../b.txt\n", stderr: "", signal: null, killed: false }; + } + if (cmd[0] === "tar" && cmd[1] === "tvf") { + return { + code: 0, + stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 a/../b.txt\n", + stderr: "", + signal: null, + killed: false, + }; + } + if (cmd[0] === "tar" && cmd[1] === "xf") { + throw new Error("should not extract"); + } + return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; + }); + + await writeDownloadSkill({ + workspaceDir, + name: "tbz2-strip-escape", + installId: "dl", + url, + stripComponents: 1, + targetDir, + }); + + const result = await installSkill({ + workspaceDir, + skillName: "tbz2-strip-escape", + installId: "dl", + }); + expect(result.ok).toBe(false); + expect( + runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"), + ).toBe(false); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); +}); diff --git a/src/agents/skills-install.download.e2e.test.ts b/src/agents/skills-install.download.e2e.test.ts new file mode 100644 index 00000000000..540922ea6f1 --- /dev/null +++ b/src/agents/skills-install.download.e2e.test.ts @@ -0,0 +1,335 @@ +import JSZip from "jszip"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { installSkill } from "./skills-install.js"; + +const runCommandWithTimeoutMock = vi.fn(); +const scanDirectoryWithSummaryMock = vi.fn(); +const fetchWithSsrFGuardMock = vi.fn(); + +const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR; + +afterEach(() => { + if (originalOpenClawStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir; + } +}); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + +vi.mock("../infra/net/fetch-guard.js", () => ({ + fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), +})); + +vi.mock("../security/skill-scanner.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), + }; +}); + +async function writeDownloadSkill(params: { + workspaceDir: string; + name: string; + installId: string; + url: string; + archive: "tar.gz" | "tar.bz2" | "zip"; + stripComponents?: number; + targetDir: string; +}): Promise { + const skillDir = path.join(params.workspaceDir, "skills", params.name); + await fs.mkdir(skillDir, { recursive: true }); + const meta = { + openclaw: { + install: [ + { + id: params.installId, + kind: "download", + url: params.url, + archive: params.archive, + extract: true, + stripComponents: params.stripComponents, + targetDir: params.targetDir, + }, + ], + }, + }; + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: ${params.name} +description: test skill +metadata: ${JSON.stringify(meta)} +--- + +# ${params.name} +`, + "utf-8", + ); + await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8"); + return skillDir; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch { + return false; + } +} + +function setTempStateDir(workspaceDir: string): string { + const stateDir = path.join(workspaceDir, "state"); + process.env.OPENCLAW_STATE_DIR = stateDir; + return stateDir; +} + +describe("installSkill download extraction safety", () => { + beforeEach(() => { + runCommandWithTimeoutMock.mockReset(); + scanDirectoryWithSummaryMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + scanDirectoryWithSummaryMock.mockResolvedValue({ + scannedFiles: 0, + critical: 0, + warn: 0, + info: 0, + findings: [], + }); + }); + + it("rejects zip slip traversal", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + const targetDir = path.join(stateDir, "tools", "zip-slip", "target"); + const outsideWriteDir = path.join(workspaceDir, "outside-write"); + const outsideWritePath = path.join(outsideWriteDir, "pwned.txt"); + const url = "https://example.invalid/evil.zip"; + + const zip = new JSZip(); + zip.file("../outside-write/pwned.txt", "pwnd"); + const buffer = await zip.generateAsync({ type: "nodebuffer" }); + + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(buffer, { status: 200 }), + release: async () => undefined, + }); + + await writeDownloadSkill({ + workspaceDir, + name: "zip-slip", + installId: "dl", + url, + archive: "zip", + targetDir, + }); + + const result = await installSkill({ workspaceDir, skillName: "zip-slip", installId: "dl" }); + expect(result.ok).toBe(false); + expect(await fileExists(outsideWritePath)).toBe(false); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("rejects tar.gz traversal", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + const targetDir = path.join(stateDir, "tools", "tar-slip", "target"); + const insideDir = path.join(workspaceDir, "inside"); + const outsideWriteDir = path.join(workspaceDir, "outside-write"); + const outsideWritePath = path.join(outsideWriteDir, "pwned.txt"); + const archivePath = path.join(workspaceDir, "evil.tgz"); + const url = "https://example.invalid/evil"; + + await fs.mkdir(insideDir, { recursive: true }); + await fs.mkdir(outsideWriteDir, { recursive: true }); + await fs.writeFile(outsideWritePath, "pwnd", "utf-8"); + + await tar.c({ cwd: insideDir, file: archivePath, gzip: true }, [ + "../outside-write/pwned.txt", + ]); + await fs.rm(outsideWriteDir, { recursive: true, force: true }); + + const buffer = await fs.readFile(archivePath); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(buffer, { status: 200 }), + release: async () => undefined, + }); + + await writeDownloadSkill({ + workspaceDir, + name: "tar-slip", + installId: "dl", + url, + archive: "tar.gz", + targetDir, + }); + + const result = await installSkill({ workspaceDir, skillName: "tar-slip", installId: "dl" }); + expect(result.ok).toBe(false); + expect(await fileExists(outsideWritePath)).toBe(false); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("extracts zip with stripComponents safely", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + const targetDir = path.join(stateDir, "tools", "zip-good", "target"); + const url = "https://example.invalid/good.zip"; + + const zip = new JSZip(); + zip.file("package/hello.txt", "hi"); + const buffer = await zip.generateAsync({ type: "nodebuffer" }); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(buffer, { status: 200 }), + release: async () => undefined, + }); + + await writeDownloadSkill({ + workspaceDir, + name: "zip-good", + installId: "dl", + url, + archive: "zip", + stripComponents: 1, + targetDir, + }); + + const result = await installSkill({ workspaceDir, skillName: "zip-good", installId: "dl" }); + expect(result.ok).toBe(true); + expect(await fs.readFile(path.join(targetDir, "hello.txt"), "utf-8")).toBe("hi"); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("rejects targetDir outside the per-skill tools root", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + const targetDir = path.join(workspaceDir, "outside"); + const url = "https://example.invalid/good.zip"; + + const zip = new JSZip(); + zip.file("hello.txt", "hi"); + const buffer = await zip.generateAsync({ type: "nodebuffer" }); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(buffer, { status: 200 }), + release: async () => undefined, + }); + + await writeDownloadSkill({ + workspaceDir, + name: "targetdir-escape", + installId: "dl", + url, + archive: "zip", + targetDir, + }); + + const result = await installSkill({ + workspaceDir, + skillName: "targetdir-escape", + installId: "dl", + }); + expect(result.ok).toBe(false); + expect(result.stderr).toContain("Refusing to install outside the skill tools directory"); + expect(fetchWithSsrFGuardMock.mock.calls.length).toBe(0); + + expect(stateDir.length).toBeGreaterThan(0); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("allows relative targetDir inside the per-skill tools root", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + const stateDir = setTempStateDir(workspaceDir); + const url = "https://example.invalid/good.zip"; + + const zip = new JSZip(); + zip.file("hello.txt", "hi"); + const buffer = await zip.generateAsync({ type: "nodebuffer" }); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(buffer, { status: 200 }), + release: async () => undefined, + }); + + await writeDownloadSkill({ + workspaceDir, + name: "relative-targetdir", + installId: "dl", + url, + archive: "zip", + targetDir: "runtime", + }); + + const result = await installSkill({ + workspaceDir, + skillName: "relative-targetdir", + installId: "dl", + }); + expect(result.ok).toBe(true); + expect( + await fs.readFile( + path.join(stateDir, "tools", "relative-targetdir", "runtime", "hello.txt"), + "utf-8", + ), + ).toBe("hi"); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("rejects relative targetDir traversal", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); + try { + setTempStateDir(workspaceDir); + const url = "https://example.invalid/good.zip"; + + const zip = new JSZip(); + zip.file("hello.txt", "hi"); + const buffer = await zip.generateAsync({ type: "nodebuffer" }); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(buffer, { status: 200 }), + release: async () => undefined, + }); + + await writeDownloadSkill({ + workspaceDir, + name: "relative-traversal", + installId: "dl", + url, + archive: "zip", + targetDir: "../outside", + }); + + const result = await installSkill({ + workspaceDir, + skillName: "relative-traversal", + installId: "dl", + }); + expect(result.ok).toBe(false); + expect(result.stderr).toContain("Refusing to install outside the skill tools directory"); + expect(fetchWithSsrFGuardMock.mock.calls.length).toBe(0); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); + } + }); +}); diff --git a/src/agents/skills-install.e2e.test.ts b/src/agents/skills-install.e2e.test.ts index eeb64121b20..696b03e828b 100644 --- a/src/agents/skills-install.e2e.test.ts +++ b/src/agents/skills-install.e2e.test.ts @@ -1,23 +1,16 @@ -import JSZip from "jszip"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import * as tar from "tar"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { installSkill } from "./skills-install.js"; const runCommandWithTimeoutMock = vi.fn(); const scanDirectoryWithSummaryMock = vi.fn(); -const fetchWithSsrFGuardMock = vi.fn(); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); -vi.mock("../infra/net/fetch-guard.js", () => ({ - fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), -})); - vi.mock("../security/skill-scanner.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -45,62 +38,10 @@ metadata: {"openclaw":{"install":[{"id":"deps","kind":"node","package":"example- return skillDir; } -async function writeDownloadSkill(params: { - workspaceDir: string; - name: string; - installId: string; - url: string; - archive: "tar.gz" | "tar.bz2" | "zip"; - stripComponents?: number; - targetDir: string; -}): Promise { - const skillDir = path.join(params.workspaceDir, "skills", params.name); - await fs.mkdir(skillDir, { recursive: true }); - const meta = { - openclaw: { - install: [ - { - id: params.installId, - kind: "download", - url: params.url, - archive: params.archive, - extract: true, - stripComponents: params.stripComponents, - targetDir: params.targetDir, - }, - ], - }, - }; - await fs.writeFile( - path.join(skillDir, "SKILL.md"), - `--- -name: ${params.name} -description: test skill -metadata: ${JSON.stringify(meta)} ---- - -# ${params.name} -`, - "utf-8", - ); - await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8"); - return skillDir; -} - -async function fileExists(filePath: string): Promise { - try { - await fs.stat(filePath); - return true; - } catch { - return false; - } -} - describe("installSkill code safety scanning", () => { beforeEach(() => { runCommandWithTimeoutMock.mockReset(); scanDirectoryWithSummaryMock.mockReset(); - fetchWithSsrFGuardMock.mockReset(); runCommandWithTimeoutMock.mockResolvedValue({ code: 0, stdout: "ok", @@ -171,346 +112,3 @@ describe("installSkill code safety scanning", () => { } }); }); - -describe("installSkill download extraction safety", () => { - beforeEach(() => { - runCommandWithTimeoutMock.mockReset(); - scanDirectoryWithSummaryMock.mockReset(); - fetchWithSsrFGuardMock.mockReset(); - scanDirectoryWithSummaryMock.mockResolvedValue({ - scannedFiles: 0, - critical: 0, - warn: 0, - info: 0, - findings: [], - }); - }); - - it("rejects zip slip traversal", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const targetDir = path.join(workspaceDir, "target"); - const outsideWriteDir = path.join(workspaceDir, "outside-write"); - const outsideWritePath = path.join(outsideWriteDir, "pwned.txt"); - const url = "https://example.invalid/evil.zip"; - - const zip = new JSZip(); - zip.file("../outside-write/pwned.txt", "pwnd"); - const buffer = await zip.generateAsync({ type: "nodebuffer" }); - - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(buffer, { status: 200 }), - release: async () => undefined, - }); - - await writeDownloadSkill({ - workspaceDir, - name: "zip-slip", - installId: "dl", - url, - archive: "zip", - targetDir, - }); - - const result = await installSkill({ workspaceDir, skillName: "zip-slip", installId: "dl" }); - expect(result.ok).toBe(false); - expect(await fileExists(outsideWritePath)).toBe(false); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } - }); - - it("rejects tar.gz traversal", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const targetDir = path.join(workspaceDir, "target"); - const insideDir = path.join(workspaceDir, "inside"); - const outsideWriteDir = path.join(workspaceDir, "outside-write"); - const outsideWritePath = path.join(outsideWriteDir, "pwned.txt"); - const archivePath = path.join(workspaceDir, "evil.tgz"); - const url = "https://example.invalid/evil"; - - await fs.mkdir(insideDir, { recursive: true }); - await fs.mkdir(outsideWriteDir, { recursive: true }); - await fs.writeFile(outsideWritePath, "pwnd", "utf-8"); - - await tar.c({ cwd: insideDir, file: archivePath, gzip: true }, [ - "../outside-write/pwned.txt", - ]); - await fs.rm(outsideWriteDir, { recursive: true, force: true }); - - const buffer = await fs.readFile(archivePath); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(buffer, { status: 200 }), - release: async () => undefined, - }); - - await writeDownloadSkill({ - workspaceDir, - name: "tar-slip", - installId: "dl", - url, - archive: "tar.gz", - targetDir, - }); - - const result = await installSkill({ workspaceDir, skillName: "tar-slip", installId: "dl" }); - expect(result.ok).toBe(false); - expect(await fileExists(outsideWritePath)).toBe(false); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } - }); - - it("extracts zip with stripComponents safely", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const targetDir = path.join(workspaceDir, "target"); - const url = "https://example.invalid/good.zip"; - - const zip = new JSZip(); - zip.file("package/hello.txt", "hi"); - const buffer = await zip.generateAsync({ type: "nodebuffer" }); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(buffer, { status: 200 }), - release: async () => undefined, - }); - - await writeDownloadSkill({ - workspaceDir, - name: "zip-good", - installId: "dl", - url, - archive: "zip", - stripComponents: 1, - targetDir, - }); - - const result = await installSkill({ workspaceDir, skillName: "zip-good", installId: "dl" }); - expect(result.ok).toBe(true); - expect(await fs.readFile(path.join(targetDir, "hello.txt"), "utf-8")).toBe("hi"); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } - }); - - it("rejects tar.bz2 traversal before extraction", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const targetDir = path.join(workspaceDir, "target"); - const url = "https://example.invalid/evil.tbz2"; - - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), - release: async () => undefined, - }); - - runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { - const cmd = argv as string[]; - if (cmd[0] === "tar" && cmd[1] === "tf") { - return { code: 0, stdout: "../outside.txt\n", stderr: "", signal: null, killed: false }; - } - if (cmd[0] === "tar" && cmd[1] === "tvf") { - return { - code: 0, - stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 ../outside.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "xf") { - throw new Error("should not extract"); - } - return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; - }); - - await writeDownloadSkill({ - workspaceDir, - name: "tbz2-slip", - installId: "dl", - url, - archive: "tar.bz2", - targetDir, - }); - - const result = await installSkill({ workspaceDir, skillName: "tbz2-slip", installId: "dl" }); - expect(result.ok).toBe(false); - expect( - runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"), - ).toBe(false); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } - }); - - it("rejects tar.bz2 archives containing symlinks", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const targetDir = path.join(workspaceDir, "target"); - const url = "https://example.invalid/evil.tbz2"; - - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), - release: async () => undefined, - }); - - runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { - const cmd = argv as string[]; - if (cmd[0] === "tar" && cmd[1] === "tf") { - return { - code: 0, - stdout: "link\nlink/pwned.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "tvf") { - return { - code: 0, - stdout: "lrwxr-xr-x 0 0 0 0 Jan 1 00:00 link -> ../outside\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "xf") { - throw new Error("should not extract"); - } - return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; - }); - - await writeDownloadSkill({ - workspaceDir, - name: "tbz2-symlink", - installId: "dl", - url, - archive: "tar.bz2", - targetDir, - }); - - const result = await installSkill({ - workspaceDir, - skillName: "tbz2-symlink", - installId: "dl", - }); - expect(result.ok).toBe(false); - expect(result.stderr.toLowerCase()).toContain("link"); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } - }); - - it("extracts tar.bz2 with stripComponents safely (preflight only)", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const targetDir = path.join(workspaceDir, "target"); - const url = "https://example.invalid/good.tbz2"; - - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), - release: async () => undefined, - }); - - runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { - const cmd = argv as string[]; - if (cmd[0] === "tar" && cmd[1] === "tf") { - return { - code: 0, - stdout: "package/hello.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "tvf") { - return { - code: 0, - stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 package/hello.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "xf") { - return { code: 0, stdout: "ok", stderr: "", signal: null, killed: false }; - } - return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; - }); - - await writeDownloadSkill({ - workspaceDir, - name: "tbz2-ok", - installId: "dl", - url, - archive: "tar.bz2", - stripComponents: 1, - targetDir, - }); - - const result = await installSkill({ workspaceDir, skillName: "tbz2-ok", installId: "dl" }); - expect(result.ok).toBe(true); - expect( - runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"), - ).toBe(true); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } - }); - - it("rejects tar.bz2 stripComponents escape", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-")); - try { - const targetDir = path.join(workspaceDir, "target"); - const url = "https://example.invalid/evil.tbz2"; - - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }), - release: async () => undefined, - }); - - runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { - const cmd = argv as string[]; - if (cmd[0] === "tar" && cmd[1] === "tf") { - return { code: 0, stdout: "a/../b.txt\n", stderr: "", signal: null, killed: false }; - } - if (cmd[0] === "tar" && cmd[1] === "tvf") { - return { - code: 0, - stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 a/../b.txt\n", - stderr: "", - signal: null, - killed: false, - }; - } - if (cmd[0] === "tar" && cmd[1] === "xf") { - throw new Error("should not extract"); - } - return { code: 0, stdout: "", stderr: "", signal: null, killed: false }; - }); - - await writeDownloadSkill({ - workspaceDir, - name: "tbz2-strip-escape", - installId: "dl", - url, - archive: "tar.bz2", - stripComponents: 1, - targetDir, - }); - - const result = await installSkill({ - workspaceDir, - skillName: "tbz2-strip-escape", - installId: "dl", - }); - expect(result.ok).toBe(false); - expect( - runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"), - ).toBe(false); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined); - } - }); -}); diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index deee4b425f7..e6528202ca5 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -1,15 +1,11 @@ -import type { ReadableStream as NodeReadableStream } from "node:stream/web"; import fs from "node:fs"; import path from "node:path"; -import { Readable } from "node:stream"; -import { pipeline } from "node:stream/promises"; import type { OpenClawConfig } from "../config/config.js"; -import { extractArchive as extractArchiveSafe } from "../infra/archive.js"; import { resolveBrewExecutable } from "../infra/brew.js"; -import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; -import { runCommandWithTimeout } from "../process/exec.js"; +import { runCommandWithTimeout, type CommandOptions } from "../process/exec.js"; import { scanDirectoryWithSummary } from "../security/skill-scanner.js"; -import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js"; +import { resolveUserPath } from "../utils.js"; +import { installDownloadSpec } from "./skills-install-download.js"; import { hasBinary, loadWorkspaceSkillEntries, @@ -18,7 +14,6 @@ import { type SkillInstallSpec, type SkillsInstallPreferences, } from "./skills.js"; -import { resolveSkillKey } from "./skills/frontmatter.js"; export type SkillInstallRequest = { workspaceDir: string; @@ -37,10 +32,6 @@ export type SkillInstallResult = { warnings?: string[]; }; -function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream { - return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function"); -} - function summarizeInstallOutput(text: string): string | undefined { const raw = text.trim(); if (!raw) { @@ -200,304 +191,6 @@ function buildInstallCommand( } } -function resolveDownloadTargetDir(entry: SkillEntry, spec: SkillInstallSpec): string { - if (spec.targetDir?.trim()) { - return resolveUserPath(spec.targetDir); - } - const key = resolveSkillKey(entry.skill, entry); - return path.join(CONFIG_DIR, "tools", key); -} - -function resolveArchiveType(spec: SkillInstallSpec, filename: string): string | undefined { - const explicit = spec.archive?.trim().toLowerCase(); - if (explicit) { - return explicit; - } - const lower = filename.toLowerCase(); - if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) { - return "tar.gz"; - } - if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) { - return "tar.bz2"; - } - if (lower.endsWith(".zip")) { - return "zip"; - } - return undefined; -} - -function normalizeArchiveEntryPath(raw: string): string { - return raw.replaceAll("\\", "/"); -} - -function isWindowsDrivePath(p: string): boolean { - return /^[a-zA-Z]:[\\/]/.test(p); -} - -function validateArchiveEntryPath(entryPath: string): void { - if (!entryPath || entryPath === "." || entryPath === "./") { - return; - } - if (isWindowsDrivePath(entryPath)) { - throw new Error(`archive entry uses a drive path: ${entryPath}`); - } - const normalized = path.posix.normalize(normalizeArchiveEntryPath(entryPath)); - if (normalized === ".." || normalized.startsWith("../")) { - throw new Error(`archive entry escapes targetDir: ${entryPath}`); - } - if (path.posix.isAbsolute(normalized) || normalized.startsWith("//")) { - throw new Error(`archive entry is absolute: ${entryPath}`); - } -} - -function resolveSafeBaseDir(rootDir: string): string { - const resolved = path.resolve(rootDir); - return resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`; -} - -function stripArchivePath(entryPath: string, stripComponents: number): string | null { - const raw = normalizeArchiveEntryPath(entryPath); - if (!raw || raw === "." || raw === "./") { - return null; - } - - // Important: tar's --strip-components semantics operate on raw path segments, - // before any normalization that would collapse "..". We mimic that so we - // can detect strip-induced escapes like "a/../b" with stripComponents=1. - const parts = raw.split("/").filter((part) => part.length > 0 && part !== "."); - const strip = Math.max(0, Math.floor(stripComponents)); - const stripped = strip === 0 ? parts.join("/") : parts.slice(strip).join("/"); - const result = path.posix.normalize(stripped); - if (!result || result === "." || result === "./") { - return null; - } - return result; -} - -function validateExtractedPathWithinRoot(params: { - rootDir: string; - relPath: string; - originalPath: string; -}): void { - const safeBase = resolveSafeBaseDir(params.rootDir); - const outPath = path.resolve(params.rootDir, params.relPath); - if (!outPath.startsWith(safeBase)) { - throw new Error(`archive entry escapes targetDir: ${params.originalPath}`); - } -} - -async function downloadFile( - url: string, - destPath: string, - timeoutMs: number, -): Promise<{ bytes: number }> { - const { response, release } = await fetchWithSsrFGuard({ - url, - timeoutMs: Math.max(1_000, timeoutMs), - }); - try { - if (!response.ok || !response.body) { - throw new Error(`Download failed (${response.status} ${response.statusText})`); - } - await ensureDir(path.dirname(destPath)); - const file = fs.createWriteStream(destPath); - const body = response.body as unknown; - const readable = isNodeReadableStream(body) - ? body - : Readable.fromWeb(body as NodeReadableStream); - await pipeline(readable, file); - const stat = await fs.promises.stat(destPath); - return { bytes: stat.size }; - } finally { - await release(); - } -} - -async function extractArchive(params: { - archivePath: string; - archiveType: string; - targetDir: string; - stripComponents?: number; - timeoutMs: number; -}): Promise<{ stdout: string; stderr: string; code: number | null }> { - const { archivePath, archiveType, targetDir, stripComponents, timeoutMs } = params; - const strip = - typeof stripComponents === "number" && Number.isFinite(stripComponents) - ? Math.max(0, Math.floor(stripComponents)) - : 0; - - try { - if (archiveType === "zip") { - await extractArchiveSafe({ - archivePath, - destDir: targetDir, - timeoutMs, - kind: "zip", - stripComponents: strip, - }); - return { stdout: "", stderr: "", code: 0 }; - } - - if (archiveType === "tar.gz") { - await extractArchiveSafe({ - archivePath, - destDir: targetDir, - timeoutMs, - kind: "tar", - stripComponents: strip, - tarGzip: true, - }); - return { stdout: "", stderr: "", code: 0 }; - } - - if (archiveType === "tar.bz2") { - if (!hasBinary("tar")) { - return { stdout: "", stderr: "tar not found on PATH", code: null }; - } - - // Preflight list to prevent zip-slip style traversal before extraction. - const listResult = await runCommandWithTimeout(["tar", "tf", archivePath], { timeoutMs }); - if (listResult.code !== 0) { - return { - stdout: listResult.stdout, - stderr: listResult.stderr || "tar list failed", - code: listResult.code, - }; - } - const entries = listResult.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - const verboseResult = await runCommandWithTimeout(["tar", "tvf", archivePath], { timeoutMs }); - if (verboseResult.code !== 0) { - return { - stdout: verboseResult.stdout, - stderr: verboseResult.stderr || "tar verbose list failed", - code: verboseResult.code, - }; - } - for (const line of verboseResult.stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const typeChar = trimmed[0]; - if (typeChar === "l" || typeChar === "h" || trimmed.includes(" -> ")) { - return { - stdout: verboseResult.stdout, - stderr: "tar archive contains link entries; refusing to extract for safety", - code: 1, - }; - } - } - - for (const entry of entries) { - validateArchiveEntryPath(entry); - const relPath = stripArchivePath(entry, strip); - if (!relPath) { - continue; - } - validateArchiveEntryPath(relPath); - validateExtractedPathWithinRoot({ rootDir: targetDir, relPath, originalPath: entry }); - } - - const argv = ["tar", "xf", archivePath, "-C", targetDir]; - if (strip > 0) { - argv.push("--strip-components", String(strip)); - } - return await runCommandWithTimeout(argv, { timeoutMs }); - } - - return { stdout: "", stderr: `unsupported archive type: ${archiveType}`, code: null }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { stdout: "", stderr: message, code: 1 }; - } -} - -async function installDownloadSpec(params: { - entry: SkillEntry; - spec: SkillInstallSpec; - timeoutMs: number; -}): Promise { - const { entry, spec, timeoutMs } = params; - const url = spec.url?.trim(); - if (!url) { - return { - ok: false, - message: "missing download url", - stdout: "", - stderr: "", - code: null, - }; - } - - let filename = ""; - try { - const parsed = new URL(url); - filename = path.basename(parsed.pathname); - } catch { - filename = path.basename(url); - } - if (!filename) { - filename = "download"; - } - - const targetDir = resolveDownloadTargetDir(entry, spec); - await ensureDir(targetDir); - - const archivePath = path.join(targetDir, filename); - let downloaded = 0; - try { - const result = await downloadFile(url, archivePath, timeoutMs); - downloaded = result.bytes; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, message, stdout: "", stderr: message, code: null }; - } - - const archiveType = resolveArchiveType(spec, filename); - const shouldExtract = spec.extract ?? Boolean(archiveType); - if (!shouldExtract) { - return { - ok: true, - message: `Downloaded to ${archivePath}`, - stdout: `downloaded=${downloaded}`, - stderr: "", - code: 0, - }; - } - - if (!archiveType) { - return { - ok: false, - message: "extract requested but archive type could not be detected", - stdout: "", - stderr: "", - code: null, - }; - } - - const extractResult = await extractArchive({ - archivePath, - archiveType, - targetDir, - stripComponents: spec.stripComponents, - timeoutMs, - }); - const success = extractResult.code === 0; - return { - ok: success, - message: success - ? `Downloaded and extracted to ${targetDir}` - : formatInstallFailureMessage(extractResult), - stdout: extractResult.stdout.trim(), - stderr: extractResult.stderr.trim(), - code: extractResult.code, - }; -} - async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise { const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable()); if (!exe) { @@ -531,6 +224,209 @@ async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise { + try { + const result = await runCommandWithTimeout(argv, optionsOrTimeout); + return { + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + }; + } catch (err) { + return { + code: null, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + }; + } +} + +async function runBestEffortCommand( + argv: string[], + optionsOrTimeout: number | CommandOptions, +): Promise { + await runCommandSafely(argv, optionsOrTimeout); +} + +function resolveBrewMissingFailure(spec: SkillInstallSpec): SkillInstallResult { + const formula = spec.formula ?? "this package"; + const hint = + process.platform === "linux" + ? `Homebrew is not installed. Install it from https://brew.sh or install "${formula}" manually using your system package manager (e.g. apt, dnf, pacman).` + : "Homebrew is not installed. Install it from https://brew.sh"; + return createInstallFailure({ message: `brew not installed β€” ${hint}` }); +} + +async function ensureUvInstalled(params: { + spec: SkillInstallSpec; + brewExe?: string; + timeoutMs: number; +}): Promise { + if (params.spec.kind !== "uv" || hasBinary("uv")) { + return undefined; + } + + if (!params.brewExe) { + return createInstallFailure({ + message: + "uv not installed β€” install manually: https://docs.astral.sh/uv/getting-started/installation/", + }); + } + + const brewResult = await runCommandSafely([params.brewExe, "install", "uv"], { + timeoutMs: params.timeoutMs, + }); + if (brewResult.code === 0) { + return undefined; + } + + return createInstallFailure({ + message: "Failed to install uv (brew)", + ...brewResult, + }); +} + +async function installGoViaApt(timeoutMs: number): Promise { + const aptInstallArgv = ["apt-get", "install", "-y", "golang-go"]; + const aptUpdateArgv = ["apt-get", "update", "-qq"]; + const aptFailureMessage = + "go not installed β€” automatic install via apt failed. Install manually: https://go.dev/doc/install"; + + const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + if (isRoot) { + // Best effort: fresh containers often need package indexes populated. + await runBestEffortCommand(aptUpdateArgv, { timeoutMs }); + const aptResult = await runCommandSafely(aptInstallArgv, { timeoutMs }); + if (aptResult.code === 0) { + return undefined; + } + return createInstallFailure({ + message: aptFailureMessage, + ...aptResult, + }); + } + + if (!hasBinary("sudo")) { + return createInstallFailure({ + message: + "go not installed β€” apt-get is available but sudo is not installed. Install manually: https://go.dev/doc/install", + }); + } + + const sudoCheck = await runCommandSafely(["sudo", "-n", "true"], { + timeoutMs: 5_000, + }); + if (sudoCheck.code !== 0) { + return createInstallFailure({ + message: + "go not installed β€” apt-get is available but sudo is not usable (missing or requires a password). Install manually: https://go.dev/doc/install", + ...sudoCheck, + }); + } + + // Best effort: fresh containers often need package indexes populated. + await runBestEffortCommand(["sudo", ...aptUpdateArgv], { timeoutMs }); + const aptResult = await runCommandSafely(["sudo", ...aptInstallArgv], { + timeoutMs, + }); + if (aptResult.code === 0) { + return undefined; + } + + return createInstallFailure({ + message: aptFailureMessage, + ...aptResult, + }); +} + +async function ensureGoInstalled(params: { + spec: SkillInstallSpec; + brewExe?: string; + timeoutMs: number; +}): Promise { + if (params.spec.kind !== "go" || hasBinary("go")) { + return undefined; + } + + if (params.brewExe) { + const brewResult = await runCommandSafely([params.brewExe, "install", "go"], { + timeoutMs: params.timeoutMs, + }); + if (brewResult.code === 0) { + return undefined; + } + return createInstallFailure({ + message: "Failed to install go (brew)", + ...brewResult, + }); + } + + if (hasBinary("apt-get")) { + return installGoViaApt(params.timeoutMs); + } + + return createInstallFailure({ + message: "go not installed β€” install manually: https://go.dev/doc/install", + }); +} + +async function executeInstallCommand(params: { + argv: string[] | null; + timeoutMs: number; + env?: NodeJS.ProcessEnv; +}): Promise { + if (!params.argv || params.argv.length === 0) { + return createInstallFailure({ message: "invalid install command" }); + } + + const result = await runCommandSafely(params.argv, { + timeoutMs: params.timeoutMs, + env: params.env, + }); + if (result.code === 0) { + return createInstallSuccess(result); + } + + return createInstallFailure({ + message: formatInstallFailureMessage(result), + ...result, + }); +} + export async function installSkill(params: SkillInstallRequest): Promise { const timeoutMs = Math.min(Math.max(params.timeoutMs ?? 300_000, 1_000), 900_000); const workspaceDir = resolveUserPath(params.workspaceDir); @@ -582,93 +478,22 @@ export async function installSkill(params: SkillInstallRequest): Promise { - const argv = command.argv; - if (!argv || argv.length === 0) { - return { code: null, stdout: "", stderr: "invalid install command" }; - } - try { - return await runCommandWithTimeout(argv, { - timeoutMs, - env, - }); - } catch (err) { - const stderr = err instanceof Error ? err.message : String(err); - return { code: null, stdout: "", stderr }; - } - })(); - - const success = result.code === 0; - return withWarnings( - { - ok: success, - message: success ? "Installed" : formatInstallFailureMessage(result), - stdout: result.stdout.trim(), - stderr: result.stderr.trim(), - code: result.code, - }, - warnings, - ); + return withWarnings(await executeInstallCommand({ argv, timeoutMs, env }), warnings); } diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index 8bcf1cd6689..28409883ea3 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveEmojiAndHomepage } from "../shared/entry-metadata.js"; -import { evaluateRequirementsFromMetadataWithRemote } from "../shared/requirements.js"; +import type { RequirementConfigCheck, Requirements } from "../shared/requirements.js"; +import { evaluateEntryMetadataRequirements } from "../shared/entry-status.js"; import { CONFIG_DIR } from "../utils.js"; import { hasBinary, @@ -18,10 +18,7 @@ import { } from "./skills.js"; import { resolveBundledSkillsContext } from "./skills/bundled-context.js"; -export type SkillStatusConfigCheck = { - path: string; - satisfied: boolean; -}; +export type SkillStatusConfigCheck = RequirementConfigCheck; export type SkillInstallOption = { id: string; @@ -45,20 +42,8 @@ export type SkillStatusEntry = { disabled: boolean; blockedByAllowlist: boolean; eligible: boolean; - requirements: { - bins: string[]; - anyBins: string[]; - env: string[]; - config: string[]; - os: string[]; - }; - missing: { - bins: string[]; - anyBins: string[]; - env: string[]; - config: string[]; - os: string[]; - }; + requirements: Requirements; + missing: Requirements; configChecks: SkillStatusConfigCheck[]; install: SkillInstallOption[]; }; @@ -80,6 +65,7 @@ function selectPreferredInstallSpec( if (install.length === 0) { return undefined; } + const indexed = install.map((spec, index) => ({ spec, index })); const findKind = (kind: SkillInstallSpec["kind"]) => indexed.find((item) => item.spec.kind === kind); @@ -88,23 +74,32 @@ function selectPreferredInstallSpec( const nodeSpec = findKind("node"); const goSpec = findKind("go"); const uvSpec = findKind("uv"); + const downloadSpec = findKind("download"); + const brewAvailable = hasBinary("brew"); - if (prefs.preferBrew && hasBinary("brew") && brewSpec) { - return brewSpec; + // Table-driven preference chain; first match wins. + const pickers: Array<() => { spec: SkillInstallSpec; index: number } | undefined> = [ + () => (prefs.preferBrew && brewAvailable ? brewSpec : undefined), + () => uvSpec, + () => nodeSpec, + // Only prefer brew when available to avoid guaranteed failure on Linux/Docker. + () => (brewAvailable ? brewSpec : undefined), + () => goSpec, + // Prefer download over an unavailable brew spec. + () => downloadSpec, + // Last resort: surface descriptive brew-missing error instead of "no installer found". + () => brewSpec, + () => indexed[0], + ]; + + for (const pick of pickers) { + const selected = pick(); + if (selected) { + return selected; + } } - if (uvSpec) { - return uvSpec; - } - if (nodeSpec) { - return nodeSpec; - } - if (brewSpec) { - return brewSpec; - } - if (goSpec) { - return goSpec; - } - return indexed[0]; + + return undefined; } function normalizeInstallOptions( @@ -184,34 +179,27 @@ function buildSkillStatus( const allowBundled = resolveBundledAllowlist(config); const blockedByAllowlist = !isBundledSkillAllowed(entry, allowBundled); const always = entry.metadata?.always === true; - const { emoji, homepage } = resolveEmojiAndHomepage({ - metadata: entry.metadata, - frontmatter: entry.frontmatter, - }); const bundled = bundledNames && bundledNames.size > 0 ? bundledNames.has(entry.skill.name) : entry.skill.source === "openclaw-bundled"; - const { - required, - missing, - eligible: requirementsSatisfied, - configChecks, - } = evaluateRequirementsFromMetadataWithRemote({ - always, - metadata: entry.metadata, - hasLocalBin: hasBinary, - localPlatform: process.platform, - remote: eligibility?.remote, - isEnvSatisfied: (envName) => - Boolean( - process.env[envName] || - skillConfig?.env?.[envName] || - (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName), - ), - isConfigSatisfied: (pathStr) => isConfigPathTruthy(config, pathStr), - }); + const { emoji, homepage, required, missing, requirementsSatisfied, configChecks } = + evaluateEntryMetadataRequirements({ + always, + metadata: entry.metadata, + frontmatter: entry.frontmatter, + hasLocalBin: hasBinary, + localPlatform: process.platform, + remote: eligibility?.remote, + isEnvSatisfied: (envName) => + Boolean( + process.env[envName] || + skillConfig?.env?.[envName] || + (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName), + ), + isConfigSatisfied: (pathStr) => isConfigPathTruthy(config, pathStr), + }); const eligible = !disabled && !blockedByAllowlist && requirementsSatisfied; return { diff --git a/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts b/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts index 44a8e0218a5..dad26e0fb74 100644 --- a/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts @@ -2,30 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { writeSkill } from "./skills.e2e-test-helpers.js"; import { buildWorkspaceSkillsPrompt } from "./skills.js"; -async function writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} - describe("buildWorkspaceSkillsPrompt", () => { it("applies bundled allowlist without affecting workspace skills", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); diff --git a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts index cc85f1f5701..af9c651fc80 100644 --- a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts @@ -2,30 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { writeSkill } from "./skills.e2e-test-helpers.js"; import { buildWorkspaceSkillsPrompt } from "./skills.js"; -async function writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} - describe("buildWorkspaceSkillsPrompt", () => { it("prefers workspace skills over managed skills", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); diff --git a/src/agents/skills.e2e-test-helpers.ts b/src/agents/skills.e2e-test-helpers.ts new file mode 100644 index 00000000000..43f6fb70398 --- /dev/null +++ b/src/agents/skills.e2e-test-helpers.ts @@ -0,0 +1,24 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function writeSkill(params: { + dir: string; + name: string; + description: string; + metadata?: string; + body?: string; +}) { + const { dir, name, description, metadata, body } = params; + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, "SKILL.md"), + `--- +name: ${name} +description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} +--- + +${body ?? `# ${name}\n`} +`, + "utf-8", + ); +} diff --git a/src/agents/skills/env-overrides.ts b/src/agents/skills/env-overrides.ts index 4d6e97a2e32..281efc8a2a7 100644 --- a/src/agents/skills/env-overrides.ts +++ b/src/agents/skills/env-overrides.ts @@ -3,34 +3,32 @@ import type { SkillEntry, SkillSnapshot } from "./types.js"; import { resolveSkillConfig } from "./config.js"; import { resolveSkillKey } from "./frontmatter.js"; -export function applySkillEnvOverrides(params: { skills: SkillEntry[]; config?: OpenClawConfig }) { - const { skills, config } = params; - const updates: Array<{ key: string; prev: string | undefined }> = []; +type EnvUpdate = { key: string; prev: string | undefined }; +type SkillConfig = NonNullable>; - for (const entry of skills) { - const skillKey = resolveSkillKey(entry.skill, entry); - const skillConfig = resolveSkillConfig(config, skillKey); - if (!skillConfig) { - continue; - } - - if (skillConfig.env) { - for (const [envKey, envValue] of Object.entries(skillConfig.env)) { - if (!envValue || process.env[envKey]) { - continue; - } - updates.push({ key: envKey, prev: process.env[envKey] }); - process.env[envKey] = envValue; +function applySkillConfigEnvOverrides(params: { + updates: EnvUpdate[]; + skillConfig: SkillConfig; + primaryEnv?: string | null; +}) { + const { updates, skillConfig, primaryEnv } = params; + if (skillConfig.env) { + for (const [envKey, envValue] of Object.entries(skillConfig.env)) { + if (!envValue || process.env[envKey]) { + continue; } - } - - const primaryEnv = entry.metadata?.primaryEnv; - if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) { - updates.push({ key: primaryEnv, prev: process.env[primaryEnv] }); - process.env[primaryEnv] = skillConfig.apiKey; + updates.push({ key: envKey, prev: process.env[envKey] }); + process.env[envKey] = envValue; } } + if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) { + updates.push({ key: primaryEnv, prev: process.env[primaryEnv] }); + process.env[primaryEnv] = skillConfig.apiKey; + } +} + +function createEnvReverter(updates: EnvUpdate[]) { return () => { for (const update of updates) { if (update.prev === undefined) { @@ -42,6 +40,27 @@ export function applySkillEnvOverrides(params: { skills: SkillEntry[]; config?: }; } +export function applySkillEnvOverrides(params: { skills: SkillEntry[]; config?: OpenClawConfig }) { + const { skills, config } = params; + const updates: EnvUpdate[] = []; + + for (const entry of skills) { + const skillKey = resolveSkillKey(entry.skill, entry); + const skillConfig = resolveSkillConfig(config, skillKey); + if (!skillConfig) { + continue; + } + + applySkillConfigEnvOverrides({ + updates, + skillConfig, + primaryEnv: entry.metadata?.primaryEnv, + }); + } + + return createEnvReverter(updates); +} + export function applySkillEnvOverridesFromSnapshot(params: { snapshot?: SkillSnapshot; config?: OpenClawConfig; @@ -50,7 +69,7 @@ export function applySkillEnvOverridesFromSnapshot(params: { if (!snapshot) { return () => {}; } - const updates: Array<{ key: string; prev: string | undefined }> = []; + const updates: EnvUpdate[] = []; for (const skill of snapshot.skills) { const skillConfig = resolveSkillConfig(config, skill.name); @@ -58,32 +77,12 @@ export function applySkillEnvOverridesFromSnapshot(params: { continue; } - if (skillConfig.env) { - for (const [envKey, envValue] of Object.entries(skillConfig.env)) { - if (!envValue || process.env[envKey]) { - continue; - } - updates.push({ key: envKey, prev: process.env[envKey] }); - process.env[envKey] = envValue; - } - } - - if (skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv]) { - updates.push({ - key: skill.primaryEnv, - prev: process.env[skill.primaryEnv], - }); - process.env[skill.primaryEnv] = skillConfig.apiKey; - } + applySkillConfigEnvOverrides({ + updates, + skillConfig, + primaryEnv: skill.primaryEnv, + }); } - return () => { - for (const update of updates) { - if (update.prev === undefined) { - delete process.env[update.key]; - } else { - process.env[update.key] = update.prev; - } - } - }; + return createEnvReverter(updates); } diff --git a/src/agents/skills/filter.test.ts b/src/agents/skills/filter.test.ts new file mode 100644 index 00000000000..8cd64e429e3 --- /dev/null +++ b/src/agents/skills/filter.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + matchesSkillFilter, + normalizeSkillFilter, + normalizeSkillFilterForComparison, +} from "./filter.js"; + +describe("skills/filter", () => { + it("normalizes configured filters with trimming", () => { + expect(normalizeSkillFilter([" weather ", "", "meme-factory"])).toEqual([ + "weather", + "meme-factory", + ]); + }); + + it("preserves explicit empty list as []", () => { + expect(normalizeSkillFilter([])).toEqual([]); + expect(normalizeSkillFilter(undefined)).toBeUndefined(); + }); + + it("normalizes for comparison with dedupe + ordering", () => { + expect(normalizeSkillFilterForComparison(["weather", "meme-factory", "weather"])).toEqual([ + "meme-factory", + "weather", + ]); + }); + + it("matches equivalent filters after normalization", () => { + expect(matchesSkillFilter(["weather", "meme-factory"], [" meme-factory ", "weather"])).toBe( + true, + ); + expect(matchesSkillFilter(undefined, undefined)).toBe(true); + expect(matchesSkillFilter([], undefined)).toBe(false); + }); +}); diff --git a/src/agents/skills/filter.ts b/src/agents/skills/filter.ts new file mode 100644 index 00000000000..a5fb8222874 --- /dev/null +++ b/src/agents/skills/filter.ts @@ -0,0 +1,31 @@ +export function normalizeSkillFilter(skillFilter?: ReadonlyArray): string[] | undefined { + if (skillFilter === undefined) { + return undefined; + } + return skillFilter.map((entry) => String(entry).trim()).filter(Boolean); +} + +export function normalizeSkillFilterForComparison( + skillFilter?: ReadonlyArray, +): string[] | undefined { + const normalized = normalizeSkillFilter(skillFilter); + if (normalized === undefined) { + return undefined; + } + return Array.from(new Set(normalized)).toSorted(); +} + +export function matchesSkillFilter( + cached?: ReadonlyArray, + next?: ReadonlyArray, +): boolean { + const cachedNormalized = normalizeSkillFilterForComparison(cached); + const nextNormalized = normalizeSkillFilterForComparison(next); + if (cachedNormalized === undefined || nextNormalized === undefined) { + return cachedNormalized === nextNormalized; + } + if (cachedNormalized.length !== nextNormalized.length) { + return false; + } + return cachedNormalized.every((entry, index) => entry === nextNormalized[index]); +} diff --git a/src/agents/skills/frontmatter.ts b/src/agents/skills/frontmatter.ts index 857bed643ea..e97b5ab68cd 100644 --- a/src/agents/skills/frontmatter.ts +++ b/src/agents/skills/frontmatter.ts @@ -12,6 +12,9 @@ import { normalizeStringList, parseFrontmatterBool, resolveOpenClawManifestBlock, + resolveOpenClawManifestInstall, + resolveOpenClawManifestOs, + resolveOpenClawManifestRequires, } from "../../shared/frontmatter.js"; export function parseFrontmatter(content: string): ParsedSkillFrontmatter { @@ -83,15 +86,9 @@ export function resolveOpenClawMetadata( if (!metadataObj) { return undefined; } - const requiresRaw = - typeof metadataObj.requires === "object" && metadataObj.requires !== null - ? (metadataObj.requires as Record) - : undefined; - const installRaw = Array.isArray(metadataObj.install) ? (metadataObj.install as unknown[]) : []; - const install = installRaw - .map((entry) => parseInstallSpec(entry)) - .filter((entry): entry is SkillInstallSpec => Boolean(entry)); - const osRaw = normalizeStringList(metadataObj.os); + const requires = resolveOpenClawManifestRequires(metadataObj); + const install = resolveOpenClawManifestInstall(metadataObj, parseInstallSpec); + const osRaw = resolveOpenClawManifestOs(metadataObj); return { always: typeof metadataObj.always === "boolean" ? metadataObj.always : undefined, emoji: typeof metadataObj.emoji === "string" ? metadataObj.emoji : undefined, @@ -99,14 +96,7 @@ export function resolveOpenClawMetadata( skillKey: typeof metadataObj.skillKey === "string" ? metadataObj.skillKey : undefined, primaryEnv: typeof metadataObj.primaryEnv === "string" ? metadataObj.primaryEnv : undefined, os: osRaw.length > 0 ? osRaw : undefined, - requires: requiresRaw - ? { - bins: normalizeStringList(requiresRaw.bins), - anyBins: normalizeStringList(requiresRaw.anyBins), - env: normalizeStringList(requiresRaw.env), - config: normalizeStringList(requiresRaw.config), - } - : undefined, + requires: requires, install: install.length > 0 ? install : undefined, }; } diff --git a/src/agents/skills/tools-dir.ts b/src/agents/skills/tools-dir.ts new file mode 100644 index 00000000000..767ad3f79fd --- /dev/null +++ b/src/agents/skills/tools-dir.ts @@ -0,0 +1,11 @@ +import path from "node:path"; +import type { SkillEntry } from "./types.js"; +import { safePathSegmentHashed } from "../../infra/install-safe-path.js"; +import { resolveConfigDir } from "../../utils.js"; +import { resolveSkillKey } from "./frontmatter.js"; + +export function resolveSkillToolsRootDir(entry: SkillEntry): string { + const key = resolveSkillKey(entry.skill, entry); + const safeKey = safePathSegmentHashed(key); + return path.join(resolveConfigDir(), "tools", safeKey); +} diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index b518d4bb601..abfb8743dd7 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -82,6 +82,8 @@ export type SkillEligibilityContext = { export type SkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; + /** Normalized agent-level filter used to build this snapshot; undefined means unrestricted. */ + skillFilter?: string[]; resolvedSkills?: Skill[]; version?: number; }; diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index ee666eacaab..51b0c2bbd1d 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -19,6 +19,7 @@ import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import { resolveSandboxPath } from "../sandbox-paths.js"; import { resolveBundledSkillsDir } from "./bundled-dir.js"; import { shouldIncludeSkill } from "./config.js"; +import { normalizeSkillFilter } from "./filter.js"; import { parseFrontmatter, resolveOpenClawMetadata, @@ -52,14 +53,16 @@ function filterSkillEntries( let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config, eligibility })); // If skillFilter is provided, only include skills in the filter list. if (skillFilter !== undefined) { - const normalized = skillFilter.map((entry) => String(entry).trim()).filter(Boolean); + const normalized = normalizeSkillFilter(skillFilter) ?? []; const label = normalized.length > 0 ? normalized.join(", ") : "(none)"; - console.log(`[skills] Applying skill filter: ${label}`); + skillsLogger.debug(`Applying skill filter: ${label}`); filtered = normalized.length > 0 ? filtered.filter((entry) => normalized.includes(entry.skill.name)) : []; - console.log(`[skills] After filter: ${filtered.map((entry) => entry.skill.name).join(", ")}`); + skillsLogger.debug( + `After skill filter: ${filtered.map((entry) => entry.skill.name).join(", ") || "(none)"}`, + ); } return filtered; } @@ -232,12 +235,14 @@ export function buildWorkspaceSkillSnapshot( const resolvedSkills = promptEntries.map((entry) => entry.skill); const remoteNote = opts?.eligibility?.remote?.note?.trim(); const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n"); + const skillFilter = normalizeSkillFilter(opts?.skillFilter); return { prompt, skills: eligible.map((entry) => ({ name: entry.skill.name, primaryEnv: entry.metadata?.primaryEnv, })), + ...(skillFilter === undefined ? {} : { skillFilter }), resolvedSkills, version: opts?.snapshotVersion, }; diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 752b2a07db9..c1d4e5013d0 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -22,6 +22,17 @@ let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfi }, }; +function loadSessionStoreFixture(): Record> { + return new Proxy(sessionStore, { + get(target, key: string | symbol) { + if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) { + return { inputTokens: 1, outputTokens: 1, totalTokens: 2 }; + } + return target[key as keyof typeof target]; + }, + }); +} + vi.mock("../gateway/call.js", () => ({ callGateway: vi.fn(async (req: unknown) => { const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } }; @@ -47,7 +58,7 @@ vi.mock("./tools/agent-step.js", () => ({ })); vi.mock("../config/sessions.js", () => ({ - loadSessionStore: vi.fn(() => sessionStore), + loadSessionStore: vi.fn(() => loadSessionStoreFixture()), resolveAgentIdFromSessionKey: () => "main", resolveStorePath: () => "/tmp/sessions.json", resolveMainSessionKey: () => "agent:main:main", @@ -93,6 +104,9 @@ describe("subagent announce formatting", () => { sessionStore = { "agent:main:subagent:test": { sessionId: "child-session-123", + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, }, }; await runSubagentAnnounceFlow({ @@ -580,6 +594,9 @@ describe("subagent announce formatting", () => { sessionStore = { "agent:main:subagent:test": { sessionId: "child-session-1", + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, }, }; @@ -742,34 +759,6 @@ describe("subagent announce formatting", () => { expect(agentSpy).not.toHaveBeenCalled(); }); - it("does not delete child session when announce is deferred for an active run", async () => { - const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); - embeddedRunMock.waitForEmbeddedPiRunEnd.mockResolvedValue(false); - sessionStore = { - "agent:main:subagent:test": { - sessionId: "child-session-active", - }, - }; - - const didAnnounce = await runSubagentAnnounceFlow({ - childSessionKey: "agent:main:subagent:test", - childRunId: "run-child-active-delete", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "context-stress-test", - timeoutMs: 1000, - cleanup: "delete", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, - }); - - expect(didAnnounce).toBe(false); - expect(sessionsDeleteSpy).not.toHaveBeenCalled(); - }); - it("normalizes requesterOrigin for direct announce delivery", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); diff --git a/src/agents/subagent-depth.test.ts b/src/agents/subagent-depth.test.ts index 66980d2d095..5d9427b7818 100644 --- a/src/agents/subagent-depth.test.ts +++ b/src/agents/subagent-depth.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; +import { resolveAgentTimeoutMs } from "./timeout.js"; describe("getSubagentDepthFromSessionStore", () => { it("uses spawnDepth from the session store when available", () => { @@ -85,3 +86,15 @@ describe("getSubagentDepthFromSessionStore", () => { expect(depth).toBe(1); }); }); + +describe("resolveAgentTimeoutMs", () => { + it("uses a timer-safe sentinel for no-timeout overrides", () => { + expect(resolveAgentTimeoutMs({ overrideSeconds: 0 })).toBe(2_147_000_000); + expect(resolveAgentTimeoutMs({ overrideMs: 0 })).toBe(2_147_000_000); + }); + + it("clamps very large timeout overrides to timer-safe values", () => { + expect(resolveAgentTimeoutMs({ overrideSeconds: 9_999_999 })).toBe(2_147_000_000); + expect(resolveAgentTimeoutMs({ overrideMs: 9_999_999_999 })).toBe(2_147_000_000); + }); +}); diff --git a/src/agents/subagent-registry.nested.test.ts b/src/agents/subagent-registry.nested.test.ts index 399917d1771..2ff207a79b2 100644 --- a/src/agents/subagent-registry.nested.test.ts +++ b/src/agents/subagent-registry.nested.test.ts @@ -14,15 +14,26 @@ vi.mock("../infra/agent-events.js", () => ({ onAgentEvent: vi.fn(() => noop), })); +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({ + agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, + })), +})); + vi.mock("./subagent-announce.js", () => ({ runSubagentAnnounceFlow: vi.fn(async () => true), buildSubagentSystemPrompt: vi.fn(() => "test prompt"), })); +vi.mock("./subagent-registry.store.js", () => ({ + loadSubagentRegistryFromDisk: vi.fn(() => new Map()), + saveSubagentRegistryToDisk: vi.fn(() => {}), +})); + describe("subagent registry nested agent tracking", () => { afterEach(async () => { const mod = await import("./subagent-registry.js"); - mod.resetSubagentRegistryForTests(); + mod.resetSubagentRegistryForTests({ persist: false }); }); it("listSubagentRunsForRequester returns children of the requesting session", async () => { diff --git a/src/agents/subagent-registry.persistence.e2e.test.ts b/src/agents/subagent-registry.persistence.e2e.test.ts index 9b3f5348c42..4a6620c4e57 100644 --- a/src/agents/subagent-registry.persistence.e2e.test.ts +++ b/src/agents/subagent-registry.persistence.e2e.test.ts @@ -2,6 +2,7 @@ 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 { captureEnv } from "../test-utils/env.js"; import { initSubagentRegistry, registerSubagentRun, @@ -29,7 +30,7 @@ vi.mock("./subagent-announce.js", () => ({ })); describe("subagent registry persistence", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); let tempStateDir: string | null = null; afterEach(async () => { @@ -39,11 +40,7 @@ describe("subagent registry persistence", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } + envSnapshot.restore(); }); it("persists runs to disk and resumes after restart", async () => { diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index a4a0a70109d..b8aebb3ec73 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const noop = () => {}; let lifecycleHandler: @@ -22,22 +22,41 @@ vi.mock("../infra/agent-events.js", () => ({ }), })); +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({ + agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, + })), +})); + const announceSpy = vi.fn(async () => true); vi.mock("./subagent-announce.js", () => ({ runSubagentAnnounceFlow: (...args: unknown[]) => announceSpy(...args), })); +vi.mock("./subagent-registry.store.js", () => ({ + loadSubagentRegistryFromDisk: vi.fn(() => new Map()), + saveSubagentRegistryToDisk: vi.fn(() => {}), +})); + describe("subagent registry steer restarts", () => { + let mod: typeof import("./subagent-registry.js"); + + beforeAll(async () => { + mod = await import("./subagent-registry.js"); + }); + + const flushAnnounce = async () => { + await new Promise((resolve) => setImmediate(resolve)); + }; + afterEach(async () => { - announceSpy.mockClear(); + announceSpy.mockReset(); + announceSpy.mockResolvedValue(true); lifecycleHandler = undefined; - const mod = await import("./subagent-registry.js"); - mod.resetSubagentRegistryForTests(); + mod.resetSubagentRegistryForTests({ persist: false }); }); it("suppresses announce for interrupted runs and only announces the replacement run", async () => { - const mod = await import("./subagent-registry.js"); - mod.registerSubagentRun({ runId: "run-old", childSessionKey: "agent:main:subagent:steer", @@ -59,7 +78,7 @@ describe("subagent registry steer restarts", () => { data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); expect(announceSpy).not.toHaveBeenCalled(); const replaced = mod.replaceSubagentRunAfterSteer({ @@ -79,7 +98,7 @@ describe("subagent registry steer restarts", () => { data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); expect(announceSpy).toHaveBeenCalledTimes(1); const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string }; @@ -87,8 +106,6 @@ describe("subagent registry steer restarts", () => { }); it("restores announce for a finished run when steer replacement dispatch fails", async () => { - const mod = await import("./subagent-registry.js"); - mod.registerSubagentRun({ runId: "run-failed-restart", childSessionKey: "agent:main:subagent:failed-restart", @@ -106,11 +123,11 @@ describe("subagent registry steer restarts", () => { data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); expect(announceSpy).not.toHaveBeenCalled(); expect(mod.clearSubagentRunSteerRestart("run-failed-restart")).toBe(true); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); expect(announceSpy).toHaveBeenCalledTimes(1); const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string }; @@ -118,7 +135,6 @@ describe("subagent registry steer restarts", () => { }); it("marks killed runs terminated and inactive", async () => { - const mod = await import("./subagent-registry.js"); const childSessionKey = "agent:main:subagent:killed"; mod.registerSubagentRun({ @@ -145,7 +161,6 @@ describe("subagent registry steer restarts", () => { }); it("retries deferred parent cleanup after a descendant announces", async () => { - const mod = await import("./subagent-registry.js"); let parentAttempts = 0; announceSpy.mockImplementation(async (params: unknown) => { const typed = params as { childRunId?: string }; @@ -178,14 +193,14 @@ describe("subagent registry steer restarts", () => { runId: "run-parent", data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); lifecycleHandler?.({ stream: "lifecycle", runId: "run-child", data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); const childRunIds = announceSpy.mock.calls.map( (call) => (call[0] as { childRunId?: string }).childRunId, diff --git a/src/agents/system-prompt.e2e.test.ts b/src/agents/system-prompt.e2e.test.ts index 65f8d7852d4..0b552fd62d3 100644 --- a/src/agents/system-prompt.e2e.test.ts +++ b/src/agents/system-prompt.e2e.test.ts @@ -48,6 +48,9 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).not.toContain("## Silent Replies"); expect(prompt).not.toContain("## Heartbeats"); expect(prompt).toContain("## Safety"); + expect(prompt).toContain( + "For long waits, avoid rapid poll loops: use exec with enough yieldMs or process(action=poll, timeout=).", + ); expect(prompt).toContain("You have no independent goals"); expect(prompt).toContain("Prioritize safety and human oversight"); expect(prompt).toContain("if instructions conflict"); @@ -120,6 +123,9 @@ describe("buildAgentSystemPrompt", () => { workspaceDir: "/tmp/openclaw", }); + expect(prompt).toContain( + "For long waits, avoid rapid poll loops: use exec with enough yieldMs or process(action=poll, timeout=).", + ); expect(prompt).toContain("Completion is push-based: it will auto-announce when done."); expect(prompt).toContain("Do not poll `subagents list` / `sessions_list` in a loop"); }); @@ -448,10 +454,12 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("Your working directory is: /workspace"); expect(prompt).toContain( - "For read/write/edit/apply_patch, file paths resolve against host workspace: /tmp/openclaw.", + "For read/write/edit/apply_patch, file paths resolve against host workspace: /tmp/openclaw. For bash/exec commands, use sandbox container paths under /workspace (or relative paths from that workdir), not host paths.", ); expect(prompt).toContain("Sandbox container workdir: /workspace"); - expect(prompt).toContain("Sandbox host workspace: /tmp/sandbox"); + expect(prompt).toContain( + "Sandbox host mount source (file tools bridge only; not valid inside sandbox exec): /tmp/sandbox", + ); expect(prompt).toContain("You are running in a sandboxed runtime"); expect(prompt).toContain("Sub-agents stay sandboxed"); expect(prompt).toContain("User can toggle with /elevated on|off|ask|full."); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 21a176ac8cf..5c7d312d459 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -4,6 +4,7 @@ import type { ResolvedTimeFormat } from "./date-time.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; /** * Controls which hardcoded sections are included in the system prompt. @@ -355,13 +356,17 @@ export function buildAgentSystemPrompt(params: { const promptMode = params.promptMode ?? "full"; const isMinimal = promptMode === "minimal" || promptMode === "none"; const sandboxContainerWorkspace = params.sandboxInfo?.containerWorkspaceDir?.trim(); + const sanitizedWorkspaceDir = sanitizeForPromptLiteral(params.workspaceDir); + const sanitizedSandboxContainerWorkspace = sandboxContainerWorkspace + ? sanitizeForPromptLiteral(sandboxContainerWorkspace) + : ""; const displayWorkspaceDir = - params.sandboxInfo?.enabled && sandboxContainerWorkspace - ? sandboxContainerWorkspace - : params.workspaceDir; + params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace + ? sanitizedSandboxContainerWorkspace + : sanitizedWorkspaceDir; const workspaceGuidance = - params.sandboxInfo?.enabled && sandboxContainerWorkspace - ? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${params.workspaceDir}. Prefer relative paths so both sandboxed exec and file tools work consistently.` + params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace + ? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${sanitizedWorkspaceDir}. For bash/exec commands, use sandbox container paths under ${sanitizedSandboxContainerWorkspace} (or relative paths from that workdir), not host paths. Prefer relative paths so both sandboxed exec and file tools work consistently.` : "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise."; const safetySection = [ "## Safety", @@ -408,7 +413,6 @@ export function buildAgentSystemPrompt(params: { "- apply_patch: apply multi-file patches", `- ${execToolName}: run shell commands (supports background via yieldMs/background)`, `- ${processToolName}: manage background exec sessions`, - `- For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=).`, "- browser: control OpenClaw's dedicated browser", "- canvas: present/eval/snapshot the Canvas", "- nodes: list/describe/notify/camera/screen on paired nodes", @@ -420,6 +424,7 @@ export function buildAgentSystemPrompt(params: { '- session_status: show usage/time/model state and answer "what model are we using?"', ].join("\n"), "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", + `For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=).`, "If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.", "Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).", "", @@ -480,21 +485,21 @@ export function buildAgentSystemPrompt(params: { "Some tools may be unavailable due to sandbox policy.", "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", params.sandboxInfo.containerWorkspaceDir - ? `Sandbox container workdir: ${params.sandboxInfo.containerWorkspaceDir}` + ? `Sandbox container workdir: ${sanitizeForPromptLiteral(params.sandboxInfo.containerWorkspaceDir)}` : "", params.sandboxInfo.workspaceDir - ? `Sandbox host workspace: ${params.sandboxInfo.workspaceDir}` + ? `Sandbox host mount source (file tools bridge only; not valid inside sandbox exec): ${sanitizeForPromptLiteral(params.sandboxInfo.workspaceDir)}` : "", params.sandboxInfo.workspaceAccess ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ params.sandboxInfo.agentWorkspaceMount - ? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})` + ? ` (mounted at ${sanitizeForPromptLiteral(params.sandboxInfo.agentWorkspaceMount)})` : "" }` : "", params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "", params.sandboxInfo.browserNoVncUrl - ? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}` + ? `Sandbox browser observer (noVNC): ${sanitizeForPromptLiteral(params.sandboxInfo.browserNoVncUrl)}` : "", params.sandboxInfo.hostBrowserAllowed === true ? "Host browser control: allowed." diff --git a/src/agents/test-helpers/fast-coding-tools.ts b/src/agents/test-helpers/fast-coding-tools.ts index 99b4ab351c8..5cc92f38acb 100644 --- a/src/agents/test-helpers/fast-coding-tools.ts +++ b/src/agents/test-helpers/fast-coding-tools.ts @@ -1,22 +1 @@ -import { vi } from "vitest"; - -const stubTool = (name: string) => ({ - name, - description: `${name} stub`, - parameters: { type: "object", properties: {} }, - execute: vi.fn(), -}); - -vi.mock("../tools/image-tool.js", () => ({ - createImageTool: () => stubTool("image"), -})); - -vi.mock("../tools/web-tools.js", () => ({ - createWebSearchTool: () => null, - createWebFetchTool: () => null, -})); - -vi.mock("../../plugins/tools.js", () => ({ - resolvePluginTools: () => [], - getPluginToolMeta: () => undefined, -})); +import "./fast-tool-stubs.js"; diff --git a/src/agents/test-helpers/fast-core-tools.ts b/src/agents/test-helpers/fast-core-tools.ts index d459c82765f..5bda64b09b6 100644 --- a/src/agents/test-helpers/fast-core-tools.ts +++ b/src/agents/test-helpers/fast-core-tools.ts @@ -1,11 +1,5 @@ import { vi } from "vitest"; - -const stubTool = (name: string) => ({ - name, - description: `${name} stub`, - parameters: { type: "object", properties: {} }, - execute: vi.fn(), -}); +import { stubTool } from "./fast-tool-stubs.js"; vi.mock("../tools/browser-tool.js", () => ({ createBrowserTool: () => stubTool("browser"), @@ -14,17 +8,3 @@ vi.mock("../tools/browser-tool.js", () => ({ vi.mock("../tools/canvas-tool.js", () => ({ createCanvasTool: () => stubTool("canvas"), })); - -vi.mock("../tools/image-tool.js", () => ({ - createImageTool: () => stubTool("image"), -})); - -vi.mock("../tools/web-tools.js", () => ({ - createWebSearchTool: () => null, - createWebFetchTool: () => null, -})); - -vi.mock("../../plugins/tools.js", () => ({ - resolvePluginTools: () => [], - getPluginToolMeta: () => undefined, -})); diff --git a/src/agents/test-helpers/fast-tool-stubs.ts b/src/agents/test-helpers/fast-tool-stubs.ts new file mode 100644 index 00000000000..da29363b50f --- /dev/null +++ b/src/agents/test-helpers/fast-tool-stubs.ts @@ -0,0 +1,30 @@ +import { vi } from "vitest"; + +export type StubTool = { + name: string; + description: string; + parameters: { type: "object"; properties: Record }; + // Keep the exported type portable: don't leak Vitest's mock types into .d.ts. + execute: (...args: unknown[]) => unknown; +}; + +export const stubTool = (name: string): StubTool => ({ + name, + description: `${name} stub`, + parameters: { type: "object", properties: {} }, + execute: vi.fn() as unknown as (...args: unknown[]) => unknown, +}); + +vi.mock("../tools/image-tool.js", () => ({ + createImageTool: () => stubTool("image"), +})); + +vi.mock("../tools/web-tools.js", () => ({ + createWebSearchTool: () => null, + createWebFetchTool: () => null, +})); + +vi.mock("../../plugins/tools.js", () => ({ + resolvePluginTools: () => [], + getPluginToolMeta: () => undefined, +})); diff --git a/src/agents/timeout.e2e.test.ts b/src/agents/timeout.e2e.test.ts deleted file mode 100644 index 37a96a9ff09..00000000000 --- a/src/agents/timeout.e2e.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveAgentTimeoutMs } from "./timeout.js"; - -describe("resolveAgentTimeoutMs", () => { - it("uses a timer-safe sentinel for no-timeout overrides", () => { - expect(resolveAgentTimeoutMs({ overrideSeconds: 0 })).toBe(2_147_000_000); - expect(resolveAgentTimeoutMs({ overrideMs: 0 })).toBe(2_147_000_000); - }); - - it("clamps very large timeout overrides to timer-safe values", () => { - expect(resolveAgentTimeoutMs({ overrideSeconds: 9_999_999 })).toBe(2_147_000_000); - expect(resolveAgentTimeoutMs({ overrideMs: 9_999_999_999 })).toBe(2_147_000_000); - }); -}); diff --git a/src/agents/tool-policy.conformance.e2e.test.ts b/src/agents/tool-policy.conformance.e2e.test.ts deleted file mode 100644 index 676a0b3023a..00000000000 --- a/src/agents/tool-policy.conformance.e2e.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { TOOL_POLICY_CONFORMANCE } from "./tool-policy.conformance.js"; -import { TOOL_GROUPS } from "./tool-policy.js"; - -describe("TOOL_POLICY_CONFORMANCE", () => { - test("matches exported TOOL_GROUPS exactly", () => { - expect(TOOL_POLICY_CONFORMANCE.toolGroups).toEqual(TOOL_GROUPS); - }); - - test("is JSON-serializable", () => { - expect(() => JSON.stringify(TOOL_POLICY_CONFORMANCE)).not.toThrow(); - }); -}); diff --git a/src/agents/tool-policy.e2e.test.ts b/src/agents/tool-policy.e2e.test.ts index b4b9d20a086..9fb44696b6b 100644 --- a/src/agents/tool-policy.e2e.test.ts +++ b/src/agents/tool-policy.e2e.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from "vitest"; -import { expandToolGroups, resolveToolProfilePolicy, TOOL_GROUPS } from "./tool-policy.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { SandboxToolPolicy } from "./sandbox/types.js"; +import type { AnyAgentTool } from "./tools/common.js"; +import { isToolAllowed, resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js"; +import { TOOL_POLICY_CONFORMANCE } from "./tool-policy.conformance.js"; +import { + applyOwnerOnlyToolPolicy, + expandToolGroups, + isOwnerOnlyToolName, + normalizeToolName, + resolveToolProfilePolicy, + TOOL_GROUPS, +} from "./tool-policy.js"; describe("tool-policy", () => { it("expands groups and normalizes aliases", () => { @@ -27,4 +39,146 @@ describe("tool-policy", () => { expect(group).toContain("subagents"); expect(group).toContain("session_status"); }); + + it("normalizes tool names and aliases", () => { + expect(normalizeToolName(" BASH ")).toBe("exec"); + expect(normalizeToolName("apply-patch")).toBe("apply_patch"); + expect(normalizeToolName("READ")).toBe("read"); + }); + + it("identifies owner-only tools", () => { + expect(isOwnerOnlyToolName("whatsapp_login")).toBe(true); + expect(isOwnerOnlyToolName("read")).toBe(false); + }); + + it("strips owner-only tools for non-owner senders", async () => { + const tools = [ + { + name: "read", + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, + { + name: "whatsapp_login", + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, + ] as unknown as AnyAgentTool[]; + + const filtered = applyOwnerOnlyToolPolicy(tools, false); + expect(filtered.map((t) => t.name)).toEqual(["read"]); + }); + + it("keeps owner-only tools for the owner sender", async () => { + const tools = [ + { + name: "read", + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, + { + name: "whatsapp_login", + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, + ] as unknown as AnyAgentTool[]; + + const filtered = applyOwnerOnlyToolPolicy(tools, true); + expect(filtered.map((t) => t.name)).toEqual(["read", "whatsapp_login"]); + }); +}); + +describe("TOOL_POLICY_CONFORMANCE", () => { + it("matches exported TOOL_GROUPS exactly", () => { + expect(TOOL_POLICY_CONFORMANCE.toolGroups).toEqual(TOOL_GROUPS); + }); + + it("is JSON-serializable", () => { + expect(() => JSON.stringify(TOOL_POLICY_CONFORMANCE)).not.toThrow(); + }); +}); + +describe("sandbox tool policy", () => { + it("allows all tools with * allow", () => { + const policy: SandboxToolPolicy = { allow: ["*"], deny: [] }; + expect(isToolAllowed(policy, "browser")).toBe(true); + }); + + it("denies all tools with * deny", () => { + const policy: SandboxToolPolicy = { allow: [], deny: ["*"] }; + expect(isToolAllowed(policy, "read")).toBe(false); + }); + + it("supports wildcard patterns", () => { + const policy: SandboxToolPolicy = { allow: ["web_*"] }; + expect(isToolAllowed(policy, "web_fetch")).toBe(true); + expect(isToolAllowed(policy, "read")).toBe(false); + }); + + it("applies deny before allow", () => { + const policy: SandboxToolPolicy = { allow: ["*"], deny: ["web_*"] }; + expect(isToolAllowed(policy, "web_fetch")).toBe(false); + expect(isToolAllowed(policy, "read")).toBe(true); + }); + + it("treats empty allowlist as allow-all (with deny exceptions)", () => { + const policy: SandboxToolPolicy = { allow: [], deny: ["web_*"] }; + expect(isToolAllowed(policy, "web_fetch")).toBe(false); + expect(isToolAllowed(policy, "read")).toBe(true); + }); + + it("expands tool groups + aliases in patterns", () => { + const policy: SandboxToolPolicy = { + allow: ["group:fs", "BASH"], + deny: ["apply_*"], + }; + expect(isToolAllowed(policy, "read")).toBe(true); + expect(isToolAllowed(policy, "exec")).toBe(true); + expect(isToolAllowed(policy, "apply_patch")).toBe(false); + }); + + it("normalizes whitespace + case", () => { + const policy: SandboxToolPolicy = { allow: [" WEB_* "] }; + expect(isToolAllowed(policy, "WEB_FETCH")).toBe(true); + }); +}); + +describe("resolveSandboxToolPolicyForAgent", () => { + it("keeps allow-all semantics when allow is []", () => { + const cfg = { + tools: { sandbox: { tools: { allow: [], deny: ["browser"] } } }, + } as unknown as OpenClawConfig; + + const resolved = resolveSandboxToolPolicyForAgent(cfg, undefined); + expect(resolved.sources.allow).toEqual({ + source: "global", + key: "tools.sandbox.tools.allow", + }); + expect(resolved.allow).toEqual([]); + expect(resolved.deny).toEqual(["browser"]); + + const policy: SandboxToolPolicy = { allow: resolved.allow, deny: resolved.deny }; + expect(isToolAllowed(policy, "read")).toBe(true); + expect(isToolAllowed(policy, "browser")).toBe(false); + }); + + it("auto-adds image to explicit allowlists unless denied", () => { + const cfg = { + tools: { sandbox: { tools: { allow: ["read"], deny: ["browser"] } } }, + } as unknown as OpenClawConfig; + + const resolved = resolveSandboxToolPolicyForAgent(cfg, undefined); + expect(resolved.allow).toEqual(["read", "image"]); + expect(resolved.deny).toEqual(["browser"]); + }); + + it("does not auto-add image when explicitly denied", () => { + const cfg = { + tools: { sandbox: { tools: { allow: ["read"], deny: ["image"] } } }, + } as unknown as OpenClawConfig; + + const resolved = resolveSandboxToolPolicyForAgent(cfg, undefined); + expect(resolved.allow).toEqual(["read"]); + expect(resolved.deny).toEqual(["image"]); + }); }); diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index d4fa9a69b15..310980474df 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -291,3 +291,13 @@ export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | deny: resolved.deny ? [...resolved.deny] : undefined, }; } + +export function mergeAlsoAllowPolicy( + policy: TPolicy | undefined, + alsoAllow?: string[], +): TPolicy | undefined { + if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) { + return policy; + } + return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) }; +} diff --git a/src/agents/tools/cron-tool.e2e.test.ts b/src/agents/tools/cron-tool.e2e.test.ts index 1adbb2cd89e..a43552643bb 100644 --- a/src/agents/tools/cron-tool.e2e.test.ts +++ b/src/agents/tools/cron-tool.e2e.test.ts @@ -443,4 +443,61 @@ describe("cron tool", () => { }; expect(call?.params?.delivery).toEqual({ mode: "none" }); }); + + it("does not infer announce delivery when mode is webhook", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); + await tool.execute("call-webhook-explicit", { + action: "add", + job: { + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, + }, + }); + + const call = callGatewayMock.mock.calls[0]?.[0] as { + params?: { delivery?: { mode?: string; channel?: string; to?: string } }; + }; + expect(call?.params?.delivery).toEqual({ + mode: "webhook", + to: "https://example.invalid/cron-finished", + }); + }); + + it("fails fast when webhook mode is missing delivery.to", async () => { + const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); + + await expect( + tool.execute("call-webhook-missing", { + action: "add", + job: { + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "webhook" }, + }, + }), + ).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL'); + expect(callGatewayMock).toHaveBeenCalledTimes(0); + }); + + it("fails fast when webhook mode uses a non-http URL", async () => { + const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); + + await expect( + tool.execute("call-webhook-invalid", { + action: "add", + job: { + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" }, + }, + }), + ).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL'); + expect(callGatewayMock).toHaveBeenCalledTimes(0); + }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 29c86e646ed..be5f1e9b84d 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -2,7 +2,9 @@ import { Type } from "@sinclair/typebox"; import type { CronDelivery, CronMessageChannel } from "../../cron/types.js"; import { loadConfig } from "../../config/config.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; +import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; +import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { isRecord, truncateUtf16Safe } from "../../utils.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; @@ -69,38 +71,13 @@ function truncateText(input: string, maxLen: number) { return `${truncated}...`; } -function normalizeContextText(raw: string) { - return raw.replace(/\s+/g, " ").trim(); -} - function extractMessageText(message: ChatMessage): { role: string; text: string } | null { const role = typeof message.role === "string" ? message.role : ""; if (role !== "user" && role !== "assistant") { return null; } - const content = message.content; - if (typeof content === "string") { - const normalized = normalizeContextText(content); - return normalized ? { role, text: normalized } : null; - } - if (!Array.isArray(content)) { - return null; - } - const chunks: string[] = []; - for (const block of content) { - if (!block || typeof block !== "object") { - continue; - } - if ((block as { type?: unknown }).type !== "text") { - continue; - } - const text = (block as { text?: unknown }).text; - if (typeof text === "string" && text.trim()) { - chunks.push(text); - } - } - const joined = normalizeContextText(chunks.join(" ")); - return joined ? { role, text: joined } : null; + const text = extractTextFromChatContent(message.content); + return text ? { role, text } : null; } async function buildReminderContextLines(params: { @@ -241,7 +218,7 @@ JOB SCHEMA (for add action): "name": "string (optional)", "schedule": { ... }, // Required: when to run "payload": { ... }, // Required: what to execute - "delivery": { ... }, // Optional: announce summary (isolated only) + "delivery": { ... }, // Optional: announce summary or webhook POST "sessionTarget": "main" | "isolated", // Required "enabled": true | false // Optional, default true } @@ -262,14 +239,17 @@ PAYLOAD TYPES (payload.kind): - "agentTurn": Runs agent with message (isolated sessions only) { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": } -DELIVERY (isolated-only, top-level): - { "mode": "none|announce", "channel": "", "to": "", "bestEffort": } +DELIVERY (top-level): + { "mode": "none|announce|webhook", "channel": "", "to": "", "bestEffort": } - Default for isolated agentTurn jobs (when delivery omitted): "announce" - - If the task needs to send to a specific chat/recipient, set delivery.channel/to here; do not call messaging tools inside the run. + - announce: send to chat channel (optional channel/to target) + - webhook: send finished-run event as HTTP POST to delivery.to (URL required) + - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run. CRITICAL CONSTRAINTS: - sessionTarget="main" REQUIRES payload.kind="systemEvent" - sessionTarget="isolated" REQUIRES payload.kind="agentTurn" +- For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL. Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event. WAKE MODES (for wake action): @@ -373,11 +353,25 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con const delivery = isRecord(deliveryValue) ? deliveryValue : undefined; const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : ""; const mode = modeRaw.trim().toLowerCase(); + if (mode === "webhook") { + const webhookUrl = normalizeHttpWebhookUrl(delivery?.to); + if (!webhookUrl) { + throw new Error( + 'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL', + ); + } + if (delivery) { + delivery.to = webhookUrl; + } + } + const hasTarget = (typeof delivery?.channel === "string" && delivery.channel.trim()) || (typeof delivery?.to === "string" && delivery.to.trim()); const shouldInfer = - (deliveryValue == null || delivery) && mode !== "none" && !hasTarget; + (deliveryValue == null || delivery) && + (mode === "" || mode === "announce") && + !hasTarget; if (shouldInfer) { const inferred = inferDeliveryFromSessionKey(opts.agentSessionKey); if (inferred) { diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 54f5e970bf1..a29f4b66235 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -1,5 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; +import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js"; +import { readDiscordComponentSpec } from "../../discord/components.js"; import { createThreadDiscord, deleteMessageDiscord, @@ -15,6 +17,7 @@ import { removeOwnReactionsDiscord, removeReactionDiscord, searchMessagesDiscord, + sendDiscordComponentMessage, sendMessageDiscord, sendPollDiscord, sendStickerDiscord, @@ -232,17 +235,54 @@ export async function handleDiscordMessagingAction( const to = readStringParam(params, "to", { required: true }); const asVoice = params.asVoice === true; const silent = params.silent === true; + const rawComponents = params.components; + const componentSpec = + rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents) + ? readDiscordComponentSpec(rawComponents) + : null; + const components: DiscordSendComponents | undefined = + Array.isArray(rawComponents) || typeof rawComponents === "function" + ? (rawComponents as DiscordSendComponents) + : undefined; const content = readStringParam(params, "content", { - required: !asVoice, + required: !asVoice && !componentSpec && !components, allowEmpty: true, }); const mediaUrl = readStringParam(params, "mediaUrl", { trim: false }) ?? readStringParam(params, "path", { trim: false }) ?? readStringParam(params, "filePath", { trim: false }); + const filename = readStringParam(params, "filename"); const replyTo = readStringParam(params, "replyTo"); - const embeds = - Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined; + const rawEmbeds = params.embeds; + const embeds: DiscordSendEmbeds | undefined = Array.isArray(rawEmbeds) + ? (rawEmbeds as DiscordSendEmbeds) + : undefined; + const sessionKey = readStringParam(params, "__sessionKey"); + const agentId = readStringParam(params, "__agentId"); + + if (componentSpec) { + if (asVoice) { + throw new Error("Discord components cannot be sent as voice messages."); + } + if (embeds?.length) { + throw new Error("Discord components cannot include embeds."); + } + const normalizedContent = content?.trim() ? content : undefined; + const payload = componentSpec.text + ? componentSpec + : { ...componentSpec, text: normalizedContent }; + const result = await sendDiscordComponentMessage(to, payload, { + ...(accountId ? { accountId } : {}), + silent, + replyTo: replyTo ?? undefined, + sessionKey: sessionKey ?? undefined, + agentId: agentId ?? undefined, + mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, + }); + return jsonResult({ ok: true, result, components: true }); + } // Handle voice message sending if (asVoice) { @@ -269,6 +309,7 @@ export async function handleDiscordMessagingAction( ...(accountId ? { accountId } : {}), mediaUrl, replyTo, + components, embeds, silent, }); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index c8f9570f3fb..0a71b8a39c9 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -138,6 +138,24 @@ export function createGatewayTool(opts?: { : undefined; const gatewayOpts = { gatewayUrl, gatewayToken, timeoutMs }; + const resolveGatewayWriteMeta = (): { + sessionKey: string | undefined; + note: string | undefined; + restartDelayMs: number | undefined; + } => { + const sessionKey = + typeof params.sessionKey === "string" && params.sessionKey.trim() + ? params.sessionKey.trim() + : opts?.agentSessionKey?.trim() || undefined; + const note = + typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; + const restartDelayMs = + typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs) + ? Math.floor(params.restartDelayMs) + : undefined; + return { sessionKey, note, restartDelayMs }; + }; + const resolveConfigWriteParams = async (): Promise<{ raw: string; baseHash: string; @@ -154,17 +172,7 @@ export function createGatewayTool(opts?: { if (!baseHash) { throw new Error("Missing baseHash from config snapshot."); } - const sessionKey = - typeof params.sessionKey === "string" && params.sessionKey.trim() - ? params.sessionKey.trim() - : opts?.agentSessionKey?.trim() || undefined; - const note = - typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; - const restartDelayMs = - typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs) - ? Math.floor(params.restartDelayMs) - : undefined; - return { raw, baseHash, sessionKey, note, restartDelayMs }; + return { raw, baseHash, ...resolveGatewayWriteMeta() }; }; if (action === "config.get") { @@ -200,16 +208,7 @@ export function createGatewayTool(opts?: { return jsonResult({ ok: true, result }); } if (action === "update.run") { - const sessionKey = - typeof params.sessionKey === "string" && params.sessionKey.trim() - ? params.sessionKey.trim() - : opts?.agentSessionKey?.trim() || undefined; - const note = - typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; - const restartDelayMs = - typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs) - ? Math.floor(params.restartDelayMs) - : undefined; + const { sessionKey, note, restartDelayMs } = resolveGatewayWriteMeta(); const updateGatewayOpts = { ...gatewayOpts, timeoutMs: timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS, diff --git a/src/agents/tools/image-tool.e2e.test.ts b/src/agents/tools/image-tool.e2e.test.ts index 5a93a32b13b..2b58753777c 100644 --- a/src/agents/tools/image-tool.e2e.test.ts +++ b/src/agents/tools/image-tool.e2e.test.ts @@ -16,6 +16,52 @@ async function writeAuthProfiles(agentDir: string, profiles: unknown) { ); } +const ONE_PIXEL_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + +async function withTempWorkspacePng( + cb: (args: { workspaceDir: string; imagePath: string }) => Promise, +) { + const workspaceParent = await fs.mkdtemp(path.join(process.cwd(), ".openclaw-workspace-image-")); + try { + const workspaceDir = path.join(workspaceParent, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + const imagePath = path.join(workspaceDir, "photo.png"); + await fs.writeFile(imagePath, Buffer.from(ONE_PIXEL_PNG_B64, "base64")); + await cb({ workspaceDir, imagePath }); + } finally { + await fs.rm(workspaceParent, { recursive: true, force: true }); + } +} + +function stubMinimaxOkFetch() { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(), + json: async () => ({ + content: "ok", + base_resp: { status_code: 0, status_msg: "" }, + }), + }); + // @ts-expect-error partial global + global.fetch = fetch; + vi.stubEnv("MINIMAX_API_KEY", "minimax-test"); + return fetch; +} + +function createMinimaxImageConfig(): OpenClawConfig { + return { + agents: { + defaults: { + model: { primary: "minimax/MiniMax-M2.1" }, + imageModel: { primary: "minimax/MiniMax-VL-01" }, + }, + }, + }; +} + describe("image tool implicit imageModel config", () => { const priorFetch = global.fetch; @@ -146,136 +192,78 @@ describe("image tool implicit imageModel config", () => { }); const tool = createImageTool({ config: cfg, agentDir, modelHasVision: true }); expect(tool).not.toBeNull(); - expect(tool?.description).toContain( - "Only use this tool when the image was NOT already provided", - ); + expect(tool?.description).toContain("Only use this tool when images were NOT already provided"); }); it("allows workspace images outside default local media roots", async () => { - const workspaceParent = await fs.mkdtemp( - path.join(process.cwd(), ".openclaw-workspace-image-"), - ); - try { - const workspaceDir = path.join(workspaceParent, "workspace"); - await fs.mkdir(workspaceDir, { recursive: true }); - const imagePath = path.join(workspaceDir, "photo.png"); - const pngB64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; - await fs.writeFile(imagePath, Buffer.from(pngB64, "base64")); - - const fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: new Headers(), - json: async () => ({ - content: "ok", - base_resp: { status_code: 0, status_msg: "" }, - }), - }); - // @ts-expect-error partial global - global.fetch = fetch; - vi.stubEnv("MINIMAX_API_KEY", "minimax-test"); - + await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => { + const fetch = stubMinimaxOkFetch(); const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); - const cfg: OpenClawConfig = { - agents: { - defaults: { - model: { primary: "minimax/MiniMax-M2.1" }, - imageModel: { primary: "minimax/MiniMax-VL-01" }, - }, - }, - }; + try { + const cfg = createMinimaxImageConfig(); - const withoutWorkspace = createImageTool({ config: cfg, agentDir }); - expect(withoutWorkspace).not.toBeNull(); - if (!withoutWorkspace) { - throw new Error("expected image tool"); + const withoutWorkspace = createImageTool({ config: cfg, agentDir }); + expect(withoutWorkspace).not.toBeNull(); + if (!withoutWorkspace) { + throw new Error("expected image tool"); + } + await expect( + withoutWorkspace.execute("t0", { + prompt: "Describe the image.", + image: imagePath, + }), + ).rejects.toThrow(/Local media path is not under an allowed directory/i); + + const withWorkspace = createImageTool({ config: cfg, agentDir, workspaceDir }); + expect(withWorkspace).not.toBeNull(); + if (!withWorkspace) { + throw new Error("expected image tool"); + } + + await expect( + withWorkspace.execute("t1", { + prompt: "Describe the image.", + image: imagePath, + }), + ).resolves.toMatchObject({ + content: [{ type: "text", text: "ok" }], + }); + + expect(fetch).toHaveBeenCalledTimes(1); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); } - await expect( - withoutWorkspace.execute("t0", { - prompt: "Describe the image.", - image: imagePath, - }), - ).rejects.toThrow(/Local media path is not under an allowed directory/i); - - const withWorkspace = createImageTool({ config: cfg, agentDir, workspaceDir }); - expect(withWorkspace).not.toBeNull(); - if (!withWorkspace) { - throw new Error("expected image tool"); - } - - await expect( - withWorkspace.execute("t1", { - prompt: "Describe the image.", - image: imagePath, - }), - ).resolves.toMatchObject({ - content: [{ type: "text", text: "ok" }], - }); - - expect(fetch).toHaveBeenCalledTimes(1); - } finally { - await fs.rm(workspaceParent, { recursive: true, force: true }); - } + }); }); it("allows workspace images via createOpenClawCodingTools default workspace root", async () => { - const workspaceParent = await fs.mkdtemp( - path.join(process.cwd(), ".openclaw-workspace-image-"), - ); - try { - const workspaceDir = path.join(workspaceParent, "workspace"); - await fs.mkdir(workspaceDir, { recursive: true }); - const imagePath = path.join(workspaceDir, "photo.png"); - const pngB64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; - await fs.writeFile(imagePath, Buffer.from(pngB64, "base64")); - - const fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: new Headers(), - json: async () => ({ - content: "ok", - base_resp: { status_code: 0, status_msg: "" }, - }), - }); - // @ts-expect-error partial global - global.fetch = fetch; - vi.stubEnv("MINIMAX_API_KEY", "minimax-test"); - + await withTempWorkspacePng(async ({ imagePath }) => { + const fetch = stubMinimaxOkFetch(); const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); - const cfg: OpenClawConfig = { - agents: { - defaults: { - model: { primary: "minimax/MiniMax-M2.1" }, - imageModel: { primary: "minimax/MiniMax-VL-01" }, - }, - }, - }; + try { + const cfg = createMinimaxImageConfig(); - const tools = createOpenClawCodingTools({ config: cfg, agentDir }); - const tool = tools.find((candidate) => candidate.name === "image"); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image tool"); + const tools = createOpenClawCodingTools({ config: cfg, agentDir }); + const tool = tools.find((candidate) => candidate.name === "image"); + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image tool"); + } + + await expect( + tool.execute("t1", { + prompt: "Describe the image.", + image: imagePath, + }), + ).resolves.toMatchObject({ + content: [{ type: "text", text: "ok" }], + }); + + expect(fetch).toHaveBeenCalledTimes(1); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); } - - await expect( - tool.execute("t1", { - prompt: "Describe the image.", - image: imagePath, - }), - ).resolves.toMatchObject({ - content: [{ type: "text", text: "ok" }], - }); - - expect(fetch).toHaveBeenCalledTimes(1); - } finally { - await fs.rm(workspaceParent, { recursive: true, force: true }); - } + }); }); it("sandboxes image paths like the read tool", async () => { diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 896b7447138..3d63623b778 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -26,6 +26,7 @@ import { const DEFAULT_PROMPT = "Describe the image."; const ANTHROPIC_IMAGE_PRIMARY = "anthropic/claude-opus-4-6"; const ANTHROPIC_IMAGE_FALLBACK = "anthropic/claude-opus-4-5"; +const DEFAULT_MAX_IMAGES = 20; export const __testing = { decodeDataUrl, @@ -182,15 +183,21 @@ function pickMaxBytes(cfg?: OpenClawConfig, maxBytesMb?: number): number | undef return undefined; } -function buildImageContext(prompt: string, base64: string, mimeType: string): Context { +function buildImageContext( + prompt: string, + images: Array<{ base64: string; mimeType: string }>, +): Context { + const content: Array< + { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } + > = [{ type: "text", text: prompt }]; + for (const img of images) { + content.push({ type: "image", data: img.base64, mimeType: img.mimeType }); + } return { messages: [ { role: "user", - content: [ - { type: "text", text: prompt }, - { type: "image", data: base64, mimeType }, - ], + content, timestamp: Date.now(), }, ], @@ -242,8 +249,7 @@ async function runImagePrompt(params: { imageModelConfig: ImageModelConfig; modelOverride?: string; prompt: string; - base64: string; - mimeType: string; + images: Array<{ base64: string; mimeType: string }>; }): Promise<{ text: string; provider: string; @@ -285,9 +291,11 @@ async function runImagePrompt(params: { }); const apiKey = requireApiKey(apiKeyInfo, model.provider); authStorage.setRuntimeApiKey(model.provider, apiKey); - const imageDataUrl = `data:${params.mimeType};base64,${params.base64}`; + // MiniMax VLM only supports a single image; use the first one. if (model.provider === "minimax") { + const first = params.images[0]; + const imageDataUrl = `data:${first.mimeType};base64,${first.base64}`; const text = await minimaxUnderstandImage({ apiKey, prompt: params.prompt, @@ -297,7 +305,7 @@ async function runImagePrompt(params: { return { text, provider: model.provider, model: model.id }; } - const context = buildImageContext(params.prompt, params.base64, params.mimeType); + const context = buildImageContext(params.prompt, params.images); const message = await complete(model, context, { apiKey, maxTokens: resolveImageToolMaxTokens(model.maxTokens), @@ -350,8 +358,8 @@ export function createImageTool(options?: { // If model has native vision, images in the prompt are auto-injected // so this tool is only needed when image wasn't provided in the prompt const description = options?.modelHasVision - ? "Analyze an image with a vision model. Only use this tool when the image was NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you." - : "Analyze an image with the configured image model (agents.defaults.imageModel). Provide a prompt and image path or URL."; + ? "Analyze one or more images with a vision model. Pass a single image path/URL or an array of up to 20. Only use this tool when images were NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you." + : "Analyze one or more images with the configured image model (agents.defaults.imageModel). Pass a single image path/URL or an array of up to 20. Provide a prompt describing what to analyze."; const localRoots = (() => { const roots = getDefaultLocalRoots(); @@ -368,44 +376,47 @@ export function createImageTool(options?: { description, parameters: Type.Object({ prompt: Type.Optional(Type.String()), - image: Type.String(), + image: Type.Union([Type.String(), Type.Array(Type.String())]), model: Type.Optional(Type.String()), maxBytesMb: Type.Optional(Type.Number()), + maxImages: Type.Optional(Type.Number()), }), execute: async (_toolCallId, args) => { const record = args && typeof args === "object" ? (args as Record) : {}; - const imageRawInput = typeof record.image === "string" ? record.image.trim() : ""; - const imageRaw = imageRawInput.startsWith("@") - ? imageRawInput.slice(1).trim() - : imageRawInput; - if (!imageRaw) { + + // MARK: - Normalize image input (string | string[]) + const rawImageInput = record.image; + const imageInputs: string[] = (() => { + if (typeof rawImageInput === "string") { + return [rawImageInput]; + } + if (Array.isArray(rawImageInput)) { + return rawImageInput.filter((v): v is string => typeof v === "string"); + } + return []; + })(); + if (imageInputs.length === 0) { throw new Error("image required"); } - // The tool accepts file paths, file/data URLs, or http(s) URLs. In some - // agent/model contexts, images can be referenced as pseudo-URIs like - // `image:0` (e.g. "first image in the prompt"). We don't have access to a - // shared image registry here, so fail gracefully instead of attempting to - // `fs.readFile("image:0")` and producing a noisy ENOENT. - const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(imageRaw); - const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(imageRaw); - const isFileUrl = /^file:/i.test(imageRaw); - const isHttpUrl = /^https?:\/\//i.test(imageRaw); - const isDataUrl = /^data:/i.test(imageRaw); - if (hasScheme && !looksLikeWindowsDrivePath && !isFileUrl && !isHttpUrl && !isDataUrl) { + // MARK: - Enforce max images cap + const maxImagesRaw = typeof record.maxImages === "number" ? record.maxImages : undefined; + const maxImages = + typeof maxImagesRaw === "number" && Number.isFinite(maxImagesRaw) && maxImagesRaw > 0 + ? Math.floor(maxImagesRaw) + : DEFAULT_MAX_IMAGES; + if (imageInputs.length > maxImages) { return { content: [ { type: "text", - text: `Unsupported image reference: ${imageRawInput}. Use a file path, a file:// URL, a data: URL, or an http(s) URL.`, + text: `Too many images: ${imageInputs.length} provided, maximum is ${maxImages}. Please reduce the number of images.`, }, ], - details: { - error: "unsupported_image_reference", - image: imageRawInput, - }, + details: { error: "too_many_images", count: imageInputs.length, max: maxImages }, }; } + const promptRaw = typeof record.prompt === "string" && record.prompt.trim() ? record.prompt.trim() @@ -419,73 +430,136 @@ export function createImageTool(options?: { options?.sandbox && options?.sandbox.root.trim() ? { root: options.sandbox.root.trim(), bridge: options.sandbox.bridge } : null; - const isUrl = isHttpUrl; - if (sandboxConfig && isUrl) { - throw new Error("Sandboxed image tool does not allow remote URLs."); - } - const resolvedImage = (() => { - if (sandboxConfig) { + // MARK: - Load and resolve each image + const loadedImages: Array<{ + base64: string; + mimeType: string; + resolvedImage: string; + rewrittenFrom?: string; + }> = []; + + for (const imageRawInput of imageInputs) { + const trimmed = imageRawInput.trim(); + const imageRaw = trimmed.startsWith("@") ? trimmed.slice(1).trim() : trimmed; + if (!imageRaw) { + throw new Error("image required (empty string in array)"); + } + + // The tool accepts file paths, file/data URLs, or http(s) URLs. In some + // agent/model contexts, images can be referenced as pseudo-URIs like + // `image:0` (e.g. "first image in the prompt"). We don't have access to a + // shared image registry here, so fail gracefully instead of attempting to + // `fs.readFile("image:0")` and producing a noisy ENOENT. + const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(imageRaw); + const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(imageRaw); + const isFileUrl = /^file:/i.test(imageRaw); + const isHttpUrl = /^https?:\/\//i.test(imageRaw); + const isDataUrl = /^data:/i.test(imageRaw); + if (hasScheme && !looksLikeWindowsDrivePath && !isFileUrl && !isHttpUrl && !isDataUrl) { + return { + content: [ + { + type: "text", + text: `Unsupported image reference: ${imageRawInput}. Use a file path, a file:// URL, a data: URL, or an http(s) URL.`, + }, + ], + details: { + error: "unsupported_image_reference", + image: imageRawInput, + }, + }; + } + + if (sandboxConfig && isHttpUrl) { + throw new Error("Sandboxed image tool does not allow remote URLs."); + } + + const resolvedImage = (() => { + if (sandboxConfig) { + return imageRaw; + } + if (imageRaw.startsWith("~")) { + return resolveUserPath(imageRaw); + } return imageRaw; - } - if (imageRaw.startsWith("~")) { - return resolveUserPath(imageRaw); - } - return imageRaw; - })(); - const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl - ? { resolved: "" } - : sandboxConfig - ? await resolveSandboxedImagePath({ - sandbox: sandboxConfig, - imagePath: resolvedImage, - }) - : { - resolved: resolvedImage.startsWith("file://") - ? resolvedImage.slice("file://".length) - : resolvedImage, - }; - const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved; + })(); + const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl + ? { resolved: "" } + : sandboxConfig + ? await resolveSandboxedImagePath({ + sandbox: sandboxConfig, + imagePath: resolvedImage, + }) + : { + resolved: resolvedImage.startsWith("file://") + ? resolvedImage.slice("file://".length) + : resolvedImage, + }; + const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved; - const media = isDataUrl - ? decodeDataUrl(resolvedImage) - : sandboxConfig - ? await loadWebMedia(resolvedPath ?? resolvedImage, { - maxBytes, - sandboxValidated: true, - readFile: (filePath) => - sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }), - }) - : await loadWebMedia(resolvedPath ?? resolvedImage, { - maxBytes, - localRoots, - }); - if (media.kind !== "image") { - throw new Error(`Unsupported media type: ${media.kind}`); + const media = isDataUrl + ? decodeDataUrl(resolvedImage) + : sandboxConfig + ? await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes, + sandboxValidated: true, + readFile: (filePath) => + sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }), + }) + : await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes, + localRoots, + }); + if (media.kind !== "image") { + throw new Error(`Unsupported media type: ${media.kind}`); + } + + const mimeType = + ("contentType" in media && media.contentType) || + ("mimeType" in media && media.mimeType) || + "image/png"; + const base64 = media.buffer.toString("base64"); + loadedImages.push({ + base64, + mimeType, + resolvedImage, + ...(resolvedPathInfo.rewrittenFrom + ? { rewrittenFrom: resolvedPathInfo.rewrittenFrom } + : {}), + }); } - const mimeType = - ("contentType" in media && media.contentType) || - ("mimeType" in media && media.mimeType) || - "image/png"; - const base64 = media.buffer.toString("base64"); + // MARK: - Run image prompt with all loaded images const result = await runImagePrompt({ cfg: options?.config, agentDir, imageModelConfig, modelOverride, prompt: promptRaw, - base64, - mimeType, + images: loadedImages.map((img) => ({ base64: img.base64, mimeType: img.mimeType })), }); + + const imageDetails = + loadedImages.length === 1 + ? { + image: loadedImages[0].resolvedImage, + ...(loadedImages[0].rewrittenFrom + ? { rewrittenFrom: loadedImages[0].rewrittenFrom } + : {}), + } + : { + images: loadedImages.map((img) => ({ + image: img.resolvedImage, + ...(img.rewrittenFrom ? { rewrittenFrom: img.rewrittenFrom } : {}), + })), + }; + return { content: [{ type: "text", text: result.text }], details: { model: `${result.provider}/${result.model}`, - image: resolvedImage, - ...(resolvedPathInfo.rewrittenFrom - ? { rewrittenFrom: resolvedPathInfo.rewrittenFrom } - : {}), + ...imageDetails, attempts: result.attempts, }, }; diff --git a/src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts b/src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts deleted file mode 100644 index 85535cedfe5..00000000000 --- a/src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("../../memory/index.js", () => { - return { - getMemorySearchManager: async () => { - return { - manager: { - search: async () => { - throw new Error("openai embeddings failed: 429 insufficient_quota"); - }, - readFile: async () => { - throw new Error("path required"); - }, - status: () => ({ - files: 0, - chunks: 0, - dirty: true, - workspaceDir: "/tmp", - dbPath: "/tmp/index.sqlite", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", - }), - }, - }; - }, - }; -}); - -import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js"; - -describe("memory tools", () => { - it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { - const cfg = { agents: { list: [{ id: "main", default: true }] } }; - const tool = createMemorySearchTool({ config: cfg }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("tool missing"); - } - - const result = await tool.execute("call_1", { query: "hello" }); - expect(result.details).toEqual({ - results: [], - disabled: true, - error: "openai embeddings failed: 429 insufficient_quota", - }); - }); - - it("does not throw when memory_get fails", async () => { - const cfg = { agents: { list: [{ id: "main", default: true }] } }; - const tool = createMemoryGetTool({ config: cfg }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("tool missing"); - } - - const result = await tool.execute("call_2", { path: "memory/NOPE.md" }); - expect(result.details).toEqual({ - path: "memory/NOPE.md", - text: "", - disabled: true, - error: "path required", - }); - }); -}); diff --git a/src/agents/tools/memory-tool.citations.e2e.test.ts b/src/agents/tools/memory-tool.e2e.test.ts similarity index 69% rename from src/agents/tools/memory-tool.citations.e2e.test.ts rename to src/agents/tools/memory-tool.e2e.test.ts index 8e4d5c1b7fd..38e2caab24d 100644 --- a/src/agents/tools/memory-tool.citations.e2e.test.ts +++ b/src/agents/tools/memory-tool.e2e.test.ts @@ -1,18 +1,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let backend: "builtin" | "qmd" = "builtin"; +let searchImpl: () => Promise = async () => [ + { + path: "MEMORY.md", + startLine: 5, + endLine: 7, + score: 0.9, + snippet: "@@ -5,3 @@\nAssistant: noted", + source: "memory" as const, + }, +]; +let readFileImpl: () => Promise = async () => ""; + const stubManager = { - search: vi.fn(async () => [ - { - path: "MEMORY.md", - startLine: 5, - endLine: 7, - score: 0.9, - snippet: "@@ -5,3 @@\nAssistant: noted", - source: "memory" as const, - }, - ]), - readFile: vi.fn(), + search: vi.fn(async () => await searchImpl()), + readFile: vi.fn(async () => await readFileImpl()), status: () => ({ backend, files: 1, @@ -37,9 +40,21 @@ vi.mock("../../memory/index.js", () => { }; }); -import { createMemorySearchTool } from "./memory-tool.js"; +import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js"; beforeEach(() => { + backend = "builtin"; + searchImpl = async () => [ + { + path: "MEMORY.md", + startLine: 5, + endLine: 7, + score: 0.9, + snippet: "@@ -5,3 @@\nAssistant: noted", + source: "memory" as const, + }, + ]; + readFileImpl = async () => ""; vi.clearAllMocks(); }); @@ -121,3 +136,46 @@ describe("memory search citations", () => { expect(details.results[0]?.snippet).not.toMatch(/Source:/); }); }); + +describe("memory tools", () => { + it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { + searchImpl = async () => { + throw new Error("openai embeddings failed: 429 insufficient_quota"); + }; + + const cfg = { agents: { list: [{ id: "main", default: true }] } }; + const tool = createMemorySearchTool({ config: cfg }); + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("tool missing"); + } + + const result = await tool.execute("call_1", { query: "hello" }); + expect(result.details).toEqual({ + results: [], + disabled: true, + error: "openai embeddings failed: 429 insufficient_quota", + }); + }); + + it("does not throw when memory_get fails", async () => { + readFileImpl = async () => { + throw new Error("path required"); + }; + + const cfg = { agents: { list: [{ id: "main", default: true }] } }; + const tool = createMemoryGetTool({ config: cfg }); + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("tool missing"); + } + + const result = await tool.execute("call_2", { path: "memory/NOPE.md" }); + expect(result.details).toEqual({ + path: "memory/NOPE.md", + text: "", + disabled: true, + error: "path required", + }); + }); +}); diff --git a/src/agents/tools/memory-tool.ts b/src/agents/tools/memory-tool.ts index 953a0582115..e0cb82d6d41 100644 --- a/src/agents/tools/memory-tool.ts +++ b/src/agents/tools/memory-tool.ts @@ -22,10 +22,7 @@ const MemoryGetSchema = Type.Object({ lines: Type.Optional(Type.Number()), }); -export function createMemorySearchTool(options: { - config?: OpenClawConfig; - agentSessionKey?: string; -}): AnyAgentTool | null { +function resolveMemoryToolContext(options: { config?: OpenClawConfig; agentSessionKey?: string }) { const cfg = options.config; if (!cfg) { return null; @@ -37,6 +34,18 @@ export function createMemorySearchTool(options: { if (!resolveMemorySearchConfig(cfg, agentId)) { return null; } + return { cfg, agentId }; +} + +export function createMemorySearchTool(options: { + config?: OpenClawConfig; + agentSessionKey?: string; +}): AnyAgentTool | null { + const ctx = resolveMemoryToolContext(options); + if (!ctx) { + return null; + } + const { cfg, agentId } = ctx; return { label: "Memory Search", name: "memory_search", @@ -91,17 +100,11 @@ export function createMemoryGetTool(options: { config?: OpenClawConfig; agentSessionKey?: string; }): AnyAgentTool | null { - const cfg = options.config; - if (!cfg) { - return null; - } - const agentId = resolveSessionAgentId({ - sessionKey: options.agentSessionKey, - config: cfg, - }); - if (!resolveMemorySearchConfig(cfg, agentId)) { + const ctx = resolveMemoryToolContext(options); + if (!ctx) { return null; } + const { cfg, agentId } = ctx; return { label: "Memory Get", name: "memory_get", diff --git a/src/agents/tools/message-tool.e2e.test.ts b/src/agents/tools/message-tool.e2e.test.ts index 5c974e001c7..6a7d2eed24b 100644 --- a/src/agents/tools/message-tool.e2e.test.ts +++ b/src/agents/tools/message-tool.e2e.test.ts @@ -44,7 +44,7 @@ describe("message tool agent routing", () => { const call = mocks.runMessageAction.mock.calls[0]?.[0]; expect(call?.agentId).toBe("alpha"); - expect(call?.sessionKey).toBeUndefined(); + expect(call?.sessionKey).toBe("agent:alpha:main"); }); }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index c30b89d4894..e894f71cf0d 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -47,6 +47,98 @@ function buildRoutingSchema() { }; } +const discordComponentEmojiSchema = Type.Object({ + name: Type.String(), + id: Type.Optional(Type.String()), + animated: Type.Optional(Type.Boolean()), +}); + +const discordComponentOptionSchema = Type.Object({ + label: Type.String(), + value: Type.String(), + description: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + default: Type.Optional(Type.Boolean()), +}); + +const discordComponentButtonSchema = Type.Object({ + label: Type.String(), + style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + url: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + disabled: Type.Optional(Type.Boolean()), +}); + +const discordComponentSelectSchema = Type.Object({ + type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])), + placeholder: Type.Optional(Type.String()), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), +}); + +const discordComponentBlockSchema = Type.Object({ + type: Type.String(), + text: Type.Optional(Type.String()), + texts: Type.Optional(Type.Array(Type.String())), + accessory: Type.Optional( + Type.Object({ + type: Type.String(), + url: Type.Optional(Type.String()), + button: Type.Optional(discordComponentButtonSchema), + }), + ), + spacing: Type.Optional(stringEnum(["small", "large"])), + divider: Type.Optional(Type.Boolean()), + buttons: Type.Optional(Type.Array(discordComponentButtonSchema)), + select: Type.Optional(discordComponentSelectSchema), + items: Type.Optional( + Type.Array( + Type.Object({ + url: Type.String(), + description: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + ), + file: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), +}); + +const discordComponentModalFieldSchema = Type.Object({ + type: Type.String(), + name: Type.Optional(Type.String()), + label: Type.String(), + description: Type.Optional(Type.String()), + placeholder: Type.Optional(Type.String()), + required: Type.Optional(Type.Boolean()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + minLength: Type.Optional(Type.Number()), + maxLength: Type.Optional(Type.Number()), + style: Type.Optional(stringEnum(["short", "paragraph"])), +}); + +const discordComponentModalSchema = Type.Object({ + title: Type.String(), + triggerLabel: Type.Optional(Type.String()), + triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + fields: Type.Array(discordComponentModalFieldSchema), +}); + +const discordComponentMessageSchema = Type.Object({ + text: Type.Optional(Type.String()), + container: Type.Optional( + Type.Object({ + accentColor: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + blocks: Type.Optional(Type.Array(discordComponentBlockSchema)), + modal: Type.Optional(discordComponentModalSchema), +}); + function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) { const props: Record = { message: Type.Optional(Type.String()), @@ -105,6 +197,7 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole }, ), ), + components: Type.Optional(discordComponentMessageSchema), }; if (!options.includeButtons) { delete props.buttons; @@ -481,6 +574,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { defaultAccountId: accountId ?? undefined, gateway, toolContext, + sessionKey: options?.agentSessionKey, agentId: options?.agentSessionKey ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) : undefined, diff --git a/src/agents/tools/sessions-access.test.ts b/src/agents/tools/sessions-access.test.ts new file mode 100644 index 00000000000..0f18191b5b9 --- /dev/null +++ b/src/agents/tools/sessions-access.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + createAgentToAgentPolicy, + createSessionVisibilityGuard, + resolveEffectiveSessionToolsVisibility, + resolveSandboxSessionToolsVisibility, + resolveSandboxedSessionToolContext, + resolveSessionToolsVisibility, +} from "./sessions-access.js"; + +describe("resolveSessionToolsVisibility", () => { + it("defaults to tree when unset or invalid", () => { + expect(resolveSessionToolsVisibility({} as OpenClawConfig)).toBe("tree"); + expect( + resolveSessionToolsVisibility({ + tools: { sessions: { visibility: "invalid" } }, + } as OpenClawConfig), + ).toBe("tree"); + }); + + it("accepts known visibility values case-insensitively", () => { + expect( + resolveSessionToolsVisibility({ + tools: { sessions: { visibility: "ALL" } }, + } as OpenClawConfig), + ).toBe("all"); + }); +}); + +describe("resolveEffectiveSessionToolsVisibility", () => { + it("clamps to tree in sandbox when sandbox visibility is spawned", () => { + const cfg = { + tools: { sessions: { visibility: "all" } }, + agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } }, + } as OpenClawConfig; + expect(resolveEffectiveSessionToolsVisibility({ cfg, sandboxed: true })).toBe("tree"); + }); + + it("preserves visibility when sandbox clamp is all", () => { + const cfg = { + tools: { sessions: { visibility: "all" } }, + agents: { defaults: { sandbox: { sessionToolsVisibility: "all" } } }, + } as OpenClawConfig; + expect(resolveEffectiveSessionToolsVisibility({ cfg, sandboxed: true })).toBe("all"); + }); +}); + +describe("sandbox session-tools context", () => { + it("defaults sandbox visibility clamp to spawned", () => { + expect(resolveSandboxSessionToolsVisibility({} as OpenClawConfig)).toBe("spawned"); + }); + + it("restricts non-subagent sandboxed sessions to spawned visibility", () => { + const cfg = { + tools: { sessions: { visibility: "all" } }, + agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } }, + } as OpenClawConfig; + const context = resolveSandboxedSessionToolContext({ + cfg, + agentSessionKey: "agent:main:main", + sandboxed: true, + }); + + expect(context.restrictToSpawned).toBe(true); + expect(context.requesterInternalKey).toBe("agent:main:main"); + expect(context.effectiveRequesterKey).toBe("agent:main:main"); + }); + + it("does not restrict subagent sessions in sandboxed mode", () => { + const cfg = { + tools: { sessions: { visibility: "all" } }, + agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } }, + } as OpenClawConfig; + const context = resolveSandboxedSessionToolContext({ + cfg, + agentSessionKey: "agent:main:subagent:abc", + sandboxed: true, + }); + + expect(context.restrictToSpawned).toBe(false); + expect(context.requesterInternalKey).toBe("agent:main:subagent:abc"); + }); +}); + +describe("createAgentToAgentPolicy", () => { + it("denies cross-agent access when disabled", () => { + const policy = createAgentToAgentPolicy({} as OpenClawConfig); + expect(policy.enabled).toBe(false); + expect(policy.isAllowed("main", "main")).toBe(true); + expect(policy.isAllowed("main", "ops")).toBe(false); + }); + + it("honors allow patterns when enabled", () => { + const policy = createAgentToAgentPolicy({ + tools: { + agentToAgent: { + enabled: true, + allow: ["ops-*", "main"], + }, + }, + } as OpenClawConfig); + + expect(policy.isAllowed("ops-a", "ops-b")).toBe(true); + expect(policy.isAllowed("main", "ops-a")).toBe(true); + expect(policy.isAllowed("guest", "ops-a")).toBe(false); + }); +}); + +describe("createSessionVisibilityGuard", () => { + it("blocks cross-agent send when agent-to-agent is disabled", async () => { + const guard = await createSessionVisibilityGuard({ + action: "send", + requesterSessionKey: "agent:main:main", + visibility: "all", + a2aPolicy: createAgentToAgentPolicy({} as OpenClawConfig), + }); + + expect(guard.check("agent:ops:main")).toEqual({ + allowed: false, + status: "forbidden", + error: + "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.", + }); + }); + + it("enforces self visibility for same-agent sessions", async () => { + const guard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: "agent:main:main", + visibility: "self", + a2aPolicy: createAgentToAgentPolicy({} as OpenClawConfig), + }); + + expect(guard.check("agent:main:main")).toEqual({ allowed: true }); + expect(guard.check("agent:main:telegram:group:1")).toEqual({ + allowed: false, + status: "forbidden", + error: + "Session history visibility is restricted to the current session (tools.sessions.visibility=self).", + }); + }); +}); diff --git a/src/agents/tools/sessions-access.ts b/src/agents/tools/sessions-access.ts new file mode 100644 index 00000000000..6574c2296cf --- /dev/null +++ b/src/agents/tools/sessions-access.ts @@ -0,0 +1,240 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { + listSpawnedSessionKeys, + resolveInternalSessionKey, + resolveMainSessionAlias, +} from "./sessions-resolution.js"; + +export type SessionToolsVisibility = "self" | "tree" | "agent" | "all"; + +export type AgentToAgentPolicy = { + enabled: boolean; + matchesAllow: (agentId: string) => boolean; + isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean; +}; + +export type SessionAccessAction = "history" | "send" | "list"; + +export type SessionAccessResult = + | { allowed: true } + | { allowed: false; error: string; status: "forbidden" }; + +export function resolveSessionToolsVisibility(cfg: OpenClawConfig): SessionToolsVisibility { + const raw = (cfg.tools as { sessions?: { visibility?: unknown } } | undefined)?.sessions + ?.visibility; + const value = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + if (value === "self" || value === "tree" || value === "agent" || value === "all") { + return value; + } + return "tree"; +} + +export function resolveEffectiveSessionToolsVisibility(params: { + cfg: OpenClawConfig; + sandboxed: boolean; +}): SessionToolsVisibility { + const visibility = resolveSessionToolsVisibility(params.cfg); + if (!params.sandboxed) { + return visibility; + } + const sandboxClamp = params.cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; + if (sandboxClamp === "spawned" && visibility !== "tree") { + return "tree"; + } + return visibility; +} + +export function resolveSandboxSessionToolsVisibility(cfg: OpenClawConfig): "spawned" | "all" { + return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; +} + +export function resolveSandboxedSessionToolContext(params: { + cfg: OpenClawConfig; + agentSessionKey?: string; + sandboxed?: boolean; +}): { + mainKey: string; + alias: string; + visibility: "spawned" | "all"; + requesterInternalKey: string | undefined; + effectiveRequesterKey: string; + restrictToSpawned: boolean; +} { + const { mainKey, alias } = resolveMainSessionAlias(params.cfg); + const visibility = resolveSandboxSessionToolsVisibility(params.cfg); + const requesterInternalKey = + typeof params.agentSessionKey === "string" && params.agentSessionKey.trim() + ? resolveInternalSessionKey({ + key: params.agentSessionKey, + alias, + mainKey, + }) + : undefined; + const effectiveRequesterKey = requesterInternalKey ?? alias; + const restrictToSpawned = + params.sandboxed === true && + visibility === "spawned" && + !!requesterInternalKey && + !isSubagentSessionKey(requesterInternalKey); + return { + mainKey, + alias, + visibility, + requesterInternalKey, + effectiveRequesterKey, + restrictToSpawned, + }; +} + +export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolicy { + const routingA2A = cfg.tools?.agentToAgent; + const enabled = routingA2A?.enabled === true; + const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; + const matchesAllow = (agentId: string) => { + if (allowPatterns.length === 0) { + return true; + } + return allowPatterns.some((pattern) => { + const raw = String(pattern ?? "").trim(); + if (!raw) { + return false; + } + if (raw === "*") { + return true; + } + if (!raw.includes("*")) { + return raw === agentId; + } + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); + return re.test(agentId); + }); + }; + const isAllowed = (requesterAgentId: string, targetAgentId: string) => { + if (requesterAgentId === targetAgentId) { + return true; + } + if (!enabled) { + return false; + } + return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId); + }; + return { enabled, matchesAllow, isAllowed }; +} + +function actionPrefix(action: SessionAccessAction): string { + if (action === "history") { + return "Session history"; + } + if (action === "send") { + return "Session send"; + } + return "Session list"; +} + +function a2aDisabledMessage(action: SessionAccessAction): string { + if (action === "history") { + return "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access."; + } + if (action === "send") { + return "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends."; + } + return "Agent-to-agent listing is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent visibility."; +} + +function a2aDeniedMessage(action: SessionAccessAction): string { + if (action === "history") { + return "Agent-to-agent history denied by tools.agentToAgent.allow."; + } + if (action === "send") { + return "Agent-to-agent messaging denied by tools.agentToAgent.allow."; + } + return "Agent-to-agent listing denied by tools.agentToAgent.allow."; +} + +function crossVisibilityMessage(action: SessionAccessAction): string { + if (action === "history") { + return "Session history visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; + } + if (action === "send") { + return "Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; + } + return "Session list visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; +} + +function selfVisibilityMessage(action: SessionAccessAction): string { + return `${actionPrefix(action)} visibility is restricted to the current session (tools.sessions.visibility=self).`; +} + +function treeVisibilityMessage(action: SessionAccessAction): string { + return `${actionPrefix(action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`; +} + +export async function createSessionVisibilityGuard(params: { + action: SessionAccessAction; + requesterSessionKey: string; + visibility: SessionToolsVisibility; + a2aPolicy: AgentToAgentPolicy; +}): Promise<{ + check: (targetSessionKey: string) => SessionAccessResult; +}> { + const requesterAgentId = resolveAgentIdFromSessionKey(params.requesterSessionKey); + const spawnedKeys = + params.visibility === "tree" + ? await listSpawnedSessionKeys({ requesterSessionKey: params.requesterSessionKey }) + : null; + + const check = (targetSessionKey: string): SessionAccessResult => { + const targetAgentId = resolveAgentIdFromSessionKey(targetSessionKey); + const isCrossAgent = targetAgentId !== requesterAgentId; + if (isCrossAgent) { + if (params.visibility !== "all") { + return { + allowed: false, + status: "forbidden", + error: crossVisibilityMessage(params.action), + }; + } + if (!params.a2aPolicy.enabled) { + return { + allowed: false, + status: "forbidden", + error: a2aDisabledMessage(params.action), + }; + } + if (!params.a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { + return { + allowed: false, + status: "forbidden", + error: a2aDeniedMessage(params.action), + }; + } + return { allowed: true }; + } + + if (params.visibility === "self" && targetSessionKey !== params.requesterSessionKey) { + return { + allowed: false, + status: "forbidden", + error: selfVisibilityMessage(params.action), + }; + } + + if ( + params.visibility === "tree" && + targetSessionKey !== params.requesterSessionKey && + !spawnedKeys?.has(targetSessionKey) + ) { + return { + allowed: false, + status: "forbidden", + error: treeVisibilityMessage(params.action), + }; + } + + return { allowed: true }; + }; + + return { check }; +} diff --git a/src/agents/tools/sessions-announce-target.e2e.test.ts b/src/agents/tools/sessions-announce-target.e2e.test.ts deleted file mode 100644 index fe28be7dff9..00000000000 --- a/src/agents/tools/sessions-announce-target.e2e.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -const loadResolveAnnounceTarget = async () => await import("./sessions-announce-target.js"); - -const installRegistry = async () => { - const { setActivePluginRegistry } = await import("../../plugins/runtime.js"); - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "discord", - source: "test", - plugin: { - id: "discord", - meta: { - id: "discord", - label: "Discord", - selectionLabel: "Discord", - docsPath: "/channels/discord", - blurb: "Discord test stub.", - }, - capabilities: { chatTypes: ["direct", "channel", "thread"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "whatsapp", - source: "test", - plugin: { - id: "whatsapp", - meta: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp", - docsPath: "/channels/whatsapp", - blurb: "WhatsApp test stub.", - preferSessionLookupForAnnounceTarget: true, - }, - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - ]), - ); -}; - -describe("resolveAnnounceTarget", () => { - beforeEach(async () => { - callGatewayMock.mockReset(); - await installRegistry(); - }); - - it("derives non-WhatsApp announce targets from the session key", async () => { - const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); - const target = await resolveAnnounceTarget({ - sessionKey: "agent:main:discord:group:dev", - displayKey: "agent:main:discord:group:dev", - }); - expect(target).toEqual({ channel: "discord", to: "channel:dev" }); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("hydrates WhatsApp accountId from sessions.list when available", async () => { - const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); - callGatewayMock.mockResolvedValueOnce({ - sessions: [ - { - key: "agent:main:whatsapp:group:123@g.us", - deliveryContext: { - channel: "whatsapp", - to: "123@g.us", - accountId: "work", - }, - }, - ], - }); - - const target = await resolveAnnounceTarget({ - sessionKey: "agent:main:whatsapp:group:123@g.us", - displayKey: "agent:main:whatsapp:group:123@g.us", - }); - expect(target).toEqual({ - channel: "whatsapp", - to: "123@g.us", - accountId: "work", - }); - expect(callGatewayMock).toHaveBeenCalledTimes(1); - const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined; - expect(first).toBeDefined(); - expect(first?.method).toBe("sessions.list"); - }); -}); diff --git a/src/agents/tools/sessions-helpers.e2e.test.ts b/src/agents/tools/sessions-helpers.e2e.test.ts deleted file mode 100644 index 887cc1f4670..00000000000 --- a/src/agents/tools/sessions-helpers.e2e.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js"; - -describe("sanitizeTextContent", () => { - it("strips minimax tool call XML and downgraded markers", () => { - const input = - 'Hello payload ' + - "[Tool Call: foo (ID: 1)] world"; - const result = sanitizeTextContent(input).trim(); - expect(result).toBe("Hello world"); - expect(result).not.toContain("invoke"); - expect(result).not.toContain("Tool Call"); - }); - - it("strips thinking tags", () => { - const input = "Before secret after"; - const result = sanitizeTextContent(input).trim(); - expect(result).toBe("Before after"); - }); -}); - -describe("extractAssistantText", () => { - it("sanitizes blocks without injecting newlines", () => { - const message = { - role: "assistant", - content: [ - { type: "text", text: "Hi " }, - { type: "text", text: "secretthere" }, - ], - }; - expect(extractAssistantText(message)).toBe("Hi there"); - }); - - it("rewrites error-ish assistant text only when the transcript marks it as an error", () => { - const message = { - role: "assistant", - stopReason: "error", - errorMessage: "500 Internal Server Error", - content: [{ type: "text", text: "500 Internal Server Error" }], - }; - expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error"); - }); - - it("keeps normal status text that mentions billing", () => { - const message = { - role: "assistant", - content: [ - { - type: "text", - text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", - }, - ], - }; - expect(extractAssistantText(message)).toBe( - "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", - ); - }); -}); diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 1b399de5a80..09c21e69998 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -1,10 +1,29 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import { callGateway } from "../../gateway/call.js"; -import { - isAcpSessionKey, - isSubagentSessionKey, - normalizeMainKey, -} from "../../routing/session-key.js"; +export type { + AgentToAgentPolicy, + SessionAccessAction, + SessionAccessResult, + SessionToolsVisibility, +} from "./sessions-access.js"; +export { + createAgentToAgentPolicy, + createSessionVisibilityGuard, + resolveEffectiveSessionToolsVisibility, + resolveSandboxSessionToolsVisibility, + resolveSandboxedSessionToolContext, + resolveSessionToolsVisibility, +} from "./sessions-access.js"; +export type { SessionReferenceResolution } from "./sessions-resolution.js"; +export { + isRequesterSpawnedSessionVisible, + listSpawnedSessionKeys, + looksLikeSessionId, + looksLikeSessionKey, + resolveDisplaySessionKey, + resolveInternalSessionKey, + resolveMainSessionAlias, + resolveSessionReference, + shouldResolveSessionIdInput, +} from "./sessions-resolution.js"; import { sanitizeUserFacingText } from "../pi-embedded-helpers.js"; import { stripDowngradedToolCallText, @@ -49,282 +68,6 @@ function normalizeKey(value?: string) { return trimmed ? trimmed : undefined; } -export function resolveMainSessionAlias(cfg: OpenClawConfig) { - const mainKey = normalizeMainKey(cfg.session?.mainKey); - const scope = cfg.session?.scope ?? "per-sender"; - const alias = scope === "global" ? "global" : mainKey; - return { mainKey, alias, scope }; -} - -export function resolveDisplaySessionKey(params: { key: string; alias: string; mainKey: string }) { - if (params.key === params.alias) { - return "main"; - } - if (params.key === params.mainKey) { - return "main"; - } - return params.key; -} - -export function resolveInternalSessionKey(params: { key: string; alias: string; mainKey: string }) { - if (params.key === "main") { - return params.alias; - } - return params.key; -} - -export function resolveSandboxSessionToolsVisibility(cfg: OpenClawConfig): "spawned" | "all" { - return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; -} - -export function resolveSandboxedSessionToolContext(params: { - cfg: OpenClawConfig; - agentSessionKey?: string; - sandboxed?: boolean; -}): { - mainKey: string; - alias: string; - visibility: "spawned" | "all"; - requesterInternalKey: string | undefined; - restrictToSpawned: boolean; -} { - const { mainKey, alias } = resolveMainSessionAlias(params.cfg); - const visibility = resolveSandboxSessionToolsVisibility(params.cfg); - const requesterInternalKey = - typeof params.agentSessionKey === "string" && params.agentSessionKey.trim() - ? resolveInternalSessionKey({ - key: params.agentSessionKey, - alias, - mainKey, - }) - : undefined; - const restrictToSpawned = - params.sandboxed === true && - visibility === "spawned" && - !!requesterInternalKey && - !isSubagentSessionKey(requesterInternalKey); - return { mainKey, alias, visibility, requesterInternalKey, restrictToSpawned }; -} - -export type AgentToAgentPolicy = { - enabled: boolean; - matchesAllow: (agentId: string) => boolean; - isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean; -}; - -export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolicy { - const routingA2A = cfg.tools?.agentToAgent; - const enabled = routingA2A?.enabled === true; - const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; - const matchesAllow = (agentId: string) => { - if (allowPatterns.length === 0) { - return true; - } - return allowPatterns.some((pattern) => { - const raw = String(pattern ?? "").trim(); - if (!raw) { - return false; - } - if (raw === "*") { - return true; - } - if (!raw.includes("*")) { - return raw === agentId; - } - const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); - return re.test(agentId); - }); - }; - const isAllowed = (requesterAgentId: string, targetAgentId: string) => { - if (requesterAgentId === targetAgentId) { - return true; - } - if (!enabled) { - return false; - } - return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId); - }; - return { enabled, matchesAllow, isAllowed }; -} - -const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function looksLikeSessionId(value: string): boolean { - return SESSION_ID_RE.test(value.trim()); -} - -export function looksLikeSessionKey(value: string): boolean { - const raw = value.trim(); - if (!raw) { - return false; - } - // These are canonical key shapes that should never be treated as sessionIds. - if (raw === "main" || raw === "global" || raw === "unknown") { - return true; - } - if (isAcpSessionKey(raw)) { - return true; - } - if (raw.startsWith("agent:")) { - return true; - } - if (raw.startsWith("cron:") || raw.startsWith("hook:")) { - return true; - } - if (raw.startsWith("node-") || raw.startsWith("node:")) { - return true; - } - if (raw.includes(":group:") || raw.includes(":channel:")) { - return true; - } - return false; -} - -export function shouldResolveSessionIdInput(value: string): boolean { - // Treat anything that doesn't look like a well-formed key as a sessionId candidate. - return looksLikeSessionId(value) || !looksLikeSessionKey(value); -} - -export type SessionReferenceResolution = - | { - ok: true; - key: string; - displayKey: string; - resolvedViaSessionId: boolean; - } - | { ok: false; status: "error" | "forbidden"; error: string }; - -async function resolveSessionKeyFromSessionId(params: { - sessionId: string; - alias: string; - mainKey: string; - requesterInternalKey?: string; - restrictToSpawned: boolean; -}): Promise { - try { - // Resolve via gateway so we respect store routing and visibility rules. - const result = await callGateway<{ key?: string }>({ - method: "sessions.resolve", - params: { - sessionId: params.sessionId, - spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, - includeGlobal: !params.restrictToSpawned, - includeUnknown: !params.restrictToSpawned, - }, - }); - const key = typeof result?.key === "string" ? result.key.trim() : ""; - if (!key) { - throw new Error( - `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, - ); - } - return { - ok: true, - key, - displayKey: resolveDisplaySessionKey({ - key, - alias: params.alias, - mainKey: params.mainKey, - }), - resolvedViaSessionId: true, - }; - } catch (err) { - if (params.restrictToSpawned) { - return { - ok: false, - status: "forbidden", - error: `Session not visible from this sandboxed agent session: ${params.sessionId}`, - }; - } - const message = err instanceof Error ? err.message : String(err); - return { - ok: false, - status: "error", - error: - message || - `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, - }; - } -} - -async function resolveSessionKeyFromKey(params: { - key: string; - alias: string; - mainKey: string; - requesterInternalKey?: string; - restrictToSpawned: boolean; -}): Promise { - try { - // Try key-based resolution first so non-standard keys keep working. - const result = await callGateway<{ key?: string }>({ - method: "sessions.resolve", - params: { - key: params.key, - spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, - }, - }); - const key = typeof result?.key === "string" ? result.key.trim() : ""; - if (!key) { - return null; - } - return { - ok: true, - key, - displayKey: resolveDisplaySessionKey({ - key, - alias: params.alias, - mainKey: params.mainKey, - }), - resolvedViaSessionId: false, - }; - } catch { - return null; - } -} - -export async function resolveSessionReference(params: { - sessionKey: string; - alias: string; - mainKey: string; - requesterInternalKey?: string; - restrictToSpawned: boolean; -}): Promise { - const raw = params.sessionKey.trim(); - if (shouldResolveSessionIdInput(raw)) { - // Prefer key resolution to avoid misclassifying custom keys as sessionIds. - const resolvedByKey = await resolveSessionKeyFromKey({ - key: raw, - alias: params.alias, - mainKey: params.mainKey, - requesterInternalKey: params.requesterInternalKey, - restrictToSpawned: params.restrictToSpawned, - }); - if (resolvedByKey) { - return resolvedByKey; - } - return await resolveSessionKeyFromSessionId({ - sessionId: raw, - alias: params.alias, - mainKey: params.mainKey, - requesterInternalKey: params.requesterInternalKey, - restrictToSpawned: params.restrictToSpawned, - }); - } - - const resolvedKey = resolveInternalSessionKey({ - key: raw, - alias: params.alias, - mainKey: params.mainKey, - }); - const displayKey = resolveDisplaySessionKey({ - key: resolvedKey, - alias: params.alias, - mainKey: params.mainKey, - }); - return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false }; -} - export function classifySessionKind(params: { key: string; gatewayKind?: string | null; diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index a2b9741d639..dae466b7230 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -3,13 +3,14 @@ import type { AnyAgentTool } from "./common.js"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { truncateUtf16Safe } from "../../utils.js"; import { jsonResult, readStringParam } from "./common.js"; import { + createSessionVisibilityGuard, createAgentToAgentPolicy, + isRequesterSpawnedSessionVisible, + resolveEffectiveSessionToolsVisibility, resolveSessionReference, - SessionListRow, resolveSandboxedSessionToolContext, stripToolMessages, } from "./sessions-helpers.js"; @@ -147,27 +148,6 @@ function enforceSessionsHistoryHardCap(params: { return { items: placeholder, bytes: jsonUtf8Bytes(placeholder), hardCapped: true }; } -async function isSpawnedSessionAllowed(params: { - requesterSessionKey: string; - targetSessionKey: string; -}): Promise { - try { - const list = await callGateway<{ sessions: Array }>({ - method: "sessions.list", - params: { - includeGlobal: false, - includeUnknown: false, - limit: 500, - spawnedBy: params.requesterSessionKey, - }, - }); - const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - return sessions.some((entry) => entry?.key === params.targetSessionKey); - } catch { - return false; - } -} - export function createSessionsHistoryTool(opts?: { agentSessionKey?: string; sandboxed?: boolean; @@ -183,7 +163,7 @@ export function createSessionsHistoryTool(opts?: { required: true, }); const cfg = loadConfig(); - const { mainKey, alias, requesterInternalKey, restrictToSpawned } = + const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } = resolveSandboxedSessionToolContext({ cfg, agentSessionKey: opts?.agentSessionKey, @@ -193,7 +173,7 @@ export function createSessionsHistoryTool(opts?: { sessionKey: sessionKeyParam, alias, mainKey, - requesterInternalKey, + requesterInternalKey: effectiveRequesterKey, restrictToSpawned, }); if (!resolvedSession.ok) { @@ -203,9 +183,9 @@ export function createSessionsHistoryTool(opts?: { const resolvedKey = resolvedSession.key; const displayKey = resolvedSession.displayKey; const resolvedViaSessionId = resolvedSession.resolvedViaSessionId; - if (restrictToSpawned && requesterInternalKey && !resolvedViaSessionId) { - const ok = await isSpawnedSessionAllowed({ - requesterSessionKey: requesterInternalKey, + if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) { + const ok = await isRequesterSpawnedSessionVisible({ + requesterSessionKey: effectiveRequesterKey, targetSessionKey: resolvedKey, }); if (!ok) { @@ -217,23 +197,22 @@ export function createSessionsHistoryTool(opts?: { } const a2aPolicy = createAgentToAgentPolicy(cfg); - const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey); - const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey); - const isCrossAgent = requesterAgentId !== targetAgentId; - if (isCrossAgent) { - if (!a2aPolicy.enabled) { - return jsonResult({ - status: "forbidden", - error: - "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.", - }); - } - if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { - return jsonResult({ - status: "forbidden", - error: "Agent-to-agent history denied by tools.agentToAgent.allow.", - }); - } + const visibility = resolveEffectiveSessionToolsVisibility({ + cfg, + sandboxed: opts?.sandboxed === true, + }); + const visibilityGuard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: effectiveRequesterKey, + visibility, + a2aPolicy, + }); + const access = visibilityGuard.check(resolvedKey); + if (!access.allowed) { + return jsonResult({ + status: access.status, + error: access.error, + }); } const limit = diff --git a/src/agents/tools/sessions-list-tool.gating.e2e.test.ts b/src/agents/tools/sessions-list-tool.gating.e2e.test.ts deleted file mode 100644 index 636c2c5a1c3..00000000000 --- a/src/agents/tools/sessions-list-tool.gating.e2e.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => - ({ - session: { scope: "per-sender", mainKey: "main" }, - tools: { agentToAgent: { enabled: false } }, - }) as never, - }; -}); - -import { createSessionsListTool } from "./sessions-list-tool.js"; - -describe("sessions_list gating", () => { - beforeEach(() => { - callGatewayMock.mockReset(); - callGatewayMock.mockResolvedValue({ - path: "/tmp/sessions.json", - sessions: [ - { key: "agent:main:main", kind: "direct" }, - { key: "agent:other:main", kind: "direct" }, - ], - }); - }); - - it("filters out other agents when tools.agentToAgent.enabled is false", async () => { - const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); - const result = await tool.execute("call1", {}); - expect(result.details).toMatchObject({ - count: 1, - sessions: [{ key: "agent:main:main" }], - }); - }); -}); diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index abbb6b4958d..277b95f3c27 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -7,10 +7,12 @@ import { callGateway } from "../../gateway/call.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { jsonResult, readStringArrayParam } from "./common.js"; import { + createSessionVisibilityGuard, createAgentToAgentPolicy, classifySessionKind, deriveChannel, resolveDisplaySessionKey, + resolveEffectiveSessionToolsVisibility, resolveInternalSessionKey, resolveSandboxedSessionToolContext, type SessionListRow, @@ -42,6 +44,11 @@ export function createSessionsListTool(opts?: { agentSessionKey: opts?.agentSessionKey, sandboxed: opts?.sandboxed, }); + const effectiveRequesterKey = requesterInternalKey ?? alias; + const visibility = resolveEffectiveSessionToolsVisibility({ + cfg, + sandboxed: opts?.sandboxed === true, + }); const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) => value.trim().toLowerCase(), @@ -72,15 +79,21 @@ export function createSessionsListTool(opts?: { activeMinutes, includeGlobal: !restrictToSpawned, includeUnknown: !restrictToSpawned, - spawnedBy: restrictToSpawned ? requesterInternalKey : undefined, + spawnedBy: restrictToSpawned ? effectiveRequesterKey : undefined, }, }); const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const storePath = typeof list?.path === "string" ? list.path : undefined; const a2aPolicy = createAgentToAgentPolicy(cfg); - const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey); + const visibilityGuard = await createSessionVisibilityGuard({ + action: "list", + requesterSessionKey: effectiveRequesterKey, + visibility, + a2aPolicy, + }); const rows: SessionListRow[] = []; + const historyTargets: Array<{ row: SessionListRow; resolvedKey: string }> = []; for (const entry of sessions) { if (!entry || typeof entry !== "object") { @@ -90,10 +103,8 @@ export function createSessionsListTool(opts?: { if (!key) { continue; } - - const entryAgentId = resolveAgentIdFromSessionKey(key); - const crossAgent = entryAgentId !== requesterAgentId; - if (crossAgent && !a2aPolicy.isAllowed(requesterAgentId, entryAgentId)) { + const access = visibilityGuard.check(key); + if (!access.allowed) { continue; } @@ -188,25 +199,41 @@ export function createSessionsListTool(opts?: { lastAccountId, transcriptPath, }; - if (messageLimit > 0) { const resolvedKey = resolveInternalSessionKey({ key: displayKey, alias, mainKey, }); - const history = await callGateway<{ messages: Array }>({ - method: "chat.history", - params: { sessionKey: resolvedKey, limit: messageLimit }, - }); - const rawMessages = Array.isArray(history?.messages) ? history.messages : []; - const filtered = stripToolMessages(rawMessages); - row.messages = filtered.length > messageLimit ? filtered.slice(-messageLimit) : filtered; + historyTargets.push({ row, resolvedKey }); } - rows.push(row); } + if (messageLimit > 0 && historyTargets.length > 0) { + const maxConcurrent = Math.min(4, historyTargets.length); + let index = 0; + const worker = async () => { + while (true) { + const next = index; + index += 1; + if (next >= historyTargets.length) { + return; + } + const target = historyTargets[next]; + const history = await callGateway<{ messages: Array }>({ + method: "chat.history", + params: { sessionKey: target.resolvedKey, limit: messageLimit }, + }); + const rawMessages = Array.isArray(history?.messages) ? history.messages : []; + const filtered = stripToolMessages(rawMessages); + target.row.messages = + filtered.length > messageLimit ? filtered.slice(-messageLimit) : filtered; + } + }; + await Promise.all(Array.from({ length: maxConcurrent }, () => worker())); + } + return jsonResult({ count: rows.length, sessions: rows, diff --git a/src/agents/tools/sessions-resolution.test.ts b/src/agents/tools/sessions-resolution.test.ts new file mode 100644 index 00000000000..a71bd4a6b7a --- /dev/null +++ b/src/agents/tools/sessions-resolution.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + looksLikeSessionId, + looksLikeSessionKey, + resolveDisplaySessionKey, + resolveInternalSessionKey, + resolveMainSessionAlias, + shouldResolveSessionIdInput, +} from "./sessions-resolution.js"; + +describe("resolveMainSessionAlias", () => { + it("uses normalized main key and global alias for global scope", () => { + const cfg = { + session: { mainKey: " Primary ", scope: "global" }, + } as OpenClawConfig; + + expect(resolveMainSessionAlias(cfg)).toEqual({ + mainKey: "primary", + alias: "global", + scope: "global", + }); + }); + + it("falls back to per-sender defaults", () => { + expect(resolveMainSessionAlias({} as OpenClawConfig)).toEqual({ + mainKey: "main", + alias: "main", + scope: "per-sender", + }); + }); +}); + +describe("session key display/internal mapping", () => { + it("maps alias and main key to display main", () => { + expect(resolveDisplaySessionKey({ key: "global", alias: "global", mainKey: "main" })).toBe( + "main", + ); + expect(resolveDisplaySessionKey({ key: "main", alias: "global", mainKey: "main" })).toBe( + "main", + ); + expect( + resolveDisplaySessionKey({ key: "agent:ops:main", alias: "global", mainKey: "main" }), + ).toBe("agent:ops:main"); + }); + + it("maps input main to alias for internal routing", () => { + expect(resolveInternalSessionKey({ key: "main", alias: "global", mainKey: "main" })).toBe( + "global", + ); + expect( + resolveInternalSessionKey({ key: "agent:ops:main", alias: "global", mainKey: "main" }), + ).toBe("agent:ops:main"); + }); +}); + +describe("session reference shape detection", () => { + it("detects session ids", () => { + expect(looksLikeSessionId("d4f5a5a1-9f75-42cf-83a6-8d170e6a1538")).toBe(true); + expect(looksLikeSessionId("not-a-uuid")).toBe(false); + }); + + it("detects canonical session key families", () => { + expect(looksLikeSessionKey("main")).toBe(true); + expect(looksLikeSessionKey("agent:main:main")).toBe(true); + expect(looksLikeSessionKey("cron:daily-report")).toBe(true); + expect(looksLikeSessionKey("node:macbook")).toBe(true); + expect(looksLikeSessionKey("telegram:group:123")).toBe(true); + expect(looksLikeSessionKey("random-slug")).toBe(false); + }); + + it("treats non-keys as session-id candidates", () => { + expect(shouldResolveSessionIdInput("agent:main:main")).toBe(false); + expect(shouldResolveSessionIdInput("d4f5a5a1-9f75-42cf-83a6-8d170e6a1538")).toBe(true); + expect(shouldResolveSessionIdInput("random-slug")).toBe(true); + }); +}); diff --git a/src/agents/tools/sessions-resolution.ts b/src/agents/tools/sessions-resolution.ts new file mode 100644 index 00000000000..b3539d08d8f --- /dev/null +++ b/src/agents/tools/sessions-resolution.ts @@ -0,0 +1,257 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { callGateway } from "../../gateway/call.js"; +import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js"; + +function normalizeKey(value?: string) { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveMainSessionAlias(cfg: OpenClawConfig) { + const mainKey = normalizeMainKey(cfg.session?.mainKey); + const scope = cfg.session?.scope ?? "per-sender"; + const alias = scope === "global" ? "global" : mainKey; + return { mainKey, alias, scope }; +} + +export function resolveDisplaySessionKey(params: { key: string; alias: string; mainKey: string }) { + if (params.key === params.alias) { + return "main"; + } + if (params.key === params.mainKey) { + return "main"; + } + return params.key; +} + +export function resolveInternalSessionKey(params: { key: string; alias: string; mainKey: string }) { + if (params.key === "main") { + return params.alias; + } + return params.key; +} + +export async function listSpawnedSessionKeys(params: { + requesterSessionKey: string; + limit?: number; +}): Promise> { + const limit = + typeof params.limit === "number" && Number.isFinite(params.limit) + ? Math.max(1, Math.floor(params.limit)) + : 500; + try { + const list = await callGateway<{ sessions: Array<{ key?: unknown }> }>({ + method: "sessions.list", + params: { + includeGlobal: false, + includeUnknown: false, + limit, + spawnedBy: params.requesterSessionKey, + }, + }); + const sessions = Array.isArray(list?.sessions) ? list.sessions : []; + const keys = sessions + .map((entry) => (typeof entry?.key === "string" ? entry.key : "")) + .map((value) => value.trim()) + .filter(Boolean); + return new Set(keys); + } catch { + return new Set(); + } +} + +export async function isRequesterSpawnedSessionVisible(params: { + requesterSessionKey: string; + targetSessionKey: string; + limit?: number; +}): Promise { + if (params.requesterSessionKey === params.targetSessionKey) { + return true; + } + const keys = await listSpawnedSessionKeys({ + requesterSessionKey: params.requesterSessionKey, + limit: params.limit, + }); + return keys.has(params.targetSessionKey); +} + +const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function looksLikeSessionId(value: string): boolean { + return SESSION_ID_RE.test(value.trim()); +} + +export function looksLikeSessionKey(value: string): boolean { + const raw = value.trim(); + if (!raw) { + return false; + } + // These are canonical key shapes that should never be treated as sessionIds. + if (raw === "main" || raw === "global" || raw === "unknown") { + return true; + } + if (isAcpSessionKey(raw)) { + return true; + } + if (raw.startsWith("agent:")) { + return true; + } + if (raw.startsWith("cron:") || raw.startsWith("hook:")) { + return true; + } + if (raw.startsWith("node-") || raw.startsWith("node:")) { + return true; + } + if (raw.includes(":group:") || raw.includes(":channel:")) { + return true; + } + return false; +} + +export function shouldResolveSessionIdInput(value: string): boolean { + // Treat anything that doesn't look like a well-formed key as a sessionId candidate. + return looksLikeSessionId(value) || !looksLikeSessionKey(value); +} + +export type SessionReferenceResolution = + | { + ok: true; + key: string; + displayKey: string; + resolvedViaSessionId: boolean; + } + | { ok: false; status: "error" | "forbidden"; error: string }; + +async function resolveSessionKeyFromSessionId(params: { + sessionId: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; +}): Promise { + try { + // Resolve via gateway so we respect store routing and visibility rules. + const result = await callGateway<{ key?: string }>({ + method: "sessions.resolve", + params: { + sessionId: params.sessionId, + spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, + includeGlobal: !params.restrictToSpawned, + includeUnknown: !params.restrictToSpawned, + }, + }); + const key = typeof result?.key === "string" ? result.key.trim() : ""; + if (!key) { + throw new Error( + `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, + ); + } + return { + ok: true, + key, + displayKey: resolveDisplaySessionKey({ + key, + alias: params.alias, + mainKey: params.mainKey, + }), + resolvedViaSessionId: true, + }; + } catch (err) { + if (params.restrictToSpawned) { + return { + ok: false, + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${params.sessionId}`, + }; + } + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + status: "error", + error: + message || + `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, + }; + } +} + +async function resolveSessionKeyFromKey(params: { + key: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; +}): Promise { + try { + // Try key-based resolution first so non-standard keys keep working. + const result = await callGateway<{ key?: string }>({ + method: "sessions.resolve", + params: { + key: params.key, + spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, + }, + }); + const key = typeof result?.key === "string" ? result.key.trim() : ""; + if (!key) { + return null; + } + return { + ok: true, + key, + displayKey: resolveDisplaySessionKey({ + key, + alias: params.alias, + mainKey: params.mainKey, + }), + resolvedViaSessionId: false, + }; + } catch { + return null; + } +} + +export async function resolveSessionReference(params: { + sessionKey: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; +}): Promise { + const raw = params.sessionKey.trim(); + if (shouldResolveSessionIdInput(raw)) { + // Prefer key resolution to avoid misclassifying custom keys as sessionIds. + const resolvedByKey = await resolveSessionKeyFromKey({ + key: raw, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + }); + if (resolvedByKey) { + return resolvedByKey; + } + return await resolveSessionKeyFromSessionId({ + sessionId: raw, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + }); + } + + const resolvedKey = resolveInternalSessionKey({ + key: raw, + alias: params.alias, + mainKey: params.mainKey, + }); + const displayKey = resolveDisplaySessionKey({ + key: resolvedKey, + alias: params.alias, + mainKey: params.mainKey, + }); + return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false }; +} + +export function normalizeOptionalKey(value?: string) { + return normalizeKey(value); +} diff --git a/src/agents/tools/sessions-send-tool.gating.e2e.test.ts b/src/agents/tools/sessions-send-tool.gating.e2e.test.ts deleted file mode 100644 index 76a242c9898..00000000000 --- a/src/agents/tools/sessions-send-tool.gating.e2e.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => - ({ - session: { scope: "per-sender", mainKey: "main" }, - tools: { agentToAgent: { enabled: false } }, - }) as never, - }; -}); - -import { createSessionsSendTool } from "./sessions-send-tool.js"; - -describe("sessions_send gating", () => { - beforeEach(() => { - callGatewayMock.mockReset(); - }); - - it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { - const tool = createSessionsSendTool({ - agentSessionKey: "agent:main:main", - agentChannel: "whatsapp", - }); - - const result = await tool.execute("call1", { - sessionKey: "agent:other:main", - message: "hi", - timeoutSeconds: 0, - }); - - expect(callGatewayMock).not.toHaveBeenCalled(); - expect(result.details).toMatchObject({ status: "forbidden" }); - }); -}); diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index e871847fb65..505201cadb4 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -3,11 +3,7 @@ import crypto from "node:crypto"; import type { AnyAgentTool } from "./common.js"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; -import { - isSubagentSessionKey, - normalizeAgentId, - resolveAgentIdFromSessionKey, -} from "../../routing/session-key.js"; +import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js"; import { type GatewayMessageChannel, @@ -16,11 +12,13 @@ import { import { AGENT_LANE_NESTED } from "../lanes.js"; import { jsonResult, readStringParam } from "./common.js"; import { + createSessionVisibilityGuard, createAgentToAgentPolicy, extractAssistantText, - resolveInternalSessionKey, - resolveMainSessionAlias, + isRequesterSpawnedSessionVisible, + resolveEffectiveSessionToolsVisibility, resolveSessionReference, + resolveSandboxedSessionToolContext, stripToolMessages, } from "./sessions-helpers.js"; import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./sessions-send-helpers.js"; @@ -49,23 +47,18 @@ export function createSessionsSendTool(opts?: { const params = args as Record; const message = readStringParam(params, "message", { required: true }); const cfg = loadConfig(); - const { mainKey, alias } = resolveMainSessionAlias(cfg); - const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; - const requesterInternalKey = - typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() - ? resolveInternalSessionKey({ - key: opts.agentSessionKey, - alias, - mainKey, - }) - : undefined; - const restrictToSpawned = - opts?.sandboxed === true && - visibility === "spawned" && - !!requesterInternalKey && - !isSubagentSessionKey(requesterInternalKey); + const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } = + resolveSandboxedSessionToolContext({ + cfg, + agentSessionKey: opts?.agentSessionKey, + sandboxed: opts?.sandboxed, + }); const a2aPolicy = createAgentToAgentPolicy(cfg); + const sessionVisibility = resolveEffectiveSessionToolsVisibility({ + cfg, + sandboxed: opts?.sandboxed === true, + }); const sessionKeyParam = readStringParam(params, "sessionKey"); const labelParam = readStringParam(params, "label")?.trim() || undefined; @@ -78,30 +71,14 @@ export function createSessionsSendTool(opts?: { }); } - const listSessions = async (listParams: Record) => { - const result = await callGateway<{ sessions: Array<{ key: string }> }>({ - method: "sessions.list", - params: listParams, - timeoutMs: 10_000, - }); - return Array.isArray(result?.sessions) ? result.sessions : []; - }; - let sessionKey = sessionKeyParam; if (!sessionKey && labelParam) { - const requesterAgentId = requesterInternalKey - ? resolveAgentIdFromSessionKey(requesterInternalKey) - : undefined; + const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey); const requestedAgentId = labelAgentIdParam ? normalizeAgentId(labelAgentIdParam) : undefined; - if ( - restrictToSpawned && - requestedAgentId && - requesterAgentId && - requestedAgentId !== requesterAgentId - ) { + if (restrictToSpawned && requestedAgentId && requestedAgentId !== requesterAgentId) { return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", @@ -130,7 +107,7 @@ export function createSessionsSendTool(opts?: { const resolveParams: Record = { label: labelParam, ...(requestedAgentId ? { agentId: requestedAgentId } : {}), - ...(restrictToSpawned ? { spawnedBy: requesterInternalKey } : {}), + ...(restrictToSpawned ? { spawnedBy: effectiveRequesterKey } : {}), }; let resolvedKey = ""; try { @@ -184,7 +161,7 @@ export function createSessionsSendTool(opts?: { sessionKey, alias, mainKey, - requesterInternalKey, + requesterInternalKey: effectiveRequesterKey, restrictToSpawned, }); if (!resolvedSession.ok) { @@ -199,14 +176,11 @@ export function createSessionsSendTool(opts?: { const displayKey = resolvedSession.displayKey; const resolvedViaSessionId = resolvedSession.resolvedViaSessionId; - if (restrictToSpawned && !resolvedViaSessionId) { - const sessions = await listSessions({ - includeGlobal: false, - includeUnknown: false, - limit: 500, - spawnedBy: requesterInternalKey, + if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) { + const ok = await isRequesterSpawnedSessionVisible({ + requesterSessionKey: effectiveRequesterKey, + targetSessionKey: resolvedKey, }); - const ok = sessions.some((entry) => entry?.key === resolvedKey); if (!ok) { return jsonResult({ runId: crypto.randomUUID(), @@ -224,27 +198,20 @@ export function createSessionsSendTool(opts?: { const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs; const idempotencyKey = crypto.randomUUID(); let runId: string = idempotencyKey; - const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey); - const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey); - const isCrossAgent = requesterAgentId !== targetAgentId; - if (isCrossAgent) { - if (!a2aPolicy.enabled) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "forbidden", - error: - "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.", - sessionKey: displayKey, - }); - } - if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "forbidden", - error: "Agent-to-agent messaging denied by tools.agentToAgent.allow.", - sessionKey: displayKey, - }); - } + const visibilityGuard = await createSessionVisibilityGuard({ + action: "send", + requesterSessionKey: effectiveRequesterKey, + visibility: sessionVisibility, + a2aPolicy, + }); + const access = visibilityGuard.check(resolvedKey); + if (!access.allowed) { + return jsonResult({ + runId: crypto.randomUUID(), + status: access.status, + error: access.error, + sessionKey: displayKey, + }); } const agentMessageContext = buildAgentToAgentMessageContext({ diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 11486c025e3..867aa85c9d9 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -28,6 +28,8 @@ const SessionsSpawnToolSchema = Type.Object({ model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + // Back-compat: older callers used timeoutSeconds for this tool. + timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), cleanup: optionalStringEnum(["delete", "keep"] as const), }); @@ -97,9 +99,15 @@ export function createSessionsSpawnTool(opts?: { }); // Default to 0 (no timeout) when omitted. Sub-agent runs are long-lived // by default and should not inherit the main agent 600s timeout. + const timeoutSecondsCandidate = + typeof params.runTimeoutSeconds === "number" + ? params.runTimeoutSeconds + : typeof params.timeoutSeconds === "number" + ? params.timeoutSeconds + : undefined; const runTimeoutSeconds = - typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) - ? Math.max(0, Math.floor(params.runTimeoutSeconds)) + typeof timeoutSecondsCandidate === "number" && Number.isFinite(timeoutSecondsCandidate) + ? Math.max(0, Math.floor(timeoutSecondsCandidate)) : 0; let modelWarning: string | undefined; let modelApplied = false; diff --git a/src/agents/tools/sessions.e2e.test.ts b/src/agents/tools/sessions.e2e.test.ts new file mode 100644 index 00000000000..4e3d6a55652 --- /dev/null +++ b/src/agents/tools/sessions.e2e.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => + ({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { agentToAgent: { enabled: false } }, + }) as never, + }; +}); + +import { createSessionsListTool } from "./sessions-list-tool.js"; +import { createSessionsSendTool } from "./sessions-send-tool.js"; + +const loadResolveAnnounceTarget = async () => await import("./sessions-announce-target.js"); + +const installRegistry = async () => { + const { setActivePluginRegistry } = await import("../../plugins/runtime.js"); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "whatsapp", + source: "test", + plugin: { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "WhatsApp test stub.", + preferSessionLookupForAnnounceTarget: true, + }, + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + ]), + ); +}; + +describe("sanitizeTextContent", () => { + it("strips minimax tool call XML and downgraded markers", () => { + const input = + 'Hello payload ' + + "[Tool Call: foo (ID: 1)] world"; + const result = sanitizeTextContent(input).trim(); + expect(result).toBe("Hello world"); + expect(result).not.toContain("invoke"); + expect(result).not.toContain("Tool Call"); + }); + + it("strips thinking tags", () => { + const input = "Before secret after"; + const result = sanitizeTextContent(input).trim(); + expect(result).toBe("Before after"); + }); +}); + +describe("extractAssistantText", () => { + it("sanitizes blocks without injecting newlines", () => { + const message = { + role: "assistant", + content: [ + { type: "text", text: "Hi " }, + { type: "text", text: "secretthere" }, + ], + }; + expect(extractAssistantText(message)).toBe("Hi there"); + }); + + it("rewrites error-ish assistant text only when the transcript marks it as an error", () => { + const message = { + role: "assistant", + stopReason: "error", + errorMessage: "500 Internal Server Error", + content: [{ type: "text", text: "500 Internal Server Error" }], + }; + expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error"); + }); + + it("keeps normal status text that mentions billing", () => { + const message = { + role: "assistant", + content: [ + { + type: "text", + text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", + }, + ], + }; + expect(extractAssistantText(message)).toBe( + "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", + ); + }); +}); + +describe("resolveAnnounceTarget", () => { + beforeEach(async () => { + callGatewayMock.mockReset(); + await installRegistry(); + }); + + it("derives non-WhatsApp announce targets from the session key", async () => { + const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:discord:group:dev", + displayKey: "agent:main:discord:group:dev", + }); + expect(target).toEqual({ channel: "discord", to: "channel:dev" }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("hydrates WhatsApp accountId from sessions.list when available", async () => { + const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); + callGatewayMock.mockResolvedValueOnce({ + sessions: [ + { + key: "agent:main:whatsapp:group:123@g.us", + deliveryContext: { + channel: "whatsapp", + to: "123@g.us", + accountId: "work", + }, + }, + ], + }); + + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:whatsapp:group:123@g.us", + displayKey: "agent:main:whatsapp:group:123@g.us", + }); + expect(target).toEqual({ + channel: "whatsapp", + to: "123@g.us", + accountId: "work", + }); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined; + expect(first).toBeDefined(); + expect(first?.method).toBe("sessions.list"); + }); +}); + +describe("sessions_list gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + callGatewayMock.mockResolvedValue({ + path: "/tmp/sessions.json", + sessions: [ + { key: "agent:main:main", kind: "direct" }, + { key: "agent:other:main", kind: "direct" }, + ], + }); + }); + + it("filters out other agents when tools.agentToAgent.enabled is false", async () => { + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + expect(result.details).toMatchObject({ + count: 1, + sessions: [{ key: "agent:main:main" }], + }); + }); +}); + +describe("sessions_send gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { + const tool = createSessionsSendTool({ + agentSessionKey: "agent:main:main", + agentChannel: "whatsapp", + }); + + const result = await tool.execute("call1", { + sessionKey: "agent:other:main", + message: "hi", + timeoutSeconds: 0, + }); + + expect(callGatewayMock).toHaveBeenCalledTimes(1); + expect(callGatewayMock.mock.calls[0]?.[0]).toMatchObject({ method: "sessions.list" }); + expect(result.details).toMatchObject({ status: "forbidden" }); + }); +}); diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts index 1eafeeb7971..803b80631cb 100644 --- a/src/agents/tools/subagents-tool.ts +++ b/src/agents/tools/subagents-tool.ts @@ -413,71 +413,43 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge const cache = new Map>(); let index = 1; + const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => { + const sessionEntry = resolveSessionEntryForKey({ + cfg, + key: entry.childSessionKey, + cache, + }).entry; + const totalTokens = resolveTotalTokens(sessionEntry); + const usageText = formatTokenUsageDisplay(sessionEntry); + const status = resolveRunStatus(entry); + const runtime = formatDurationCompact(runtimeMs); + const label = truncateLine(resolveRunLabel(entry), 48); + const task = truncateLine(entry.task.trim(), 72); + const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; + const baseView = { + index, + runId: entry.runId, + sessionKey: entry.childSessionKey, + label, + task, + status, + runtime, + runtimeMs, + model: resolveModelRef(sessionEntry) || entry.model, + totalTokens, + startedAt: entry.startedAt, + }; + index += 1; + return { line, view: entry.endedAt ? { ...baseView, endedAt: entry.endedAt } : baseView }; + }; const active = runs .filter((entry) => !entry.endedAt) - .map((entry) => { - const sessionEntry = resolveSessionEntryForKey({ - cfg, - key: entry.childSessionKey, - cache, - }).entry; - const totalTokens = resolveTotalTokens(sessionEntry); - const usageText = formatTokenUsageDisplay(sessionEntry); - const status = resolveRunStatus(entry); - const runtime = formatDurationCompact(now - (entry.startedAt ?? entry.createdAt)); - const label = truncateLine(resolveRunLabel(entry), 48); - const task = truncateLine(entry.task.trim(), 72); - const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; - const view = { - index, - runId: entry.runId, - sessionKey: entry.childSessionKey, - label, - task, - status, - runtime, - runtimeMs: now - (entry.startedAt ?? entry.createdAt), - model: resolveModelRef(sessionEntry) || entry.model, - totalTokens, - startedAt: entry.startedAt, - }; - index += 1; - return { line, view }; - }); + .map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt))); const recent = runs .filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff) - .map((entry) => { - const sessionEntry = resolveSessionEntryForKey({ - cfg, - key: entry.childSessionKey, - cache, - }).entry; - const totalTokens = resolveTotalTokens(sessionEntry); - const usageText = formatTokenUsageDisplay(sessionEntry); - const status = resolveRunStatus(entry); - const runtime = formatDurationCompact( - (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt), - ); - const label = truncateLine(resolveRunLabel(entry), 48); - const task = truncateLine(entry.task.trim(), 72); - const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; - const view = { - index, - runId: entry.runId, - sessionKey: entry.childSessionKey, - label, - task, - status, - runtime, - runtimeMs: (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt), - model: resolveModelRef(sessionEntry) || entry.model, - totalTokens, - startedAt: entry.startedAt, - endedAt: entry.endedAt, - }; - index += 1; - return { line, view }; - }); + .map((entry) => + buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)), + ); const text = buildListText({ active, recent, recentMinutes }); return jsonResult({ diff --git a/src/agents/tools/web-fetch-utils.ts b/src/agents/tools/web-fetch-utils.ts index 09716e2cd46..a9ef9d5ba45 100644 --- a/src/agents/tools/web-fetch-utils.ts +++ b/src/agents/tools/web-fetch-utils.ts @@ -1,5 +1,8 @@ export type ExtractMode = "markdown" | "text"; +const READABILITY_MAX_HTML_CHARS = 1_000_000; +const READABILITY_MAX_ESTIMATED_NESTING_DEPTH = 3_000; + let readabilityDepsPromise: | Promise<{ Readability: typeof import("@mozilla/readability").Readability; @@ -107,6 +110,100 @@ export function truncateText( return { text: value.slice(0, maxChars), truncated: true }; } +function exceedsEstimatedHtmlNestingDepth(html: string, maxDepth: number): boolean { + // Cheap heuristic to skip Readability+DOM parsing on pathological HTML (deep nesting => stack/memory blowups). + // Not an HTML parser; tuned to catch attacker-controlled "
..." cases. + const voidTags = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + ]); + + let depth = 0; + const len = html.length; + for (let i = 0; i < len; i++) { + if (html.charCodeAt(i) !== 60) { + continue; // '<' + } + const next = html.charCodeAt(i + 1); + if (next === 33 || next === 63) { + continue; // or + } + + let j = i + 1; + let closing = false; + if (html.charCodeAt(j) === 47) { + closing = true; + j += 1; + } + + while (j < len && html.charCodeAt(j) <= 32) { + j += 1; + } + + const nameStart = j; + while (j < len) { + const c = html.charCodeAt(j); + const isNameChar = + (c >= 65 && c <= 90) || // A-Z + (c >= 97 && c <= 122) || // a-z + (c >= 48 && c <= 57) || // 0-9 + c === 58 || // : + c === 45; // - + if (!isNameChar) { + break; + } + j += 1; + } + + const tagName = html.slice(nameStart, j).toLowerCase(); + if (!tagName) { + continue; + } + + if (closing) { + depth = Math.max(0, depth - 1); + continue; + } + + if (voidTags.has(tagName)) { + continue; + } + + // Best-effort self-closing detection: scan a short window for "/>". + let selfClosing = false; + for (let k = j; k < len && k < j + 200; k++) { + const c = html.charCodeAt(k); + if (c === 62) { + if (html.charCodeAt(k - 1) === 47) { + selfClosing = true; + } + break; + } + } + if (selfClosing) { + continue; + } + + depth += 1; + if (depth > maxDepth) { + return true; + } + } + return false; +} + export async function extractReadableContent(params: { html: string; url: string; @@ -120,6 +217,12 @@ export async function extractReadableContent(params: { } return rendered; }; + if ( + params.html.length > READABILITY_MAX_HTML_CHARS || + exceedsEstimatedHtmlNestingDepth(params.html, READABILITY_MAX_ESTIMATED_NESTING_DEPTH) + ) { + return fallback(); + } try { const { Readability, parseHTML } = await loadReadabilityDeps(); const { document } = parseHTML(params.html); diff --git a/src/agents/tools/web-fetch.response-limit.test.ts b/src/agents/tools/web-fetch.response-limit.test.ts new file mode 100644 index 00000000000..2755fd0b1c7 --- /dev/null +++ b/src/agents/tools/web-fetch.response-limit.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../../infra/net/ssrf.js"; +import { createWebFetchTool } from "./web-tools.js"; + +// Avoid dynamic-importing heavy readability deps in this unit test suite. +vi.mock("./web-fetch-utils.js", async () => { + const actual = + await vi.importActual("./web-fetch-utils.js"); + return { + ...actual, + extractReadableContent: vi.fn().mockResolvedValue({ + title: "HTML Page", + text: "HTML Page\n\nContent here.", + }), + }; +}); + +const lookupMock = vi.fn(); +const resolvePinnedHostname = ssrf.resolvePinnedHostname; +const baseToolConfig = { + config: { + tools: { + web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false }, maxResponseBytes: 1024 } }, + }, + }, +} as const; + +describe("web_fetch response size limits", () => { + const priorFetch = global.fetch; + + beforeEach(() => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) => + resolvePinnedHostname(hostname, lookupMock), + ); + }); + + afterEach(() => { + // @ts-expect-error restore + global.fetch = priorFetch; + lookupMock.mockReset(); + vi.restoreAllMocks(); + }); + + it("caps response bytes and does not hang on endless streams", async () => { + const chunk = new TextEncoder().encode("
hi
"); + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(chunk); + }, + }); + const response = new Response(stream, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + + const fetchSpy = vi.fn().mockResolvedValue(response); + // @ts-expect-error mock fetch + global.fetch = fetchSpy; + + const tool = createWebFetchTool(baseToolConfig); + const result = await tool?.execute?.("call", { url: "https://example.com/stream" }); + + expect(result?.details?.warning).toContain("Response body truncated"); + }); +}); diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index a703aa54f3a..fdb5ade5172 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -33,8 +33,12 @@ export { extractReadableContent } from "./web-fetch-utils.js"; const EXTRACT_MODES = ["markdown", "text"] as const; const DEFAULT_FETCH_MAX_CHARS = 50_000; +const DEFAULT_FETCH_MAX_RESPONSE_BYTES = 2_000_000; +const FETCH_MAX_RESPONSE_BYTES_MIN = 32_000; +const FETCH_MAX_RESPONSE_BYTES_MAX = 10_000_000; const DEFAULT_FETCH_MAX_REDIRECTS = 3; const DEFAULT_ERROR_MAX_CHARS = 4_000; +const DEFAULT_ERROR_MAX_BYTES = 64_000; const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000; const DEFAULT_FETCH_USER_AGENT = @@ -108,6 +112,18 @@ function resolveFetchMaxCharsCap(fetch?: WebFetchConfig): number { return Math.max(100, Math.floor(raw)); } +function resolveFetchMaxResponseBytes(fetch?: WebFetchConfig): number { + const raw = + fetch && "maxResponseBytes" in fetch && typeof fetch.maxResponseBytes === "number" + ? fetch.maxResponseBytes + : undefined; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { + return DEFAULT_FETCH_MAX_RESPONSE_BYTES; + } + const value = Math.floor(raw); + return Math.min(FETCH_MAX_RESPONSE_BYTES_MAX, Math.max(FETCH_MAX_RESPONSE_BYTES_MIN, value)); +} + function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig { if (!fetch || typeof fetch !== "object") { return undefined; @@ -409,15 +425,7 @@ export async function fetchFirecrawlContent(params: { }; } -async function runWebFetch(params: { - url: string; - extractMode: ExtractMode; - maxChars: number; - maxRedirects: number; - timeoutSeconds: number; - cacheTtlMs: number; - userAgent: string; - readabilityEnabled: boolean; +type FirecrawlRuntimeParams = { firecrawlEnabled: boolean; firecrawlApiKey?: string; firecrawlBaseUrl: string; @@ -426,7 +434,72 @@ async function runWebFetch(params: { firecrawlProxy: "auto" | "basic" | "stealth"; firecrawlStoreInCache: boolean; firecrawlTimeoutSeconds: number; -}): Promise> { +}; + +type WebFetchRuntimeParams = FirecrawlRuntimeParams & { + url: string; + extractMode: ExtractMode; + maxChars: number; + maxResponseBytes: number; + maxRedirects: number; + timeoutSeconds: number; + cacheTtlMs: number; + userAgent: string; + readabilityEnabled: boolean; +}; + +function toFirecrawlContentParams( + params: FirecrawlRuntimeParams & { url: string; extractMode: ExtractMode }, +): Parameters[0] | null { + if (!params.firecrawlEnabled || !params.firecrawlApiKey) { + return null; + } + return { + url: params.url, + extractMode: params.extractMode, + apiKey: params.firecrawlApiKey, + baseUrl: params.firecrawlBaseUrl, + onlyMainContent: params.firecrawlOnlyMainContent, + maxAgeMs: params.firecrawlMaxAgeMs, + proxy: params.firecrawlProxy, + storeInCache: params.firecrawlStoreInCache, + timeoutSeconds: params.firecrawlTimeoutSeconds, + }; +} + +async function maybeFetchFirecrawlWebFetchPayload( + params: WebFetchRuntimeParams & { + urlToFetch: string; + finalUrlFallback: string; + statusFallback: number; + cacheKey: string; + tookMs: number; + }, +): Promise | null> { + const firecrawlParams = toFirecrawlContentParams({ + ...params, + url: params.urlToFetch, + extractMode: params.extractMode, + }); + if (!firecrawlParams) { + return null; + } + + const firecrawl = await fetchFirecrawlContent(firecrawlParams); + const payload = buildFirecrawlWebFetchPayload({ + firecrawl, + rawUrl: params.url, + finalUrlFallback: params.finalUrlFallback, + statusFallback: params.statusFallback, + extractMode: params.extractMode, + maxChars: params.maxChars, + tookMs: params.tookMs, + }); + writeCache(FETCH_CACHE, params.cacheKey, payload, params.cacheTtlMs); + return payload; +} + +async function runWebFetch(params: WebFetchRuntimeParams): Promise> { const cacheKey = normalizeCacheKey( `fetch:${params.url}:${params.extractMode}:${params.maxChars}`, ); @@ -477,28 +550,15 @@ async function runWebFetch(params: { if (error instanceof SsrFBlockedError) { throw error; } - if (params.firecrawlEnabled && params.firecrawlApiKey) { - const firecrawl = await fetchFirecrawlContent({ - url: finalUrl, - extractMode: params.extractMode, - apiKey: params.firecrawlApiKey, - baseUrl: params.firecrawlBaseUrl, - onlyMainContent: params.firecrawlOnlyMainContent, - maxAgeMs: params.firecrawlMaxAgeMs, - proxy: params.firecrawlProxy, - storeInCache: params.firecrawlStoreInCache, - timeoutSeconds: params.firecrawlTimeoutSeconds, - }); - const payload = buildFirecrawlWebFetchPayload({ - firecrawl, - rawUrl: params.url, - finalUrlFallback: finalUrl, - statusFallback: 200, - extractMode: params.extractMode, - maxChars: params.maxChars, - tookMs: Date.now() - start, - }); - writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); + const payload = await maybeFetchFirecrawlWebFetchPayload({ + ...params, + urlToFetch: finalUrl, + finalUrlFallback: finalUrl, + statusFallback: 200, + cacheKey, + tookMs: Date.now() - start, + }); + if (payload) { return payload; } throw error; @@ -506,31 +566,19 @@ async function runWebFetch(params: { try { if (!res.ok) { - if (params.firecrawlEnabled && params.firecrawlApiKey) { - const firecrawl = await fetchFirecrawlContent({ - url: params.url, - extractMode: params.extractMode, - apiKey: params.firecrawlApiKey, - baseUrl: params.firecrawlBaseUrl, - onlyMainContent: params.firecrawlOnlyMainContent, - maxAgeMs: params.firecrawlMaxAgeMs, - proxy: params.firecrawlProxy, - storeInCache: params.firecrawlStoreInCache, - timeoutSeconds: params.firecrawlTimeoutSeconds, - }); - const payload = buildFirecrawlWebFetchPayload({ - firecrawl, - rawUrl: params.url, - finalUrlFallback: finalUrl, - statusFallback: res.status, - extractMode: params.extractMode, - maxChars: params.maxChars, - tookMs: Date.now() - start, - }); - writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); + const payload = await maybeFetchFirecrawlWebFetchPayload({ + ...params, + urlToFetch: params.url, + finalUrlFallback: finalUrl, + statusFallback: res.status, + cacheKey, + tookMs: Date.now() - start, + }); + if (payload) { return payload; } - const rawDetail = await readResponseText(res); + const rawDetailResult = await readResponseText(res, { maxBytes: DEFAULT_ERROR_MAX_BYTES }); + const rawDetail = rawDetailResult.text; const detail = formatWebFetchErrorDetail({ detail: rawDetail, contentType: res.headers.get("content-type"), @@ -542,7 +590,11 @@ async function runWebFetch(params: { const contentType = res.headers.get("content-type") ?? "application/octet-stream"; const normalizedContentType = normalizeContentType(contentType) ?? "application/octet-stream"; - const body = await readResponseText(res); + const bodyResult = await readResponseText(res, { maxBytes: params.maxResponseBytes }); + const body = bodyResult.text; + const responseTruncatedWarning = bodyResult.truncated + ? `Response body truncated after ${params.maxResponseBytes} bytes.` + : undefined; let title: string | undefined; let extractor = "raw"; @@ -593,6 +645,7 @@ async function runWebFetch(params: { const wrapped = wrapWebFetchContent(text, params.maxChars); const wrappedTitle = title ? wrapWebFetchField(title) : undefined; + const wrappedWarning = wrapWebFetchField(responseTruncatedWarning); const payload = { url: params.url, // Keep raw for tool chaining finalUrl, // Keep raw @@ -613,6 +666,7 @@ async function runWebFetch(params: { fetchedAt: new Date().toISOString(), tookMs: Date.now() - start, text: wrapped.text, + warning: wrappedWarning, }; writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); return payload; @@ -623,33 +677,15 @@ async function runWebFetch(params: { } } -async function tryFirecrawlFallback(params: { - url: string; - extractMode: ExtractMode; - firecrawlEnabled: boolean; - firecrawlApiKey?: string; - firecrawlBaseUrl: string; - firecrawlOnlyMainContent: boolean; - firecrawlMaxAgeMs: number; - firecrawlProxy: "auto" | "basic" | "stealth"; - firecrawlStoreInCache: boolean; - firecrawlTimeoutSeconds: number; -}): Promise<{ text: string; title?: string } | null> { - if (!params.firecrawlEnabled || !params.firecrawlApiKey) { +async function tryFirecrawlFallback( + params: FirecrawlRuntimeParams & { url: string; extractMode: ExtractMode }, +): Promise<{ text: string; title?: string } | null> { + const firecrawlParams = toFirecrawlContentParams(params); + if (!firecrawlParams) { return null; } try { - const firecrawl = await fetchFirecrawlContent({ - url: params.url, - extractMode: params.extractMode, - apiKey: params.firecrawlApiKey, - baseUrl: params.firecrawlBaseUrl, - onlyMainContent: params.firecrawlOnlyMainContent, - maxAgeMs: params.firecrawlMaxAgeMs, - proxy: params.firecrawlProxy, - storeInCache: params.firecrawlStoreInCache, - timeoutSeconds: params.firecrawlTimeoutSeconds, - }); + const firecrawl = await fetchFirecrawlContent(firecrawlParams); return { text: firecrawl.text, title: firecrawl.title }; } catch { return null; @@ -695,6 +731,7 @@ export function createWebFetchTool(options?: { const userAgent = (fetch && "userAgent" in fetch && typeof fetch.userAgent === "string" && fetch.userAgent) || DEFAULT_FETCH_USER_AGENT; + const maxResponseBytes = resolveFetchMaxResponseBytes(fetch); return { label: "Web Fetch", name: "web_fetch", @@ -715,6 +752,7 @@ export function createWebFetchTool(options?: { DEFAULT_FETCH_MAX_CHARS, maxCharsCap, ), + maxResponseBytes, maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS), timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), diff --git a/src/agents/tools/web-search.e2e.test.ts b/src/agents/tools/web-search.e2e.test.ts index e8896f908b4..975f92be877 100644 --- a/src/agents/tools/web-search.e2e.test.ts +++ b/src/agents/tools/web-search.e2e.test.ts @@ -1,30 +1,7 @@ import { describe, expect, it } from "vitest"; +import { withEnv } from "../../test-utils/env.js"; import { __testing } from "./web-search.js"; -function withEnv(env: Record, fn: () => T): T { - const prev: Record = {}; - for (const [key, value] of Object.entries(env)) { - prev[key] = process.env[key]; - if (value === undefined) { - // Make tests hermetic even on machines with real keys set. - delete process.env[key]; - } else { - process.env[key] = value; - } - } - try { - return fn(); - } finally { - for (const [key, value] of Object.entries(prev)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - } -} - const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index f2e059f439c..be174b951d3 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -486,7 +486,8 @@ async function runPerplexitySearch(params: { }); if (!res.ok) { - const detail = await readResponseText(res); + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; throw new Error(`Perplexity API error (${res.status}): ${detail || res.statusText}`); } @@ -535,7 +536,8 @@ async function runGrokSearch(params: { }); if (!res.ok) { - const detail = await readResponseText(res); + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; throw new Error(`xAI API error (${res.status}): ${detail || res.statusText}`); } @@ -665,7 +667,8 @@ async function runWebSearch(params: { }); if (!res.ok) { - const detail = await readResponseText(res); + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`); } diff --git a/src/agents/tools/web-shared.ts b/src/agents/tools/web-shared.ts index 2a7353796e2..da0fbb38beb 100644 --- a/src/agents/tools/web-shared.ts +++ b/src/agents/tools/web-shared.ts @@ -86,10 +86,85 @@ export function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): return controller.signal; } -export async function readResponseText(res: Response): Promise { +export type ReadResponseTextResult = { + text: string; + truncated: boolean; + bytesRead: number; +}; + +export async function readResponseText( + res: Response, + options?: { maxBytes?: number }, +): Promise { + const maxBytesRaw = options?.maxBytes; + const maxBytes = + typeof maxBytesRaw === "number" && Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 + ? Math.floor(maxBytesRaw) + : undefined; + + const body = (res as unknown as { body?: unknown }).body; + if ( + maxBytes && + body && + typeof body === "object" && + "getReader" in body && + typeof (body as { getReader: () => unknown }).getReader === "function" + ) { + const reader = (body as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let bytesRead = 0; + let truncated = false; + const parts: string[] = []; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + if (!value || value.byteLength === 0) { + continue; + } + + let chunk = value; + if (bytesRead + chunk.byteLength > maxBytes) { + const remaining = Math.max(0, maxBytes - bytesRead); + if (remaining <= 0) { + truncated = true; + break; + } + chunk = chunk.subarray(0, remaining); + truncated = true; + } + + bytesRead += chunk.byteLength; + parts.push(decoder.decode(chunk, { stream: true })); + + if (truncated || bytesRead >= maxBytes) { + truncated = true; + break; + } + } + } catch { + // Best-effort: return whatever we decoded so far. + } finally { + if (truncated) { + try { + await reader.cancel(); + } catch { + // ignore + } + } + } + + parts.push(decoder.decode()); + return { text: parts.join(""), truncated, bytesRead }; + } + try { - return await res.text(); + const text = await res.text(); + return { text, truncated: false, bytesRead: text.length }; } catch { - return ""; + return { text: "", truncated: false, bytesRead: 0 }; } } diff --git a/src/agents/transcript-policy.e2e.test.ts b/src/agents/transcript-policy.e2e.test.ts index 669f69384e8..58f23e21ccc 100644 --- a/src/agents/transcript-policy.e2e.test.ts +++ b/src/agents/transcript-policy.e2e.test.ts @@ -2,15 +2,15 @@ import { describe, expect, it } from "vitest"; import { resolveTranscriptPolicy } from "./transcript-policy.js"; describe("resolveTranscriptPolicy e2e smoke", () => { - it("uses strict tool-call sanitization for OpenAI models", () => { + it("uses images-only sanitization without tool-call id rewriting for OpenAI models", () => { const policy = resolveTranscriptPolicy({ provider: "openai", modelId: "gpt-4o", modelApi: "openai", }); expect(policy.sanitizeMode).toBe("images-only"); - expect(policy.sanitizeToolCallIds).toBe(true); - expect(policy.toolCallIdMode).toBe("strict"); + expect(policy.sanitizeToolCallIds).toBe(false); + expect(policy.toolCallIdMode).toBeUndefined(); }); it("uses strict9 tool-call sanitization for Mistral-family models", () => { diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 6ae7883db17..56c1230b65a 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -30,13 +30,13 @@ describe("resolveTranscriptPolicy", () => { expect(policy.toolCallIdMode).toBe("strict9"); }); - it("enables sanitizeToolCallIds for OpenAI provider", () => { + it("disables sanitizeToolCallIds for OpenAI provider", () => { const policy = resolveTranscriptPolicy({ provider: "openai", modelId: "gpt-4o", modelApi: "openai", }); - expect(policy.sanitizeToolCallIds).toBe(true); - expect(policy.toolCallIdMode).toBe("strict"); + expect(policy.sanitizeToolCallIds).toBe(false); + expect(policy.toolCallIdMode).toBeUndefined(); }); }); diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index e25ea55458c..22e173320b5 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -95,7 +95,7 @@ export function resolveTranscriptPolicy(params: { const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini; - const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic || isOpenAi; + const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic; const toolCallIdMode: ToolCallIdMode | undefined = isMistral ? "strict9" : sanitizeToolCallIds @@ -109,7 +109,7 @@ export function resolveTranscriptPolicy(params: { return { sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only", - sanitizeToolCallIds, + sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds, toolCallIdMode, repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing, preserveSignatures: isAntigravityClaudeModel, diff --git a/src/agents/workspace-run.ts b/src/agents/workspace-run.ts index 1061a0344ed..8ba281c485d 100644 --- a/src/agents/workspace-run.ts +++ b/src/agents/workspace-run.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; +import { logWarn } from "../logger.js"; import { redactIdentifier } from "../logging/redact-identifier.js"; import { classifySessionKeyShape, @@ -8,6 +9,7 @@ import { } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; export type WorkspaceFallbackReason = "missing" | "blank" | "invalid_type"; type AgentIdSource = "explicit" | "session_key" | "default"; @@ -84,8 +86,12 @@ export function resolveRunWorkspaceDir(params: { if (typeof requested === "string") { const trimmed = requested.trim(); if (trimmed) { + const sanitized = sanitizeForPromptLiteral(trimmed); + if (sanitized !== trimmed) { + logWarn("Control/format characters stripped from workspaceDir (OC-19 hardening)."); + } return { - workspaceDir: resolveUserPath(trimmed), + workspaceDir: resolveUserPath(sanitized), usedFallback: false, agentId, agentIdSource, @@ -96,8 +102,12 @@ export function resolveRunWorkspaceDir(params: { const fallbackReason: WorkspaceFallbackReason = requested == null ? "missing" : typeof requested === "string" ? "blank" : "invalid_type"; const fallbackWorkspace = resolveAgentWorkspaceDir(params.config ?? {}, agentId); + const sanitizedFallback = sanitizeForPromptLiteral(fallbackWorkspace); + if (sanitizedFallback !== fallbackWorkspace) { + logWarn("Control/format characters stripped from fallback workspaceDir (OC-19 hardening)."); + } return { - workspaceDir: resolveUserPath(fallbackWorkspace), + workspaceDir: resolveUserPath(sanitizedFallback), usedFallback: true, fallbackReason, agentId, diff --git a/src/agents/workspace.load-extra-bootstrap-files.test.ts b/src/agents/workspace.load-extra-bootstrap-files.test.ts index 32586029c02..0a478524aef 100644 --- a/src/agents/workspace.load-extra-bootstrap-files.test.ts +++ b/src/agents/workspace.load-extra-bootstrap-files.test.ts @@ -1,12 +1,31 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { makeTempWorkspace } from "../test-helpers/workspace.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { loadExtraBootstrapFiles } from "./workspace.js"; describe("loadExtraBootstrapFiles", () => { + let fixtureRoot = ""; + let fixtureCount = 0; + + const createWorkspaceDir = async (prefix: string) => { + const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); + await fs.mkdir(dir, { recursive: true }); + return dir; + }; + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-extra-bootstrap-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + it("loads recognized bootstrap files from glob patterns", async () => { - const workspaceDir = await makeTempWorkspace("openclaw-extra-bootstrap-glob-"); + const workspaceDir = await createWorkspaceDir("glob"); const packageDir = path.join(workspaceDir, "packages", "core"); await fs.mkdir(packageDir, { recursive: true }); await fs.writeFile(path.join(packageDir, "TOOLS.md"), "tools", "utf-8"); @@ -20,7 +39,7 @@ describe("loadExtraBootstrapFiles", () => { }); it("keeps path-traversal attempts outside workspace excluded", async () => { - const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-root-"); + const rootDir = await createWorkspaceDir("root"); const workspaceDir = path.join(rootDir, "workspace"); const outsideDir = path.join(rootDir, "outside"); await fs.mkdir(workspaceDir, { recursive: true }); @@ -37,7 +56,7 @@ describe("loadExtraBootstrapFiles", () => { return; } - const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-symlink-"); + const rootDir = await createWorkspaceDir("symlink"); const realWorkspace = path.join(rootDir, "real-workspace"); const linkedWorkspace = path.join(rootDir, "linked-workspace"); await fs.mkdir(realWorkspace, { recursive: true }); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index bf5f33992a0..9e1c081c7ec 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -184,6 +184,18 @@ async function readWorkspaceOnboardingState(statePath: string): Promise { + const statePath = resolveWorkspaceStatePath(resolveUserPath(dir)); + return await readWorkspaceOnboardingState(statePath); +} + +export async function isWorkspaceOnboardingCompleted(dir: string): Promise { + const state = await readWorkspaceOnboardingStateForDir(dir); + return ( + typeof state.onboardingCompletedAt === "string" && state.onboardingCompletedAt.trim().length > 0 + ); +} + async function writeWorkspaceOnboardingState( statePath: string, state: WorkspaceOnboardingState, diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 204f88ad397..e91b9e86833 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -298,7 +298,7 @@ function splitByNewline( return lines; } -export function chunkText(text: string, limit: number): string[] { +function resolveChunkEarlyReturn(text: string, limit: number): string[] | undefined { if (!text) { return []; } @@ -308,6 +308,14 @@ export function chunkText(text: string, limit: number): string[] { if (text.length <= limit) { return [text]; } + return undefined; +} + +export function chunkText(text: string, limit: number): string[] { + const early = resolveChunkEarlyReturn(text, limit); + if (early) { + return early; + } const chunks: string[] = []; let remaining = text; @@ -346,14 +354,9 @@ export function chunkText(text: string, limit: number): string[] { } export function chunkMarkdownText(text: string, limit: number): string[] { - if (!text) { - return []; - } - if (limit <= 0) { - return [text]; - } - if (text.length <= limit) { - return [text]; + const early = resolveChunkEarlyReturn(text, limit); + if (early) { + return early; } const chunks: string[] = []; diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts index 5ac5281acb6..6322d7c9a8d 100644 --- a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts +++ b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts @@ -127,7 +127,10 @@ describe("group intro prompts", () => { vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "discord"'); expect(extraSystemPrompt).toContain( - `You are replying inside a Discord group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`, + ); + expect(extraSystemPrompt).toContain( + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); @@ -158,8 +161,12 @@ describe("group intro prompts", () => { const extraSystemPrompt = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "whatsapp"'); + expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`); expect(extraSystemPrompt).toContain( - `You are replying inside a WhatsApp group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `WhatsApp IDs: SenderId is the participant JID (group participant id).`, + ); + expect(extraSystemPrompt).toContain( + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); @@ -190,8 +197,9 @@ describe("group intro prompts", () => { const extraSystemPrompt = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "telegram"'); + expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`); expect(extraSystemPrompt).toContain( - `You are replying inside a Telegram group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index 76b0889e8c4..7f4172f1d2f 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { getAbortMemory, getAbortMemorySizeForTest, + isAbortRequestText, isAbortTrigger, resetAbortMemoryForTest, setAbortMemory, @@ -75,6 +76,17 @@ describe("abort detection", () => { expect(isAbortTrigger("/stop")).toBe(false); }); + it("isAbortRequestText aligns abort command semantics", () => { + expect(isAbortRequestText("/stop")).toBe(true); + expect(isAbortRequestText("stop")).toBe(true); + expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); + + expect(isAbortRequestText("/status")).toBe(false); + expect(isAbortRequestText("stop please")).toBe(false); + expect(isAbortRequestText("/abort")).toBe(false); + expect(isAbortRequestText("/abort now")).toBe(false); + }); + it("removes abort memory entry when flag is reset", () => { setAbortMemory("session-1", true); expect(getAbortMemory("session-1")).toBe(true); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index f2b4e8bc709..3b55c08bb45 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -19,7 +19,7 @@ import { import { logVerbose } from "../../globals.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveCommandAuthorization } from "../command-auth.js"; -import { normalizeCommandBody } from "../commands-registry.js"; +import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { clearSessionQueues } from "./queue.js"; @@ -35,6 +35,17 @@ export function isAbortTrigger(text?: string): boolean { return ABORT_TRIGGERS.has(normalized); } +export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean { + if (!text) { + return false; + } + const normalized = normalizeCommandBody(text, options).trim(); + if (!normalized) { + return false; + } + return normalized.toLowerCase() === "/stop" || isAbortTrigger(normalized); +} + export function getAbortMemory(key: string): boolean | undefined { const normalized = key.trim(); if (!normalized) { @@ -202,8 +213,7 @@ export async function tryFastAbortFromMessage(params: { const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? ""); const isGroup = ctx.ChatType?.trim().toLowerCase() === "group"; const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw; - const normalized = normalizeCommandBody(stripped); - const abortRequested = normalized === "/stop" || isAbortTrigger(stripped); + const abortRequested = isAbortRequestText(stripped); if (!abortRequested) { return { handled: false, aborted: false }; } diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 22c489c5354..de81ffec664 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -19,6 +19,7 @@ import { registerAgentRunContext } from "../../infra/agent-events.js"; import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js"; import { resolveMemoryFlushContextWindowTokens, + resolveMemoryFlushPromptForRun, resolveMemoryFlushSettings, shouldRunMemoryFlush, } from "./memory-flush.js"; @@ -133,7 +134,10 @@ export async function runMemoryFlushIfNeeded(params: { agentDir: params.followupRun.run.agentDir, config: params.followupRun.run.config, skillsSnapshot: params.followupRun.run.skillsSnapshot, - prompt: memoryFlushSettings.prompt, + prompt: resolveMemoryFlushPromptForRun({ + prompt: memoryFlushSettings.prompt, + cfg: params.cfg, + }), extraSystemPrompt: flushSystemPrompt, ownerNumbers: params.followupRun.run.ownerNumbers, enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider), diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts deleted file mode 100644 index 145b93bd61d..00000000000 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { TemplateContext } from "../templating.js"; -import { buildThreadingToolContext } from "./agent-runner-utils.js"; - -describe("buildThreadingToolContext", () => { - const cfg = {} as OpenClawConfig; - - it("uses conversation id for WhatsApp", () => { - const sessionCtx = { - Provider: "whatsapp", - From: "123@g.us", - To: "+15550001", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("123@g.us"); - }); - - it("falls back to To for WhatsApp when From is missing", () => { - const sessionCtx = { - Provider: "whatsapp", - To: "+15550001", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("+15550001"); - }); - - it("uses the recipient id for other channels", () => { - const sessionCtx = { - Provider: "telegram", - From: "user:42", - To: "chat:99", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("chat:99"); - }); - - it("uses the sender handle for iMessage direct chats", () => { - const sessionCtx = { - Provider: "imessage", - ChatType: "direct", - From: "imessage:+15550001", - To: "chat_id:12", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("imessage:+15550001"); - }); - - it("uses chat_id for iMessage groups", () => { - const sessionCtx = { - Provider: "imessage", - ChatType: "group", - From: "imessage:group:7", - To: "chat_id:7", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("chat_id:7"); - }); - - it("prefers MessageThreadId for Slack tool threading", () => { - const sessionCtx = { - Provider: "slack", - To: "channel:C1", - MessageThreadId: "123.456", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("C1"); - expect(result.currentThreadTs).toBe("123.456"); - }); -}); diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts deleted file mode 100644 index 9c14f82c77f..00000000000 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts +++ /dev/null @@ -1,583 +0,0 @@ -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import * as sessions from "../../config/sessions.js"; -import { - createMinimalRun, - getRunEmbeddedPiAgentMock, - installRunReplyAgentTypingHeartbeatTestHooks, -} from "./agent-runner.heartbeat-typing.test-harness.js"; - -type AgentRunParams = { - onPartialReply?: (payload: { text?: string }) => Promise | void; - onAssistantMessageStart?: () => Promise | void; - onReasoningStream?: (payload: { text?: string }) => Promise | void; - onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onAgentEvent?: (evt: { stream: string; data: Record }) => void; -}; - -const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - -let fixtureRoot = ""; -let caseId = 0; - -type StateEnvSnapshot = { - OPENCLAW_STATE_DIR: string | undefined; -}; - -function snapshotStateEnv(): StateEnvSnapshot { - return { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR }; -} - -function restoreStateEnv(snapshot: StateEnvSnapshot) { - if (snapshot.OPENCLAW_STATE_DIR === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = snapshot.OPENCLAW_STATE_DIR; - } -} - -async function withTempStateDir(fn: (stateDir: string) => Promise): Promise { - const stateDir = path.join(fixtureRoot, `case-${++caseId}`); - await fs.mkdir(stateDir, { recursive: true }); - const envSnapshot = snapshotStateEnv(); - process.env.OPENCLAW_STATE_DIR = stateDir; - try { - return await fn(stateDir); - } finally { - restoreStateEnv(envSnapshot); - } -} - -async function writeCorruptGeminiSessionFixture(params: { - stateDir: string; - sessionId: string; - persistStore: boolean; -}) { - const storePath = path.join(params.stateDir, "sessions", "sessions.json"); - const sessionEntry = { sessionId: params.sessionId, updatedAt: Date.now() }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - if (params.persistStore) { - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - } - - const transcriptPath = sessions.resolveSessionTranscriptPath(params.sessionId); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "bad", "utf-8"); - - return { storePath, sessionEntry, sessionStore, transcriptPath }; -} - -describe("runReplyAgent typing (heartbeat)", () => { - installRunReplyAgentTypingHeartbeatTestHooks(); - - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-typing-heartbeat-")); - }); - - afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - } - }); - - beforeEach(() => { - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - }); - - it("signals typing for normal runs", async () => { - const onPartialReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: false, onPartialReply }, - }); - await run(); - - expect(onPartialReply).toHaveBeenCalled(); - expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); - expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("signals typing even without consumer partial handler", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - }); - await run(); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("never signals typing for heartbeat runs", async () => { - const onPartialReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: true, onPartialReply }, - }); - await run(); - - expect(onPartialReply).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("suppresses partial streaming for NO_REPLY", async () => { - const onPartialReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "NO_REPLY" }); - return { payloads: [{ text: "NO_REPLY" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: false, onPartialReply }, - typingMode: "message", - }); - await run(); - - expect(onPartialReply).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("does not start typing on assistant message start without prior text in message mode", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onAssistantMessageStart?.(); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - }); - await run(); - - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("starts typing from reasoning stream in thinking mode", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onReasoningStream?.({ text: "Reasoning:\n_step_" }); - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "thinking", - }); - await run(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("suppresses typing in never mode", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "never", - }); - await run(); - - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("signals typing on normalized block replies", async () => { - const onBlockReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onBlockReply?.({ text: "\n\nchunk", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - blockStreamingEnabled: true, - opts: { onBlockReply }, - }); - await run(); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("chunk"); - expect(onBlockReply).toHaveBeenCalled(); - const [blockPayload, blockOpts] = onBlockReply.mock.calls[0] ?? []; - expect(blockPayload).toMatchObject({ text: "chunk", audioAsVoice: false }); - expect(blockOpts).toMatchObject({ - abortSignal: expect.any(AbortSignal), - timeoutMs: expect.any(Number), - }); - }); - - it("signals typing on tool results", async () => { - const onToolResult = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onToolResult?.({ text: "tooling", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - opts: { onToolResult }, - }); - await run(); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("tooling"); - expect(onToolResult).toHaveBeenCalledWith({ - text: "tooling", - mediaUrls: [], - }); - }); - - it("skips typing for silent tool results", async () => { - const onToolResult = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - opts: { onToolResult }, - }); - await run(); - - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(onToolResult).not.toHaveBeenCalled(); - }); - - it("announces auto-compaction in verbose mode and tracks count", async () => { - await withTempStateDir(async (stateDir) => { - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const sessionEntry = { sessionId: "session", updatedAt: Date.now() }; - const sessionStore = { main: sessionEntry }; - - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: false }, - }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run } = createMinimalRun({ - resolvedVerboseLevel: "on", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - expect(Array.isArray(res)).toBe(true); - const payloads = res as { text?: string }[]; - expect(payloads[0]?.text).toContain("Auto-compaction complete"); - expect(payloads[0]?.text).toContain("count 1"); - expect(sessionStore.main.compactionCount).toBe(1); - }); - }); - - it("retries after compaction failure by resetting the session", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ - text: expect.stringContaining("Context limit exceeded during compaction"), - }); - expect(payload.text?.toLowerCase()).toContain("reset"); - expect(sessionStore.main.sessionId).not.toBe(sessionId); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); - }); - }); - - it("retries after context overflow payload by resetting the session", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ - payloads: [{ text: "Context overflow: prompt too large", isError: true }], - meta: { - durationMs: 1, - error: { - kind: "context_overflow", - message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - }, - }, - })); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ - text: expect.stringContaining("Context limit exceeded"), - }); - expect(payload.text?.toLowerCase()).toContain("reset"); - expect(sessionStore.main.sessionId).not.toBe(sessionId); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); - }); - }); - - it("resets the session after role ordering payloads", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ - payloads: [{ text: "Message ordering conflict - please try again.", isError: true }], - meta: { - durationMs: 1, - error: { - kind: "role_ordering", - message: 'messages: roles must alternate between "user" and "assistant"', - }, - }, - })); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ - text: expect.stringContaining("Message ordering conflict"), - }); - expect(payload.text?.toLowerCase()).toContain("reset"); - expect(sessionStore.main.sessionId).not.toBe(sessionId); - await expect(fs.access(transcriptPath)).rejects.toBeDefined(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); - }); - }); - - it("resets corrupted Gemini sessions and deletes transcripts", async () => { - await withTempStateDir(async (stateDir) => { - const { storePath, sessionEntry, sessionStore, transcriptPath } = - await writeCorruptGeminiSessionFixture({ - stateDir, - sessionId: "session-corrupt", - persistStore: true, - }); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - "function call turn comes immediately after a user turn or after a function response turn", - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Session history was corrupted"), - }); - expect(sessionStore.main).toBeUndefined(); - await expect(fs.access(transcriptPath)).rejects.toThrow(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main).toBeUndefined(); - }); - }); - - it("keeps sessions intact on other errors", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session-ok"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const sessionEntry = { sessionId, updatedAt: Date.now() }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error("INVALID_ARGUMENT: some other failure"); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Agent failed before reply"), - }); - expect(sessionStore.main).toBeDefined(); - await expect(fs.access(transcriptPath)).resolves.toBeUndefined(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main).toBeDefined(); - }); - }); - - it("still replies even if session reset fails to persist", async () => { - await withTempStateDir(async (stateDir) => { - const saveSpy = vi - .spyOn(sessions, "saveSessionStore") - .mockRejectedValueOnce(new Error("boom")); - try { - const { storePath, sessionEntry, sessionStore, transcriptPath } = - await writeCorruptGeminiSessionFixture({ - stateDir, - sessionId: "session-corrupt", - persistStore: false, - }); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - "function call turn comes immediately after a user turn or after a function response turn", - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Session history was corrupted"), - }); - expect(sessionStore.main).toBeUndefined(); - await expect(fs.access(transcriptPath)).rejects.toThrow(); - } finally { - saveSpy.mockRestore(); - } - }); - }); - - it("returns friendly message for role ordering errors thrown as exceptions", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error("400 Incorrect role information"); - }); - - const { run } = createMinimalRun({}); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Message ordering conflict"), - }); - expect(res).toMatchObject({ - text: expect.not.stringContaining("400"), - }); - }); - - it("returns friendly message for 'roles must alternate' errors thrown as exceptions", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error('messages: roles must alternate between "user" and "assistant"'); - }); - - const { run } = createMinimalRun({}); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Message ordering conflict"), - }); - }); - - it("rewrites Bun socket errors into friendly text", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ - payloads: [ - { - text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", - isError: true, - }, - ], - meta: {}, - })); - - const { run } = createMinimalRun(); - const res = await run(); - const payloads = Array.isArray(res) ? res : res ? [res] : []; - expect(payloads.length).toBe(1); - expect(payloads[0]?.text).toContain("LLM connection failed"); - expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly"); - expect(payloads[0]?.text).toContain("```"); - }); -}); diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts deleted file mode 100644 index d1c176d494f..00000000000 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { beforeAll, beforeEach, vi } from "vitest"; -import type { SessionEntry } from "../../config/sessions.js"; -import type { TypingMode } from "../../config/types.js"; -import type { TemplateContext } from "../templating.js"; -import type { GetReplyOptions } from "../types.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; -import { createMockTypingController } from "./test-helpers.js"; - -// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). -// oxlint-disable-next-line typescript/no-explicit-any -type AnyMock = any; - -const state = vi.hoisted(() => ({ - runEmbeddedPiAgentMock: vi.fn(), -})); - -let runReplyAgentPromise: - | Promise<(typeof import("./agent-runner.js"))["runReplyAgent"]> - | undefined; - -async function getRunReplyAgent() { - if (!runReplyAgentPromise) { - runReplyAgentPromise = import("./agent-runner.js").then((m) => m.runReplyAgent); - } - return await runReplyAgentPromise; -} - -export function getRunEmbeddedPiAgentMock(): AnyMock { - return state.runEmbeddedPiAgentMock; -} - -export function installRunReplyAgentTypingHeartbeatTestHooks() { - beforeAll(async () => { - // Avoid attributing the initial agent-runner import cost to the first test case. - await getRunReplyAgent(); - }); - beforeEach(() => { - state.runEmbeddedPiAgentMock.mockReset(); - }); -} - -vi.mock("../../agents/model-fallback.js", async () => { - const { modelFallbackMockFactory } = await import("./agent-runner.test-harness.mocks.js"); - return modelFallbackMockFactory(); -}); - -vi.mock("../../agents/pi-embedded.js", async () => { - const { embeddedPiMockFactory } = await import("./agent-runner.test-harness.mocks.js"); - return embeddedPiMockFactory(state); -}); - -vi.mock("./queue.js", async () => { - const { queueMockFactory } = await import("./agent-runner.test-harness.mocks.js"); - return await queueMockFactory(); -}); - -export function createMinimalRun(params?: { - opts?: GetReplyOptions; - resolvedVerboseLevel?: "off" | "on"; - sessionStore?: Record; - sessionEntry?: SessionEntry; - sessionKey?: string; - storePath?: string; - typingMode?: TypingMode; - blockStreamingEnabled?: boolean; -}) { - const typing = createMockTypingController(); - const opts = params?.opts; - const sessionCtx = { - Provider: "whatsapp", - MessageSid: "msg", - } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; - const sessionKey = params?.sessionKey ?? "main"; - const followupRun = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), - run: { - sessionId: "session", - sessionKey, - messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: {}, - skillsSnapshot: {}, - provider: "anthropic", - model: "claude", - thinkLevel: "low", - verboseLevel: params?.resolvedVerboseLevel ?? "off", - elevatedLevel: "off", - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - timeoutMs: 1_000, - blockReplyBreak: "message_end", - }, - } as unknown as FollowupRun; - - return { - typing, - opts, - run: async () => { - const runReplyAgent = await getRunReplyAgent(); - return runReplyAgent({ - commandBody: "hello", - followupRun, - queueKey: "main", - resolvedQueue, - shouldSteer: false, - shouldFollowup: false, - isActive: false, - isStreaming: false, - opts, - typing, - sessionEntry: params?.sessionEntry, - sessionStore: params?.sessionStore, - sessionKey, - storePath: params?.storePath, - sessionCtx, - defaultModel: "anthropic/claude-opus-4-5", - resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off", - isNewSession: false, - blockStreamingEnabled: params?.blockStreamingEnabled ?? false, - resolvedBlockStreamingBreak: "message_end", - shouldInjectGroupIntro: false, - typingMode: params?.typingMode ?? "instant", - }); - }, - }; -} diff --git a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts b/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts deleted file mode 100644 index e13de88c54d..00000000000 --- a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -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 { - createBaseRun, - getRunCliAgentMock, - getRunEmbeddedPiAgentMock, - seedSessionStore, - type EmbeddedRunParams, -} from "./agent-runner.memory-flush.test-harness.js"; -import { DEFAULT_MEMORY_FLUSH_PROMPT } from "./memory-flush.js"; - -let runReplyAgent: typeof import("./agent-runner.js").runReplyAgent; - -let fixtureRoot = ""; -let caseId = 0; - -async function withTempStore(fn: (storePath: string) => Promise): Promise { - const dir = path.join(fixtureRoot, `case-${++caseId}`); - await fs.mkdir(dir, { recursive: true }); - return await fn(path.join(dir, "sessions.json")); -} - -async function runReplyAgentWithBase(params: { - baseRun: ReturnType; - storePath: string; - sessionKey: string; - sessionEntry: Record; - commandBody: string; - typingMode?: "instant"; -}): Promise { - const { typing, sessionCtx, resolvedQueue, followupRun } = params.baseRun; - await runReplyAgent({ - commandBody: params.commandBody, - followupRun, - queueKey: params.sessionKey, - resolvedQueue, - shouldSteer: false, - shouldFollowup: false, - isActive: false, - isStreaming: false, - typing, - sessionCtx, - sessionEntry: params.sessionEntry, - sessionStore: { [params.sessionKey]: params.sessionEntry }, - sessionKey: params.sessionKey, - storePath: params.storePath, - defaultModel: "anthropic/claude-opus-4-5", - agentCfgContextTokens: 100_000, - resolvedVerboseLevel: "off", - isNewSession: false, - blockStreamingEnabled: false, - resolvedBlockStreamingBreak: "message_end", - shouldInjectGroupIntro: false, - typingMode: params.typingMode ?? "instant", - }); -} - -async function expectMemoryFlushSkippedWithWorkspaceAccess( - workspaceAccess: "ro" | "none", -): Promise { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array<{ prompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - config: { - agents: { - defaults: { - sandbox: { mode: "all", workspaceAccess }, - }, - }, - }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(calls.map((call) => call.prompt)).toEqual(["hello"]); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); - }); -} - -beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-flush-")); - ({ runReplyAgent } = await import("./agent-runner.js")); -}); - -afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - } -}); - -describe("runReplyAgent memory flush", () => { - it("skips memory flush for CLI providers", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const runCliAgentMock = getRunCliAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - runCliAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - runEmbeddedPiAgentMock.mockImplementation(async () => ({ - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - })); - runCliAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - runOverrides: { provider: "codex-cli" }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(runCliAgentMock).toHaveBeenCalledTimes(1); - const call = runCliAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined; - expect(call?.prompt).toBe("hello"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); - }); - }); - - it("uses configured prompts for memory flush runs", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push(params); - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { - return { payloads: [], meta: {} }; - } - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - config: { - agents: { - defaults: { - compaction: { - memoryFlush: { - prompt: "Write notes.", - systemPrompt: "Flush memory now.", - }, - }, - }, - }, - }, - runOverrides: { extraSystemPrompt: "extra system" }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - const flushCall = calls[0]; - expect(flushCall?.prompt).toContain("Write notes."); - expect(flushCall?.prompt).toContain("NO_REPLY"); - expect(flushCall?.extraSystemPrompt).toContain("extra system"); - expect(flushCall?.extraSystemPrompt).toContain("Flush memory now."); - expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY"); - expect(calls[1]?.prompt).toBe("hello"); - }); - }); - - it("runs a memory flush turn and updates session metadata", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array<{ prompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { - return { payloads: [], meta: {} }; - } - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(calls.map((call) => call.prompt)).toEqual([DEFAULT_MEMORY_FLUSH_PROMPT, "hello"]); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number"); - expect(stored[sessionKey].memoryFlushCompactionCount).toBe(1); - }); - }); - - it("skips memory flush when disabled in config", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - runEmbeddedPiAgentMock.mockImplementation(async () => ({ - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - })); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - config: { agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } } }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined; - expect(call?.prompt).toBe("hello"); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); - }); - }); - - it("skips memory flush after a prior flush in the same compaction cycle", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 2, - memoryFlushCompactionCount: 2, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array<{ prompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(calls.map((call) => call.prompt)).toEqual(["hello"]); - }); - }); - - it("skips memory flush when the sandbox workspace is read-only", async () => { - await expectMemoryFlushSkippedWithWorkspaceAccess("ro"); - }); - - it("skips memory flush when the sandbox workspace is none", async () => { - await expectMemoryFlushSkippedWithWorkspaceAccess("none"); - }); - - it("increments compaction count when flush compaction completes", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { - params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: false }, - }); - return { payloads: [], meta: {} }; - } - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(2); - expect(stored[sessionKey].memoryFlushCompactionCount).toBe(2); - }); - }); -}); diff --git a/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts b/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts deleted file mode 100644 index 131da7c5240..00000000000 --- a/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts +++ /dev/null @@ -1,119 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { vi } from "vitest"; -import type { TemplateContext } from "../templating.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; -import { createMockTypingController } from "./test-helpers.js"; - -// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). -// oxlint-disable-next-line typescript/no-explicit-any -type AnyMock = any; - -type EmbeddedRunParams = { - prompt?: string; - extraSystemPrompt?: string; - onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; -}; - -const state = vi.hoisted(() => ({ - runEmbeddedPiAgentMock: vi.fn(), - runCliAgentMock: vi.fn(), -})); - -export function getRunEmbeddedPiAgentMock(): AnyMock { - return state.runEmbeddedPiAgentMock; -} - -export function getRunCliAgentMock(): AnyMock { - return state.runCliAgentMock; -} - -export type { EmbeddedRunParams }; - -vi.mock("../../agents/model-fallback.js", async () => { - const { modelFallbackMockFactory } = await import("./agent-runner.test-harness.mocks.js"); - return modelFallbackMockFactory(); -}); - -vi.mock("../../agents/cli-runner.js", () => ({ - runCliAgent: (params: unknown) => state.runCliAgentMock(params), -})); - -vi.mock("../../agents/pi-embedded.js", async () => { - const { embeddedPiMockFactory } = await import("./agent-runner.test-harness.mocks.js"); - return embeddedPiMockFactory(state); -}); - -vi.mock("./queue.js", async () => { - const { queueMockFactory } = await import("./agent-runner.test-harness.mocks.js"); - return await queueMockFactory(); -}); - -export async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - entry: Record; -}) { - await fs.mkdir(path.dirname(params.storePath), { recursive: true }); - await fs.writeFile( - params.storePath, - JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), - "utf-8", - ); -} - -export function createBaseRun(params: { - storePath: string; - sessionEntry: Record; - config?: Record; - runOverrides?: Partial; -}) { - const typing = createMockTypingController(); - const sessionCtx = { - Provider: "whatsapp", - OriginatingTo: "+15550001111", - AccountId: "primary", - MessageSid: "msg", - } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; - const followupRun = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), - run: { - agentId: "main", - agentDir: "/tmp/agent", - sessionId: "session", - sessionKey: "main", - messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: params.config ?? {}, - skillsSnapshot: {}, - 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; - const run = { - ...followupRun.run, - ...params.runOverrides, - config: params.config ?? followupRun.run.config, - }; - - return { - typing, - sessionCtx, - resolvedQueue, - followupRun: { ...followupRun, run }, - }; -} diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index d602b0a73f6..99ec07a6245 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -8,7 +8,6 @@ import type { TemplateContext } from "../templating.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; import { loadSessionStore, saveSessionStore } from "../../config/sessions.js"; import { onAgentEvent } from "../../infra/agent-events.js"; -import { DEFAULT_MEMORY_FLUSH_PROMPT } from "./memory-flush.js"; import { createMockTypingController } from "./test-helpers.js"; const runEmbeddedPiAgentMock = vi.fn(); @@ -948,7 +947,7 @@ describe("runReplyAgent fallback reasoning tags", () => { it("enforces during memory flush on fallback providers", async () => { runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedPiAgentParams) => { - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { + if (params.prompt?.includes("Pre-compaction memory flush.")) { return { payloads: [], meta: {} }; } return { payloads: [{ text: "ok" }], meta: {} }; @@ -968,9 +967,10 @@ describe("runReplyAgent fallback reasoning tags", () => { }, }); - const flushCall = runEmbeddedPiAgentMock.mock.calls.find( - ([params]) => - (params as EmbeddedPiAgentParams | undefined)?.prompt === DEFAULT_MEMORY_FLUSH_PROMPT, + const flushCall = runEmbeddedPiAgentMock.mock.calls.find(([params]) => + (params as EmbeddedPiAgentParams | undefined)?.prompt?.includes( + "Pre-compaction memory flush.", + ), )?.[0] as EmbeddedPiAgentParams | undefined; expect(flushCall?.enforceFinalTag).toBe(true); diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts new file mode 100644 index 00000000000..29d1be5b80a --- /dev/null +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -0,0 +1,1032 @@ +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { TypingMode } from "../../config/types.js"; +import type { TemplateContext } from "../templating.js"; +import type { GetReplyOptions } from "../types.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import * as sessions from "../../config/sessions.js"; +import { createMockTypingController } from "./test-helpers.js"; + +type AgentRunParams = { + onPartialReply?: (payload: { text?: string }) => Promise | void; + onAssistantMessageStart?: () => Promise | void; + onReasoningStream?: (payload: { text?: string }) => Promise | void; + onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onAgentEvent?: (evt: { stream: string; data: Record }) => void; +}; + +type EmbeddedRunParams = { + prompt?: string; + extraSystemPrompt?: string; + onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; +}; + +const state = vi.hoisted(() => ({ + runEmbeddedPiAgentMock: vi.fn(), + runCliAgentMock: vi.fn(), +})); + +let runReplyAgentPromise: + | Promise<(typeof import("./agent-runner.js"))["runReplyAgent"]> + | undefined; + +async function getRunReplyAgent() { + if (!runReplyAgentPromise) { + runReplyAgentPromise = import("./agent-runner.js").then((m) => m.runReplyAgent); + } + return await runReplyAgentPromise; +} + +vi.mock("../../agents/model-fallback.js", () => ({ + runWithModelFallback: async ({ + provider, + model, + run, + }: { + provider: string; + model: string; + run: (provider: string, model: string) => Promise; + }) => ({ + result: await run(provider, model), + provider, + model, + }), +})); + +vi.mock("../../agents/pi-embedded.js", () => ({ + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), +})); + +vi.mock("../../agents/cli-runner.js", () => ({ + runCliAgent: (params: unknown) => state.runCliAgentMock(params), +})); + +vi.mock("./queue.js", () => ({ + enqueueFollowupRun: vi.fn(), + scheduleFollowupDrain: vi.fn(), +})); + +beforeAll(async () => { + // Avoid attributing the initial agent-runner import cost to the first test case. + await getRunReplyAgent(); +}); + +beforeEach(() => { + state.runEmbeddedPiAgentMock.mockReset(); + state.runCliAgentMock.mockReset(); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); +}); + +function createMinimalRun(params?: { + opts?: GetReplyOptions; + resolvedVerboseLevel?: "off" | "on"; + sessionStore?: Record; + sessionEntry?: SessionEntry; + sessionKey?: string; + storePath?: string; + typingMode?: TypingMode; + blockStreamingEnabled?: boolean; +}) { + const typing = createMockTypingController(); + const opts = params?.opts; + const sessionCtx = { + Provider: "whatsapp", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const sessionKey = params?.sessionKey ?? "main"; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + sessionId: "session", + sessionKey, + messageProvider: "whatsapp", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, + skillsSnapshot: {}, + provider: "anthropic", + model: "claude", + thinkLevel: "low", + verboseLevel: params?.resolvedVerboseLevel ?? "off", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun; + + return { + typing, + opts, + run: async () => { + const runReplyAgent = await getRunReplyAgent(); + return runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: "main", + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + opts, + typing, + sessionEntry: params?.sessionEntry, + sessionStore: params?.sessionStore, + sessionKey, + storePath: params?.storePath, + sessionCtx, + defaultModel: "anthropic/claude-opus-4-5", + resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off", + isNewSession: false, + blockStreamingEnabled: params?.blockStreamingEnabled ?? false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: params?.typingMode ?? "instant", + }); + }, + }; +} + +async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; +}) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); +} + +function createBaseRun(params: { + storePath: string; + sessionEntry: Record; + config?: Record; + runOverrides?: Partial; +}) { + const typing = createMockTypingController(); + const sessionCtx = { + Provider: "whatsapp", + OriginatingTo: "+15550001111", + AccountId: "primary", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + agentId: "main", + agentDir: "/tmp/agent", + sessionId: "session", + sessionKey: "main", + messageProvider: "whatsapp", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: params.config ?? {}, + skillsSnapshot: {}, + 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; + const run = { + ...followupRun.run, + ...params.runOverrides, + config: params.config ?? followupRun.run.config, + }; + + return { + typing, + sessionCtx, + resolvedQueue, + followupRun: { ...followupRun, run }, + }; +} + +async function runReplyAgentWithBase(params: { + baseRun: ReturnType; + storePath: string; + sessionKey: string; + sessionEntry: Record; + commandBody: string; + typingMode?: "instant"; +}): Promise { + const runReplyAgent = await getRunReplyAgent(); + const { typing, sessionCtx, resolvedQueue, followupRun } = params.baseRun; + await runReplyAgent({ + commandBody: params.commandBody, + followupRun, + queueKey: params.sessionKey, + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + sessionEntry: params.sessionEntry, + sessionStore: { [params.sessionKey]: params.sessionEntry } as Record, + sessionKey: params.sessionKey, + storePath: params.storePath, + defaultModel: "anthropic/claude-opus-4-5", + agentCfgContextTokens: 100_000, + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: params.typingMode ?? "instant", + }); +} + +describe("runReplyAgent typing (heartbeat)", () => { + let fixtureRoot = ""; + let caseId = 0; + + type StateEnvSnapshot = { + OPENCLAW_STATE_DIR: string | undefined; + }; + + function snapshotStateEnv(): StateEnvSnapshot { + return { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR }; + } + + function restoreStateEnv(snapshot: StateEnvSnapshot) { + if (snapshot.OPENCLAW_STATE_DIR === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = snapshot.OPENCLAW_STATE_DIR; + } + } + + async function withTempStateDir(fn: (stateDir: string) => Promise): Promise { + const stateDir = path.join(fixtureRoot, `case-${++caseId}`); + await fs.mkdir(stateDir, { recursive: true }); + const envSnapshot = snapshotStateEnv(); + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + return await fn(stateDir); + } finally { + restoreStateEnv(envSnapshot); + } + } + + async function writeCorruptGeminiSessionFixture(params: { + stateDir: string; + sessionId: string; + persistStore: boolean; + }) { + const storePath = path.join(params.stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId: params.sessionId, updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + if (params.persistStore) { + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + } + + const transcriptPath = sessions.resolveSessionTranscriptPath(params.sessionId); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "bad", "utf-8"); + + return { storePath, sessionEntry, sessionStore, transcriptPath }; + } + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-typing-heartbeat-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("signals typing for normal runs", async () => { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: false, onPartialReply }, + }); + await run(); + + expect(onPartialReply).toHaveBeenCalled(); + expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); + expect(typing.startTypingLoop).toHaveBeenCalled(); + }); + + it("never signals typing for heartbeat runs", async () => { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: true, onPartialReply }, + }); + await run(); + + expect(onPartialReply).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("suppresses partial streaming for NO_REPLY", async () => { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "NO_REPLY" }); + return { payloads: [{ text: "NO_REPLY" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: false, onPartialReply }, + typingMode: "message", + }); + await run(); + + expect(onPartialReply).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("does not start typing on assistant message start without prior text in message mode", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onAssistantMessageStart?.(); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + }); + await run(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("starts typing from reasoning stream in thinking mode", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onReasoningStream?.({ text: "Reasoning:\n_step_" }); + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "thinking", + }); + await run(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("suppresses typing in never mode", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "never", + }); + await run(); + + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("signals typing on normalized block replies", async () => { + const onBlockReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onBlockReply?.({ text: "\n\nchunk", mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + blockStreamingEnabled: true, + opts: { onBlockReply }, + }); + await run(); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("chunk"); + expect(onBlockReply).toHaveBeenCalled(); + const [blockPayload, blockOpts] = onBlockReply.mock.calls[0] ?? []; + expect(blockPayload).toMatchObject({ text: "chunk", audioAsVoice: false }); + expect(blockOpts).toMatchObject({ + abortSignal: expect.any(AbortSignal), + timeoutMs: expect.any(Number), + }); + }); + + it("signals typing on tool results", async () => { + const onToolResult = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onToolResult?.({ text: "tooling", mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + opts: { onToolResult }, + }); + await run(); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("tooling"); + expect(onToolResult).toHaveBeenCalledWith({ + text: "tooling", + mediaUrls: [], + }); + }); + + it("skips typing for silent tool results", async () => { + const onToolResult = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + opts: { onToolResult }, + }); + await run(); + + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(onToolResult).not.toHaveBeenCalled(); + }); + + it("announces auto-compaction in verbose mode and tracks count", async () => { + await withTempStateDir(async (stateDir) => { + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId: "session", updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: false }, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run } = createMinimalRun({ + resolvedVerboseLevel: "on", + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + expect(Array.isArray(res)).toBe(true); + const payloads = res as { text?: string }[]; + expect(payloads[0]?.text).toContain("Auto-compaction complete"); + expect(payloads[0]?.text).toContain("count 1"); + expect(sessionStore.main.compactionCount).toBe(1); + }); + }); + + it("retries after compaction failure by resetting the session", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Context limit exceeded during compaction"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + }); + }); + + it("retries after context overflow payload by resetting the session", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: "Context overflow: prompt too large", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "context_overflow", + message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + }, + }, + })); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Context limit exceeded"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + }); + }); + + it("resets the session after role ordering payloads", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: "Message ordering conflict - please try again.", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "role_ordering", + message: 'messages: roles must alternate between "user" and "assistant"', + }, + }, + })); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + await expect(fs.access(transcriptPath)).rejects.toBeDefined(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + }); + }); + + it("resets corrupted Gemini sessions and deletes transcripts", async () => { + await withTempStateDir(async (stateDir) => { + const { storePath, sessionEntry, sessionStore, transcriptPath } = + await writeCorruptGeminiSessionFixture({ + stateDir, + sessionId: "session-corrupt", + persistStore: true, + }); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + "function call turn comes immediately after a user turn or after a function response turn", + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Session history was corrupted"), + }); + expect(sessionStore.main).toBeUndefined(); + await expect(fs.access(transcriptPath)).rejects.toThrow(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main).toBeUndefined(); + }); + }); + + it("keeps sessions intact on other errors", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session-ok"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId, updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error("INVALID_ARGUMENT: some other failure"); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Agent failed before reply"), + }); + expect(sessionStore.main).toBeDefined(); + await expect(fs.access(transcriptPath)).resolves.toBeUndefined(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main).toBeDefined(); + }); + }); + + it("still replies even if session reset fails to persist", async () => { + await withTempStateDir(async (stateDir) => { + const saveSpy = vi + .spyOn(sessions, "saveSessionStore") + .mockRejectedValueOnce(new Error("boom")); + try { + const { storePath, sessionEntry, sessionStore, transcriptPath } = + await writeCorruptGeminiSessionFixture({ + stateDir, + sessionId: "session-corrupt", + persistStore: false, + }); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + "function call turn comes immediately after a user turn or after a function response turn", + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Session history was corrupted"), + }); + expect(sessionStore.main).toBeUndefined(); + await expect(fs.access(transcriptPath)).rejects.toThrow(); + } finally { + saveSpy.mockRestore(); + } + }); + }); + + it("returns friendly message for role ordering errors thrown as exceptions", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error("400 Incorrect role information"); + }); + + const { run } = createMinimalRun({}); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + expect(res).toMatchObject({ + text: expect.not.stringContaining("400"), + }); + }); + + it("rewrites Bun socket errors into friendly text", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [ + { + text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", + isError: true, + }, + ], + meta: {}, + })); + + const { run } = createMinimalRun(); + const res = await run(); + const payloads = Array.isArray(res) ? res : res ? [res] : []; + expect(payloads.length).toBe(1); + expect(payloads[0]?.text).toContain("LLM connection failed"); + expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly"); + expect(payloads[0]?.text).toContain("```"); + }); +}); + +describe("runReplyAgent memory flush", () => { + let fixtureRoot = ""; + let caseId = 0; + + async function withTempStore(fn: (storePath: string) => Promise): Promise { + const dir = path.join(fixtureRoot, `case-${++caseId}`); + await fs.mkdir(dir, { recursive: true }); + return await fn(path.join(dir, "sessions.json")); + } + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-memory-flush-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("skips memory flush for CLI providers", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + state.runEmbeddedPiAgentMock.mockImplementation(async () => ({ + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + })); + state.runCliAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + runOverrides: { provider: "codex-cli" }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(state.runCliAgentMock).toHaveBeenCalledTimes(1); + const call = state.runCliAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined; + expect(call?.prompt).toBe("hello"); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + }); + + it("runs a memory flush turn and updates session metadata", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + if (params.prompt?.includes("Pre-compaction memory flush.")) { + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.prompt).toContain("Pre-compaction memory flush."); + expect(calls[0]?.prompt).toContain("Current time:"); + expect(calls[0]?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/); + expect(calls[1]?.prompt).toBe("hello"); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number"); + expect(stored[sessionKey].memoryFlushCompactionCount).toBe(1); + }); + }); + + it("skips memory flush when disabled in config", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + state.runEmbeddedPiAgentMock.mockImplementation(async () => ({ + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + })); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + config: { agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } } }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const call = state.runEmbeddedPiAgentMock.mock.calls[0]?.[0] as + | { prompt?: string } + | undefined; + expect(call?.prompt).toBe("hello"); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); + }); + }); + + it("skips memory flush after a prior flush in the same compaction cycle", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 2, + memoryFlushCompactionCount: 2, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(calls.map((call) => call.prompt)).toEqual(["hello"]); + }); + }); + + it("increments compaction count when flush compaction completes", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + if (params.prompt?.includes("Pre-compaction memory flush.")) { + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: false }, + }); + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(2); + expect(stored[sessionKey].memoryFlushCompactionCount).toBe(2); + }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner.test-harness.mocks.ts b/src/auto-reply/reply/agent-runner.test-harness.mocks.ts deleted file mode 100644 index 01888f761de..00000000000 --- a/src/auto-reply/reply/agent-runner.test-harness.mocks.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { vi } from "vitest"; - -export function modelFallbackMockFactory(): Record { - return { - runWithModelFallback: async ({ - provider, - model, - run, - }: { - provider: string; - model: string; - run: (provider: string, model: string) => Promise; - }) => ({ - result: await run(provider, model), - provider, - model, - }), - }; -} - -export function embeddedPiMockFactory(state: { - runEmbeddedPiAgentMock: (params: unknown) => unknown; -}): Record { - return { - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), - }; -} - -export async function queueMockFactory(): Promise> { - const actual = await vi.importActual("./queue.js"); - return { - ...actual, - enqueueFollowupRun: vi.fn(), - scheduleFollowupDrain: vi.fn(), - }; -} diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 73a380e705c..c8f8eba129a 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -157,22 +157,26 @@ export async function runReplyAgent(params: { buffer: createAudioAsVoiceBuffer({ isAudioPayload }), }) : null; + const touchActiveSessionEntry = async () => { + if (!activeSessionEntry || !activeSessionStore || !sessionKey) { + return; + } + const updatedAt = Date.now(); + activeSessionEntry.updatedAt = updatedAt; + activeSessionStore[sessionKey] = activeSessionEntry; + if (storePath) { + await updateSessionStoreEntry({ + storePath, + sessionKey, + update: async () => ({ updatedAt }), + }); + } + }; if (shouldSteer && isStreaming) { const steered = queueEmbeddedPiMessage(followupRun.run.sessionId, followupRun.prompt); if (steered && !shouldFollowup) { - if (activeSessionEntry && activeSessionStore && sessionKey) { - const updatedAt = Date.now(); - activeSessionEntry.updatedAt = updatedAt; - activeSessionStore[sessionKey] = activeSessionEntry; - if (storePath) { - await updateSessionStoreEntry({ - storePath, - sessionKey, - update: async () => ({ updatedAt }), - }); - } - } + await touchActiveSessionEntry(); typing.cleanup(); return undefined; } @@ -180,18 +184,7 @@ export async function runReplyAgent(params: { if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) { enqueueFollowupRun(queueKey, followupRun, resolvedQueue); - if (activeSessionEntry && activeSessionStore && sessionKey) { - const updatedAt = Date.now(); - activeSessionEntry.updatedAt = updatedAt; - activeSessionStore[sessionKey] = activeSessionEntry; - if (storePath) { - await updateSessionStoreEntry({ - storePath, - sessionKey, - update: async () => ({ updatedAt }), - }); - } - } + await touchActiveSessionEntry(); typing.cleanup(); return undefined; } @@ -457,6 +450,7 @@ export async function runReplyAgent(params: { promptTokens, total: totalTokens, }, + lastCallUsage: runResult.meta.agentMeta?.lastCallUsage, context: { limit: contextTokensUsed, used: totalTokens, diff --git a/src/auto-reply/reply/commands-approve.test.ts b/src/auto-reply/reply/commands-approve.test.ts deleted file mode 100644 index cfb1f3cb7f0..00000000000 --- a/src/auto-reply/reply/commands-approve.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { callGateway } from "../../gateway/call.js"; -import { handleCommands } from "./commands.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; - -vi.mock("../../gateway/call.js", () => ({ - callGateway: vi.fn(), -})); - -describe("/approve command", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("rejects invalid usage", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Usage: /approve"); - }); - - it("submits approval", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { SenderId: "123" }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(mockCallGateway).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); - - it("rejects gateway clients without approvals scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.write"], - }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("requires operator.approvals"); - expect(mockCallGateway).not.toHaveBeenCalled(); - }); - - it("allows gateway clients with approvals scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.approvals"], - }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(mockCallGateway).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); - - it("allows gateway clients with admin scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.admin"], - }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(mockCallGateway).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); -}); diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts deleted file mode 100644 index 7c418ac239a..00000000000 --- a/src/auto-reply/reply/commands-compact.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; -import { handleCompactCommand } from "./commands-compact.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; - -vi.mock("../../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn(), - compactEmbeddedPiSession: vi.fn(), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../infra/system-events.js", () => ({ - enqueueSystemEvent: vi.fn(), -})); - -vi.mock("./session-updates.js", () => ({ - incrementCompactionCount: vi.fn(), -})); - -describe("/compact command", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns null when command is not /compact", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/status", cfg); - - const result = await handleCompactCommand( - { - ...params, - }, - true, - ); - - expect(result).toBeNull(); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); - }); - - it("rejects unauthorized /compact commands", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/compact", cfg); - - const result = await handleCompactCommand( - { - ...params, - command: { - ...params.command, - isAuthorizedSender: false, - senderId: "unauthorized", - }, - }, - true, - ); - - expect(result).toEqual({ shouldContinue: false }); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); - }); - - it("routes manual compaction with explicit trigger and context metadata", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: "/tmp/openclaw-session-store.json" }, - } as OpenClawConfig; - const params = buildCommandTestParams("/compact: focus on decisions", cfg, { - From: "+15550001", - To: "+15550002", - }); - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ - ok: true, - compacted: false, - }); - - const result = await handleCompactCommand( - { - ...params, - sessionEntry: { - sessionId: "session-1", - groupId: "group-1", - groupChannel: "#general", - space: "workspace-1", - spawnedBy: "agent:main:parent", - totalTokens: 12345, - }, - }, - true, - ); - - expect(result?.shouldContinue).toBe(false); - expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce(); - expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: "session-1", - sessionKey: "agent:main:main", - trigger: "manual", - customInstructions: "focus on decisions", - messageChannel: "whatsapp", - groupId: "group-1", - groupChannel: "#general", - groupSpace: "workspace-1", - spawnedBy: "agent:main:parent", - }), - ); - }); -}); diff --git a/src/auto-reply/reply/commands-info.test.ts b/src/auto-reply/reply/commands-info.test.ts deleted file mode 100644 index 9751c39cca5..00000000000 --- a/src/auto-reply/reply/commands-info.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildCommandsPaginationKeyboard } from "./commands-info.js"; - -describe("buildCommandsPaginationKeyboard", () => { - it("adds agent id to callback data when provided", () => { - const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); - expect(keyboard[0]).toEqual([ - { text: "β—€ Prev", callback_data: "commands_page_1:agent-main" }, - { text: "2/3", callback_data: "commands_page_noop:agent-main" }, - { text: "Next β–Ά", callback_data: "commands_page_3:agent-main" }, - ]); - }); -}); diff --git a/src/auto-reply/reply/commands-parsing.test.ts b/src/auto-reply/reply/commands-parsing.test.ts deleted file mode 100644 index 47309f93217..00000000000 --- a/src/auto-reply/reply/commands-parsing.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { extractMessageText } from "./commands-subagents.js"; -import { handleCommands } from "./commands.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; -import { parseConfigCommand } from "./config-commands.js"; -import { parseDebugCommand } from "./debug-commands.js"; - -describe("parseConfigCommand", () => { - it("parses show/unset", () => { - expect(parseConfigCommand("/config")).toEqual({ action: "show" }); - expect(parseConfigCommand("/config show")).toEqual({ - action: "show", - path: undefined, - }); - expect(parseConfigCommand("/config show foo.bar")).toEqual({ - action: "show", - path: "foo.bar", - }); - expect(parseConfigCommand("/config get foo.bar")).toEqual({ - action: "show", - path: "foo.bar", - }); - expect(parseConfigCommand("/config unset foo.bar")).toEqual({ - action: "unset", - path: "foo.bar", - }); - }); - - it("parses set with JSON", () => { - const cmd = parseConfigCommand('/config set foo={"a":1}'); - expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); - }); -}); - -describe("parseDebugCommand", () => { - it("parses show/reset", () => { - expect(parseDebugCommand("/debug")).toEqual({ action: "show" }); - expect(parseDebugCommand("/debug show")).toEqual({ action: "show" }); - expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" }); - }); - - it("parses set with JSON", () => { - const cmd = parseDebugCommand('/debug set foo={"a":1}'); - expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); - }); - - it("parses unset", () => { - const cmd = parseDebugCommand("/debug unset foo.bar"); - expect(cmd).toEqual({ action: "unset", path: "foo.bar" }); - }); -}); - -describe("extractMessageText", () => { - it("preserves user text that looks like tool call markers", () => { - const message = { - role: "user", - content: "Here [Tool Call: foo (ID: 1)] ok", - }; - const result = extractMessageText(message); - expect(result?.text).toContain("[Tool Call: foo (ID: 1)]"); - }); - - it("sanitizes assistant tool call markers", () => { - const message = { - role: "assistant", - content: "Here [Tool Call: foo (ID: 1)] ok", - }; - const result = extractMessageText(message); - expect(result?.text).toBe("Here ok"); - }); -}); - -describe("handleCommands /config configWrites gating", () => { - it("blocks /config set when channel config writes are disabled", async () => { - const cfg = { - commands: { config: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, - } as OpenClawConfig; - const params = buildCommandTestParams('/config set messages.ackReaction=":)"', cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config writes are disabled"); - }); -}); diff --git a/src/auto-reply/reply/commands-policy.test.ts b/src/auto-reply/reply/commands-policy.test.ts deleted file mode 100644 index c93b818e25f..00000000000 --- a/src/auto-reply/reply/commands-policy.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { MsgContext } from "../templating.js"; -import { buildCommandContext, handleCommands } from "./commands.js"; -import { parseInlineDirectives } from "./directive-handling.js"; - -const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); -const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); -const writeConfigFileMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../config/config.js", async () => { - const actual = - await vi.importActual("../../config/config.js"); - return { - ...actual, - readConfigFileSnapshot: readConfigFileSnapshotMock, - validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, - writeConfigFile: writeConfigFileMock, - }; -}); - -const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn()); -const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); -const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../pairing/pairing-store.js", async () => { - const actual = await vi.importActual( - "../../pairing/pairing-store.js", - ); - return { - ...actual, - readChannelAllowFromStore: readChannelAllowFromStoreMock, - addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, - removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, - }; -}); - -vi.mock("../../channels/plugins/pairing.js", async () => { - const actual = await vi.importActual( - "../../channels/plugins/pairing.js", - ); - return { - ...actual, - listPairingChannels: () => ["telegram"], - }; -}); - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, - { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" }, - { provider: "openai", id: "gpt-4.1", name: "GPT-4.1" }, - { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, - { provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" }, - ]), -})); - -function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial) { - const ctx = { - Body: commandBody, - CommandBody: commandBody, - CommandSource: "text", - CommandAuthorized: true, - Provider: "telegram", - Surface: "telegram", - ...ctxOverrides, - } as MsgContext; - - const command = buildCommandContext({ - ctx, - cfg, - isGroup: false, - triggerBodyNormalized: commandBody.trim().toLowerCase(), - commandAuthorized: true, - }); - - return { - ctx, - cfg, - command, - directives: parseInlineDirectives(commandBody), - elevated: { enabled: true, allowed: true, failures: [] }, - sessionKey: "agent:main:main", - workspaceDir: "/tmp", - defaultGroupActivation: () => "mention", - resolvedVerboseLevel: "off" as const, - resolvedReasoningLevel: "off" as const, - resolveDefaultThinkingLevel: async () => undefined, - provider: "telegram", - model: "test-model", - contextTokens: 0, - isGroup: false, - }; -} - -describe("handleCommands /allowlist", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("lists config + store allowFrom entries", async () => { - readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]); - - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["123", "@Alice"] } }, - } as OpenClawConfig; - const params = buildParams("/allowlist list dm", cfg); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Channel: telegram"); - expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice"); - expect(result.reply?.text).toContain("Paired allowFrom (store): 456"); - }); - - it("adds entries to config and pairing store", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { telegram: { 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: { allowFrom: ["123"] } }, - } as OpenClawConfig; - const params = buildParams("/allowlist add dm 789", cfg); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - channels: { telegram: { allowFrom: ["123", "789"] } }, - }), - ); - expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ - channel: "telegram", - entry: "789", - }); - expect(result.reply?.text).toContain("DM allowlist added"); - }); - - it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - slack: { - allowFrom: ["U111", "U222"], - dm: { allowFrom: ["U111", "U222"] }, - configWrites: true, - }, - }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - - const cfg = { - commands: { text: true, config: true }, - channels: { - slack: { - allowFrom: ["U111", "U222"], - dm: { allowFrom: ["U111", "U222"] }, - configWrites: true, - }, - }, - } as OpenClawConfig; - - const params = buildParams("/allowlist remove dm U111", cfg, { - Provider: "slack", - Surface: "slack", - }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledTimes(1); - const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; - expect(written.channels?.slack?.allowFrom).toEqual(["U222"]); - expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain("channels.slack.allowFrom"); - }); - - it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - discord: { - allowFrom: ["111", "222"], - dm: { allowFrom: ["111", "222"] }, - configWrites: true, - }, - }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - - const cfg = { - commands: { text: true, config: true }, - channels: { - discord: { - allowFrom: ["111", "222"], - dm: { allowFrom: ["111", "222"] }, - configWrites: true, - }, - }, - } as OpenClawConfig; - - const params = buildParams("/allowlist remove dm 111", cfg, { - Provider: "discord", - Surface: "discord", - }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledTimes(1); - const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; - expect(written.channels?.discord?.allowFrom).toEqual(["222"]); - expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain("channels.discord.allowFrom"); - }); -}); - -describe("/models command", () => { - const cfg = { - commands: { text: true }, - agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, - } as unknown as OpenClawConfig; - - it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => { - const params = buildParams("/models", cfg, { Provider: surface, Surface: surface }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Providers:"); - expect(result.reply?.text).toContain("anthropic"); - expect(result.reply?.text).toContain("Use: /models "); - }); - - it("lists providers on telegram (buttons)", async () => { - const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toBe("Select a provider:"); - const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } }) - ?.telegram?.buttons; - expect(buttons).toBeDefined(); - expect(buttons?.length).toBeGreaterThan(0); - }); - - it("lists provider models with pagination hints", async () => { - // Use discord surface for text-based output tests - const params = buildParams("/models anthropic", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (anthropic)"); - expect(result.reply?.text).toContain("page 1/"); - expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); - expect(result.reply?.text).toContain("Switch: /model "); - expect(result.reply?.text).toContain("All: /models anthropic all"); - }); - - it("ignores page argument when all flag is present", async () => { - // Use discord surface for text-based output tests - const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (anthropic)"); - expect(result.reply?.text).toContain("page 1/1"); - expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); - expect(result.reply?.text).not.toContain("Page out of range"); - }); - - it("errors on out-of-range pages", async () => { - // Use discord surface for text-based output tests - const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Page out of range"); - expect(result.reply?.text).toContain("valid: 1-"); - }); - - it("handles unknown providers", async () => { - const params = buildParams("/models not-a-provider", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Unknown provider"); - expect(result.reply?.text).toContain("Available providers"); - }); - - it("lists configured models outside the curated catalog", async () => { - const customCfg = { - commands: { text: true }, - agents: { - defaults: { - model: { - primary: "localai/ultra-chat", - fallbacks: ["anthropic/claude-opus-4-5"], - }, - imageModel: "visionpro/studio-v1", - }, - }, - } as unknown as OpenClawConfig; - - // Use discord surface for text-based output tests - const providerList = await handleCommands( - buildParams("/models", customCfg, { Surface: "discord" }), - ); - expect(providerList.reply?.text).toContain("localai"); - expect(providerList.reply?.text).toContain("visionpro"); - - const result = await handleCommands( - buildParams("/models localai", customCfg, { Surface: "discord" }), - ); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (localai)"); - expect(result.reply?.text).toContain("localai/ultra-chat"); - expect(result.reply?.text).not.toContain("Unknown provider"); - }); -}); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 20091a5ce98..205c6b38764 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -53,6 +53,30 @@ function resolveAbortTarget(params: { return { entry: undefined, key: targetSessionKey, sessionId: undefined }; } +async function applyAbortTarget(params: { + abortTarget: ReturnType; + sessionStore?: Record; + storePath?: string; + abortKey?: string; +}) { + const { abortTarget } = params; + if (abortTarget.sessionId) { + abortEmbeddedPiRun(abortTarget.sessionId); + } + if (abortTarget.entry && params.sessionStore && abortTarget.key) { + abortTarget.entry.abortedLastRun = true; + abortTarget.entry.updatedAt = Date.now(); + params.sessionStore[abortTarget.key] = abortTarget.entry; + if (params.storePath) { + await updateSessionStore(params.storePath, (store) => { + store[abortTarget.key] = abortTarget.entry; + }); + } + } else if (params.abortKey) { + setAbortMemory(params.abortKey, true); + } +} + export const handleActivationCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; @@ -304,27 +328,18 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand sessionEntry: params.sessionEntry, sessionStore: params.sessionStore, }); - if (abortTarget.sessionId) { - abortEmbeddedPiRun(abortTarget.sessionId); - } const cleared = clearSessionQueues([abortTarget.key, abortTarget.sessionId]); if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { logVerbose( `stop: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, ); } - if (abortTarget.entry && params.sessionStore && abortTarget.key) { - abortTarget.entry.abortedLastRun = true; - abortTarget.entry.updatedAt = Date.now(); - params.sessionStore[abortTarget.key] = abortTarget.entry; - if (params.storePath) { - await updateSessionStore(params.storePath, (store) => { - store[abortTarget.key] = abortTarget.entry; - }); - } - } else if (params.command.abortKey) { - setAbortMemory(params.command.abortKey, true); - } + await applyAbortTarget({ + abortTarget, + sessionStore: params.sessionStore, + storePath: params.storePath, + abortKey: params.command.abortKey, + }); // Trigger internal hook for stop command const hookEvent = createInternalHookEvent( @@ -361,20 +376,11 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman sessionEntry: params.sessionEntry, sessionStore: params.sessionStore, }); - if (abortTarget.sessionId) { - abortEmbeddedPiRun(abortTarget.sessionId); - } - if (abortTarget.entry && params.sessionStore && abortTarget.key) { - abortTarget.entry.abortedLastRun = true; - abortTarget.entry.updatedAt = Date.now(); - params.sessionStore[abortTarget.key] = abortTarget.entry; - if (params.storePath) { - await updateSessionStore(params.storePath, (store) => { - store[abortTarget.key] = abortTarget.entry; - }); - } - } else if (params.command.abortKey) { - setAbortMemory(params.command.abortKey, true); - } + await applyAbortTarget({ + abortTarget, + sessionStore: params.sessionStore, + storePath: params.storePath, + abortKey: params.command.abortKey, + }); return { shouldContinue: false, reply: { text: "βš™οΈ Agent was aborted." } }; }; diff --git a/src/auto-reply/reply/commands-setunset.ts b/src/auto-reply/reply/commands-setunset.ts new file mode 100644 index 00000000000..137973a5e69 --- /dev/null +++ b/src/auto-reply/reply/commands-setunset.ts @@ -0,0 +1,38 @@ +import { parseConfigValue } from "./config-value.js"; + +export type SetUnsetParseResult = + | { kind: "set"; path: string; value: unknown } + | { kind: "unset"; path: string } + | { kind: "error"; message: string }; + +export function parseSetUnsetCommand(params: { + slash: string; + action: "set" | "unset"; + args: string; +}): SetUnsetParseResult { + const action = params.action; + const args = params.args.trim(); + if (action === "unset") { + if (!args) { + return { kind: "error", message: `Usage: ${params.slash} unset path` }; + } + return { kind: "unset", path: args }; + } + if (!args) { + return { kind: "error", message: `Usage: ${params.slash} set path=value` }; + } + const eqIndex = args.indexOf("="); + if (eqIndex <= 0) { + return { kind: "error", message: `Usage: ${params.slash} set path=value` }; + } + const path = args.slice(0, eqIndex).trim(); + const rawValue = args.slice(eqIndex + 1); + if (!path) { + return { kind: "error", message: `Usage: ${params.slash} set path=value` }; + } + const parsed = parseConfigValue(rawValue); + if (parsed.error) { + return { kind: "error", message: parsed.error }; + } + return { kind: "set", path, value: parsed.value }; +} diff --git a/src/auto-reply/reply/commands-slash-parse.ts b/src/auto-reply/reply/commands-slash-parse.ts new file mode 100644 index 00000000000..8cf5541e31b --- /dev/null +++ b/src/auto-reply/reply/commands-slash-parse.ts @@ -0,0 +1,46 @@ +export type SlashCommandParseResult = + | { kind: "no-match" } + | { kind: "empty" } + | { kind: "invalid" } + | { kind: "parsed"; action: string; args: string }; + +export type ParsedSlashCommand = + | { ok: true; action: string; args: string } + | { ok: false; message: string }; + +export function parseSlashCommandActionArgs(raw: string, slash: string): SlashCommandParseResult { + const trimmed = raw.trim(); + const slashLower = slash.toLowerCase(); + if (!trimmed.toLowerCase().startsWith(slashLower)) { + return { kind: "no-match" }; + } + const rest = trimmed.slice(slash.length).trim(); + if (!rest) { + return { kind: "empty" }; + } + const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); + if (!match) { + return { kind: "invalid" }; + } + const action = match[1]?.toLowerCase() ?? ""; + const args = (match[2] ?? "").trim(); + return { kind: "parsed", action, args }; +} + +export function parseSlashCommandOrNull( + raw: string, + slash: string, + opts: { invalidMessage: string; defaultAction?: string }, +): ParsedSlashCommand | null { + const parsed = parseSlashCommandActionArgs(raw, slash); + if (parsed.kind === "no-match") { + return null; + } + if (parsed.kind === "invalid") { + return { ok: false, message: opts.invalidMessage }; + } + if (parsed.kind === "empty") { + return { ok: true, action: opts.defaultAction ?? "show", args: "" }; + } + return { ok: true, action: parsed.action, args: parsed.args }; +} diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index 35e3556d394..b4d201e9479 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -27,6 +27,7 @@ import { callGateway } from "../../gateway/call.js"; import { logVerbose } from "../../globals.js"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { formatDurationCompact, formatTokenUsageDisplay, @@ -202,45 +203,15 @@ function buildSubagentsHelp() { type ChatMessage = { role?: unknown; content?: unknown; - name?: unknown; - toolName?: unknown; }; -function normalizeMessageText(text: string) { - return text.replace(/\s+/g, " ").trim(); -} - export function extractMessageText(message: ChatMessage): { role: string; text: string } | null { const role = typeof message.role === "string" ? message.role : ""; const shouldSanitize = role === "assistant"; - const content = message.content; - if (typeof content === "string") { - const normalized = normalizeMessageText( - shouldSanitize ? sanitizeTextContent(content) : content, - ); - return normalized ? { role, text: normalized } : null; - } - if (!Array.isArray(content)) { - return null; - } - const chunks: string[] = []; - for (const block of content) { - if (!block || typeof block !== "object") { - continue; - } - if ((block as { type?: unknown }).type !== "text") { - continue; - } - const text = (block as { text?: unknown }).text; - if (typeof text === "string") { - const value = shouldSanitize ? sanitizeTextContent(text) : text; - if (value.trim()) { - chunks.push(value); - } - } - } - const joined = normalizeMessageText(chunks.join(" ")); - return joined ? { role, text: joined } : null; + const text = extractTextFromChatContent(message.content, { + sanitizeText: shouldSanitize ? sanitizeTextContent : undefined, + }); + return text ? { role, text } : null; } function formatLogLines(messages: ChatMessage[]) { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 431755561dc..1351296a2a1 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; import { @@ -13,14 +13,96 @@ import { updateSessionStore } from "../../config/sessions.js"; import * as internalHooks from "../../hooks/internal-hooks.js"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { resetBashChatCommandForTests } from "./bash-command.js"; +import { handleCompactCommand } from "./commands-compact.js"; +import { buildCommandsPaginationKeyboard } from "./commands-info.js"; +import { extractMessageText } from "./commands-subagents.js"; import { buildCommandTestParams } from "./commands.test-harness.js"; +import { parseConfigCommand } from "./config-commands.js"; +import { parseDebugCommand } from "./debug-commands.js"; +import { parseInlineDirectives } from "./directive-handling.js"; + +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); +const writeConfigFileMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../config/config.js", async () => { + const actual = + await vi.importActual("../../config/config.js"); + return { + ...actual, + readConfigFileSnapshot: readConfigFileSnapshotMock, + validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, + writeConfigFile: writeConfigFileMock, + }; +}); + +const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn()); +const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); +const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../pairing/pairing-store.js", async () => { + const actual = await vi.importActual( + "../../pairing/pairing-store.js", + ); + return { + ...actual, + readChannelAllowFromStore: readChannelAllowFromStoreMock, + addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, + removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, + }; +}); + +vi.mock("../../channels/plugins/pairing.js", async () => { + const actual = await vi.importActual( + "../../channels/plugins/pairing.js", + ); + return { + ...actual, + listPairingChannels: () => ["telegram"], + }; +}); + +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(async () => [ + { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" }, + { provider: "openai", id: "gpt-4.1", name: "GPT-4.1" }, + { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, + { provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" }, + ]), +})); + +vi.mock("../../agents/pi-embedded.js", () => { + const resolveEmbeddedSessionLane = (key: string) => { + const cleaned = key.trim() || "main"; + return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`; + }; + return { + abortEmbeddedPiRun: vi.fn(), + compactEmbeddedPiSession: vi.fn(), + isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), + isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + resolveEmbeddedSessionLane, + runEmbeddedPiAgent: vi.fn(), + waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("../../infra/system-events.js", () => ({ + enqueueSystemEvent: vi.fn(), +})); + +vi.mock("./session-updates.js", () => ({ + incrementCompactionCount: vi.fn(), +})); const callGatewayMock = vi.fn(); vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); -import { handleCommands } from "./commands.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; // Avoid expensive workspace scans during /context tests. vi.mock("./commands-context-report.js", () => ({ @@ -104,6 +186,293 @@ describe("handleCommands gating", () => { }); }); +describe("/approve command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects invalid usage", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/approve", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Usage: /approve"); + }); + + it("submits approval", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); + + it("rejects gateway clients without approvals scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.write"], + }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("requires operator.approvals"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("allows gateway clients with approvals scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.approvals"], + }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); + + it("allows gateway clients with admin scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.admin"], + }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); +}); + +describe("/compact command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when command is not /compact", async () => { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/status", cfg); + + const result = await handleCompactCommand( + { + ...params, + }, + true, + ); + + expect(result).toBeNull(); + expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); + }); + + it("rejects unauthorized /compact commands", async () => { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/compact", cfg); + + const result = await handleCompactCommand( + { + ...params, + command: { + ...params.command, + isAuthorizedSender: false, + senderId: "unauthorized", + }, + }, + true, + ); + + expect(result).toEqual({ shouldContinue: false }); + expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); + }); + + it("routes manual compaction with explicit trigger and context metadata", async () => { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: "/tmp/openclaw-session-store.json" }, + } as OpenClawConfig; + const params = buildParams("/compact: focus on decisions", cfg, { + From: "+15550001", + To: "+15550002", + }); + vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + ok: true, + compacted: false, + }); + + const result = await handleCompactCommand( + { + ...params, + sessionEntry: { + sessionId: "session-1", + groupId: "group-1", + groupChannel: "#general", + space: "workspace-1", + spawnedBy: "agent:main:parent", + totalTokens: 12345, + }, + }, + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce(); + expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + sessionKey: "agent:main:main", + trigger: "manual", + customInstructions: "focus on decisions", + messageChannel: "whatsapp", + groupId: "group-1", + groupChannel: "#general", + groupSpace: "workspace-1", + spawnedBy: "agent:main:parent", + }), + ); + }); +}); + +describe("buildCommandsPaginationKeyboard", () => { + it("adds agent id to callback data when provided", () => { + const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); + expect(keyboard[0]).toEqual([ + { text: "β—€ Prev", callback_data: "commands_page_1:agent-main" }, + { text: "2/3", callback_data: "commands_page_noop:agent-main" }, + { text: "Next β–Ά", callback_data: "commands_page_3:agent-main" }, + ]); + }); +}); + +describe("parseConfigCommand", () => { + it("parses show/unset", () => { + expect(parseConfigCommand("/config")).toEqual({ action: "show" }); + expect(parseConfigCommand("/config show")).toEqual({ + action: "show", + path: undefined, + }); + expect(parseConfigCommand("/config show foo.bar")).toEqual({ + action: "show", + path: "foo.bar", + }); + expect(parseConfigCommand("/config get foo.bar")).toEqual({ + action: "show", + path: "foo.bar", + }); + expect(parseConfigCommand("/config unset foo.bar")).toEqual({ + action: "unset", + path: "foo.bar", + }); + }); + + it("parses set with JSON", () => { + const cmd = parseConfigCommand('/config set foo={"a":1}'); + expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); + }); +}); + +describe("parseDebugCommand", () => { + it("parses show/reset", () => { + expect(parseDebugCommand("/debug")).toEqual({ action: "show" }); + expect(parseDebugCommand("/debug show")).toEqual({ action: "show" }); + expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" }); + }); + + it("parses set with JSON", () => { + const cmd = parseDebugCommand('/debug set foo={"a":1}'); + expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); + }); + + it("parses unset", () => { + const cmd = parseDebugCommand("/debug unset foo.bar"); + expect(cmd).toEqual({ action: "unset", path: "foo.bar" }); + }); +}); + +describe("extractMessageText", () => { + it("preserves user text that looks like tool call markers", () => { + const message = { + role: "user", + content: "Here [Tool Call: foo (ID: 1)] ok", + }; + const result = extractMessageText(message); + expect(result?.text).toContain("[Tool Call: foo (ID: 1)]"); + }); + + it("sanitizes assistant tool call markers", () => { + const message = { + role: "assistant", + content: "Here [Tool Call: foo (ID: 1)] ok", + }; + const result = extractMessageText(message); + expect(result?.text).toBe("Here ok"); + }); +}); + +describe("handleCommands /config configWrites gating", () => { + it("blocks /config set when channel config writes are disabled", async () => { + const cfg = { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, + } as OpenClawConfig; + const params = buildParams('/config set messages.ackReaction=":)"', cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config writes are disabled"); + }); +}); + describe("handleCommands bash alias", () => { it("routes !poll through the /bash handler", async () => { resetBashChatCommandForTests(); @@ -130,6 +499,289 @@ describe("handleCommands bash alias", () => { }); }); +function buildPolicyParams( + commandBody: string, + cfg: OpenClawConfig, + ctxOverrides?: Partial, +) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "text", + CommandAuthorized: true, + Provider: "telegram", + Surface: "telegram", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "telegram", + model: "test-model", + contextTokens: 0, + isGroup: false, + }; +} + +describe("handleCommands /allowlist", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists config + store allowFrom entries", async () => { + readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]); + + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["123", "@Alice"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist list dm", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Channel: telegram"); + expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice"); + expect(result.reply?.text).toContain("Paired allowFrom (store): 456"); + }); + + it("adds entries to config and pairing store", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { 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: { allowFrom: ["123"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist add dm 789", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { telegram: { allowFrom: ["123", "789"] } }, + }), + ); + expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + }); + expect(result.reply?.text).toContain("DM allowlist added"); + }); + + it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { + slack: { + allowFrom: ["U111", "U222"], + dm: { allowFrom: ["U111", "U222"] }, + configWrites: true, + }, + }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + + const cfg = { + commands: { text: true, config: true }, + channels: { + slack: { + allowFrom: ["U111", "U222"], + dm: { allowFrom: ["U111", "U222"] }, + configWrites: true, + }, + }, + } as OpenClawConfig; + + const params = buildPolicyParams("/allowlist remove dm U111", cfg, { + Provider: "slack", + Surface: "slack", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledTimes(1); + const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; + expect(written.channels?.slack?.allowFrom).toEqual(["U222"]); + expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined(); + expect(result.reply?.text).toContain("channels.slack.allowFrom"); + }); + + it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { + discord: { + allowFrom: ["111", "222"], + dm: { allowFrom: ["111", "222"] }, + configWrites: true, + }, + }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + + const cfg = { + commands: { text: true, config: true }, + channels: { + discord: { + allowFrom: ["111", "222"], + dm: { allowFrom: ["111", "222"] }, + configWrites: true, + }, + }, + } as OpenClawConfig; + + const params = buildPolicyParams("/allowlist remove dm 111", cfg, { + Provider: "discord", + Surface: "discord", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledTimes(1); + const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; + expect(written.channels?.discord?.allowFrom).toEqual(["222"]); + expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined(); + expect(result.reply?.text).toContain("channels.discord.allowFrom"); + }); +}); + +describe("/models command", () => { + const cfg = { + commands: { text: true }, + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + } as unknown as OpenClawConfig; + + it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => { + const params = buildPolicyParams("/models", cfg, { Provider: surface, Surface: surface }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Providers:"); + expect(result.reply?.text).toContain("anthropic"); + expect(result.reply?.text).toContain("Use: /models "); + }); + + it("lists providers on telegram (buttons)", async () => { + const params = buildPolicyParams("/models", cfg, { Provider: "telegram", Surface: "telegram" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toBe("Select a provider:"); + const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } }) + ?.telegram?.buttons; + expect(buttons).toBeDefined(); + expect(buttons?.length).toBeGreaterThan(0); + }); + + it("lists provider models with pagination hints", async () => { + // Use discord surface for text-based output tests + const params = buildPolicyParams("/models anthropic", cfg, { Surface: "discord" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (anthropic)"); + expect(result.reply?.text).toContain("page 1/"); + expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); + expect(result.reply?.text).toContain("Switch: /model "); + expect(result.reply?.text).toContain("All: /models anthropic all"); + }); + + it("ignores page argument when all flag is present", async () => { + // Use discord surface for text-based output tests + const params = buildPolicyParams("/models anthropic 3 all", cfg, { Surface: "discord" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (anthropic)"); + expect(result.reply?.text).toContain("page 1/1"); + expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); + expect(result.reply?.text).not.toContain("Page out of range"); + }); + + it("errors on out-of-range pages", async () => { + // Use discord surface for text-based output tests + const params = buildPolicyParams("/models anthropic 4", cfg, { Surface: "discord" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Page out of range"); + expect(result.reply?.text).toContain("valid: 1-"); + }); + + it("handles unknown providers", async () => { + const params = buildPolicyParams("/models not-a-provider", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Unknown provider"); + expect(result.reply?.text).toContain("Available providers"); + }); + + it("lists configured models outside the curated catalog", async () => { + const customCfg = { + commands: { text: true }, + agents: { + defaults: { + model: { + primary: "localai/ultra-chat", + fallbacks: ["anthropic/claude-opus-4-5"], + }, + imageModel: "visionpro/studio-v1", + }, + }, + } as unknown as OpenClawConfig; + + // Use discord surface for text-based output tests + const providerList = await handleCommands( + buildPolicyParams("/models", customCfg, { Surface: "discord" }), + ); + expect(providerList.reply?.text).toContain("localai"); + expect(providerList.reply?.text).toContain("visionpro"); + + const result = await handleCommands( + buildPolicyParams("/models localai", customCfg, { Surface: "discord" }), + ); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (localai)"); + expect(result.reply?.text).toContain("localai/ultra-chat"); + expect(result.reply?.text).not.toContain("Unknown provider"); + }); +}); + describe("handleCommands plugin commands", () => { it("dispatches registered plugin commands", async () => { clearPluginCommands(); diff --git a/src/auto-reply/reply/config-commands.ts b/src/auto-reply/reply/config-commands.ts index b78baa45905..fc924985c58 100644 --- a/src/auto-reply/reply/config-commands.ts +++ b/src/auto-reply/reply/config-commands.ts @@ -1,4 +1,5 @@ -import { parseConfigValue } from "./config-value.js"; +import { parseSetUnsetCommand } from "./commands-setunset.js"; +import { parseSlashCommandOrNull } from "./commands-slash-parse.js"; export type ConfigCommand = | { action: "show"; path?: string } @@ -7,60 +8,31 @@ export type ConfigCommand = | { action: "error"; message: string }; export function parseConfigCommand(raw: string): ConfigCommand | null { - const trimmed = raw.trim(); - if (!trimmed.toLowerCase().startsWith("/config")) { + const parsed = parseSlashCommandOrNull(raw, "/config", { + invalidMessage: "Invalid /config syntax.", + }); + if (!parsed) { return null; } - const rest = trimmed.slice("/config".length).trim(); - if (!rest) { - return { action: "show" }; + if (!parsed.ok) { + return { action: "error", message: parsed.message }; } - - const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); - if (!match) { - return { action: "error", message: "Invalid /config syntax." }; - } - const action = match[1].toLowerCase(); - const args = (match[2] ?? "").trim(); + const { action, args } = parsed; switch (action) { case "show": return { action: "show", path: args || undefined }; case "get": return { action: "show", path: args || undefined }; - case "unset": { - if (!args) { - return { action: "error", message: "Usage: /config unset path" }; - } - return { action: "unset", path: args }; - } + case "unset": case "set": { - if (!args) { - return { - action: "error", - message: "Usage: /config set path=value", - }; + const parsed = parseSetUnsetCommand({ slash: "/config", action, args }); + if (parsed.kind === "error") { + return { action: "error", message: parsed.message }; } - const eqIndex = args.indexOf("="); - if (eqIndex <= 0) { - return { - action: "error", - message: "Usage: /config set path=value", - }; - } - const path = args.slice(0, eqIndex).trim(); - const rawValue = args.slice(eqIndex + 1); - if (!path) { - return { - action: "error", - message: "Usage: /config set path=value", - }; - } - const parsed = parseConfigValue(rawValue); - if (parsed.error) { - return { action: "error", message: parsed.error }; - } - return { action: "set", path, value: parsed.value }; + return parsed.kind === "set" + ? { action: "set", path: parsed.path, value: parsed.value } + : { action: "unset", path: parsed.path }; } default: return { diff --git a/src/auto-reply/reply/debug-commands.ts b/src/auto-reply/reply/debug-commands.ts index 5f9f8c9fd0e..089caf2a5e5 100644 --- a/src/auto-reply/reply/debug-commands.ts +++ b/src/auto-reply/reply/debug-commands.ts @@ -1,4 +1,5 @@ -import { parseConfigValue } from "./config-value.js"; +import { parseSetUnsetCommand } from "./commands-setunset.js"; +import { parseSlashCommandOrNull } from "./commands-slash-parse.js"; export type DebugCommand = | { action: "show" } @@ -8,60 +9,31 @@ export type DebugCommand = | { action: "error"; message: string }; export function parseDebugCommand(raw: string): DebugCommand | null { - const trimmed = raw.trim(); - if (!trimmed.toLowerCase().startsWith("/debug")) { + const parsed = parseSlashCommandOrNull(raw, "/debug", { + invalidMessage: "Invalid /debug syntax.", + }); + if (!parsed) { return null; } - const rest = trimmed.slice("/debug".length).trim(); - if (!rest) { - return { action: "show" }; + if (!parsed.ok) { + return { action: "error", message: parsed.message }; } - - const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); - if (!match) { - return { action: "error", message: "Invalid /debug syntax." }; - } - const action = match[1].toLowerCase(); - const args = (match[2] ?? "").trim(); + const { action, args } = parsed; switch (action) { case "show": return { action: "show" }; case "reset": return { action: "reset" }; - case "unset": { - if (!args) { - return { action: "error", message: "Usage: /debug unset path" }; - } - return { action: "unset", path: args }; - } + case "unset": case "set": { - if (!args) { - return { - action: "error", - message: "Usage: /debug set path=value", - }; + const parsed = parseSetUnsetCommand({ slash: "/debug", action, args }); + if (parsed.kind === "error") { + return { action: "error", message: parsed.message }; } - const eqIndex = args.indexOf("="); - if (eqIndex <= 0) { - return { - action: "error", - message: "Usage: /debug set path=value", - }; - } - const path = args.slice(0, eqIndex).trim(); - const rawValue = args.slice(eqIndex + 1); - if (!path) { - return { - action: "error", - message: "Usage: /debug set path=value", - }; - } - const parsed = parseConfigValue(rawValue); - if (parsed.error) { - return { action: "error", message: parsed.error }; - } - return { action: "set", path, value: parsed.value }; + return parsed.kind === "set" + ? { action: "set", path: parsed.path, value: parsed.value } + : { action: "unset", path: parsed.path }; } default: return { diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts index e83aa889dfc..fdea7c75e01 100644 --- a/src/auto-reply/reply/directive-handling.fast-lane.ts +++ b/src/auto-reply/reply/directive-handling.fast-lane.ts @@ -1,7 +1,7 @@ import type { ReplyPayload } from "../types.js"; import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js"; -import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js"; import { handleDirectiveOnly } from "./directive-handling.impl.js"; +import { resolveCurrentDirectiveLevels } from "./directive-handling.levels.js"; import { isDirectiveOnly } from "./directive-handling.parse.js"; export async function applyInlineDirectivesFastLane( @@ -48,19 +48,12 @@ export async function applyInlineDirectivesFastLane( } const agentCfg = params.agentCfg; - const resolvedDefaultThinkLevel = - (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? - (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? - (await modelState.resolveDefaultThinkingLevel()); - const currentThinkLevel = resolvedDefaultThinkLevel; - const currentVerboseLevel = - (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? - (agentCfg?.verboseDefault as VerboseLevel | undefined); - const currentReasoningLevel = - (sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; - const currentElevatedLevel = - (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? - (agentCfg?.elevatedDefault as ElevatedLevel | undefined); + const { currentThinkLevel, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel } = + await resolveCurrentDirectiveLevels({ + sessionEntry, + agentCfg, + resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(), + }); const directiveAck = await handleDirectiveOnly({ cfg, diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index cc8b5aef608..838d7aeee70 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -21,10 +21,9 @@ import { import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js"; import { formatDirectiveAck, - formatElevatedEvent, formatElevatedRuntimeHint, formatElevatedUnavailableText, - formatReasoningEvent, + enqueueModeSwitchEvents, withOptions, } from "./directive-handling.shared.js"; @@ -363,20 +362,13 @@ export async function handleDirectiveOnly( }); } } - if (elevatedChanged) { - const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel; - enqueueSystemEvent(formatElevatedEvent(nextElevated), { - sessionKey, - contextKey: "mode:elevated", - }); - } - if (reasoningChanged) { - const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel; - enqueueSystemEvent(formatReasoningEvent(nextReasoning), { - sessionKey, - contextKey: "mode:reasoning", - }); - } + enqueueModeSwitchEvents({ + enqueueSystemEvent, + sessionEntry, + sessionKey, + elevatedChanged, + reasoningChanged, + }); const parts: string[] = []; if (directives.hasThinkDirective && directives.thinkLevel) { diff --git a/src/auto-reply/reply/directive-handling.levels.ts b/src/auto-reply/reply/directive-handling.levels.ts new file mode 100644 index 00000000000..61f9aef1c79 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.levels.ts @@ -0,0 +1,41 @@ +import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; + +export async function resolveCurrentDirectiveLevels(params: { + sessionEntry?: { + thinkingLevel?: unknown; + verboseLevel?: unknown; + reasoningLevel?: unknown; + elevatedLevel?: unknown; + }; + agentCfg?: { + thinkingDefault?: unknown; + verboseDefault?: unknown; + elevatedDefault?: unknown; + }; + resolveDefaultThinkingLevel: () => Promise; +}): Promise<{ + currentThinkLevel: ThinkLevel | undefined; + currentVerboseLevel: VerboseLevel | undefined; + currentReasoningLevel: ReasoningLevel; + currentElevatedLevel: ElevatedLevel | undefined; +}> { + const resolvedDefaultThinkLevel = + (params.sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? + (params.agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? + (await params.resolveDefaultThinkingLevel()); + const currentThinkLevel = resolvedDefaultThinkLevel; + const currentVerboseLevel = + (params.sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? + (params.agentCfg?.verboseDefault as VerboseLevel | undefined); + const currentReasoningLevel = + (params.sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; + const currentElevatedLevel = + (params.sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? + (params.agentCfg?.elevatedDefault as ElevatedLevel | undefined); + return { + currentThinkLevel, + currentVerboseLevel, + currentReasoningLevel, + currentElevatedLevel, + }; +} diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index 225cae08145..a7c97ad4486 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -20,7 +20,7 @@ import { enqueueSystemEvent } from "../../infra/system-events.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; import { resolveProfileOverride } from "./directive-handling.auth.js"; -import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js"; +import { enqueueModeSwitchEvents } from "./directive-handling.shared.js"; export async function persistInlineDirectives(params: { directives: InlineDirectives; @@ -199,20 +199,13 @@ export async function persistInlineDirectives(params: { store[sessionKey] = sessionEntry; }); } - if (elevatedChanged) { - const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel; - enqueueSystemEvent(formatElevatedEvent(nextElevated), { - sessionKey, - contextKey: "mode:elevated", - }); - } - if (reasoningChanged) { - const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel; - enqueueSystemEvent(formatReasoningEvent(nextReasoning), { - sessionKey, - contextKey: "mode:reasoning", - }); - } + enqueueModeSwitchEvents({ + enqueueSystemEvent, + sessionEntry, + sessionKey, + elevatedChanged, + reasoningChanged, + }); } } diff --git a/src/auto-reply/reply/directive-handling.shared.ts b/src/auto-reply/reply/directive-handling.shared.ts index 04d7ad0f64b..01a61b773a3 100644 --- a/src/auto-reply/reply/directive-handling.shared.ts +++ b/src/auto-reply/reply/directive-handling.shared.ts @@ -40,6 +40,29 @@ export const formatReasoningEvent = (level: ReasoningLevel) => { return "Reasoning OFF β€” hide ."; }; +export function enqueueModeSwitchEvents(params: { + enqueueSystemEvent: (text: string, meta: { sessionKey: string; contextKey: string }) => void; + sessionEntry: { elevatedLevel?: string | null; reasoningLevel?: string | null }; + sessionKey: string; + elevatedChanged?: boolean; + reasoningChanged?: boolean; +}): void { + if (params.elevatedChanged) { + const nextElevated = (params.sessionEntry.elevatedLevel ?? "off") as ElevatedLevel; + params.enqueueSystemEvent(formatElevatedEvent(nextElevated), { + sessionKey: params.sessionKey, + contextKey: "mode:elevated", + }); + } + if (params.reasoningChanged) { + const nextReasoning = (params.sessionEntry.reasoningLevel ?? "off") as ReasoningLevel; + params.enqueueSystemEvent(formatReasoningEvent(nextReasoning), { + sessionKey: params.sessionKey, + contextKey: "mode:reasoning", + }); + } +} + export function formatElevatedUnavailableText(params: { runtimeSandboxed: boolean; failures?: Array<{ gate: string; key: string }>; diff --git a/src/auto-reply/reply/formatting.test.ts b/src/auto-reply/reply/formatting.test.ts deleted file mode 100644 index e6fb0689881..00000000000 --- a/src/auto-reply/reply/formatting.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { parseAudioTag } from "./audio-tags.js"; -import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; -import { createReplyReferencePlanner } from "./reply-reference.js"; -import { createStreamingDirectiveAccumulator } from "./streaming-directives.js"; - -describe("parseAudioTag", () => { - it("detects audio_as_voice and strips the tag", () => { - const result = parseAudioTag("Hello [[audio_as_voice]] world"); - expect(result.audioAsVoice).toBe(true); - expect(result.hadTag).toBe(true); - expect(result.text).toBe("Hello world"); - }); - - it("returns empty output for missing text", () => { - const result = parseAudioTag(undefined); - expect(result.audioAsVoice).toBe(false); - expect(result.hadTag).toBe(false); - expect(result.text).toBe(""); - }); - - it("removes tag-only messages", () => { - const result = parseAudioTag("[[audio_as_voice]]"); - expect(result.audioAsVoice).toBe(true); - expect(result.text).toBe(""); - }); -}); - -describe("block reply coalescer", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("coalesces chunks within the idle window", async () => { - vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "Hello" }); - coalescer.enqueue({ text: "world" }); - - await vi.advanceTimersByTimeAsync(100); - expect(flushes).toEqual(["Hello world"]); - coalescer.stop(); - }); - - it("waits until minChars before idle flush", async () => { - vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "short" }); - await vi.advanceTimersByTimeAsync(50); - expect(flushes).toEqual([]); - - coalescer.enqueue({ text: "message" }); - await vi.advanceTimersByTimeAsync(50); - expect(flushes).toEqual(["short message"]); - coalescer.stop(); - }); - - it("flushes each enqueued payload separately when flushOnEnqueue is set", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "First paragraph" }); - coalescer.enqueue({ text: "Second paragraph" }); - coalescer.enqueue({ text: "Third paragraph" }); - - await Promise.resolve(); - expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]); - coalescer.stop(); - }); - - it("still accumulates when flushOnEnqueue is not set (default)", async () => { - vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "First paragraph" }); - coalescer.enqueue({ text: "Second paragraph" }); - - await vi.advanceTimersByTimeAsync(100); - expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]); - coalescer.stop(); - }); - - it("flushes short payloads immediately when flushOnEnqueue is set", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "Hi" }); - await Promise.resolve(); - expect(flushes).toEqual(["Hi"]); - coalescer.stop(); - }); - - it("resets char budget per paragraph with flushOnEnqueue", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - // Each 20-char payload fits within maxChars=30 individually - coalescer.enqueue({ text: "12345678901234567890" }); - coalescer.enqueue({ text: "abcdefghijklmnopqrst" }); - - await Promise.resolve(); - // Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split. - // With flushOnEnqueue, each is sent independently within budget. - expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]); - coalescer.stop(); - }); - - it("flushes buffered text before media payloads", () => { - const flushes: Array<{ text?: string; mediaUrls?: string[] }> = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push({ - text: payload.text, - mediaUrls: payload.mediaUrls, - }); - }, - }); - - coalescer.enqueue({ text: "Hello" }); - coalescer.enqueue({ text: "world" }); - coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] }); - void coalescer.flush({ force: true }); - - expect(flushes[0].text).toBe("Hello world"); - expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]); - coalescer.stop(); - }); -}); - -describe("createReplyReferencePlanner", () => { - it("disables references when mode is off", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "off", - startId: "parent", - }); - expect(planner.use()).toBeUndefined(); - expect(planner.hasReplied()).toBe(false); - }); - - it("uses startId once when mode is first", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "first", - startId: "parent", - }); - expect(planner.use()).toBe("parent"); - expect(planner.hasReplied()).toBe(true); - planner.markSent(); - expect(planner.use()).toBeUndefined(); - }); - - it("returns startId for every call when mode is all", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "all", - startId: "parent", - }); - expect(planner.use()).toBe("parent"); - expect(planner.use()).toBe("parent"); - }); - - it("respects replyToMode off even with existingId", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "off", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBeUndefined(); - expect(planner.hasReplied()).toBe(false); - }); - - it("uses existingId once when mode is first", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "first", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBe("thread-1"); - expect(planner.hasReplied()).toBe(true); - expect(planner.use()).toBeUndefined(); - }); - - it("uses existingId on every call when mode is all", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "all", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBe("thread-1"); - expect(planner.use()).toBe("thread-1"); - }); - - it("honors allowReference=false", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "all", - startId: "parent", - allowReference: false, - }); - expect(planner.use()).toBeUndefined(); - expect(planner.hasReplied()).toBe(false); - planner.markSent(); - expect(planner.hasReplied()).toBe(true); - }); -}); - -describe("createStreamingDirectiveAccumulator", () => { - it("stashes reply_to_current until a renderable chunk arrives", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to_current]]")).toBeNull(); - - const result = accumulator.consume("Hello"); - expect(result?.text).toBe("Hello"); - expect(result?.replyToCurrent).toBe(true); - expect(result?.replyToTag).toBe(true); - }); - - it("handles reply tags split across chunks", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to_")).toBeNull(); - - const result = accumulator.consume("current]] Yo"); - expect(result?.text).toBe("Yo"); - expect(result?.replyToCurrent).toBe(true); - expect(result?.replyToTag).toBe(true); - }); - - it("propagates explicit reply ids across chunks", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull(); - - const result = accumulator.consume("Hi"); - expect(result?.text).toBe("Hi"); - expect(result?.replyToId).toBe("abc-123"); - expect(result?.replyToTag).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 0068aed5415..b6ecc4c8300 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { MsgContext } from "../templating.js"; -import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; +import type { ElevatedLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import type { createModelSelectionState } from "./model-selection.js"; import type { TypingController } from "./typing.js"; @@ -13,6 +13,7 @@ import { isDirectiveOnly, persistInlineDirectives, } from "./directive-handling.js"; +import { resolveCurrentDirectiveLevels } from "./directive-handling.levels.js"; import { clearInlineDirectives } from "./get-reply-directives-utils.js"; type AgentDefaults = NonNullable["defaults"]; @@ -122,19 +123,17 @@ export async function applyInlineDirectiveOverrides(params: { typing.cleanup(); return { kind: "reply", reply: undefined }; } - const resolvedDefaultThinkLevel = - (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? - (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? - (await modelState.resolveDefaultThinkingLevel()); + const { + currentThinkLevel: resolvedDefaultThinkLevel, + currentVerboseLevel, + currentReasoningLevel, + currentElevatedLevel, + } = await resolveCurrentDirectiveLevels({ + sessionEntry, + agentCfg, + resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(), + }); const currentThinkLevel = resolvedDefaultThinkLevel; - const currentVerboseLevel = - (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? - (agentCfg?.verboseDefault as VerboseLevel | undefined); - const currentReasoningLevel = - (sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; - const currentElevatedLevel = - (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? - (agentCfg?.elevatedDefault as ElevatedLevel | undefined); const directiveReply = await handleDirectiveOnly({ cfg, directives, diff --git a/src/auto-reply/reply/get-reply-directives-utils.ts b/src/auto-reply/reply/get-reply-directives-utils.ts index c6b926ee6dc..02c60a31fac 100644 --- a/src/auto-reply/reply/get-reply-directives-utils.ts +++ b/src/auto-reply/reply/get-reply-directives-utils.ts @@ -1,5 +1,22 @@ import type { InlineDirectives } from "./directive-handling.js"; +const CLEARED_EXEC_FIELDS = { + hasExecDirective: false, + execHost: undefined, + execSecurity: undefined, + execAsk: undefined, + execNode: undefined, + rawExecHost: undefined, + rawExecSecurity: undefined, + rawExecAsk: undefined, + rawExecNode: undefined, + hasExecOptions: false, + invalidExecHost: false, + invalidExecSecurity: false, + invalidExecAsk: false, + invalidExecNode: false, +} satisfies Partial; + export function clearInlineDirectives(cleaned: string): InlineDirectives { return { cleaned, @@ -15,20 +32,7 @@ export function clearInlineDirectives(cleaned: string): InlineDirectives { hasElevatedDirective: false, elevatedLevel: undefined, rawElevatedLevel: undefined, - hasExecDirective: false, - execHost: undefined, - execSecurity: undefined, - execAsk: undefined, - execNode: undefined, - rawExecHost: undefined, - rawExecSecurity: undefined, - rawExecAsk: undefined, - rawExecNode: undefined, - hasExecOptions: false, - invalidExecHost: false, - invalidExecSecurity: false, - invalidExecAsk: false, - invalidExecNode: false, + ...CLEARED_EXEC_FIELDS, hasStatusDirective: false, hasModelDirective: false, rawModelDirective: undefined, @@ -45,3 +49,10 @@ export function clearInlineDirectives(cleaned: string): InlineDirectives { hasQueueOptions: false, }; } + +export function clearExecInlineDirectives(directives: InlineDirectives): InlineDirectives { + return { + ...directives, + ...CLEARED_EXEC_FIELDS, + }; +} diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 683011ae13c..417bdf6541e 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -14,7 +14,7 @@ import { resolveBlockStreamingChunking } from "./block-streaming.js"; import { buildCommandContext } from "./commands.js"; import { type InlineDirectives, parseInlineDirectives } from "./directive-handling.js"; import { applyInlineDirectiveOverrides } from "./get-reply-directives-apply.js"; -import { clearInlineDirectives } from "./get-reply-directives-utils.js"; +import { clearExecInlineDirectives, clearInlineDirectives } from "./get-reply-directives-utils.js"; import { defaultGroupActivation, resolveGroupRequireMention } from "./groups.js"; import { CURRENT_MESSAGE_MARKER, stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { createModelSelectionState, resolveContextTokens } from "./model-selection.js"; @@ -169,27 +169,34 @@ export async function resolveReplyDirectives(params: { surface: command.surface, commandSource: ctx.CommandSource, }); - const shouldResolveSkillCommands = - allowTextCommands && command.commandBodyNormalized.includes("/"); - const skillCommands = shouldResolveSkillCommands - ? listSkillCommandsForWorkspace({ - workspaceDir, - cfg, - skillFilter, - }) - : []; const reservedCommands = new Set( listChatCommands().flatMap((cmd) => cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()), ), ); - for (const command of skillCommands) { - reservedCommands.add(command.name.toLowerCase()); - } - const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {}) + + const rawAliases = Object.values(cfg.agents?.defaults?.models ?? {}) .map((entry) => entry.alias?.trim()) .filter((alias): alias is string => Boolean(alias)) .filter((alias) => !reservedCommands.has(alias.toLowerCase())); + + // Only load workspace skill commands when we actually need them to filter aliases. + // This avoids scanning skills for messages that only use inline directives like /think:/verbose:. + const skillCommands = + allowTextCommands && rawAliases.length > 0 + ? listSkillCommandsForWorkspace({ + workspaceDir, + cfg, + skillFilter, + }) + : []; + for (const command of skillCommands) { + reservedCommands.add(command.name.toLowerCase()); + } + + const configuredAliases = rawAliases.filter( + (alias) => !reservedCommands.has(alias.toLowerCase()), + ); const allowStatusDirective = allowTextCommands && command.isAuthorizedSender; let parsedDirectives = parseInlineDirectives(commandText, { modelAliases: configuredAliases, @@ -215,23 +222,7 @@ export async function resolveReplyDirectives(params: { } if (isGroup && ctx.WasMentioned !== true && parsedDirectives.hasExecDirective) { if (parsedDirectives.execSecurity !== "deny") { - parsedDirectives = { - ...parsedDirectives, - hasExecDirective: false, - execHost: undefined, - execSecurity: undefined, - execAsk: undefined, - execNode: undefined, - rawExecHost: undefined, - rawExecSecurity: undefined, - rawExecAsk: undefined, - rawExecNode: undefined, - hasExecOptions: false, - invalidExecHost: false, - invalidExecSecurity: false, - invalidExecAsk: false, - invalidExecNode: false, - }; + parsedDirectives = clearExecInlineDirectives(parsedDirectives); } } const hasInlineDirective = diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 3d6c9296ee0..a2d153e1134 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -11,12 +11,52 @@ import { createOpenClawTools } from "../../agents/openclaw-tools.js"; import { getChannelDock } from "../../channels/dock.js"; import { logVerbose } from "../../globals.js"; import { resolveGatewayMessageChannel } from "../../utils/message-channel.js"; +import { listChatCommands } from "../commands-registry.js"; import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js"; import { getAbortMemory } from "./abort.js"; import { buildStatusReply, handleCommands } from "./commands.js"; import { isDirectiveOnly } from "./directive-handling.js"; import { extractInlineSimpleCommand } from "./reply-inline.js"; +const builtinSlashCommands = (() => { + const reserved = new Set(); + for (const command of listChatCommands()) { + if (command.nativeName) { + reserved.add(command.nativeName.toLowerCase()); + } + for (const alias of command.textAliases) { + const trimmed = alias.trim(); + if (!trimmed.startsWith("/")) { + continue; + } + reserved.add(trimmed.slice(1).toLowerCase()); + } + } + for (const name of [ + "think", + "verbose", + "reasoning", + "elevated", + "exec", + "model", + "status", + "queue", + ]) { + reserved.add(name); + } + return reserved; +})(); + +function resolveSlashCommandName(commandBodyNormalized: string): string | null { + const trimmed = commandBodyNormalized.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + const match = trimmed.match(/^\/([^\s:]+)(?::|\s|$)/); + const name = match?.[1]?.trim().toLowerCase() ?? ""; + return name ? name : null; +} + export type InlineActionResult = | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } | { @@ -135,7 +175,12 @@ export async function handleInlineActions(params: { let directives = initialDirectives; let cleanedBody = initialCleanedBody; - const shouldLoadSkillCommands = command.commandBodyNormalized.startsWith("/"); + const slashCommandName = resolveSlashCommandName(command.commandBodyNormalized); + const shouldLoadSkillCommands = + allowTextCommands && + slashCommandName !== null && + // `/skill …` needs the full skill command list. + (slashCommandName === "skill" || !builtinSlashCommands.has(slashCommandName)); const skillCommands = shouldLoadSkillCommands && params.skillCommands ? params.skillCommands diff --git a/src/auto-reply/reply/history.test.ts b/src/auto-reply/reply/history.test.ts deleted file mode 100644 index 7991731daf6..00000000000 --- a/src/auto-reply/reply/history.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - appendHistoryEntry, - buildHistoryContext, - buildHistoryContextFromEntries, - buildHistoryContextFromMap, - buildPendingHistoryContextFromMap, - clearHistoryEntriesIfEnabled, - HISTORY_CONTEXT_MARKER, - recordPendingHistoryEntryIfEnabled, -} from "./history.js"; -import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; - -describe("history helpers", () => { - it("returns current message when history is empty", () => { - const result = buildHistoryContext({ - historyText: " ", - currentMessage: "hello", - }); - expect(result).toBe("hello"); - }); - - it("wraps history entries and excludes current by default", () => { - const result = buildHistoryContextFromEntries({ - entries: [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ], - currentMessage: "current", - formatEntry: (entry) => `${entry.sender}: ${entry.body}`, - }); - - expect(result).toContain(HISTORY_CONTEXT_MARKER); - expect(result).toContain("A: one"); - expect(result).not.toContain("B: two"); - expect(result).toContain(CURRENT_MESSAGE_MARKER); - expect(result).toContain("current"); - }); - - it("trims history to configured limit", () => { - const historyMap = new Map(); - - appendHistoryEntry({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "A", body: "one" }, - }); - appendHistoryEntry({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "B", body: "two" }, - }); - appendHistoryEntry({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "C", body: "three" }, - }); - - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two", "three"]); - }); - - it("builds context from map and appends entry", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); - - const result = buildHistoryContextFromMap({ - historyMap, - historyKey: "group", - limit: 3, - entry: { sender: "C", body: "three" }, - currentMessage: "current", - formatEntry: (entry) => `${entry.sender}: ${entry.body}`, - }); - - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]); - expect(result).toContain(HISTORY_CONTEXT_MARKER); - expect(result).toContain("A: one"); - expect(result).toContain("B: two"); - expect(result).not.toContain("C: three"); - }); - - it("builds context from pending map without appending", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); - - const result = buildPendingHistoryContextFromMap({ - historyMap, - historyKey: "group", - limit: 3, - currentMessage: "current", - formatEntry: (entry) => `${entry.sender}: ${entry.body}`, - }); - - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); - expect(result).toContain(HISTORY_CONTEXT_MARKER); - expect(result).toContain("A: one"); - expect(result).toContain("B: two"); - expect(result).toContain(CURRENT_MESSAGE_MARKER); - expect(result).toContain("current"); - }); - - it("records pending entries only when enabled", () => { - const historyMap = new Map(); - - recordPendingHistoryEntryIfEnabled({ - historyMap, - historyKey: "group", - limit: 0, - entry: { sender: "A", body: "one" }, - }); - expect(historyMap.get("group")).toEqual(undefined); - - recordPendingHistoryEntryIfEnabled({ - historyMap, - historyKey: "group", - limit: 2, - entry: null, - }); - expect(historyMap.get("group")).toEqual(undefined); - - recordPendingHistoryEntryIfEnabled({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "B", body: "two" }, - }); - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]); - }); - - it("clears history entries only when enabled", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); - - clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 }); - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); - - clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 }); - expect(historyMap.get("group")).toEqual([]); - }); -}); diff --git a/src/auto-reply/reply/inbound-context.providers-contract.test.ts b/src/auto-reply/reply/inbound-context.providers-contract.test.ts deleted file mode 100644 index a75b2996c30..00000000000 --- a/src/auto-reply/reply/inbound-context.providers-contract.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it } from "vitest"; -import type { MsgContext } from "../templating.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; -import { finalizeInboundContext } from "./inbound-context.js"; - -describe("inbound context contract (providers + extensions)", () => { - const cases: Array<{ name: string; ctx: MsgContext }> = [ - { - name: "whatsapp group", - ctx: { - Provider: "whatsapp", - Surface: "whatsapp", - ChatType: "group", - From: "123@g.us", - To: "+15550001111", - Body: "[WhatsApp 123@g.us] hi", - RawBody: "hi", - CommandBody: "hi", - SenderName: "Alice", - }, - }, - { - name: "telegram group", - ctx: { - Provider: "telegram", - Surface: "telegram", - ChatType: "group", - From: "group:123", - To: "telegram:123", - Body: "[Telegram group:123] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "Telegram Group", - SenderName: "Alice", - }, - }, - { - name: "slack channel", - ctx: { - Provider: "slack", - Surface: "slack", - ChatType: "channel", - From: "slack:channel:C123", - To: "channel:C123", - Body: "[Slack #general] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "#general", - SenderName: "Alice", - }, - }, - { - name: "discord channel", - ctx: { - Provider: "discord", - Surface: "discord", - ChatType: "channel", - From: "group:123", - To: "channel:123", - Body: "[Discord #general] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "#general", - SenderName: "Alice", - }, - }, - { - name: "signal dm", - ctx: { - Provider: "signal", - Surface: "signal", - ChatType: "direct", - From: "signal:+15550001111", - To: "signal:+15550002222", - Body: "[Signal] hi", - RawBody: "hi", - CommandBody: "hi", - }, - }, - { - name: "imessage group", - ctx: { - Provider: "imessage", - Surface: "imessage", - ChatType: "group", - From: "group:chat_id:123", - To: "chat_id:123", - Body: "[iMessage Group] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "iMessage Group", - SenderName: "Alice", - }, - }, - { - name: "matrix channel", - ctx: { - Provider: "matrix", - Surface: "matrix", - ChatType: "channel", - From: "matrix:channel:!room:example.org", - To: "room:!room:example.org", - Body: "[Matrix] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "#general", - SenderName: "Alice", - }, - }, - { - name: "msteams channel", - ctx: { - Provider: "msteams", - Surface: "msteams", - ChatType: "channel", - From: "msteams:channel:19:abc@thread.tacv2", - To: "msteams:channel:19:abc@thread.tacv2", - Body: "[Teams] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "Teams Channel", - SenderName: "Alice", - }, - }, - { - name: "zalo dm", - ctx: { - Provider: "zalo", - Surface: "zalo", - ChatType: "direct", - From: "zalo:123", - To: "zalo:123", - Body: "[Zalo] hi", - RawBody: "hi", - CommandBody: "hi", - }, - }, - { - name: "zalouser group", - ctx: { - Provider: "zalouser", - Surface: "zalouser", - ChatType: "group", - From: "group:123", - To: "zalouser:123", - Body: "[Zalo Personal] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "Zalouser Group", - SenderName: "Alice", - }, - }, - ]; - - for (const entry of cases) { - it(entry.name, () => { - const ctx = finalizeInboundContext({ ...entry.ctx }); - expectInboundContextContract(ctx); - }); - } -}); diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index f358aebc794..a1daa577d08 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -1,6 +1,53 @@ import { describe, expect, it } from "vitest"; import type { TemplateContext } from "../templating.js"; -import { buildInboundUserContextPrefix } from "./inbound-meta.js"; +import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js"; + +function parseInboundMetaPayload(text: string): Record { + const match = text.match(/```json\n([\s\S]*?)\n```/); + if (!match?.[1]) { + throw new Error("missing inbound meta json block"); + } + return JSON.parse(match[1]) as Record; +} + +describe("buildInboundMetaSystemPrompt", () => { + it("includes trusted message and routing ids for tool actions", () => { + const prompt = buildInboundMetaSystemPrompt({ + MessageSid: "123", + MessageSidFull: "123", + ReplyToId: "99", + OriginatingTo: "telegram:5494292670", + OriginatingChannel: "telegram", + Provider: "telegram", + Surface: "telegram", + ChatType: "direct", + } as TemplateContext); + + const payload = parseInboundMetaPayload(prompt); + expect(payload["schema"]).toBe("openclaw.inbound_meta.v1"); + expect(payload["message_id"]).toBe("123"); + expect(payload["message_id_full"]).toBeUndefined(); + expect(payload["reply_to_id"]).toBe("99"); + expect(payload["chat_id"]).toBe("telegram:5494292670"); + expect(payload["channel"]).toBe("telegram"); + }); + + it("keeps message_id_full only when it differs from message_id", () => { + const prompt = buildInboundMetaSystemPrompt({ + MessageSid: "short-id", + MessageSidFull: "full-provider-message-id", + OriginatingTo: "channel:C1", + OriginatingChannel: "slack", + Provider: "slack", + Surface: "slack", + ChatType: "group", + } as TemplateContext); + + const payload = parseInboundMetaPayload(prompt); + expect(payload["message_id"]).toBe("short-id"); + expect(payload["message_id_full"]).toBe("full-provider-message-id"); + }); +}); describe("buildInboundUserContextPrefix", () => { it("omits conversation label block for direct chats", () => { diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index 83676810238..03c06b7e2b0 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -13,11 +13,19 @@ function safeTrim(value: unknown): string | undefined { export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string { const chatType = normalizeChatType(ctx.ChatType); const isDirect = !chatType || chatType === "direct"; + const messageId = safeTrim(ctx.MessageSid); + const messageIdFull = safeTrim(ctx.MessageSidFull); + const replyToId = safeTrim(ctx.ReplyToId); + const chatId = safeTrim(ctx.OriginatingTo); // Keep system metadata strictly free of attacker-controlled strings (sender names, group subjects, etc.). // Those belong in the user-role "untrusted context" blocks. const payload = { schema: "openclaw.inbound_meta.v1", + message_id: messageId, + message_id_full: messageIdFull && messageIdFull !== messageId ? messageIdFull : undefined, + chat_id: chatId, + reply_to_id: replyToId, channel: safeTrim(ctx.OriginatingChannel) ?? safeTrim(ctx.Surface) ?? safeTrim(ctx.Provider), provider: safeTrim(ctx.Provider), surface: safeTrim(ctx.Surface), diff --git a/src/auto-reply/reply/inbound-text.test.ts b/src/auto-reply/reply/inbound-text.test.ts deleted file mode 100644 index 2b54a71299a..00000000000 --- a/src/auto-reply/reply/inbound-text.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeInboundTextNewlines } from "./inbound-text.js"; - -describe("normalizeInboundTextNewlines", () => { - it("converts CRLF to LF", () => { - expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); - }); - - it("converts CR to LF", () => { - expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld"); - }); - - it("preserves literal backslash-n sequences in Windows paths", () => { - // Windows paths like C:\Work\nxxx should NOT have \n converted to newlines - const windowsPath = "C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md"); - }); - - it("preserves backslash-n in messages containing Windows paths", () => { - const message = "Please read the file at C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(message)).toBe( - "Please read the file at C:\\Work\\nxxx\\README.md", - ); - }); - - it("preserves multiple backslash-n sequences", () => { - const message = "C:\\new\\notes\\nested"; - expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested"); - }); - - it("still normalizes actual CRLF while preserving backslash-n", () => { - const message = "Line 1\r\nC:\\Work\\nxxx"; - expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx"); - }); -}); diff --git a/src/auto-reply/reply/line-directives.test.ts b/src/auto-reply/reply/line-directives.test.ts deleted file mode 100644 index bf60232b854..00000000000 --- a/src/auto-reply/reply/line-directives.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseLineDirectives, hasLineDirectives } from "./line-directives.js"; - -const getLineData = (result: ReturnType) => - (result.channelData?.line as Record | undefined) ?? {}; - -describe("hasLineDirectives", () => { - it("detects quick_replies directive", () => { - expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true); - }); - - it("detects location directive", () => { - expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true); - }); - - it("detects confirm directive", () => { - expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true); - }); - - it("detects buttons directive", () => { - expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true); - }); - - it("returns false for regular text", () => { - expect(hasLineDirectives("Just regular text")).toBe(false); - }); - - it("returns false for similar but invalid patterns", () => { - expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false); - }); - - it("detects media_player directive", () => { - expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true); - }); - - it("detects event directive", () => { - expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true); - }); - - it("detects agenda directive", () => { - expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true); - }); - - it("detects device directive", () => { - expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true); - }); - - it("detects appletv_remote directive", () => { - expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true); - }); -}); - -describe("parseLineDirectives", () => { - describe("quick_replies", () => { - it("parses quick_replies and removes from text", () => { - const result = parseLineDirectives({ - text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]", - }); - - expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]); - expect(result.text).toBe("Choose one:"); - }); - - it("handles quick_replies in middle of text", () => { - const result = parseLineDirectives({ - text: "Before [[quick_replies: A, B]] After", - }); - - expect(getLineData(result).quickReplies).toEqual(["A", "B"]); - expect(result.text).toBe("Before After"); - }); - - it("merges with existing quickReplies", () => { - const result = parseLineDirectives({ - text: "Text [[quick_replies: C, D]]", - channelData: { line: { quickReplies: ["A", "B"] } }, - }); - - expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]); - }); - }); - - describe("location", () => { - it("parses location with all fields", () => { - const result = parseLineDirectives({ - text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]", - }); - - expect(getLineData(result).location).toEqual({ - title: "Tokyo Station", - address: "Tokyo, Japan", - latitude: 35.6812, - longitude: 139.7671, - }); - expect(result.text).toBe("Here's the location:"); - }); - - it("ignores invalid coordinates", () => { - const result = parseLineDirectives({ - text: "[[location: Place | Address | invalid | 139.7]]", - }); - - expect(getLineData(result).location).toBeUndefined(); - }); - - it("does not override existing location", () => { - const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 }; - const result = parseLineDirectives({ - text: "[[location: New | New Addr | 35.6 | 139.7]]", - channelData: { line: { location: existing } }, - }); - - expect(getLineData(result).location).toEqual(existing); - }); - }); - - describe("confirm", () => { - it("parses simple confirm", () => { - const result = parseLineDirectives({ - text: "[[confirm: Delete this item? | Yes | No]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "confirm", - text: "Delete this item?", - confirmLabel: "Yes", - confirmData: "yes", - cancelLabel: "No", - cancelData: "no", - altText: "Delete this item?", - }); - // Text is undefined when directive consumes entire text - expect(result.text).toBeUndefined(); - }); - - it("parses confirm with custom data", () => { - const result = parseLineDirectives({ - text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "confirm", - text: "Proceed?", - confirmLabel: "OK", - confirmData: "action=confirm", - cancelLabel: "Cancel", - cancelData: "action=cancel", - altText: "Proceed?", - }); - }); - }); - - describe("buttons", () => { - it("parses buttons with message actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "buttons", - title: "Menu", - text: "Select an option", - actions: [ - { type: "message", label: "Help", data: "/help" }, - { type: "message", label: "Status", data: "/status" }, - ], - altText: "Menu: Select an option", - }); - }); - - it("parses buttons with uri actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Links | Visit us | Site:https://example.com]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.[0]).toEqual({ - type: "uri", - label: "Site", - uri: "https://example.com", - }); - } - }); - - it("parses buttons with postback actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Actions | Choose | Select:action=select&id=1]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.[0]).toEqual({ - type: "postback", - label: "Select", - data: "action=select&id=1", - }); - } - }); - - it("limits to 4 actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.length).toBe(4); - } - }); - }); - - describe("media_player", () => { - it("parses media_player with all fields", () => { - const result = parseLineDirectives({ - text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]", - }); - - const flexMessage = getLineData(result).flexMessage as { - altText?: string; - contents?: { footer?: { contents?: unknown[] } }; - }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("🎡 Bohemian Rhapsody - Queen"); - const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } }; - expect(contents.footer?.contents?.length).toBeGreaterThan(0); - expect(result.text).toBe("Now playing:"); - }); - - it("parses media_player with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[media_player: Unknown Track]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("🎡 Unknown Track"); - }); - - it("handles paused status", () => { - const result = parseLineDirectives({ - text: "[[media_player: Song | Artist | Player | | paused]]", - }); - - const flexMessage = getLineData(result).flexMessage as { - contents?: { body: { contents: unknown[] } }; - }; - expect(flexMessage).toBeDefined(); - const contents = flexMessage?.contents as { body: { contents: unknown[] } }; - expect(contents).toBeDefined(); - }); - }); - - describe("event", () => { - it("parses event with all fields", () => { - const result = parseLineDirectives({ - text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("πŸ“… Team Meeting - January 24, 2026 2:00 PM - 3:00 PM"); - }); - - it("parses event with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[event: Birthday Party | March 15]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("πŸ“… Birthday Party - March 15"); - }); - }); - - describe("agenda", () => { - it("parses agenda with multiple events", () => { - const result = parseLineDirectives({ - text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("πŸ“‹ Today's Schedule (3 events)"); - }); - - it("parses agenda with events without times", () => { - const result = parseLineDirectives({ - text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("πŸ“‹ Tasks (3 events)"); - }); - }); - - describe("device", () => { - it("parses device with controls", () => { - const result = parseLineDirectives({ - text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("πŸ“± TV: Playing"); - }); - - it("parses device with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[device: Speaker]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("πŸ“± Speaker"); - }); - }); - - describe("appletv_remote", () => { - it("parses appletv_remote with status", () => { - const result = parseLineDirectives({ - text: "[[appletv_remote: Apple TV | Playing]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toContain("Apple TV"); - }); - - it("parses appletv_remote with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[appletv_remote: Apple TV]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - }); - }); - - describe("combined directives", () => { - it("handles text with no directives", () => { - const result = parseLineDirectives({ - text: "Just plain text here", - }); - - expect(result.text).toBe("Just plain text here"); - expect(getLineData(result).quickReplies).toBeUndefined(); - expect(getLineData(result).location).toBeUndefined(); - expect(getLineData(result).templateMessage).toBeUndefined(); - }); - - it("preserves other payload fields", () => { - const result = parseLineDirectives({ - text: "Hello [[quick_replies: A, B]]", - mediaUrl: "https://example.com/image.jpg", - replyToId: "msg123", - }); - - expect(result.mediaUrl).toBe("https://example.com/image.jpg"); - expect(result.replyToId).toBe("msg123"); - expect(getLineData(result).quickReplies).toEqual(["A", "B"]); - }); - }); -}); diff --git a/src/auto-reply/reply/memory-flush.test.ts b/src/auto-reply/reply/memory-flush.test.ts index e3dcc124e18..362b1b10a2c 100644 --- a/src/auto-reply/reply/memory-flush.test.ts +++ b/src/auto-reply/reply/memory-flush.test.ts @@ -1,133 +1,36 @@ import { describe, expect, it } from "vitest"; -import { - DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, - resolveMemoryFlushContextWindowTokens, - resolveMemoryFlushSettings, - shouldRunMemoryFlush, -} from "./memory-flush.js"; +import { resolveMemoryFlushPromptForRun } from "./memory-flush.js"; -describe("memory flush settings", () => { - it("defaults to enabled with fallback prompt and system prompt", () => { - const settings = resolveMemoryFlushSettings(); - expect(settings).not.toBeNull(); - expect(settings?.enabled).toBe(true); - expect(settings?.prompt.length).toBeGreaterThan(0); - expect(settings?.systemPrompt.length).toBeGreaterThan(0); - }); - - it("respects disable flag", () => { - expect( - resolveMemoryFlushSettings({ - agents: { - defaults: { compaction: { memoryFlush: { enabled: false } } }, - }, - }), - ).toBeNull(); - }); - - it("appends NO_REPLY hint when missing", () => { - const settings = resolveMemoryFlushSettings({ - agents: { - defaults: { - compaction: { - memoryFlush: { - prompt: "Write memories now.", - systemPrompt: "Flush memory.", - }, - }, - }, +describe("resolveMemoryFlushPromptForRun", () => { + const cfg = { + agents: { + defaults: { + userTimezone: "America/New_York", + timeFormat: "12", }, + }, + }; + + it("replaces YYYY-MM-DD using user timezone and appends current time", () => { + const prompt = resolveMemoryFlushPromptForRun({ + prompt: "Store durable notes in memory/YYYY-MM-DD.md", + cfg, + nowMs: Date.UTC(2026, 1, 16, 15, 0, 0), }); - expect(settings?.prompt).toContain("NO_REPLY"); - expect(settings?.systemPrompt).toContain("NO_REPLY"); - }); -}); - -describe("shouldRunMemoryFlush", () => { - it("requires totalTokens and threshold", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 0 }, - contextWindowTokens: 16_000, - reserveTokensFloor: 20_000, - softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, - }), - ).toBe(false); - }); - - it("skips when entry is missing", () => { - expect( - shouldRunMemoryFlush({ - entry: undefined, - contextWindowTokens: 16_000, - reserveTokensFloor: 1_000, - softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, - }), - ).toBe(false); - }); - - it("skips when under threshold", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 10_000 }, - contextWindowTokens: 100_000, - reserveTokensFloor: 20_000, - softThresholdTokens: 10_000, - }), - ).toBe(false); - }); - - it("triggers at the threshold boundary", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 85 }, - contextWindowTokens: 100, - reserveTokensFloor: 10, - softThresholdTokens: 5, - }), - ).toBe(true); - }); - - it("skips when already flushed for current compaction count", () => { - expect( - shouldRunMemoryFlush({ - entry: { - totalTokens: 90_000, - compactionCount: 2, - memoryFlushCompactionCount: 2, - }, - contextWindowTokens: 100_000, - reserveTokensFloor: 5_000, - softThresholdTokens: 2_000, - }), - ).toBe(false); - }); - - it("runs when above threshold and not flushed", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 96_000, compactionCount: 1 }, - contextWindowTokens: 100_000, - reserveTokensFloor: 5_000, - softThresholdTokens: 2_000, - }), - ).toBe(true); - }); - - it("ignores stale cached totals", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 96_000, totalTokensFresh: false, compactionCount: 1 }, - contextWindowTokens: 100_000, - reserveTokensFloor: 5_000, - softThresholdTokens: 2_000, - }), - ).toBe(false); - }); -}); - -describe("resolveMemoryFlushContextWindowTokens", () => { - it("falls back to agent config or default tokens", () => { - expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000); + + expect(prompt).toContain("memory/2026-02-16.md"); + expect(prompt).toContain("Current time:"); + expect(prompt).toContain("(America/New_York)"); + }); + + it("does not append a duplicate current time line", () => { + const prompt = resolveMemoryFlushPromptForRun({ + prompt: "Store notes.\nCurrent time: already present", + cfg, + nowMs: Date.UTC(2026, 1, 16, 15, 0, 0), + }); + + expect(prompt).toContain("Current time: already present"); + expect((prompt.match(/Current time:/g) ?? []).length).toBe(1); }); }); diff --git a/src/auto-reply/reply/memory-flush.ts b/src/auto-reply/reply/memory-flush.ts index 8ff6f1b1b6f..a3b50ae3444 100644 --- a/src/auto-reply/reply/memory-flush.ts +++ b/src/auto-reply/reply/memory-flush.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import { lookupContextTokens } from "../../agents/context.js"; +import { resolveCronStyleNow } from "../../agents/current-time.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../../agents/pi-settings.js"; import { resolveFreshSessionTotalTokens, type SessionEntry } from "../../config/sessions.js"; @@ -20,6 +21,40 @@ export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [ `You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`, ].join(" "); +function formatDateStampInTimezone(nowMs: number, timezone: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date(nowMs)); + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const day = parts.find((part) => part.type === "day")?.value; + if (year && month && day) { + return `${year}-${month}-${day}`; + } + return new Date(nowMs).toISOString().slice(0, 10); +} + +export function resolveMemoryFlushPromptForRun(params: { + prompt: string; + cfg?: OpenClawConfig; + nowMs?: number; +}): string { + const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now(); + const { userTimezone, timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs); + const dateStamp = formatDateStampInTimezone(nowMs, userTimezone); + const withDate = params.prompt.replaceAll("YYYY-MM-DD", dateStamp).trimEnd(); + if (!withDate) { + return timeLine; + } + if (withDate.includes("Current time:")) { + return withDate; + } + return `${withDate}\n${timeLine}`; +} + export type MemoryFlushSettings = { enabled: boolean; softThresholdTokens: number; diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts deleted file mode 100644 index 8b700d23b1f..00000000000 --- a/src/auto-reply/reply/mentions.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { matchesMentionWithExplicit } from "./mentions.js"; - -describe("matchesMentionWithExplicit", () => { - const mentionRegexes = [/\bopenclaw\b/i]; - - it("checks mentionPatterns even when explicit mention is available", () => { - const result = matchesMentionWithExplicit({ - text: "@openclaw hello", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: true, - }, - }); - expect(result).toBe(true); - }); - - it("returns false when explicit is false and no regex match", () => { - const result = matchesMentionWithExplicit({ - text: "<@999999> hello", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: true, - }, - }); - expect(result).toBe(false); - }); - - it("returns true when explicitly mentioned even if regexes do not match", () => { - const result = matchesMentionWithExplicit({ - text: "<@123456>", - mentionRegexes: [], - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: true, - canResolveExplicit: true, - }, - }); - expect(result).toBe(true); - }); - - it("falls back to regex matching when explicit mention cannot be resolved", () => { - const result = matchesMentionWithExplicit({ - text: "openclaw please", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: false, - }, - }); - expect(result).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/model-selection.override-respected.test.ts b/src/auto-reply/reply/model-selection.override-respected.test.ts deleted file mode 100644 index b3457fc5596..00000000000 --- a/src/auto-reply/reply/model-selection.override-respected.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createModelSelectionState } from "./model-selection.js"; - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ - { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, - { provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }, - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, - ]), -})); - -const defaultProvider = "inferencer"; -const defaultModel = "deepseek-v3-4bit-mlx"; - -const makeEntry = (overrides: Record = {}) => ({ - sessionId: "session-id", - updatedAt: Date.now(), - ...overrides, -}); - -describe("createModelSelectionState respects session model override", () => { - it("applies session modelOverride when set", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - providerOverride: "kimi-coding", - modelOverride: "k2p5", - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - expect(state.provider).toBe("kimi-coding"); - expect(state.model).toBe("k2p5"); - }); - - it("falls back to default when no modelOverride is set", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry(); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - expect(state.provider).toBe(defaultProvider); - expect(state.model).toBe(defaultModel); - }); - - it("respects modelOverride even when session model field differs", async () => { - // This tests the scenario from issue #14783: user switches model via /model, - // the override is stored, but session.model still reflects the last-used - // fallback model. The override should take precedence. - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - // Last-used model (from fallback) - should NOT be used for selection - model: "k2p5", - modelProvider: "kimi-coding", - contextTokens: 262_000, - // User's explicit override - SHOULD be used - providerOverride: "anthropic", - modelOverride: "claude-opus-4-5", - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - // Should use the override, not the last-used model - expect(state.provider).toBe("anthropic"); - expect(state.model).toBe("claude-opus-4-5"); - }); - - it("uses default provider when providerOverride is not set but modelOverride is", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - modelOverride: "deepseek-v3-4bit-mlx", - // no providerOverride - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - expect(state.provider).toBe(defaultProvider); - expect(state.model).toBe("deepseek-v3-4bit-mlx"); - }); -}); diff --git a/src/auto-reply/reply/model-selection.inherit-parent.test.ts b/src/auto-reply/reply/model-selection.test.ts similarity index 56% rename from src/auto-reply/reply/model-selection.inherit-parent.test.ts rename to src/auto-reply/reply/model-selection.test.ts index e80088b42a0..3da30c3c6da 100644 --- a/src/auto-reply/reply/model-selection.inherit-parent.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -4,44 +4,46 @@ import { createModelSelectionState } from "./model-selection.js"; vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ + { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, + { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, + { provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, ]), })); -const defaultProvider = "openai"; -const defaultModel = "gpt-4o-mini"; - const makeEntry = (overrides: Record = {}) => ({ sessionId: "session-id", updatedAt: Date.now(), ...overrides, }); -async function resolveState(params: { - cfg: OpenClawConfig; - sessionEntry: ReturnType; - sessionStore: Record>; - sessionKey: string; - parentSessionKey?: string; -}) { - return createModelSelectionState({ - cfg: params.cfg, - agentCfg: params.cfg.agents?.defaults, - sessionEntry: params.sessionEntry, - sessionStore: params.sessionStore, - sessionKey: params.sessionKey, - parentSessionKey: params.parentSessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); -} - describe("createModelSelectionState parent inheritance", () => { + const defaultProvider = "openai"; + const defaultModel = "gpt-4o-mini"; + + async function resolveState(params: { + cfg: OpenClawConfig; + sessionEntry: ReturnType; + sessionStore: Record>; + sessionKey: string; + parentSessionKey?: string; + }) { + return createModelSelectionState({ + cfg: params.cfg, + agentCfg: params.cfg.agents?.defaults, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + sessionKey: params.sessionKey, + parentSessionKey: params.parentSessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + } + it("inherits parent override from explicit parentSessionKey", async () => { const cfg = {} as OpenClawConfig; const parentKey = "agent:main:discord:channel:c1"; @@ -212,3 +214,112 @@ describe("createModelSelectionState parent inheritance", () => { expect(state.model).toBe("claude-opus-4-5"); }); }); + +describe("createModelSelectionState respects session model override", () => { + const defaultProvider = "inferencer"; + const defaultModel = "deepseek-v3-4bit-mlx"; + + it("applies session modelOverride when set", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry({ + providerOverride: "kimi-coding", + modelOverride: "k2p5", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe("kimi-coding"); + expect(state.model).toBe("k2p5"); + }); + + it("falls back to default when no modelOverride is set", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry(); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe(defaultProvider); + expect(state.model).toBe(defaultModel); + }); + + it("respects modelOverride even when session model field differs", async () => { + // From issue #14783: stored override should beat last-used fallback model. + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry({ + model: "k2p5", + modelProvider: "kimi-coding", + contextTokens: 262_000, + providerOverride: "anthropic", + modelOverride: "claude-opus-4-5", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe("anthropic"); + expect(state.model).toBe("claude-opus-4-5"); + }); + + it("uses default provider when providerOverride is not set but modelOverride is", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry({ + modelOverride: "deepseek-v3-4bit-mlx", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe(defaultProvider); + expect(state.model).toBe("deepseek-v3-4bit-mlx"); + }); +}); diff --git a/src/auto-reply/reply/normalize-reply.test.ts b/src/auto-reply/reply/normalize-reply.test.ts deleted file mode 100644 index 26866892669..00000000000 --- a/src/auto-reply/reply/normalize-reply.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { SILENT_REPLY_TOKEN } from "../tokens.js"; -import { normalizeReplyPayload } from "./normalize-reply.js"; - -// Keep channelData-only payloads so channel-specific replies survive normalization. -describe("normalizeReplyPayload", () => { - it("keeps channelData-only replies", () => { - const payload = { - channelData: { - line: { - flexMessage: { type: "bubble" }, - }, - }, - }; - - const normalized = normalizeReplyPayload(payload); - - expect(normalized).not.toBeNull(); - expect(normalized?.text).toBeUndefined(); - expect(normalized?.channelData).toEqual(payload.channelData); - }); - - it("records silent skips", () => { - const reasons: string[] = []; - const normalized = normalizeReplyPayload( - { text: SILENT_REPLY_TOKEN }, - { - onSkip: (reason) => reasons.push(reason), - }, - ); - - expect(normalized).toBeNull(); - expect(reasons).toEqual(["silent"]); - }); - - it("records empty skips", () => { - const reasons: string[] = []; - const normalized = normalizeReplyPayload( - { text: " " }, - { - onSkip: (reason) => reasons.push(reason), - }, - ); - - expect(normalized).toBeNull(); - expect(reasons).toEqual(["empty"]); - }); -}); diff --git a/src/auto-reply/reply/queue.collect-routing.test.ts b/src/auto-reply/reply/queue.collect-routing.test.ts deleted file mode 100644 index 8d6fab2e9a3..00000000000 --- a/src/auto-reply/reply/queue.collect-routing.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; -import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js"; - -const COLLECT_SETTINGS: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", -}; - -function createSettings(overrides?: Partial): QueueSettings { - return { ...COLLECT_SETTINGS, ...overrides } as QueueSettings; -} - -function createRunCollector() { - const calls: FollowupRun[] = []; - const runFollowup = async (run: FollowupRun) => { - calls.push(run); - }; - return { calls, runFollowup }; -} - -async function drainAndWait(params: { - key: string; - calls: FollowupRun[]; - runFollowup: (run: FollowupRun) => Promise; - count: number; -}) { - scheduleFollowupDrain(params.key, params.runFollowup); - await expect.poll(() => params.calls.length).toBe(params.count); -} - -function createRun(params: { - prompt: string; - messageId?: string; - originatingChannel?: FollowupRun["originatingChannel"]; - originatingTo?: string; - originatingAccountId?: string; - originatingThreadId?: string | number; -}): FollowupRun { - return { - prompt: params.prompt, - messageId: params.messageId, - enqueuedAt: Date.now(), - originatingChannel: params.originatingChannel, - originatingTo: params.originatingTo, - originatingAccountId: params.originatingAccountId, - originatingThreadId: params.originatingThreadId, - run: { - agentId: "agent", - agentDir: "/tmp", - sessionId: "sess", - sessionFile: "/tmp/session.json", - workspaceDir: "/tmp", - config: {} as OpenClawConfig, - provider: "openai", - model: "gpt-test", - timeoutMs: 10_000, - blockReplyBreak: "text_end", - }, - }; -} - -describe("followup queue deduplication", () => { - it("deduplicates messages with same Discord message_id", async () => { - const key = `test-dedup-message-id-${Date.now()}`; - const { calls, runFollowup } = createRunCollector(); - const settings = createSettings(); - - // First enqueue should succeed - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "[Discord Guild #test channel id:123] Hello", - messageId: "m1", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - settings, - ); - expect(first).toBe(true); - - // Second enqueue with same message id should be deduplicated - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "[Discord Guild #test channel id:123] Hello (dupe)", - messageId: "m1", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - settings, - ); - expect(second).toBe(false); - - // Third enqueue with different message id should succeed - const third = enqueueFollowupRun( - key, - createRun({ - prompt: "[Discord Guild #test channel id:123] World", - messageId: "m2", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - settings, - ); - expect(third).toBe(true); - - await drainAndWait({ key, calls, runFollowup, count: 1 }); - // Should collect both unique messages - expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); - }); - - it("deduplicates exact prompt when routing matches and no message id", async () => { - const key = `test-dedup-whatsapp-${Date.now()}`; - const settings = createSettings(); - - // First enqueue should succeed - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(first).toBe(true); - - // Second enqueue with same prompt should be allowed (default dedupe: message id only) - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(second).toBe(true); - - // Third enqueue with different prompt should succeed - const third = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world 2", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(third).toBe(true); - }); - - it("does not deduplicate across different providers without message id", async () => { - const key = `test-dedup-cross-provider-${Date.now()}`; - const settings = createSettings(); - - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "Same text", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(first).toBe(true); - - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "Same text", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - settings, - ); - expect(second).toBe(true); - }); - - it("can opt-in to prompt-based dedupe when message id is absent", async () => { - const key = `test-dedup-prompt-mode-${Date.now()}`; - const settings = createSettings(); - - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - "prompt", - ); - expect(first).toBe(true); - - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - "prompt", - ); - expect(second).toBe(false); - }); -}); - -describe("followup queue collect routing", () => { - it("does not collect when destinations differ", async () => { - const key = `test-collect-diff-to-${Date.now()}`; - const { calls, runFollowup } = createRunCollector(); - const settings = createSettings(); - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:B", - }), - settings, - ); - - await drainAndWait({ key, calls, runFollowup, count: 2 }); - expect(calls[0]?.prompt).toBe("one"); - expect(calls[1]?.prompt).toBe("two"); - }); - - it("collects when channel+destination match", async () => { - const key = `test-collect-same-to-${Date.now()}`; - const { calls, runFollowup } = createRunCollector(); - const settings = createSettings(); - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:A", - }), - settings, - ); - - await drainAndWait({ key, calls, runFollowup, count: 1 }); - expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); - expect(calls[0]?.originatingChannel).toBe("slack"); - expect(calls[0]?.originatingTo).toBe("channel:A"); - }); - - it("collects Slack messages in same thread and preserves string thread id", async () => { - const key = `test-collect-slack-thread-same-${Date.now()}`; - const { calls, runFollowup } = createRunCollector(); - const settings = createSettings(); - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000001", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000001", - }), - settings, - ); - - await drainAndWait({ key, calls, runFollowup, count: 1 }); - expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); - expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); - }); - - it("does not collect Slack messages when thread ids differ", async () => { - const key = `test-collect-slack-thread-diff-${Date.now()}`; - const { calls, runFollowup } = createRunCollector(); - const settings = createSettings(); - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000001", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000002", - }), - settings, - ); - - await drainAndWait({ key, calls, runFollowup, count: 2 }); - expect(calls[0]?.prompt).toBe("one"); - expect(calls[1]?.prompt).toBe("two"); - expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); - expect(calls[1]?.originatingThreadId).toBe("1706000000.000002"); - }); - - it("retries collect-mode batches without losing queued items", async () => { - const key = `test-collect-retry-${Date.now()}`; - const calls: FollowupRun[] = []; - let attempt = 0; - const runFollowup = async (run: FollowupRun) => { - attempt += 1; - if (attempt === 1) { - throw new Error("transient failure"); - } - calls.push(run); - }; - const settings = createSettings(); - - enqueueFollowupRun(key, createRun({ prompt: "one" }), settings); - enqueueFollowupRun(key, createRun({ prompt: "two" }), settings); - - scheduleFollowupDrain(key, runFollowup); - await expect.poll(() => calls.length).toBe(1); - expect(calls[0]?.prompt).toContain("Queued #1\none"); - expect(calls[0]?.prompt).toContain("Queued #2\ntwo"); - }); - - it("retries overflow summary delivery without losing dropped previews", async () => { - const key = `test-overflow-summary-retry-${Date.now()}`; - const calls: FollowupRun[] = []; - let attempt = 0; - const runFollowup = async (run: FollowupRun) => { - attempt += 1; - if (attempt === 1) { - throw new Error("transient failure"); - } - calls.push(run); - }; - const settings = createSettings({ mode: "followup", cap: 1 }); - - enqueueFollowupRun(key, createRun({ prompt: "first" }), settings); - enqueueFollowupRun(key, createRun({ prompt: "second" }), settings); - - scheduleFollowupDrain(key, runFollowup); - await expect.poll(() => calls.length).toBe(1); - expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); - expect(calls[0]?.prompt).toContain("- first"); - }); -}); diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts new file mode 100644 index 00000000000..c314997929f --- /dev/null +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -0,0 +1,1317 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { MsgContext, TemplateContext } from "../templating.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; +import { defaultRuntime } from "../../runtime.js"; +import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; +import { finalizeInboundContext } from "./inbound-context.js"; +import { buildInboundUserContextPrefix } from "./inbound-meta.js"; +import { normalizeInboundTextNewlines } from "./inbound-text.js"; +import { parseLineDirectives, hasLineDirectives } from "./line-directives.js"; +import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js"; +import { createReplyDispatcher } from "./reply-dispatcher.js"; +import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; + +describe("buildInboundUserContextPrefix", () => { + it("omits conversation label block for direct chats", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "direct", + ConversationLabel: "openclaw-tui", + } as TemplateContext); + + expect(text).toBe(""); + }); + + it("keeps conversation label for group chats", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "group", + ConversationLabel: "ops-room", + } as TemplateContext); + + expect(text).toContain("Conversation info (untrusted metadata):"); + expect(text).toContain('"conversation_label": "ops-room"'); + }); +}); + +describe("normalizeInboundTextNewlines", () => { + it("converts CRLF to LF", () => { + expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); + }); + + it("converts CR to LF", () => { + expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld"); + }); + + it("preserves literal backslash-n sequences in Windows paths", () => { + const windowsPath = "C:\\Work\\nxxx\\README.md"; + expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md"); + }); + + it("preserves backslash-n in messages containing Windows paths", () => { + const message = "Please read the file at C:\\Work\\nxxx\\README.md"; + expect(normalizeInboundTextNewlines(message)).toBe( + "Please read the file at C:\\Work\\nxxx\\README.md", + ); + }); + + it("preserves multiple backslash-n sequences", () => { + const message = "C:\\new\\notes\\nested"; + expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested"); + }); + + it("still normalizes actual CRLF while preserving backslash-n", () => { + const message = "Line 1\r\nC:\\Work\\nxxx"; + expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx"); + }); +}); + +describe("inbound context contract (providers + extensions)", () => { + const cases: Array<{ name: string; ctx: MsgContext }> = [ + { + name: "whatsapp group", + ctx: { + Provider: "whatsapp", + Surface: "whatsapp", + ChatType: "group", + From: "123@g.us", + To: "+15550001111", + Body: "[WhatsApp 123@g.us] hi", + RawBody: "hi", + CommandBody: "hi", + SenderName: "Alice", + }, + }, + { + name: "telegram group", + ctx: { + Provider: "telegram", + Surface: "telegram", + ChatType: "group", + From: "group:123", + To: "telegram:123", + Body: "[Telegram group:123] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Telegram Group", + SenderName: "Alice", + }, + }, + { + name: "slack channel", + ctx: { + Provider: "slack", + Surface: "slack", + ChatType: "channel", + From: "slack:channel:C123", + To: "channel:C123", + Body: "[Slack #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "discord channel", + ctx: { + Provider: "discord", + Surface: "discord", + ChatType: "channel", + From: "group:123", + To: "channel:123", + Body: "[Discord #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "signal dm", + ctx: { + Provider: "signal", + Surface: "signal", + ChatType: "direct", + From: "signal:+15550001111", + To: "signal:+15550002222", + Body: "[Signal] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "imessage group", + ctx: { + Provider: "imessage", + Surface: "imessage", + ChatType: "group", + From: "group:chat_id:123", + To: "chat_id:123", + Body: "[iMessage Group] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "iMessage Group", + SenderName: "Alice", + }, + }, + { + name: "matrix channel", + ctx: { + Provider: "matrix", + Surface: "matrix", + ChatType: "channel", + From: "matrix:channel:!room:example.org", + To: "room:!room:example.org", + Body: "[Matrix] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "msteams channel", + ctx: { + Provider: "msteams", + Surface: "msteams", + ChatType: "channel", + From: "msteams:channel:19:abc@thread.tacv2", + To: "msteams:channel:19:abc@thread.tacv2", + Body: "[Teams] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Teams Channel", + SenderName: "Alice", + }, + }, + { + name: "zalo dm", + ctx: { + Provider: "zalo", + Surface: "zalo", + ChatType: "direct", + From: "zalo:123", + To: "zalo:123", + Body: "[Zalo] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "zalouser group", + ctx: { + Provider: "zalouser", + Surface: "zalouser", + ChatType: "group", + From: "group:123", + To: "zalouser:123", + Body: "[Zalo Personal] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Zalouser Group", + SenderName: "Alice", + }, + }, + ]; + + for (const entry of cases) { + it(entry.name, () => { + const ctx = finalizeInboundContext({ ...entry.ctx }); + expectInboundContextContract(ctx); + }); + } +}); + +const getLineData = (result: ReturnType) => + (result.channelData?.line as Record | undefined) ?? {}; + +describe("hasLineDirectives", () => { + it("detects quick_replies directive", () => { + expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true); + }); + + it("detects location directive", () => { + expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true); + }); + + it("detects confirm directive", () => { + expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true); + }); + + it("detects buttons directive", () => { + expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true); + }); + + it("returns false for regular text", () => { + expect(hasLineDirectives("Just regular text")).toBe(false); + }); + + it("returns false for similar but invalid patterns", () => { + expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false); + }); + + it("detects media_player directive", () => { + expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true); + }); + + it("detects event directive", () => { + expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true); + }); + + it("detects agenda directive", () => { + expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true); + }); + + it("detects device directive", () => { + expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true); + }); + + it("detects appletv_remote directive", () => { + expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true); + }); +}); + +describe("parseLineDirectives", () => { + describe("quick_replies", () => { + it("parses quick_replies and removes from text", () => { + const result = parseLineDirectives({ + text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]", + }); + + expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]); + expect(result.text).toBe("Choose one:"); + }); + + it("handles quick_replies in middle of text", () => { + const result = parseLineDirectives({ + text: "Before [[quick_replies: A, B]] After", + }); + + expect(getLineData(result).quickReplies).toEqual(["A", "B"]); + expect(result.text).toBe("Before After"); + }); + + it("merges with existing quickReplies", () => { + const result = parseLineDirectives({ + text: "Text [[quick_replies: C, D]]", + channelData: { line: { quickReplies: ["A", "B"] } }, + }); + + expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]); + }); + }); + + describe("location", () => { + it("parses location with all fields", () => { + const result = parseLineDirectives({ + text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]", + }); + + expect(getLineData(result).location).toEqual({ + title: "Tokyo Station", + address: "Tokyo, Japan", + latitude: 35.6812, + longitude: 139.7671, + }); + expect(result.text).toBe("Here's the location:"); + }); + + it("ignores invalid coordinates", () => { + const result = parseLineDirectives({ + text: "[[location: Place | Address | invalid | 139.7]]", + }); + + expect(getLineData(result).location).toBeUndefined(); + }); + + it("does not override existing location", () => { + const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 }; + const result = parseLineDirectives({ + text: "[[location: New | New Addr | 35.6 | 139.7]]", + channelData: { line: { location: existing } }, + }); + + expect(getLineData(result).location).toEqual(existing); + }); + }); + + describe("confirm", () => { + it("parses simple confirm", () => { + const result = parseLineDirectives({ + text: "[[confirm: Delete this item? | Yes | No]]", + }); + + expect(getLineData(result).templateMessage).toEqual({ + type: "confirm", + text: "Delete this item?", + confirmLabel: "Yes", + confirmData: "yes", + cancelLabel: "No", + cancelData: "no", + altText: "Delete this item?", + }); + // Text is undefined when directive consumes entire text + expect(result.text).toBeUndefined(); + }); + + it("parses confirm with custom data", () => { + const result = parseLineDirectives({ + text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]", + }); + + expect(getLineData(result).templateMessage).toEqual({ + type: "confirm", + text: "Proceed?", + confirmLabel: "OK", + confirmData: "action=confirm", + cancelLabel: "Cancel", + cancelData: "action=cancel", + altText: "Proceed?", + }); + }); + }); + + describe("buttons", () => { + it("parses buttons with message actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]", + }); + + expect(getLineData(result).templateMessage).toEqual({ + type: "buttons", + title: "Menu", + text: "Select an option", + actions: [ + { type: "message", label: "Help", data: "/help" }, + { type: "message", label: "Status", data: "/status" }, + ], + altText: "Menu: Select an option", + }); + }); + + it("parses buttons with uri actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Links | Visit us | Site:https://example.com]]", + }); + + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type).toBe("buttons"); + if (templateMessage?.type === "buttons") { + expect(templateMessage.actions?.[0]).toEqual({ + type: "uri", + label: "Site", + uri: "https://example.com", + }); + } + }); + + it("parses buttons with postback actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Actions | Choose | Select:action=select&id=1]]", + }); + + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type).toBe("buttons"); + if (templateMessage?.type === "buttons") { + expect(templateMessage.actions?.[0]).toEqual({ + type: "postback", + label: "Select", + data: "action=select&id=1", + }); + } + }); + + it("limits to 4 actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]", + }); + + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type).toBe("buttons"); + if (templateMessage?.type === "buttons") { + expect(templateMessage.actions?.length).toBe(4); + } + }); + }); + + describe("media_player", () => { + it("parses media_player with all fields", () => { + const result = parseLineDirectives({ + text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]", + }); + + const flexMessage = getLineData(result).flexMessage as { + altText?: string; + contents?: { footer?: { contents?: unknown[] } }; + }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("🎡 Bohemian Rhapsody - Queen"); + const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } }; + expect(contents.footer?.contents?.length).toBeGreaterThan(0); + expect(result.text).toBe("Now playing:"); + }); + + it("parses media_player with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[media_player: Unknown Track]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("🎡 Unknown Track"); + }); + + it("handles paused status", () => { + const result = parseLineDirectives({ + text: "[[media_player: Song | Artist | Player | | paused]]", + }); + + const flexMessage = getLineData(result).flexMessage as { + contents?: { body: { contents: unknown[] } }; + }; + expect(flexMessage).toBeDefined(); + const contents = flexMessage?.contents as { body: { contents: unknown[] } }; + expect(contents).toBeDefined(); + }); + }); + + describe("event", () => { + it("parses event with all fields", () => { + const result = parseLineDirectives({ + text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("πŸ“… Team Meeting - January 24, 2026 2:00 PM - 3:00 PM"); + }); + + it("parses event with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[event: Birthday Party | March 15]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("πŸ“… Birthday Party - March 15"); + }); + }); + + describe("agenda", () => { + it("parses agenda with multiple events", () => { + const result = parseLineDirectives({ + text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("πŸ“‹ Today's Schedule (3 events)"); + }); + + it("parses agenda with events without times", () => { + const result = parseLineDirectives({ + text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("πŸ“‹ Tasks (3 events)"); + }); + }); + + describe("device", () => { + it("parses device with controls", () => { + const result = parseLineDirectives({ + text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("πŸ“± TV: Playing"); + }); + + it("parses device with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[device: Speaker]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("πŸ“± Speaker"); + }); + }); + + describe("appletv_remote", () => { + it("parses appletv_remote with status", () => { + const result = parseLineDirectives({ + text: "[[appletv_remote: Apple TV | Playing]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toContain("Apple TV"); + }); + + it("parses appletv_remote with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[appletv_remote: Apple TV]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + }); + }); + + describe("combined directives", () => { + it("handles text with no directives", () => { + const result = parseLineDirectives({ + text: "Just plain text here", + }); + + expect(result.text).toBe("Just plain text here"); + expect(getLineData(result).quickReplies).toBeUndefined(); + expect(getLineData(result).location).toBeUndefined(); + expect(getLineData(result).templateMessage).toBeUndefined(); + }); + + it("preserves other payload fields", () => { + const result = parseLineDirectives({ + text: "Hello [[quick_replies: A, B]]", + mediaUrl: "https://example.com/image.jpg", + replyToId: "msg123", + }); + + expect(result.mediaUrl).toBe("https://example.com/image.jpg"); + expect(result.replyToId).toBe("msg123"); + expect(getLineData(result).quickReplies).toEqual(["A", "B"]); + }); + }); +}); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let previousRuntimeError: typeof defaultRuntime.error; + +beforeAll(() => { + previousRuntimeError = defaultRuntime.error; + defaultRuntime.error = undefined; +}); + +afterAll(() => { + defaultRuntime.error = previousRuntimeError; +}); + +function createRun(params: { + prompt: string; + messageId?: string; + originatingChannel?: FollowupRun["originatingChannel"]; + originatingTo?: string; + originatingAccountId?: string; + originatingThreadId?: string | number; +}): FollowupRun { + return { + prompt: params.prompt, + messageId: params.messageId, + enqueuedAt: Date.now(), + originatingChannel: params.originatingChannel, + originatingTo: params.originatingTo, + originatingAccountId: params.originatingAccountId, + originatingThreadId: params.originatingThreadId, + run: { + agentId: "agent", + agentDir: "/tmp", + sessionId: "sess", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp", + config: {} as OpenClawConfig, + provider: "openai", + model: "gpt-test", + timeoutMs: 10_000, + blockReplyBreak: "text_end", + }, + }; +} + +describe("followup queue deduplication", () => { + it("deduplicates messages with same Discord message_id", async () => { + const key = `test-dedup-message-id-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + // First enqueue should succeed + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] Hello", + messageId: "m1", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(first).toBe(true); + + // Second enqueue with same message id should be deduplicated + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] Hello (dupe)", + messageId: "m1", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(second).toBe(false); + + // Third enqueue with different message id should succeed + const third = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] World", + messageId: "m2", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(third).toBe(true); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + // Should collect both unique messages + expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); + }); + + it("deduplicates exact prompt when routing matches and no message id", async () => { + const key = `test-dedup-whatsapp-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + // First enqueue should succeed + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(first).toBe(true); + + // Second enqueue with same prompt should be allowed (default dedupe: message id only) + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(second).toBe(true); + + // Third enqueue with different prompt should succeed + const third = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world 2", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(third).toBe(true); + }); + + it("does not deduplicate across different providers without message id", async () => { + const key = `test-dedup-cross-provider-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Same text", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(first).toBe(true); + + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Same text", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(second).toBe(true); + }); + + it("can opt-in to prompt-based dedupe when message id is absent", async () => { + const key = `test-dedup-prompt-mode-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + "prompt", + ); + expect(first).toBe(true); + + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + "prompt", + ); + expect(second).toBe(false); + }); +}); + +describe("followup queue collect routing", () => { + it("does not collect when destinations differ", async () => { + const key = `test-collect-diff-to-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 2; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:B", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toBe("one"); + expect(calls[1]?.prompt).toBe("two"); + }); + + it("collects when channel+destination match", async () => { + const key = `test-collect-same-to-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:A", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); + expect(calls[0]?.originatingChannel).toBe("slack"); + expect(calls[0]?.originatingTo).toBe("channel:A"); + }); + + it("collects Slack messages in same thread and preserves string thread id", async () => { + const key = `test-collect-slack-thread-same-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000001", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000001", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); + expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); + }); + + it("does not collect Slack messages when thread ids differ", async () => { + const key = `test-collect-slack-thread-diff-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 2; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000001", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000002", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toBe("one"); + expect(calls[1]?.prompt).toBe("two"); + expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); + expect(calls[1]?.originatingThreadId).toBe("1706000000.000002"); + }); + + it("retries collect-mode batches without losing queued items", async () => { + const key = `test-collect-retry-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + let attempt = 0; + const runFollowup = async (run: FollowupRun) => { + attempt += 1; + if (attempt === 1) { + throw new Error("transient failure"); + } + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun(key, createRun({ prompt: "one" }), settings); + enqueueFollowupRun(key, createRun({ prompt: "two" }), settings); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("Queued #1\none"); + expect(calls[0]?.prompt).toContain("Queued #2\ntwo"); + }); + + it("retries overflow summary delivery without losing dropped previews", async () => { + const key = `test-overflow-summary-retry-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + let attempt = 0; + const runFollowup = async (run: FollowupRun) => { + attempt += 1; + if (attempt === 1) { + throw new Error("transient failure"); + } + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun(key, createRun({ prompt: "first" }), settings); + enqueueFollowupRun(key, createRun({ prompt: "second" }), settings); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[0]?.prompt).toContain("- first"); + }); +}); + +const emptyCfg = {} as OpenClawConfig; + +describe("createReplyDispatcher", () => { + it("drops empty payloads and silent tokens without media", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ deliver }); + + expect(dispatcher.sendFinalReply({})).toBe(false); + expect(dispatcher.sendFinalReply({ text: " " })).toBe(false); + expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); + expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false); + expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(false); + + await dispatcher.waitForIdle(); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("strips heartbeat tokens and applies responsePrefix", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const onHeartbeatStrip = vi.fn(); + const dispatcher = createReplyDispatcher({ + deliver, + responsePrefix: "PFX", + onHeartbeatStrip, + }); + + expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false); + expect(dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` })).toBe(true); + await dispatcher.waitForIdle(); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver.mock.calls[0][0].text).toBe("PFX hello"); + expect(onHeartbeatStrip).toHaveBeenCalledTimes(2); + }); + + it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ + deliver, + responsePrefix: "PFX", + }); + + expect( + dispatcher.sendFinalReply({ + text: "PFX already", + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); + expect( + dispatcher.sendFinalReply({ + text: HEARTBEAT_TOKEN, + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); + expect( + dispatcher.sendFinalReply({ + text: `${SILENT_REPLY_TOKEN} -- explanation`, + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); + + await dispatcher.waitForIdle(); + + expect(deliver).toHaveBeenCalledTimes(3); + expect(deliver.mock.calls[0][0].text).toBe("PFX already"); + expect(deliver.mock.calls[1][0].text).toBe(""); + expect(deliver.mock.calls[2][0].text).toBe(""); + }); + + it("preserves ordering across tool, block, and final replies", async () => { + const delivered: string[] = []; + const deliver = vi.fn(async (_payload, info) => { + delivered.push(info.kind); + if (info.kind === "tool") { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }); + const dispatcher = createReplyDispatcher({ deliver }); + + dispatcher.sendToolResult({ text: "tool" }); + dispatcher.sendBlockReply({ text: "block" }); + dispatcher.sendFinalReply({ text: "final" }); + + await dispatcher.waitForIdle(); + expect(delivered).toEqual(["tool", "block", "final"]); + }); + + it("fires onIdle when the queue drains", async () => { + const deliver = vi.fn(async () => await new Promise((resolve) => setTimeout(resolve, 5))); + const onIdle = vi.fn(); + const dispatcher = createReplyDispatcher({ deliver, onIdle }); + + dispatcher.sendToolResult({ text: "one" }); + dispatcher.sendFinalReply({ text: "two" }); + + await dispatcher.waitForIdle(); + dispatcher.markComplete(); + await Promise.resolve(); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it("delays block replies after the first when humanDelay is natural", async () => { + vi.useFakeTimers(); + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ + deliver, + humanDelay: { mode: "natural" }, + }); + + dispatcher.sendBlockReply({ text: "first" }); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + + dispatcher.sendBlockReply({ text: "second" }); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(799); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await dispatcher.waitForIdle(); + expect(deliver).toHaveBeenCalledTimes(2); + + randomSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("uses custom bounds for humanDelay and clamps when max <= min", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ + deliver, + humanDelay: { mode: "custom", minMs: 1200, maxMs: 400 }, + }); + + dispatcher.sendBlockReply({ text: "first" }); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + + dispatcher.sendBlockReply({ text: "second" }); + await vi.advanceTimersByTimeAsync(1199); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await dispatcher.waitForIdle(); + expect(deliver).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); +}); + +describe("resolveReplyToMode", () => { + it("defaults to off for Telegram", () => { + expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("off"); + }); + + it("defaults to off for Discord and Slack", () => { + expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off"); + expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off"); + }); + + it("defaults to all when channel is unknown", () => { + expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all"); + }); + + it("uses configured value when present", () => { + const cfg = { + channels: { + telegram: { replyToMode: "all" }, + discord: { replyToMode: "first" }, + slack: { replyToMode: "all" }, + }, + } as OpenClawConfig; + expect(resolveReplyToMode(cfg, "telegram")).toBe("all"); + expect(resolveReplyToMode(cfg, "discord")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack")).toBe("all"); + }); + + it("uses chat-type replyToMode overrides for Slack when configured", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + replyToModeByChatType: { direct: "all", group: "first" }, + }, + }, + } as OpenClawConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); + expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); + expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off"); + }); + + it("falls back to top-level replyToMode when no chat-type override is set", () => { + const cfg = { + channels: { + slack: { + replyToMode: "first", + }, + }, + } as OpenClawConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first"); + }); + + it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + dm: { replyToMode: "all" }, + }, + }, + } as OpenClawConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); + }); +}); + +describe("createReplyToModeFilter", () => { + it("drops replyToId when mode is off", () => { + const filter = createReplyToModeFilter("off"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined(); + }); + + it("keeps replyToId when mode is off and reply tags are allowed", () => { + const filter = createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true }); + expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1"); + }); + + it("keeps replyToId when mode is all", () => { + const filter = createReplyToModeFilter("all"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); + }); + + it("keeps only the first replyToId when mode is first", () => { + const filter = createReplyToModeFilter("first"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); + expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined(); + }); +}); diff --git a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts b/src/auto-reply/reply/reply-payloads.auto-threading.test.ts deleted file mode 100644 index 80578f4b721..00000000000 --- a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { applyReplyThreading } from "./reply-payloads.js"; - -describe("applyReplyThreading auto-threading", () => { - it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => { - const result = applyReplyThreading({ - payloads: [{ text: "Hello" }], - replyToMode: "first", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - }); - - it("threads only first payload when mode is 'first'", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }, { text: "B" }], - replyToMode: "first", - currentMessageId: "42", - }); - - expect(result).toHaveLength(2); - expect(result[0].replyToId).toBe("42"); - expect(result[1].replyToId).toBeUndefined(); - }); - - it("threads all payloads when mode is 'all'", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }, { text: "B" }], - replyToMode: "all", - currentMessageId: "42", - }); - - expect(result).toHaveLength(2); - expect(result[0].replyToId).toBe("42"); - expect(result[1].replyToId).toBe("42"); - }); - - it("strips replyToId when mode is 'off'", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }], - replyToMode: "off", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBeUndefined(); - }); - - it("does not bypass off mode for Slack when reply is implicit", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }], - replyToMode: "off", - replyToChannel: "slack", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBeUndefined(); - }); - - it("keeps explicit tags for Slack when off mode allows tags", () => { - const result = applyReplyThreading({ - payloads: [{ text: "[[reply_to_current]]A" }], - replyToMode: "off", - replyToChannel: "slack", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - expect(result[0].replyToTag).toBe(true); - }); - - it("keeps explicit tags for Telegram when off mode is enabled", () => { - const result = applyReplyThreading({ - payloads: [{ text: "[[reply_to_current]]A" }], - replyToMode: "off", - replyToChannel: "telegram", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - expect(result[0].replyToTag).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts new file mode 100644 index 00000000000..2b1d1367ac3 --- /dev/null +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; +import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { TemplateContext } from "../templating.js"; +import { formatDurationCompact } from "../../infra/format-time/format-duration.js"; +import { buildThreadingToolContext } from "./agent-runner-utils.js"; +import { applyReplyThreading } from "./reply-payloads.js"; +import { + formatRunLabel, + formatRunStatus, + resolveSubagentLabel, + sortSubagentRuns, +} from "./subagents-utils.js"; + +describe("buildThreadingToolContext", () => { + const cfg = {} as OpenClawConfig; + + it("uses conversation id for WhatsApp", () => { + const sessionCtx = { + Provider: "whatsapp", + From: "123@g.us", + To: "+15550001", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("123@g.us"); + }); + + it("falls back to To for WhatsApp when From is missing", () => { + const sessionCtx = { + Provider: "whatsapp", + To: "+15550001", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("+15550001"); + }); + + it("uses the recipient id for other channels", () => { + const sessionCtx = { + Provider: "telegram", + From: "user:42", + To: "chat:99", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("chat:99"); + }); + + it("uses the sender handle for iMessage direct chats", () => { + const sessionCtx = { + Provider: "imessage", + ChatType: "direct", + From: "imessage:+15550001", + To: "chat_id:12", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("imessage:+15550001"); + }); + + it("uses chat_id for iMessage groups", () => { + const sessionCtx = { + Provider: "imessage", + ChatType: "group", + From: "imessage:group:7", + To: "chat_id:7", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("chat_id:7"); + }); + + it("prefers MessageThreadId for Slack tool threading", () => { + const sessionCtx = { + Provider: "slack", + To: "channel:C1", + MessageThreadId: "123.456", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("C1"); + expect(result.currentThreadTs).toBe("123.456"); + }); +}); + +describe("applyReplyThreading auto-threading", () => { + it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => { + const result = applyReplyThreading({ + payloads: [{ text: "Hello" }], + replyToMode: "first", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + }); + + it("threads only first payload when mode is 'first'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }, { text: "B" }], + replyToMode: "first", + currentMessageId: "42", + }); + + expect(result).toHaveLength(2); + expect(result[0].replyToId).toBe("42"); + expect(result[1].replyToId).toBeUndefined(); + }); + + it("threads all payloads when mode is 'all'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }, { text: "B" }], + replyToMode: "all", + currentMessageId: "42", + }); + + expect(result).toHaveLength(2); + expect(result[0].replyToId).toBe("42"); + expect(result[1].replyToId).toBe("42"); + }); + + it("strips replyToId when mode is 'off'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }], + replyToMode: "off", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBeUndefined(); + }); + + it("does not bypass off mode for Slack when reply is implicit", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }], + replyToMode: "off", + replyToChannel: "slack", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBeUndefined(); + }); + + it("keeps explicit tags for Slack when off mode allows tags", () => { + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to_current]]A" }], + replyToMode: "off", + replyToChannel: "slack", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + expect(result[0].replyToTag).toBe(true); + }); + + it("keeps explicit tags for Telegram when off mode is enabled", () => { + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to_current]]A" }], + replyToMode: "off", + replyToChannel: "telegram", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + expect(result[0].replyToTag).toBe(true); + }); +}); + +const baseRun: SubagentRunRecord = { + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, +}; + +describe("subagents utils", () => { + it("resolves labels from label, task, or fallback", () => { + expect(resolveSubagentLabel({ ...baseRun, label: "Label" })).toBe("Label"); + expect(resolveSubagentLabel({ ...baseRun, label: " ", task: "Task" })).toBe("Task"); + expect(resolveSubagentLabel({ ...baseRun, label: " ", task: " " }, "fallback")).toBe( + "fallback", + ); + }); + + it("formats run labels with truncation", () => { + const long = "x".repeat(100); + const run = { ...baseRun, label: long }; + const formatted = formatRunLabel(run, { maxLength: 10 }); + expect(formatted.startsWith("x".repeat(10))).toBe(true); + expect(formatted.endsWith("…")).toBe(true); + }); + + it("sorts subagent runs by newest start/created time", () => { + const runs: SubagentRunRecord[] = [ + { ...baseRun, runId: "run-1", createdAt: 1000, startedAt: 1000 }, + { ...baseRun, runId: "run-2", createdAt: 1200, startedAt: 1200 }, + { ...baseRun, runId: "run-3", createdAt: 900 }, + ]; + const sorted = sortSubagentRuns(runs); + expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]); + }); + + it("formats run status from outcome and timestamps", () => { + expect(formatRunStatus({ ...baseRun })).toBe("running"); + expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done"); + expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe( + "timeout", + ); + }); + + it("formats duration compact for seconds and minutes", () => { + expect(formatDurationCompact(45_000)).toBe("45s"); + expect(formatDurationCompact(65_000)).toBe("1m5s"); + }); +}); diff --git a/src/auto-reply/reply/reply-routing.test.ts b/src/auto-reply/reply/reply-routing.test.ts deleted file mode 100644 index 78a4010c53c..00000000000 --- a/src/auto-reply/reply/reply-routing.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; -import { createReplyDispatcher } from "./reply-dispatcher.js"; -import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; - -const emptyCfg = {} as OpenClawConfig; - -describe("createReplyDispatcher", () => { - it("drops empty payloads and silent tokens without media", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ deliver }); - - expect(dispatcher.sendFinalReply({})).toBe(false); - expect(dispatcher.sendFinalReply({ text: " " })).toBe(false); - expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); - expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false); - expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(false); - - await dispatcher.waitForIdle(); - expect(deliver).not.toHaveBeenCalled(); - }); - - it("strips heartbeat tokens and applies responsePrefix", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const onHeartbeatStrip = vi.fn(); - const dispatcher = createReplyDispatcher({ - deliver, - responsePrefix: "PFX", - onHeartbeatStrip, - }); - - expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false); - expect(dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` })).toBe(true); - await dispatcher.waitForIdle(); - - expect(deliver).toHaveBeenCalledTimes(1); - expect(deliver.mock.calls[0][0].text).toBe("PFX hello"); - expect(onHeartbeatStrip).toHaveBeenCalledTimes(2); - }); - - it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - responsePrefix: "PFX", - }); - - expect( - dispatcher.sendFinalReply({ - text: "PFX already", - mediaUrl: "file:///tmp/photo.jpg", - }), - ).toBe(true); - expect( - dispatcher.sendFinalReply({ - text: HEARTBEAT_TOKEN, - mediaUrl: "file:///tmp/photo.jpg", - }), - ).toBe(true); - expect( - dispatcher.sendFinalReply({ - text: `${SILENT_REPLY_TOKEN} -- explanation`, - mediaUrl: "file:///tmp/photo.jpg", - }), - ).toBe(true); - - await dispatcher.waitForIdle(); - - expect(deliver).toHaveBeenCalledTimes(3); - expect(deliver.mock.calls[0][0].text).toBe("PFX already"); - expect(deliver.mock.calls[1][0].text).toBe(""); - expect(deliver.mock.calls[2][0].text).toBe(""); - }); - - it("preserves ordering across tool, block, and final replies", async () => { - const delivered: string[] = []; - const deliver = vi.fn(async (_payload, info) => { - delivered.push(info.kind); - if (info.kind === "tool") { - await new Promise((resolve) => setTimeout(resolve, 5)); - } - }); - const dispatcher = createReplyDispatcher({ deliver }); - - dispatcher.sendToolResult({ text: "tool" }); - dispatcher.sendBlockReply({ text: "block" }); - dispatcher.sendFinalReply({ text: "final" }); - - await dispatcher.waitForIdle(); - expect(delivered).toEqual(["tool", "block", "final"]); - }); - - it("fires onIdle when the queue drains", async () => { - const deliver = vi.fn(async () => await new Promise((resolve) => setTimeout(resolve, 5))); - const onIdle = vi.fn(); - const dispatcher = createReplyDispatcher({ deliver, onIdle }); - - dispatcher.sendToolResult({ text: "one" }); - dispatcher.sendFinalReply({ text: "two" }); - - await dispatcher.waitForIdle(); - dispatcher.markComplete(); - await Promise.resolve(); - expect(onIdle).toHaveBeenCalledTimes(1); - }); - - it("delays block replies after the first when humanDelay is natural", async () => { - vi.useFakeTimers(); - const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - humanDelay: { mode: "natural" }, - }); - - dispatcher.sendBlockReply({ text: "first" }); - await Promise.resolve(); - expect(deliver).toHaveBeenCalledTimes(1); - - dispatcher.sendBlockReply({ text: "second" }); - await Promise.resolve(); - expect(deliver).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(799); - expect(deliver).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - await dispatcher.waitForIdle(); - expect(deliver).toHaveBeenCalledTimes(2); - - randomSpy.mockRestore(); - vi.useRealTimers(); - }); - - it("uses custom bounds for humanDelay and clamps when max <= min", async () => { - vi.useFakeTimers(); - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - humanDelay: { mode: "custom", minMs: 1200, maxMs: 400 }, - }); - - dispatcher.sendBlockReply({ text: "first" }); - await Promise.resolve(); - expect(deliver).toHaveBeenCalledTimes(1); - - dispatcher.sendBlockReply({ text: "second" }); - await vi.advanceTimersByTimeAsync(1199); - expect(deliver).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - await dispatcher.waitForIdle(); - expect(deliver).toHaveBeenCalledTimes(2); - - vi.useRealTimers(); - }); -}); - -describe("resolveReplyToMode", () => { - it("defaults to off for Telegram", () => { - expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("off"); - }); - - it("defaults to off for Discord and Slack", () => { - expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off"); - expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off"); - }); - - it("defaults to all when channel is unknown", () => { - expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all"); - }); - - it("uses configured value when present", () => { - const cfg = { - channels: { - telegram: { replyToMode: "all" }, - discord: { replyToMode: "first" }, - slack: { replyToMode: "all" }, - }, - } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "telegram")).toBe("all"); - expect(resolveReplyToMode(cfg, "discord")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack")).toBe("all"); - }); - - it("uses chat-type replyToMode overrides for Slack when configured", () => { - const cfg = { - channels: { - slack: { - replyToMode: "off", - replyToModeByChatType: { direct: "all", group: "first" }, - }, - }, - } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); - expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); - expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off"); - }); - - it("falls back to top-level replyToMode when no chat-type override is set", () => { - const cfg = { - channels: { - slack: { - replyToMode: "first", - }, - }, - } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first"); - }); - - it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => { - const cfg = { - channels: { - slack: { - replyToMode: "off", - dm: { replyToMode: "all" }, - }, - }, - } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); - }); -}); - -describe("createReplyToModeFilter", () => { - it("drops replyToId when mode is off", () => { - const filter = createReplyToModeFilter("off"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined(); - }); - - it("keeps replyToId when mode is off and reply tags are allowed", () => { - const filter = createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true }); - expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1"); - }); - - it("keeps replyToId when mode is all", () => { - const filter = createReplyToModeFilter("all"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); - }); - - it("keeps only the first replyToId when mode is first", () => { - const filter = createReplyToModeFilter("first"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); - expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined(); - }); -}); diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts new file mode 100644 index 00000000000..182506b4e48 --- /dev/null +++ b/src/auto-reply/reply/reply-state.test.ts @@ -0,0 +1,381 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import { + appendHistoryEntry, + buildHistoryContext, + buildHistoryContextFromEntries, + buildHistoryContextFromMap, + buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, + HISTORY_CONTEXT_MARKER, + recordPendingHistoryEntryIfEnabled, +} from "./history.js"; +import { + DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, + resolveMemoryFlushContextWindowTokens, + resolveMemoryFlushSettings, + shouldRunMemoryFlush, +} from "./memory-flush.js"; +import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; +import { incrementCompactionCount } from "./session-updates.js"; + +async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; +}) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); +} + +describe("history helpers", () => { + it("returns current message when history is empty", () => { + const result = buildHistoryContext({ + historyText: " ", + currentMessage: "hello", + }); + expect(result).toBe("hello"); + }); + + it("wraps history entries and excludes current by default", () => { + const result = buildHistoryContextFromEntries({ + entries: [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ], + currentMessage: "current", + formatEntry: (entry) => `${entry.sender}: ${entry.body}`, + }); + + expect(result).toContain(HISTORY_CONTEXT_MARKER); + expect(result).toContain("A: one"); + expect(result).not.toContain("B: two"); + expect(result).toContain(CURRENT_MESSAGE_MARKER); + expect(result).toContain("current"); + }); + + it("trims history to configured limit", () => { + const historyMap = new Map(); + + appendHistoryEntry({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "A", body: "one" }, + }); + appendHistoryEntry({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "B", body: "two" }, + }); + appendHistoryEntry({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "C", body: "three" }, + }); + + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two", "three"]); + }); + + it("builds context from map and appends entry", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + const result = buildHistoryContextFromMap({ + historyMap, + historyKey: "group", + limit: 3, + entry: { sender: "C", body: "three" }, + currentMessage: "current", + formatEntry: (entry) => `${entry.sender}: ${entry.body}`, + }); + + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]); + expect(result).toContain(HISTORY_CONTEXT_MARKER); + expect(result).toContain("A: one"); + expect(result).toContain("B: two"); + expect(result).not.toContain("C: three"); + }); + + it("builds context from pending map without appending", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + const result = buildPendingHistoryContextFromMap({ + historyMap, + historyKey: "group", + limit: 3, + currentMessage: "current", + formatEntry: (entry) => `${entry.sender}: ${entry.body}`, + }); + + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); + expect(result).toContain(HISTORY_CONTEXT_MARKER); + expect(result).toContain("A: one"); + expect(result).toContain("B: two"); + expect(result).toContain(CURRENT_MESSAGE_MARKER); + expect(result).toContain("current"); + }); + + it("records pending entries only when enabled", () => { + const historyMap = new Map(); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 0, + entry: { sender: "A", body: "one" }, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: null, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "B", body: "two" }, + }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]); + }); + + it("clears history entries only when enabled", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 }); + expect(historyMap.get("group")).toEqual([]); + }); +}); + +describe("memory flush settings", () => { + it("defaults to enabled with fallback prompt and system prompt", () => { + const settings = resolveMemoryFlushSettings(); + expect(settings).not.toBeNull(); + expect(settings?.enabled).toBe(true); + expect(settings?.prompt.length).toBeGreaterThan(0); + expect(settings?.systemPrompt.length).toBeGreaterThan(0); + }); + + it("respects disable flag", () => { + expect( + resolveMemoryFlushSettings({ + agents: { + defaults: { compaction: { memoryFlush: { enabled: false } } }, + }, + }), + ).toBeNull(); + }); + + it("appends NO_REPLY hint when missing", () => { + const settings = resolveMemoryFlushSettings({ + agents: { + defaults: { + compaction: { + memoryFlush: { + prompt: "Write memories now.", + systemPrompt: "Flush memory.", + }, + }, + }, + }, + }); + expect(settings?.prompt).toContain("NO_REPLY"); + expect(settings?.systemPrompt).toContain("NO_REPLY"); + }); +}); + +describe("shouldRunMemoryFlush", () => { + it("requires totalTokens and threshold", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 0 }, + contextWindowTokens: 16_000, + reserveTokensFloor: 20_000, + softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, + }), + ).toBe(false); + }); + + it("skips when entry is missing", () => { + expect( + shouldRunMemoryFlush({ + entry: undefined, + contextWindowTokens: 16_000, + reserveTokensFloor: 1_000, + softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, + }), + ).toBe(false); + }); + + it("skips when under threshold", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 10_000 }, + contextWindowTokens: 100_000, + reserveTokensFloor: 20_000, + softThresholdTokens: 10_000, + }), + ).toBe(false); + }); + + it("triggers at the threshold boundary", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 85 }, + contextWindowTokens: 100, + reserveTokensFloor: 10, + softThresholdTokens: 5, + }), + ).toBe(true); + }); + + it("skips when already flushed for current compaction count", () => { + expect( + shouldRunMemoryFlush({ + entry: { + totalTokens: 90_000, + compactionCount: 2, + memoryFlushCompactionCount: 2, + }, + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }), + ).toBe(false); + }); + + it("runs when above threshold and not flushed", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 96_000, compactionCount: 1 }, + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }), + ).toBe(true); + }); + + it("ignores stale cached totals", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 96_000, totalTokensFresh: false, compactionCount: 1 }, + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }), + ).toBe(false); + }); +}); + +describe("resolveMemoryFlushContextWindowTokens", () => { + it("falls back to agent config or default tokens", () => { + expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000); + }); +}); + +describe("incrementCompactionCount", () => { + it("increments compaction count", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + + const count = await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + }); + expect(count).toBe(3); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(3); + }); + + it("updates totalTokens when tokensAfter is provided", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { + sessionId: "s1", + updatedAt: Date.now(), + compactionCount: 0, + totalTokens: 180_000, + inputTokens: 170_000, + outputTokens: 10_000, + } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + + await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + tokensAfter: 12_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(1); + expect(stored[sessionKey].totalTokens).toBe(12_000); + // input/output cleared since we only have the total estimate + expect(stored[sessionKey].inputTokens).toBeUndefined(); + expect(stored[sessionKey].outputTokens).toBeUndefined(); + }); + + it("does not update totalTokens when tokensAfter is not provided", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { + sessionId: "s1", + updatedAt: Date.now(), + compactionCount: 0, + totalTokens: 180_000, + } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + + await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(1); + // totalTokens unchanged + expect(stored[sessionKey].totalTokens).toBe(180_000); + }); +}); diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts new file mode 100644 index 00000000000..94f68652f11 --- /dev/null +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -0,0 +1,781 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; +import { parseAudioTag } from "./audio-tags.js"; +import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; +import { matchesMentionWithExplicit } from "./mentions.js"; +import { normalizeReplyPayload } from "./normalize-reply.js"; +import { createReplyReferencePlanner } from "./reply-reference.js"; +import { + extractShortModelName, + hasTemplateVariables, + resolveResponsePrefixTemplate, +} from "./response-prefix-template.js"; +import { createStreamingDirectiveAccumulator } from "./streaming-directives.js"; +import { createMockTypingController } from "./test-helpers.js"; +import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; +import { createTypingController } from "./typing.js"; + +describe("matchesMentionWithExplicit", () => { + const mentionRegexes = [/\bopenclaw\b/i]; + + it("checks mentionPatterns even when explicit mention is available", () => { + const result = matchesMentionWithExplicit({ + text: "@openclaw hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); + expect(result).toBe(true); + }); + + it("returns false when explicit is false and no regex match", () => { + const result = matchesMentionWithExplicit({ + text: "<@999999> hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); + expect(result).toBe(false); + }); + + it("returns true when explicitly mentioned even if regexes do not match", () => { + const result = matchesMentionWithExplicit({ + text: "<@123456>", + mentionRegexes: [], + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: true, + canResolveExplicit: true, + }, + }); + expect(result).toBe(true); + }); + + it("falls back to regex matching when explicit mention cannot be resolved", () => { + const result = matchesMentionWithExplicit({ + text: "openclaw please", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: false, + }, + }); + expect(result).toBe(true); + }); +}); + +// Keep channelData-only payloads so channel-specific replies survive normalization. +describe("normalizeReplyPayload", () => { + it("keeps channelData-only replies", () => { + const payload = { + channelData: { + line: { + flexMessage: { type: "bubble" }, + }, + }, + }; + + const normalized = normalizeReplyPayload(payload); + + expect(normalized).not.toBeNull(); + expect(normalized?.text).toBeUndefined(); + expect(normalized?.channelData).toEqual(payload.channelData); + }); + + it("records silent skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: SILENT_REPLY_TOKEN }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["silent"]); + }); + + it("records empty skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: " " }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["empty"]); + }); +}); + +describe("typing controller", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("stops after run completion and dispatcher idle", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(1_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + }); + + it("keeps typing until both idle and run completion are set", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + }); + + it("does not start typing after run completion", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + typing.markRunComplete(); + await typing.startTypingOnText("late text"); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).not.toHaveBeenCalled(); + }); + + it("does not restart typing after it has stopped", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + typing.markRunComplete(); + typing.markDispatchIdle(); + + vi.advanceTimersByTime(5_000); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + // Late callbacks should be ignored and must not restart the interval. + await typing.startTypingOnText("late tool result"); + vi.advanceTimersByTime(5_000); + expect(onReplyStart).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveTypingMode", () => { + it("defaults to instant for direct chats", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("instant"); + }); + + it("defaults to message for group chats without mentions", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: true, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("message"); + }); + + it("defaults to instant for mentioned group chats", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }), + ).toBe("instant"); + }); + + it("honors configured mode across contexts", () => { + expect( + resolveTypingMode({ + configured: "thinking", + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("thinking"); + expect( + resolveTypingMode({ + configured: "message", + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }), + ).toBe("message"); + }); + + it("forces never for heartbeat runs", () => { + expect( + resolveTypingMode({ + configured: "instant", + isGroupChat: false, + wasMentioned: false, + isHeartbeat: true, + }), + ).toBe("never"); + }); +}); + +describe("createTypingSignaler", () => { + it("signals immediately for instant mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "instant", + isHeartbeat: false, + }); + + await signaler.signalRunStart(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + }); + + it("signals on text for message mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalTextDelta("hello"); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("signals on message start for message mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalMessageStart(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + await signaler.signalTextDelta("hello"); + expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); + }); + + it("signals on reasoning for thinking mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "thinking", + isHeartbeat: false, + }); + + await signaler.signalReasoningDelta(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + await signaler.signalTextDelta("hi"); + expect(typing.startTypingLoop).toHaveBeenCalled(); + }); + + it("refreshes ttl on text for thinking mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "thinking", + isHeartbeat: false, + }); + + await signaler.signalTextDelta("hi"); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("starts typing on tool start before text", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalToolStart(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("refreshes ttl on tool start when active after text", async () => { + const typing = createMockTypingController({ + isActive: vi.fn(() => true), + }); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalTextDelta("hello"); + typing.startTypingLoop.mockClear(); + typing.startTypingOnText.mockClear(); + typing.refreshTypingTtl.mockClear(); + await signaler.signalToolStart(); + + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("suppresses typing when disabled", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "instant", + isHeartbeat: true, + }); + + await signaler.signalRunStart(); + await signaler.signalTextDelta("hi"); + await signaler.signalReasoningDelta(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); +}); + +describe("parseAudioTag", () => { + it("detects audio_as_voice and strips the tag", () => { + const result = parseAudioTag("Hello [[audio_as_voice]] world"); + expect(result.audioAsVoice).toBe(true); + expect(result.hadTag).toBe(true); + expect(result.text).toBe("Hello world"); + }); + + it("returns empty output for missing text", () => { + const result = parseAudioTag(undefined); + expect(result.audioAsVoice).toBe(false); + expect(result.hadTag).toBe(false); + expect(result.text).toBe(""); + }); + + it("removes tag-only messages", () => { + const result = parseAudioTag("[[audio_as_voice]]"); + expect(result.audioAsVoice).toBe(true); + expect(result.text).toBe(""); + }); +}); + +describe("block reply coalescer", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("coalesces chunks within the idle window", async () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "Hello" }); + coalescer.enqueue({ text: "world" }); + + await vi.advanceTimersByTimeAsync(100); + expect(flushes).toEqual(["Hello world"]); + coalescer.stop(); + }); + + it("waits until minChars before idle flush", async () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "short" }); + await vi.advanceTimersByTimeAsync(50); + expect(flushes).toEqual([]); + + coalescer.enqueue({ text: "message" }); + await vi.advanceTimersByTimeAsync(50); + expect(flushes).toEqual(["short message"]); + coalescer.stop(); + }); + + it("flushes each enqueued payload separately when flushOnEnqueue is set", async () => { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "First paragraph" }); + coalescer.enqueue({ text: "Second paragraph" }); + coalescer.enqueue({ text: "Third paragraph" }); + + await Promise.resolve(); + expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]); + coalescer.stop(); + }); + + it("still accumulates when flushOnEnqueue is not set (default)", async () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "First paragraph" }); + coalescer.enqueue({ text: "Second paragraph" }); + + await vi.advanceTimersByTimeAsync(100); + expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]); + coalescer.stop(); + }); + + it("flushes short payloads immediately when flushOnEnqueue is set", async () => { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "Hi" }); + await Promise.resolve(); + expect(flushes).toEqual(["Hi"]); + coalescer.stop(); + }); + + it("resets char budget per paragraph with flushOnEnqueue", async () => { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + // Each 20-char payload fits within maxChars=30 individually + coalescer.enqueue({ text: "12345678901234567890" }); + coalescer.enqueue({ text: "abcdefghijklmnopqrst" }); + + await Promise.resolve(); + // Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split. + // With flushOnEnqueue, each is sent independently within budget. + expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]); + coalescer.stop(); + }); + + it("flushes buffered text before media payloads", () => { + const flushes: Array<{ text?: string; mediaUrls?: string[] }> = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push({ + text: payload.text, + mediaUrls: payload.mediaUrls, + }); + }, + }); + + coalescer.enqueue({ text: "Hello" }); + coalescer.enqueue({ text: "world" }); + coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] }); + void coalescer.flush({ force: true }); + + expect(flushes[0].text).toBe("Hello world"); + expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]); + coalescer.stop(); + }); +}); + +describe("createReplyReferencePlanner", () => { + it("disables references when mode is off", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "off", + startId: "parent", + }); + expect(planner.use()).toBeUndefined(); + }); + + it("uses startId once when mode is first", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "first", + startId: "parent", + }); + expect(planner.use()).toBe("parent"); + expect(planner.hasReplied()).toBe(true); + planner.markSent(); + expect(planner.use()).toBeUndefined(); + }); + + it("returns startId for every call when mode is all", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "all", + startId: "parent", + }); + expect(planner.use()).toBe("parent"); + expect(planner.use()).toBe("parent"); + }); + + it("uses existingId once when mode is first", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "first", + existingId: "thread-1", + startId: "parent", + }); + expect(planner.use()).toBe("thread-1"); + expect(planner.use()).toBeUndefined(); + }); + + it("honors allowReference=false", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "all", + startId: "parent", + allowReference: false, + }); + expect(planner.use()).toBeUndefined(); + expect(planner.hasReplied()).toBe(false); + planner.markSent(); + expect(planner.hasReplied()).toBe(true); + }); +}); + +describe("createStreamingDirectiveAccumulator", () => { + it("stashes reply_to_current until a renderable chunk arrives", () => { + const accumulator = createStreamingDirectiveAccumulator(); + + expect(accumulator.consume("[[reply_to_current]]")).toBeNull(); + + const result = accumulator.consume("Hello"); + expect(result?.text).toBe("Hello"); + expect(result?.replyToCurrent).toBe(true); + expect(result?.replyToTag).toBe(true); + }); + + it("handles reply tags split across chunks", () => { + const accumulator = createStreamingDirectiveAccumulator(); + expect(accumulator.consume("[[reply_to_")).toBeNull(); + + const result = accumulator.consume("current]] Yo"); + expect(result?.text).toBe("Yo"); + expect(result?.replyToCurrent).toBe(true); + }); + + it("propagates explicit reply ids across chunks", () => { + const accumulator = createStreamingDirectiveAccumulator(); + + expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull(); + + const result = accumulator.consume("Hi"); + expect(result?.text).toBe("Hi"); + expect(result?.replyToId).toBe("abc-123"); + expect(result?.replyToTag).toBe(true); + }); +}); + +describe("resolveResponsePrefixTemplate", () => { + it("returns undefined for undefined template", () => { + expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined(); + }); + + it("returns template as-is when no variables present", () => { + expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]"); + }); + + it("resolves {model} variable", () => { + const result = resolveResponsePrefixTemplate("[{model}]", { + model: "gpt-5.2", + }); + expect(result).toBe("[gpt-5.2]"); + }); + + it("resolves {modelFull} variable", () => { + const result = resolveResponsePrefixTemplate("[{modelFull}]", { + modelFull: "openai-codex/gpt-5.2", + }); + expect(result).toBe("[openai-codex/gpt-5.2]"); + }); + + it("resolves {provider} variable", () => { + const result = resolveResponsePrefixTemplate("[{provider}]", { + provider: "anthropic", + }); + expect(result).toBe("[anthropic]"); + }); + + it("resolves {thinkingLevel} variable", () => { + const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", { + thinkingLevel: "high", + }); + expect(result).toBe("think:high"); + }); + + it("resolves {think} as alias for thinkingLevel", () => { + const result = resolveResponsePrefixTemplate("think:{think}", { + thinkingLevel: "low", + }); + expect(result).toBe("think:low"); + }); + + it("resolves {identity.name} variable", () => { + const result = resolveResponsePrefixTemplate("[{identity.name}]", { + identityName: "OpenClaw", + }); + expect(result).toBe("[OpenClaw]"); + }); + + it("resolves {identityName} as alias", () => { + const result = resolveResponsePrefixTemplate("[{identityName}]", { + identityName: "OpenClaw", + }); + expect(result).toBe("[OpenClaw]"); + }); + + it("leaves unresolved variables as-is", () => { + const result = resolveResponsePrefixTemplate("[{model}]", {}); + expect(result).toBe("[{model}]"); + }); + + it("leaves unrecognized variables as-is", () => { + const result = resolveResponsePrefixTemplate("[{unknownVar}]", { + model: "gpt-5.2", + }); + expect(result).toBe("[{unknownVar}]"); + }); + + it("handles case insensitivity", () => { + const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", { + model: "gpt-5.2", + thinkingLevel: "low", + }); + expect(result).toBe("[gpt-5.2 | low]"); + }); + + it("handles mixed resolved and unresolved variables", () => { + const result = resolveResponsePrefixTemplate("[{model} | {provider}]", { + model: "gpt-5.2", + // provider not provided + }); + expect(result).toBe("[gpt-5.2 | {provider}]"); + }); + + it("handles complex template with all variables", () => { + const result = resolveResponsePrefixTemplate( + "[{identity.name}] {provider}/{model} (think:{thinkingLevel})", + { + identityName: "OpenClaw", + provider: "anthropic", + model: "claude-opus-4-5", + thinkingLevel: "high", + }, + ); + expect(result).toBe("[OpenClaw] anthropic/claude-opus-4-5 (think:high)"); + }); +}); + +describe("extractShortModelName", () => { + it("strips provider prefix", () => { + expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex"); + }); + + it("strips date suffix", () => { + expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); + }); + + it("strips -latest suffix", () => { + expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2"); + }); + + it("preserves version numbers that look like dates but are not", () => { + // Date suffix must be exactly 8 digits at the end + expect(extractShortModelName("model-123456789")).toBe("model-123456789"); + }); +}); + +describe("hasTemplateVariables", () => { + it("returns false for empty string", () => { + expect(hasTemplateVariables("")).toBe(false); + }); + + it("handles consecutive calls correctly (regex lastIndex reset)", () => { + // First call + expect(hasTemplateVariables("[{model}]")).toBe(true); + // Second call should still work + expect(hasTemplateVariables("[{model}]")).toBe(true); + // Static string should return false + expect(hasTemplateVariables("[Claude]")).toBe(false); + }); +}); diff --git a/src/auto-reply/reply/response-prefix-template.test.ts b/src/auto-reply/reply/response-prefix-template.test.ts deleted file mode 100644 index 41c28e23ed9..00000000000 --- a/src/auto-reply/reply/response-prefix-template.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - extractShortModelName, - hasTemplateVariables, - resolveResponsePrefixTemplate, -} from "./response-prefix-template.js"; - -describe("resolveResponsePrefixTemplate", () => { - it("returns undefined for undefined template", () => { - expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined(); - }); - - it("returns template as-is when no variables present", () => { - expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]"); - }); - - it("resolves {model} variable", () => { - const result = resolveResponsePrefixTemplate("[{model}]", { - model: "gpt-5.2", - }); - expect(result).toBe("[gpt-5.2]"); - }); - - it("resolves {modelFull} variable", () => { - const result = resolveResponsePrefixTemplate("[{modelFull}]", { - modelFull: "openai-codex/gpt-5.2", - }); - expect(result).toBe("[openai-codex/gpt-5.2]"); - }); - - it("resolves {provider} variable", () => { - const result = resolveResponsePrefixTemplate("[{provider}]", { - provider: "anthropic", - }); - expect(result).toBe("[anthropic]"); - }); - - it("resolves {thinkingLevel} variable", () => { - const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", { - thinkingLevel: "high", - }); - expect(result).toBe("think:high"); - }); - - it("resolves {think} as alias for thinkingLevel", () => { - const result = resolveResponsePrefixTemplate("think:{think}", { - thinkingLevel: "low", - }); - expect(result).toBe("think:low"); - }); - - it("resolves {identity.name} variable", () => { - const result = resolveResponsePrefixTemplate("[{identity.name}]", { - identityName: "OpenClaw", - }); - expect(result).toBe("[OpenClaw]"); - }); - - it("resolves {identityName} as alias", () => { - const result = resolveResponsePrefixTemplate("[{identityName}]", { - identityName: "OpenClaw", - }); - expect(result).toBe("[OpenClaw]"); - }); - - it("resolves multiple variables", () => { - const result = resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", { - model: "claude-opus-4-5", - thinkingLevel: "high", - }); - expect(result).toBe("[claude-opus-4-5 | think:high]"); - }); - - it("leaves unresolved variables as-is", () => { - const result = resolveResponsePrefixTemplate("[{model}]", {}); - expect(result).toBe("[{model}]"); - }); - - it("leaves unrecognized variables as-is", () => { - const result = resolveResponsePrefixTemplate("[{unknownVar}]", { - model: "gpt-5.2", - }); - expect(result).toBe("[{unknownVar}]"); - }); - - it("handles case insensitivity", () => { - const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", { - model: "gpt-5.2", - thinkingLevel: "low", - }); - expect(result).toBe("[gpt-5.2 | low]"); - }); - - it("handles mixed resolved and unresolved variables", () => { - const result = resolveResponsePrefixTemplate("[{model} | {provider}]", { - model: "gpt-5.2", - // provider not provided - }); - expect(result).toBe("[gpt-5.2 | {provider}]"); - }); - - it("handles complex template with all variables", () => { - const result = resolveResponsePrefixTemplate( - "[{identity.name}] {provider}/{model} (think:{thinkingLevel})", - { - identityName: "OpenClaw", - provider: "anthropic", - model: "claude-opus-4-5", - thinkingLevel: "high", - }, - ); - expect(result).toBe("[OpenClaw] anthropic/claude-opus-4-5 (think:high)"); - }); -}); - -describe("extractShortModelName", () => { - it("strips provider prefix", () => { - expect(extractShortModelName("openai/gpt-5.2")).toBe("gpt-5.2"); - expect(extractShortModelName("anthropic/claude-opus-4-5")).toBe("claude-opus-4-5"); - expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex"); - }); - - it("strips date suffix", () => { - expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); - expect(extractShortModelName("gpt-5.2-20250115")).toBe("gpt-5.2"); - }); - - it("strips -latest suffix", () => { - expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2"); - expect(extractShortModelName("claude-sonnet-latest")).toBe("claude-sonnet"); - }); - - it("handles model without provider", () => { - expect(extractShortModelName("gpt-5.2")).toBe("gpt-5.2"); - expect(extractShortModelName("claude-opus-4-5")).toBe("claude-opus-4-5"); - }); - - it("handles full path with provider and date suffix", () => { - expect(extractShortModelName("anthropic/claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); - }); - - it("preserves version numbers that look like dates but are not", () => { - // Date suffix must be exactly 8 digits at the end - expect(extractShortModelName("model-v1234567")).toBe("model-v1234567"); - expect(extractShortModelName("model-123456789")).toBe("model-123456789"); - }); -}); - -describe("hasTemplateVariables", () => { - it("returns false for undefined", () => { - expect(hasTemplateVariables(undefined)).toBe(false); - }); - - it("returns false for empty string", () => { - expect(hasTemplateVariables("")).toBe(false); - }); - - it("returns false for static prefix", () => { - expect(hasTemplateVariables("[Claude]")).toBe(false); - }); - - it("returns true when template variables present", () => { - expect(hasTemplateVariables("[{model}]")).toBe(true); - expect(hasTemplateVariables("{provider}")).toBe(true); - expect(hasTemplateVariables("prefix {thinkingLevel} suffix")).toBe(true); - }); - - it("returns true for multiple variables", () => { - expect(hasTemplateVariables("[{model} | {provider}]")).toBe(true); - }); - - it("handles consecutive calls correctly (regex lastIndex reset)", () => { - // First call - expect(hasTemplateVariables("[{model}]")).toBe(true); - // Second call should still work - expect(hasTemplateVariables("[{model}]")).toBe(true); - // Static string should return false - expect(hasTemplateVariables("[Claude]")).toBe(false); - }); -}); diff --git a/src/auto-reply/reply/session-resets.test.ts b/src/auto-reply/reply/session-resets.test.ts deleted file mode 100644 index 9c105c0307b..00000000000 --- a/src/auto-reply/reply/session-resets.test.ts +++ /dev/null @@ -1,689 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { buildModelAliasIndex } from "../../agents/model-selection.js"; -import { saveSessionStore } from "../../config/sessions.js"; -import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; -import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; -import { applyResetModelOverride } from "./session-reset-model.js"; -import { prependSystemEvents } from "./session-updates.js"; -import { initSessionState } from "./session.js"; - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ - { provider: "minimax", id: "m2.1", name: "M2.1" }, - { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, - ]), -})); - -let suiteRoot = ""; -let suiteCase = 0; - -beforeAll(async () => { - suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-resets-suite-")); -}); - -afterAll(async () => { - await fs.rm(suiteRoot, { recursive: true, force: true }); - suiteRoot = ""; - suiteCase = 0; -}); - -async function createStorePath(prefix: string): Promise { - const root = path.join(suiteRoot, `${prefix}${++suiteCase}`); - await fs.mkdir(root); - return path.join(root, "sessions.json"); -} - -describe("initSessionState reset triggers in WhatsApp groups", () => { - async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - sessionId: string; - }): Promise { - await saveSessionStore(params.storePath, { - [params.sessionKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - }, - }); - } - - function makeCfg(params: { storePath: string; allowFrom: string[] }): OpenClawConfig { - return { - session: { store: params.storePath, idleMinutes: 999 }, - channels: { - whatsapp: { - allowFrom: params.allowFrom, - groupPolicy: "open", - }, - }, - } as OpenClawConfig; - } - - it("Reset trigger /new works for authorized sender in WhatsApp group", async () => { - const storePath = await createStorePath("openclaw-group-reset-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] PeschiΓ±o: /new\\n[from: PeschiΓ±o (+41796666864)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "PeschiΓ±o", - SenderE164: "+41796666864", - SenderId: "41796666864:0@s.whatsapp.net", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new blocked for unauthorized sender in existing session", async () => { - const storePath = await createStorePath("openclaw-group-reset-unauth-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "OtherPerson", - SenderE164: "+1555123456", - SenderId: "1555123456:0@s.whatsapp.net", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.sessionId).toBe(existingSessionId); - expect(result.isNewSession).toBe(false); - }); - - it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => { - const storePath = await createStorePath("openclaw-group-rawbody-"); - const sessionKey = "agent:main:whatsapp:group:g1"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["*"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+1111", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - SenderE164: "+1222", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new works when SenderId is LID but SenderE164 is authorized", async () => { - const storePath = await createStorePath("openclaw-group-reset-lid-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Owner", - SenderE164: "+41796666864", - SenderId: "123@lid", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new blocked when SenderId is LID but SenderE164 is unauthorized", async () => { - const storePath = await createStorePath("openclaw-group-reset-lid-unauth-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Other", - SenderE164: "+1555123456", - SenderId: "123@lid", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.sessionId).toBe(existingSessionId); - expect(result.isNewSession).toBe(false); - }); -}); - -describe("initSessionState reset triggers in Slack channels", () => { - async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - sessionId: string; - }): Promise { - const { saveSessionStore } = await import("../../config/sessions.js"); - await saveSessionStore(params.storePath, { - [params.sessionKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - }, - }); - } - - it("Reset trigger /reset works when Slack message has a leading <@...> mention token", async () => { - const storePath = await createStorePath("openclaw-slack-channel-reset-"); - const sessionKey = "agent:main:slack:channel:c1"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const channelMessageCtx = { - Body: "<@U123> /reset", - RawBody: "<@U123> /reset", - CommandBody: "<@U123> /reset", - From: "slack:channel:C1", - To: "channel:C1", - ChatType: "channel", - SessionKey: sessionKey, - Provider: "slack", - Surface: "slack", - SenderId: "U123", - SenderName: "Owner", - }; - - const result = await initSessionState({ - ctx: channelMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new preserves args when Slack message has a leading <@...> mention token", async () => { - const storePath = await createStorePath("openclaw-slack-channel-new-"); - const sessionKey = "agent:main:slack:channel:c2"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const channelMessageCtx = { - Body: "<@U123> /new take notes", - RawBody: "<@U123> /new take notes", - CommandBody: "<@U123> /new take notes", - From: "slack:channel:C2", - To: "channel:C2", - ChatType: "channel", - SessionKey: sessionKey, - Provider: "slack", - Surface: "slack", - SenderId: "U123", - SenderName: "Owner", - }; - - const result = await initSessionState({ - ctx: channelMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe("take notes"); - }); -}); - -describe("applyResetModelOverride", () => { - it("selects a model hint and strips it from the body", async () => { - const cfg = {} as OpenClawConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - }; - const sessionStore = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, - resetTriggered: true, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - }); - - expect(sessionEntry.providerOverride).toBe("minimax"); - expect(sessionEntry.modelOverride).toBe("m2.1"); - expect(sessionCtx.BodyStripped).toBe("summarize"); - }); - - it("clears auth profile overrides when reset applies a model", async () => { - const cfg = {} as OpenClawConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - authProfileOverride: "anthropic:default", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: 2, - }; - const sessionStore = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, - resetTriggered: true, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - }); - - expect(sessionEntry.authProfileOverride).toBeUndefined(); - expect(sessionEntry.authProfileOverrideSource).toBeUndefined(); - expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined(); - }); - - it("skips when resetTriggered is false", async () => { - const cfg = {} as OpenClawConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - }; - const sessionStore = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, - resetTriggered: false, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - }); - - expect(sessionEntry.providerOverride).toBeUndefined(); - expect(sessionEntry.modelOverride).toBeUndefined(); - expect(sessionCtx.BodyStripped).toBe("minimax summarize"); - }); -}); - -describe("initSessionState preserves behavior overrides across /new and /reset", () => { - async function seedSessionStoreWithOverrides(params: { - storePath: string; - sessionKey: string; - sessionId: string; - overrides: Record; - }): Promise { - const { saveSessionStore } = await import("../../config/sessions.js"); - await saveSessionStore(params.storePath, { - [params.sessionKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - ...params.overrides, - }, - }); - } - - it("/new preserves verboseLevel from previous session", async () => { - const storePath = await createStorePath("openclaw-reset-verbose-"); - const sessionKey = "agent:main:telegram:dm:user1"; - const existingSessionId = "existing-session-verbose"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: { verboseLevel: "on" }, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/new", - RawBody: "/new", - CommandBody: "/new", - From: "user1", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.sessionEntry.verboseLevel).toBe("on"); - }); - - it("/reset preserves thinkingLevel and reasoningLevel from previous session", async () => { - const storePath = await createStorePath("openclaw-reset-thinking-"); - const sessionKey = "agent:main:telegram:dm:user2"; - const existingSessionId = "existing-session-thinking"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: { thinkingLevel: "full", reasoningLevel: "high" }, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/reset", - RawBody: "/reset", - CommandBody: "/reset", - From: "user2", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionEntry.thinkingLevel).toBe("full"); - expect(result.sessionEntry.reasoningLevel).toBe("high"); - }); - - it("/new preserves ttsAuto from previous session", async () => { - const storePath = await createStorePath("openclaw-reset-tts-"); - const sessionKey = "agent:main:telegram:dm:user3"; - const existingSessionId = "existing-session-tts"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: { ttsAuto: "on" }, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/new", - RawBody: "/new", - CommandBody: "/new", - From: "user3", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.sessionEntry.ttsAuto).toBe("on"); - }); - - it("archives previous transcript file on /new reset", async () => { - const storePath = await createStorePath("openclaw-reset-archive-"); - const sessionKey = "agent:main:telegram:dm:user-archive"; - const existingSessionId = "existing-session-archive"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: {}, - }); - const transcriptPath = path.join(path.dirname(storePath), `${existingSessionId}.jsonl`); - await fs.writeFile( - transcriptPath, - `${JSON.stringify({ message: { role: "user", content: "hello" } })}\n`, - "utf-8", - ); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/new", - RawBody: "/new", - CommandBody: "/new", - From: "user-archive", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - const files = await fs.readdir(path.dirname(storePath)); - expect(files.some((f) => f.startsWith(`${existingSessionId}.jsonl.reset.`))).toBe(true); - }); - - it("idle-based new session does NOT preserve overrides (no entry to read)", async () => { - const storePath = await createStorePath("openclaw-idle-no-preserve-"); - const sessionKey = "agent:main:telegram:dm:new-user"; - - const cfg = { - session: { store: storePath, idleMinutes: 0 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "hello", - RawBody: "hello", - CommandBody: "hello", - From: "new-user", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(false); - expect(result.sessionEntry.verboseLevel).toBeUndefined(); - expect(result.sessionEntry.thinkingLevel).toBeUndefined(); - }); -}); - -describe("prependSystemEvents", () => { - it("adds a local timestamp to queued system events by default", async () => { - vi.useFakeTimers(); - try { - const timestamp = new Date("2026-01-12T20:19:17Z"); - const expectedTimestamp = formatZonedTimestamp(timestamp, { displaySeconds: true }); - vi.setSystemTime(timestamp); - - enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); - - const result = await prependSystemEvents({ - cfg: {} as OpenClawConfig, - sessionKey: "agent:main:main", - isMainSession: false, - isNewSession: false, - prefixedBodyBase: "User: hi", - }); - - expect(expectedTimestamp).toBeDefined(); - expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); - } finally { - resetSystemEventsForTest(); - vi.useRealTimers(); - } - }); -}); diff --git a/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts b/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts deleted file mode 100644 index 5a90b4ed5f8..00000000000 --- a/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { SessionEntry } from "../../config/sessions.js"; -import { incrementCompactionCount } from "./session-updates.js"; - -async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - entry: Record; -}) { - await fs.mkdir(path.dirname(params.storePath), { recursive: true }); - await fs.writeFile( - params.storePath, - JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), - "utf-8", - ); -} - -describe("incrementCompactionCount", () => { - it("increments compaction count", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - const count = await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - }); - expect(count).toBe(3); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(3); - }); - - it("updates totalTokens when tokensAfter is provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { - sessionId: "s1", - updatedAt: Date.now(), - compactionCount: 0, - totalTokens: 180_000, - inputTokens: 170_000, - outputTokens: 10_000, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - tokensAfter: 12_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(1); - expect(stored[sessionKey].totalTokens).toBe(12_000); - // input/output cleared since we only have the total estimate - expect(stored[sessionKey].inputTokens).toBeUndefined(); - expect(stored[sessionKey].outputTokens).toBeUndefined(); - }); - - it("does not update totalTokens when tokensAfter is not provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { - sessionId: "s1", - updatedAt: Date.now(), - compactionCount: 0, - totalTokens: 180_000, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(1); - // totalTokens unchanged - expect(stored[sessionKey].totalTokens).toBe(180_000); - }); -}); diff --git a/src/auto-reply/reply/session-usage.test.ts b/src/auto-reply/reply/session-usage.test.ts deleted file mode 100644 index ab44c53ed29..00000000000 --- a/src/auto-reply/reply/session-usage.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { persistSessionUsageUpdate } from "./session-usage.js"; - -async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - entry: Record; -}) { - await fs.mkdir(path.dirname(params.storePath), { recursive: true }); - await fs.writeFile( - params.storePath, - JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), - "utf-8", - ); -} - -describe("persistSessionUsageUpdate", () => { - it("uses lastCallUsage for totalTokens when provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now(), totalTokens: 100_000 }, - }); - - // Accumulated usage (sums all API calls) β€” inflated - const accumulatedUsage = { input: 180_000, output: 10_000, total: 190_000 }; - // Last individual API call's usage β€” actual context after compaction - const lastCallUsage = { input: 12_000, output: 2_000, total: 14_000 }; - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: accumulatedUsage, - lastCallUsage, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - // totalTokens should reflect lastCallUsage (12_000 input), not accumulated (180_000) - expect(stored[sessionKey].totalTokens).toBe(12_000); - expect(stored[sessionKey].totalTokensFresh).toBe(true); - // inputTokens/outputTokens still reflect accumulated usage for cost tracking - expect(stored[sessionKey].inputTokens).toBe(180_000); - expect(stored[sessionKey].outputTokens).toBe(10_000); - }); - - it("marks totalTokens as unknown when no fresh context snapshot is available", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now() }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 50_000, output: 5_000, total: 55_000 }, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].totalTokens).toBeUndefined(); - expect(stored[sessionKey].totalTokensFresh).toBe(false); - }); - - it("uses promptTokens when available without lastCallUsage", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now() }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 50_000, output: 5_000, total: 55_000 }, - promptTokens: 42_000, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].totalTokens).toBe(42_000); - expect(stored[sessionKey].totalTokensFresh).toBe(true); - }); - - it("keeps non-clamped lastCallUsage totalTokens when exceeding context window", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now() }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 300_000, output: 10_000, total: 310_000 }, - lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 }, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].totalTokens).toBe(250_000); - expect(stored[sessionKey].totalTokensFresh).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index b1215603737..5eb8bedc65b 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -3,9 +3,27 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { buildModelAliasIndex } from "../../agents/model-selection.js"; import { saveSessionStore } from "../../config/sessions.js"; +import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; +import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; +import { applyResetModelOverride } from "./session-reset-model.js"; +import { prependSystemEvents } from "./session-updates.js"; +import { persistSessionUsageUpdate } from "./session-usage.js"; import { initSessionState } from "./session.js"; +// Perf: session-store locks are exercised elsewhere; most session tests don't need FS lock files. +vi.mock("../../agents/session-write-lock.js", () => ({ + acquireSessionWriteLock: async () => ({ release: async () => {} }), +})); + +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(async () => [ + { provider: "minimax", id: "m2.1", name: "M2.1" }, + { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, + ]), +})); + let suiteRoot = ""; let suiteCase = 0; @@ -25,8 +43,16 @@ async function makeCaseDir(prefix: string): Promise { return dir; } +async function makeStorePath(prefix: string): Promise { + const root = await makeCaseDir(prefix); + return path.join(root, "sessions.json"); +} + +const createStorePath = makeStorePath; + describe("initSessionState thread forking", () => { it("forks a new session from the parent session file", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const root = await makeCaseDir("openclaw-thread-session-"); const sessionsDir = path.join(root, "sessions"); await fs.mkdir(sessionsDir); @@ -96,6 +122,7 @@ describe("initSessionState thread forking", () => { parentSession?: string; }; expect(parsedHeader.parentSession).toBe(parentSessionFile); + warn.mockRestore(); }); it("records topic-specific session files when MessageThreadId is present", async () => { @@ -506,3 +533,762 @@ describe("initSessionState channel reset overrides", () => { expect(result.sessionEntry.sessionId).toBe(sessionId); }); }); + +describe("initSessionState reset triggers in WhatsApp groups", () => { + async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + sessionId: string; + }): Promise { + await saveSessionStore(params.storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + }, + }); + } + + function makeCfg(params: { storePath: string; allowFrom: string[] }): OpenClawConfig { + return { + session: { store: params.storePath, idleMinutes: 999 }, + channels: { + whatsapp: { + allowFrom: params.allowFrom, + groupPolicy: "open", + }, + }, + } as OpenClawConfig; + } + + it("Reset trigger /new works for authorized sender in WhatsApp group", async () => { + const storePath = await createStorePath("openclaw-group-reset-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] PeschiΓ±o: /new\\n[from: PeschiΓ±o (+41796666864)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "PeschiΓ±o", + SenderE164: "+41796666864", + SenderId: "41796666864:0@s.whatsapp.net", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new blocked for unauthorized sender in existing session", async () => { + const storePath = await createStorePath("openclaw-group-reset-unauth-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "OtherPerson", + SenderE164: "+1555123456", + SenderId: "1555123456:0@s.whatsapp.net", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.sessionId).toBe(existingSessionId); + expect(result.isNewSession).toBe(false); + }); + + it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => { + const storePath = await createStorePath("openclaw-group-rawbody-"); + const sessionKey = "agent:main:whatsapp:group:g1"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["*"], + }); + + const groupMessageCtx = { + Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+1111", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + SenderE164: "+1222", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new works when SenderId is LID but SenderE164 is authorized", async () => { + const storePath = await createStorePath("openclaw-group-reset-lid-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "Owner", + SenderE164: "+41796666864", + SenderId: "123@lid", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new blocked when SenderId is LID but SenderE164 is unauthorized", async () => { + const storePath = await createStorePath("openclaw-group-reset-lid-unauth-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "Other", + SenderE164: "+1555123456", + SenderId: "123@lid", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.sessionId).toBe(existingSessionId); + expect(result.isNewSession).toBe(false); + }); +}); + +describe("initSessionState reset triggers in Slack channels", () => { + async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + sessionId: string; + }): Promise { + await saveSessionStore(params.storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + }, + }); + } + + it("Reset trigger /reset works when Slack message has a leading <@...> mention token", async () => { + const storePath = await createStorePath("openclaw-slack-channel-reset-"); + const sessionKey = "agent:main:slack:channel:c1"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const channelMessageCtx = { + Body: "<@U123> /reset", + RawBody: "<@U123> /reset", + CommandBody: "<@U123> /reset", + From: "slack:channel:C1", + To: "channel:C1", + ChatType: "channel", + SessionKey: sessionKey, + Provider: "slack", + Surface: "slack", + SenderId: "U123", + SenderName: "Owner", + }; + + const result = await initSessionState({ + ctx: channelMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new preserves args when Slack message has a leading <@...> mention token", async () => { + const storePath = await createStorePath("openclaw-slack-channel-new-"); + const sessionKey = "agent:main:slack:channel:c2"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const channelMessageCtx = { + Body: "<@U123> /new take notes", + RawBody: "<@U123> /new take notes", + CommandBody: "<@U123> /new take notes", + From: "slack:channel:C2", + To: "channel:C2", + ChatType: "channel", + SessionKey: sessionKey, + Provider: "slack", + Surface: "slack", + SenderId: "U123", + SenderName: "Owner", + }; + + const result = await initSessionState({ + ctx: channelMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe("take notes"); + }); +}); + +describe("applyResetModelOverride", () => { + it("selects a model hint and strips it from the body", async () => { + const cfg = {} as OpenClawConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + }; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + const sessionCtx = { BodyStripped: "minimax summarize" }; + const ctx = { ChatType: "direct" }; + + await applyResetModelOverride({ + cfg, + resetTriggered: true, + bodyStripped: "minimax summarize", + sessionCtx, + ctx, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex, + }); + + expect(sessionEntry.providerOverride).toBe("minimax"); + expect(sessionEntry.modelOverride).toBe("m2.1"); + expect(sessionCtx.BodyStripped).toBe("summarize"); + }); + + it("clears auth profile overrides when reset applies a model", async () => { + const cfg = {} as OpenClawConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + authProfileOverride: "anthropic:default", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 2, + }; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + const sessionCtx = { BodyStripped: "minimax summarize" }; + const ctx = { ChatType: "direct" }; + + await applyResetModelOverride({ + cfg, + resetTriggered: true, + bodyStripped: "minimax summarize", + sessionCtx, + ctx, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex, + }); + + expect(sessionEntry.authProfileOverride).toBeUndefined(); + expect(sessionEntry.authProfileOverrideSource).toBeUndefined(); + expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined(); + }); + + it("skips when resetTriggered is false", async () => { + const cfg = {} as OpenClawConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + }; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + const sessionCtx = { BodyStripped: "minimax summarize" }; + const ctx = { ChatType: "direct" }; + + await applyResetModelOverride({ + cfg, + resetTriggered: false, + bodyStripped: "minimax summarize", + sessionCtx, + ctx, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex, + }); + + expect(sessionEntry.providerOverride).toBeUndefined(); + expect(sessionEntry.modelOverride).toBeUndefined(); + expect(sessionCtx.BodyStripped).toBe("minimax summarize"); + }); +}); + +describe("initSessionState preserves behavior overrides across /new and /reset", () => { + async function seedSessionStoreWithOverrides(params: { + storePath: string; + sessionKey: string; + sessionId: string; + overrides: Record; + }): Promise { + await saveSessionStore(params.storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + ...params.overrides, + }, + }); + } + + it("/new preserves verboseLevel from previous session", async () => { + const storePath = await createStorePath("openclaw-reset-verbose-"); + const sessionKey = "agent:main:telegram:dm:user1"; + const existingSessionId = "existing-session-verbose"; + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { verboseLevel: "on" }, + }); + await fs.writeFile( + path.join(path.dirname(storePath), `${existingSessionId}.jsonl`), + "", + "utf-8", + ); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + From: "user1", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.sessionEntry.verboseLevel).toBe("on"); + }); + + it("/reset preserves thinkingLevel and reasoningLevel from previous session", async () => { + const storePath = await createStorePath("openclaw-reset-thinking-"); + const sessionKey = "agent:main:telegram:dm:user2"; + const existingSessionId = "existing-session-thinking"; + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { thinkingLevel: "high", reasoningLevel: "low" }, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/reset", + RawBody: "/reset", + CommandBody: "/reset", + From: "user2", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.sessionEntry.thinkingLevel).toBe("high"); + expect(result.sessionEntry.reasoningLevel).toBe("low"); + }); + + it("/new in a new session does not preserve overrides", async () => { + const storePath = await createStorePath("openclaw-new-no-preserve-"); + const sessionKey = "agent:main:telegram:dm:user3"; + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + From: "user3", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionEntry.verboseLevel).toBeUndefined(); + expect(result.sessionEntry.thinkingLevel).toBeUndefined(); + }); + + it("archives the old session store entry on /new", async () => { + const storePath = await createStorePath("openclaw-archive-old-"); + const sessionKey = "agent:main:telegram:dm:user-archive"; + const existingSessionId = "existing-session-archive"; + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { verboseLevel: "on" }, + }); + const sessionUtils = await import("../../gateway/session-utils.fs.js"); + const archiveSpy = vi.spyOn(sessionUtils, "archiveSessionTranscripts"); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + From: "user-archive", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(archiveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: existingSessionId, + storePath, + reason: "reset", + }), + ); + archiveSpy.mockRestore(); + }); + + it("idle-based new session does NOT preserve overrides (no entry to read)", async () => { + const storePath = await createStorePath("openclaw-idle-no-preserve-"); + const sessionKey = "agent:main:telegram:dm:new-user"; + + const cfg = { + session: { store: storePath, idleMinutes: 0 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "hello", + RawBody: "hello", + CommandBody: "hello", + From: "new-user", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(false); + expect(result.sessionEntry.verboseLevel).toBeUndefined(); + expect(result.sessionEntry.thinkingLevel).toBeUndefined(); + }); +}); + +describe("prependSystemEvents", () => { + it("adds a local timestamp to queued system events by default", async () => { + vi.useFakeTimers(); + try { + const timestamp = new Date("2026-01-12T20:19:17Z"); + const expectedTimestamp = formatZonedTimestamp(timestamp, { displaySeconds: true }); + vi.setSystemTime(timestamp); + + enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); + + const result = await prependSystemEvents({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:main", + isMainSession: false, + isNewSession: false, + prefixedBodyBase: "User: hi", + }); + + expect(expectedTimestamp).toBeDefined(); + expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); + } finally { + resetSystemEventsForTest(); + vi.useRealTimers(); + } + }); +}); + +describe("persistSessionUsageUpdate", () => { + async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; + }) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); + } + + it("uses lastCallUsage for totalTokens when provided", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now(), totalTokens: 100_000 }, + }); + + const accumulatedUsage = { input: 180_000, output: 10_000, total: 190_000 }; + const lastCallUsage = { input: 12_000, output: 2_000, total: 14_000 }; + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: accumulatedUsage, + lastCallUsage, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(12_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + expect(stored[sessionKey].inputTokens).toBe(180_000); + expect(stored[sessionKey].outputTokens).toBe(10_000); + }); + + it("marks totalTokens as unknown when no fresh context snapshot is available", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 50_000, output: 5_000, total: 55_000 }, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBeUndefined(); + expect(stored[sessionKey].totalTokensFresh).toBe(false); + }); + + it("uses promptTokens when available without lastCallUsage", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 50_000, output: 5_000, total: 55_000 }, + promptTokens: 42_000, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(42_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + }); + + it("keeps non-clamped lastCallUsage totalTokens when exceeding context window", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 300_000, output: 10_000, total: 310_000 }, + lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 }, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(250_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + }); +}); diff --git a/src/auto-reply/reply/subagents-utils.test.ts b/src/auto-reply/reply/subagents-utils.test.ts deleted file mode 100644 index b66a70680da..00000000000 --- a/src/auto-reply/reply/subagents-utils.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; -import { formatDurationCompact } from "../../infra/format-time/format-duration.js"; -import { - formatRunLabel, - formatRunStatus, - resolveSubagentLabel, - sortSubagentRuns, -} from "./subagents-utils.js"; - -const baseRun: SubagentRunRecord = { - runId: "run-1", - childSessionKey: "agent:main:subagent:abc", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "do thing", - cleanup: "keep", - createdAt: 1000, - startedAt: 1000, -}; - -describe("subagents utils", () => { - it("resolves labels from label, task, or fallback", () => { - expect(resolveSubagentLabel({ ...baseRun, label: "Label" })).toBe("Label"); - expect(resolveSubagentLabel({ ...baseRun, label: " ", task: "Task" })).toBe("Task"); - expect(resolveSubagentLabel({ ...baseRun, label: " ", task: " " }, "fallback")).toBe( - "fallback", - ); - }); - - it("formats run labels with truncation", () => { - const long = "x".repeat(100); - const run = { ...baseRun, label: long }; - const formatted = formatRunLabel(run, { maxLength: 10 }); - expect(formatted.startsWith("x".repeat(10))).toBe(true); - expect(formatted.endsWith("…")).toBe(true); - }); - - it("sorts subagent runs by newest start/created time", () => { - const runs: SubagentRunRecord[] = [ - { ...baseRun, runId: "run-1", createdAt: 1000, startedAt: 1000 }, - { ...baseRun, runId: "run-2", createdAt: 1200, startedAt: 1200 }, - { ...baseRun, runId: "run-3", createdAt: 900 }, - ]; - const sorted = sortSubagentRuns(runs); - expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]); - }); - - it("formats run status from outcome and timestamps", () => { - expect(formatRunStatus({ ...baseRun })).toBe("running"); - expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done"); - expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe( - "timeout", - ); - }); - - it("formats duration compact for seconds and minutes", () => { - expect(formatDurationCompact(45_000)).toBe("45s"); - expect(formatDurationCompact(65_000)).toBe("1m5s"); - }); -}); diff --git a/src/auto-reply/reply/typing.test.ts b/src/auto-reply/reply/typing.test.ts deleted file mode 100644 index edefc57f8ee..00000000000 --- a/src/auto-reply/reply/typing.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createMockTypingController } from "./test-helpers.js"; -import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; -import { createTypingController } from "./typing.js"; - -describe("typing controller", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("stops after run completion and dispatcher idle", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - - typing.markRunComplete(); - vi.advanceTimersByTime(1_000); - expect(onReplyStart).toHaveBeenCalledTimes(4); - - typing.markDispatchIdle(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(4); - }); - - it("keeps typing until both idle and run completion are set", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - typing.markDispatchIdle(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - - typing.markRunComplete(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - }); - - it("does not start typing after run completion", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - typing.markRunComplete(); - await typing.startTypingOnText("late text"); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).not.toHaveBeenCalled(); - }); - - it("does not restart typing after it has stopped", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - typing.markRunComplete(); - typing.markDispatchIdle(); - - vi.advanceTimersByTime(5_000); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - // Late callbacks should be ignored and must not restart the interval. - await typing.startTypingOnText("late tool result"); - vi.advanceTimersByTime(5_000); - expect(onReplyStart).toHaveBeenCalledTimes(1); - }); -}); - -describe("resolveTypingMode", () => { - it("defaults to instant for direct chats", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: false, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("instant"); - }); - - it("defaults to message for group chats without mentions", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: true, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("message"); - }); - - it("defaults to instant for mentioned group chats", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: true, - wasMentioned: true, - isHeartbeat: false, - }), - ).toBe("instant"); - }); - - it("honors configured mode across contexts", () => { - expect( - resolveTypingMode({ - configured: "thinking", - isGroupChat: false, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("thinking"); - expect( - resolveTypingMode({ - configured: "message", - isGroupChat: true, - wasMentioned: true, - isHeartbeat: false, - }), - ).toBe("message"); - }); - - it("forces never for heartbeat runs", () => { - expect( - resolveTypingMode({ - configured: "instant", - isGroupChat: false, - wasMentioned: false, - isHeartbeat: true, - }), - ).toBe("never"); - }); -}); - -describe("createTypingSignaler", () => { - it("signals immediately for instant mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "instant", - isHeartbeat: false, - }); - - await signaler.signalRunStart(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("signals on text for message mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hello"); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("signals on message start for message mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalMessageStart(); - - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - await signaler.signalTextDelta("hello"); - expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); - }); - - it("signals on reasoning for thinking mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "thinking", - isHeartbeat: false, - }); - - await signaler.signalReasoningDelta(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - await signaler.signalTextDelta("hi"); - expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("refreshes ttl on text for thinking mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "thinking", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hi"); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.refreshTypingTtl).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("starts typing on tool start before text", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalToolStart(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.refreshTypingTtl).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("refreshes ttl on tool start when active after text", async () => { - const typing = createMockTypingController({ - isActive: vi.fn(() => true), - }); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hello"); - typing.startTypingLoop.mockClear(); - typing.startTypingOnText.mockClear(); - typing.refreshTypingTtl.mockClear(); - await signaler.signalToolStart(); - - expect(typing.refreshTypingTtl).toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("suppresses typing when disabled", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "instant", - isHeartbeat: true, - }); - - await signaler.signalRunStart(); - await signaler.signalTextDelta("hi"); - await signaler.signalReasoningDelta(); - - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); -}); diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts new file mode 100644 index 00000000000..ab23bca95e7 --- /dev/null +++ b/src/browser/browser-utils.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from "vitest"; +import type { BrowserServerState } from "./server-context.js"; +import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js"; +import { __test } from "./client-fetch.js"; +import { resolveBrowserConfig, resolveProfile } from "./config.js"; +import { shouldRejectBrowserMutation } from "./csrf.js"; +import { toBoolean } from "./routes/utils.js"; +import { listKnownProfileNames } from "./server-context.js"; +import { resolveTargetIdFromTabs } from "./target-id.js"; + +describe("toBoolean", () => { + it("parses yes/no and 1/0", () => { + expect(toBoolean("yes")).toBe(true); + expect(toBoolean("1")).toBe(true); + expect(toBoolean("no")).toBe(false); + expect(toBoolean("0")).toBe(false); + }); + + it("returns undefined for on/off strings", () => { + expect(toBoolean("on")).toBeUndefined(); + expect(toBoolean("off")).toBeUndefined(); + }); + + it("passes through boolean values", () => { + expect(toBoolean(true)).toBe(true); + expect(toBoolean(false)).toBe(false); + }); +}); + +describe("browser target id resolution", () => { + it("resolves exact ids", () => { + const res = resolveTargetIdFromTabs("FULL", [{ targetId: "AAA" }, { targetId: "FULL" }]); + expect(res).toEqual({ ok: true, targetId: "FULL" }); + }); + + it("resolves unique prefixes (case-insensitive)", () => { + const res = resolveTargetIdFromTabs("57a01309", [ + { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, + ]); + expect(res).toEqual({ + ok: true, + targetId: "57A01309E14B5DEE0FB41F908515A2FC", + }); + }); + + it("fails on ambiguous prefixes", () => { + const res = resolveTargetIdFromTabs("57A0", [ + { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, + { targetId: "57A0BEEF000000000000000000000000" }, + ]); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.reason).toBe("ambiguous"); + expect(res.matches?.length).toBe(2); + } + }); + + it("fails when no tab matches", () => { + const res = resolveTargetIdFromTabs("NOPE", [{ targetId: "AAA" }]); + expect(res).toEqual({ ok: false, reason: "not_found" }); + }); +}); + +describe("browser CSRF loopback mutation guard", () => { + it("rejects mutating methods from non-loopback origin", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + origin: "https://evil.example", + }), + ).toBe(true); + }); + + it("allows mutating methods from loopback origin", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + origin: "http://127.0.0.1:18789", + }), + ).toBe(false); + + expect( + shouldRejectBrowserMutation({ + method: "POST", + origin: "http://localhost:18789", + }), + ).toBe(false); + }); + + it("allows mutating methods without origin/referer (non-browser clients)", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + }), + ).toBe(false); + }); + + it("rejects mutating methods with origin=null", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + origin: "null", + }), + ).toBe(true); + }); + + it("rejects mutating methods from non-loopback referer", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + referer: "https://evil.example/attack", + }), + ).toBe(true); + }); + + it("rejects cross-site mutations via Sec-Fetch-Site when present", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + secFetchSite: "cross-site", + }), + ).toBe(true); + }); + + it("does not reject non-mutating methods", () => { + expect( + shouldRejectBrowserMutation({ + method: "GET", + origin: "https://evil.example", + }), + ).toBe(false); + + expect( + shouldRejectBrowserMutation({ + method: "OPTIONS", + origin: "https://evil.example", + }), + ).toBe(false); + }); +}); + +describe("cdp.helpers", () => { + it("preserves query params when appending CDP paths", () => { + const url = appendCdpPath("https://example.com?token=abc", "/json/version"); + expect(url).toBe("https://example.com/json/version?token=abc"); + }); + + it("appends paths under a base prefix", () => { + const url = appendCdpPath("https://example.com/chrome/?token=abc", "json/list"); + expect(url).toBe("https://example.com/chrome/json/list?token=abc"); + }); + + it("adds basic auth headers when credentials are present", () => { + const headers = getHeadersWithAuth("https://user:pass@example.com"); + expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("keeps preexisting authorization headers", () => { + const headers = getHeadersWithAuth("https://user:pass@example.com", { + Authorization: "Bearer token", + }); + expect(headers.Authorization).toBe("Bearer token"); + }); +}); + +describe("fetchBrowserJson loopback auth (bridge auth registry)", () => { + it("falls back to per-port bridge auth when config auth is not available", async () => { + const port = 18765; + const getBridgeAuthForPort = vi.fn((candidate: number) => + candidate === port ? { token: "registry-token" } : undefined, + ); + const init = __test.withLoopbackBrowserAuth(`http://127.0.0.1:${port}/`, undefined, { + loadConfig: () => ({}), + resolveBrowserControlAuth: () => ({}), + getBridgeAuthForPort, + }); + const headers = new Headers(init.headers ?? {}); + expect(headers.get("authorization")).toBe("Bearer registry-token"); + expect(getBridgeAuthForPort).toHaveBeenCalledWith(port); + }); +}); + +describe("browser server-context listKnownProfileNames", () => { + it("includes configured and runtime-only profile names", () => { + const resolved = resolveBrowserConfig({ + defaultProfile: "openclaw", + profiles: { + openclaw: { cdpPort: 18800, color: "#FF4500" }, + }, + }); + const openclaw = resolveProfile(resolved, "openclaw"); + if (!openclaw) { + throw new Error("expected openclaw profile"); + } + + const state: BrowserServerState = { + server: null as unknown as BrowserServerState["server"], + port: 18791, + resolved, + profiles: new Map([ + [ + "stale-removed", + { + profile: { ...openclaw, name: "stale-removed" }, + running: null, + }, + ], + ]), + }; + + expect(listKnownProfileNames(state).toSorted()).toEqual([ + "chrome", + "openclaw", + "stale-removed", + ]); + }); +}); diff --git a/src/browser/cdp.helpers.test.ts b/src/browser/cdp.helpers.test.ts deleted file mode 100644 index b41864ee431..00000000000 --- a/src/browser/cdp.helpers.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js"; - -describe("cdp.helpers", () => { - it("preserves query params when appending CDP paths", () => { - const url = appendCdpPath("https://example.com?token=abc", "/json/version"); - expect(url).toBe("https://example.com/json/version?token=abc"); - }); - - it("appends paths under a base prefix", () => { - const url = appendCdpPath("https://example.com/chrome/?token=abc", "json/list"); - expect(url).toBe("https://example.com/chrome/json/list?token=abc"); - }); - - it("adds basic auth headers when credentials are present", () => { - const headers = getHeadersWithAuth("https://user:pass@example.com"); - expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); - }); - - it("keeps preexisting authorization headers", () => { - const headers = getHeadersWithAuth("https://user:pass@example.com", { - Authorization: "Bearer token", - }); - expect(headers.Authorization).toBe("Bearer token"); - }); -}); diff --git a/src/browser/chrome.test.ts b/src/browser/chrome.test.ts index 471218a1c7c..0551b27c287 100644 --- a/src/browser/chrome.test.ts +++ b/src/browser/chrome.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { decorateOpenClawProfile, ensureProfileCleanExit, @@ -23,112 +23,111 @@ async function readJson(filePath: string): Promise> { } describe("browser chrome profile decoration", () => { + let fixtureRoot = ""; + let fixtureCount = 0; + + const createUserDataDir = async () => { + const dir = path.join(fixtureRoot, `profile-${fixtureCount++}`); + await fsp.mkdir(dir, { recursive: true }); + return dir; + }; + + beforeAll(async () => { + fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-suite-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fsp.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("writes expected name + signed ARGB seed to Chrome prefs", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); + const userDataDir = await createUserDataDir(); + decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); - const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0; + const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0; - const localState = await readJson(path.join(userDataDir, "Local State")); - const profile = localState.profile as Record; - const infoCache = profile.info_cache as Record; - const def = infoCache.Default as Record; + const localState = await readJson(path.join(userDataDir, "Local State")); + const profile = localState.profile as Record; + const infoCache = profile.info_cache as Record; + const def = infoCache.Default as Record; - expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); - expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); - expect(def.profile_color_seed).toBe(expectedSignedArgb); - expect(def.profile_highlight_color).toBe(expectedSignedArgb); - expect(def.default_avatar_fill_color).toBe(expectedSignedArgb); - expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb); + expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); + expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); + expect(def.profile_color_seed).toBe(expectedSignedArgb); + expect(def.profile_highlight_color).toBe(expectedSignedArgb); + expect(def.default_avatar_fill_color).toBe(expectedSignedArgb); + expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb); - const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); - const browser = prefs.browser as Record; - const theme = browser.theme as Record; - const autogenerated = prefs.autogenerated as Record; - const autogeneratedTheme = autogenerated.theme as Record; + const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); + const browser = prefs.browser as Record; + const theme = browser.theme as Record; + const autogenerated = prefs.autogenerated as Record; + const autogeneratedTheme = autogenerated.theme as Record; - expect(theme.user_color2).toBe(expectedSignedArgb); - expect(autogeneratedTheme.color).toBe(expectedSignedArgb); + expect(theme.user_color2).toBe(expectedSignedArgb); + expect(autogeneratedTheme.color).toBe(expectedSignedArgb); - const marker = await fsp.readFile( - path.join(userDataDir, ".openclaw-profile-decorated"), - "utf-8", - ); - expect(marker.trim()).toMatch(/^\d+$/); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + const marker = await fsp.readFile( + path.join(userDataDir, ".openclaw-profile-decorated"), + "utf-8", + ); + expect(marker.trim()).toMatch(/^\d+$/); }); it("best-effort writes name when color is invalid", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - decorateOpenClawProfile(userDataDir, { color: "lobster-orange" }); - const localState = await readJson(path.join(userDataDir, "Local State")); - const profile = localState.profile as Record; - const infoCache = profile.info_cache as Record; - const def = infoCache.Default as Record; + const userDataDir = await createUserDataDir(); + decorateOpenClawProfile(userDataDir, { color: "lobster-orange" }); + const localState = await readJson(path.join(userDataDir, "Local State")); + const profile = localState.profile as Record; + const infoCache = profile.info_cache as Record; + const def = infoCache.Default as Record; - expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); - expect(def.profile_color_seed).toBeUndefined(); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); + expect(def.profile_color_seed).toBeUndefined(); }); it("recovers from missing/invalid preference files", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true }); - await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON - await fsp.writeFile( - path.join(userDataDir, "Default", "Preferences"), - "[]", // valid JSON but wrong shape - "utf-8", - ); + const userDataDir = await createUserDataDir(); + await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true }); + await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON + await fsp.writeFile( + path.join(userDataDir, "Default", "Preferences"), + "[]", // valid JSON but wrong shape + "utf-8", + ); - decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); + decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); - const localState = await readJson(path.join(userDataDir, "Local State")); - expect(typeof localState.profile).toBe("object"); + const localState = await readJson(path.join(userDataDir, "Local State")); + expect(typeof localState.profile).toBe("object"); - const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); - expect(typeof prefs.profile).toBe("object"); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); + expect(typeof prefs.profile).toBe("object"); }); it("writes clean exit prefs to avoid restore prompts", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - ensureProfileCleanExit(userDataDir); - const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); - expect(prefs.exit_type).toBe("Normal"); - expect(prefs.exited_cleanly).toBe(true); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + const userDataDir = await createUserDataDir(); + ensureProfileCleanExit(userDataDir); + const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); + expect(prefs.exit_type).toBe("Normal"); + expect(prefs.exited_cleanly).toBe(true); }); it("is idempotent when rerun on an existing profile", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); - decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); + const userDataDir = await createUserDataDir(); + decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); + decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); - const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); - const profile = prefs.profile as Record; - expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); + const profile = prefs.profile as Record; + expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); }); }); diff --git a/src/browser/client-actions-core.ts b/src/browser/client-actions-core.ts index c3d17922c65..cce39c03e27 100644 --- a/src/browser/client-actions-core.ts +++ b/src/browser/client-actions-core.ts @@ -3,20 +3,9 @@ import type { BrowserActionPathResult, BrowserActionTabResult, } from "./client-actions-types.js"; +import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js"; import { fetchBrowserJson } from "./client-fetch.js"; -function buildProfileQuery(profile?: string): string { - return profile ? `?profile=${encodeURIComponent(profile)}` : ""; -} - -function withBaseUrl(baseUrl: string | undefined, path: string): string { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return path; - } - return `${trimmed.replace(/\/$/, "")}${path}`; -} - export type BrowserFormField = { ref: string; type: string; diff --git a/src/browser/client-actions-observe.ts b/src/browser/client-actions-observe.ts index 13ac92b05b7..6cc68541c20 100644 --- a/src/browser/client-actions-observe.ts +++ b/src/browser/client-actions-observe.ts @@ -4,20 +4,9 @@ import type { BrowserNetworkRequest, BrowserPageError, } from "./pw-session.js"; +import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js"; import { fetchBrowserJson } from "./client-fetch.js"; -function buildProfileQuery(profile?: string): string { - return profile ? `?profile=${encodeURIComponent(profile)}` : ""; -} - -function withBaseUrl(baseUrl: string | undefined, path: string): string { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return path; - } - return `${trimmed.replace(/\/$/, "")}${path}`; -} - export async function browserConsoleMessages( baseUrl: string | undefined, opts: { level?: string; targetId?: string; profile?: string } = {}, diff --git a/src/browser/client-actions-state.ts b/src/browser/client-actions-state.ts index b2f351b33d1..ad04b652c76 100644 --- a/src/browser/client-actions-state.ts +++ b/src/browser/client-actions-state.ts @@ -1,18 +1,7 @@ import type { BrowserActionOk, BrowserActionTargetOk } from "./client-actions-types.js"; +import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js"; import { fetchBrowserJson } from "./client-fetch.js"; -function buildProfileQuery(profile?: string): string { - return profile ? `?profile=${encodeURIComponent(profile)}` : ""; -} - -function withBaseUrl(baseUrl: string | undefined, path: string): string { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return path; - } - return `${trimmed.replace(/\/$/, "")}${path}`; -} - export async function browserCookies( baseUrl: string | undefined, opts: { targetId?: string; profile?: string } = {}, diff --git a/src/browser/client-actions-url.ts b/src/browser/client-actions-url.ts new file mode 100644 index 00000000000..25c47fa6dba --- /dev/null +++ b/src/browser/client-actions-url.ts @@ -0,0 +1,11 @@ +export function buildProfileQuery(profile?: string): string { + return profile ? `?profile=${encodeURIComponent(profile)}` : ""; +} + +export function withBaseUrl(baseUrl: string | undefined, path: string): string { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return path; + } + return `${trimmed.replace(/\/$/, "")}${path}`; +} diff --git a/src/browser/client-fetch.bridge-auth-registry.test.ts b/src/browser/client-fetch.bridge-auth-registry.test.ts deleted file mode 100644 index 8e8ef5848b6..00000000000 --- a/src/browser/client-fetch.bridge-auth-registry.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { __test } from "./client-fetch.js"; - -describe("fetchBrowserJson loopback auth (bridge auth registry)", () => { - it("falls back to per-port bridge auth when config auth is not available", async () => { - const port = 18765; - const getBridgeAuthForPort = vi.fn((candidate: number) => - candidate === port ? { token: "registry-token" } : undefined, - ); - const init = __test.withLoopbackBrowserAuth(`http://127.0.0.1:${port}/`, undefined, { - loadConfig: () => ({}), - resolveBrowserControlAuth: () => ({}), - getBridgeAuthForPort, - }); - const headers = new Headers(init.headers ?? {}); - expect(headers.get("authorization")).toBe("Bearer registry-token"); - expect(getBridgeAuthForPort).toHaveBeenCalledWith(port); - }); -}); diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index 3fe71934b3e..c8617d0f79c 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -90,9 +90,16 @@ function withLoopbackBrowserAuth( } function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error { - const hint = isAbsoluteHttp(url) - ? "If this is a sandboxed session, ensure the sandbox browser is running and try again." - : `Start (or restart) the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`) and try again.`; + const isLocal = !isAbsoluteHttp(url); + // Human-facing hint for logs/diagnostics. + const operatorHint = isLocal + ? `Restart the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`).` + : "If this is a sandboxed session, ensure the sandbox browser is running."; + // Model-facing suffix: explicitly tell the LLM NOT to retry. + // Without this, models see "try again" and enter an infinite tool-call loop. + const modelHint = + "Do NOT retry the browser tool β€” it will keep failing. " + + "Use an alternative approach or inform the user that the browser is currently unavailable."; const msg = String(err); const msgLower = msg.toLowerCase(); const looksLikeTimeout = @@ -103,10 +110,12 @@ function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): msgLower.includes("aborterror"); if (looksLikeTimeout) { return new Error( - `Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${hint}`, + `Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint} ${modelHint}`, ); } - return new Error(`Can't reach the OpenClaw browser control service. ${hint} (${msg})`); + return new Error( + `Can't reach the OpenClaw browser control service. ${operatorHint} ${modelHint} (${msg})`, + ); } async function fetchHttpJson( diff --git a/src/browser/csrf.test.ts b/src/browser/csrf.test.ts deleted file mode 100644 index 6f4bedd692f..00000000000 --- a/src/browser/csrf.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldRejectBrowserMutation } from "./csrf.js"; - -describe("browser CSRF loopback mutation guard", () => { - it("rejects mutating methods from non-loopback origin", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - origin: "https://evil.example", - }), - ).toBe(true); - }); - - it("allows mutating methods from loopback origin", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - origin: "http://127.0.0.1:18789", - }), - ).toBe(false); - - expect( - shouldRejectBrowserMutation({ - method: "POST", - origin: "http://localhost:18789", - }), - ).toBe(false); - }); - - it("allows mutating methods without origin/referer (non-browser clients)", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - }), - ).toBe(false); - }); - - it("rejects mutating methods with origin=null", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - origin: "null", - }), - ).toBe(true); - }); - - it("rejects mutating methods from non-loopback referer", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - referer: "https://evil.example/attack", - }), - ).toBe(true); - }); - - it("rejects cross-site mutations via Sec-Fetch-Site when present", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - secFetchSite: "cross-site", - }), - ).toBe(true); - }); - - it("does not reject non-mutating methods", () => { - expect( - shouldRejectBrowserMutation({ - method: "GET", - origin: "https://evil.example", - }), - ).toBe(false); - - expect( - shouldRejectBrowserMutation({ - method: "OPTIONS", - origin: "https://evil.example", - }), - ).toBe(false); - }); -}); diff --git a/src/browser/profiles.test.ts b/src/browser/profiles.test.ts index bc1ff087600..b5b4d0fdbaa 100644 --- a/src/browser/profiles.test.ts +++ b/src/browser/profiles.test.ts @@ -102,10 +102,6 @@ describe("getUsedPorts", () => { expect(getUsedPorts(undefined)).toEqual(new Set()); }); - it("returns empty set for empty profiles object", () => { - expect(getUsedPorts({})).toEqual(new Set()); - }); - it("extracts ports from profile configs", () => { const profiles = { openclaw: { cdpPort: 18792 }, @@ -227,10 +223,6 @@ describe("getUsedColors", () => { expect(getUsedColors(undefined)).toEqual(new Set()); }); - it("returns empty set for empty profiles object", () => { - expect(getUsedColors({})).toEqual(new Set()); - }); - it("extracts and uppercases colors from profile configs", () => { const profiles = { openclaw: { color: "#ff4500" }, diff --git a/src/browser/routes/utils.test.ts b/src/browser/routes/utils.test.ts deleted file mode 100644 index 4f7762a944e..00000000000 --- a/src/browser/routes/utils.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { toBoolean } from "./utils.js"; - -describe("toBoolean", () => { - it("parses yes/no and 1/0", () => { - expect(toBoolean("yes")).toBe(true); - expect(toBoolean("1")).toBe(true); - expect(toBoolean("no")).toBe(false); - expect(toBoolean("0")).toBe(false); - }); - - it("returns undefined for on/off strings", () => { - expect(toBoolean("on")).toBeUndefined(); - expect(toBoolean("off")).toBeUndefined(); - }); - - it("passes through boolean values", () => { - expect(toBoolean(true)).toBe(true); - expect(toBoolean(false)).toBe(false); - }); -}); diff --git a/src/browser/server-context.list-known-profile-names.test.ts b/src/browser/server-context.list-known-profile-names.test.ts deleted file mode 100644 index 04c897563e9..00000000000 --- a/src/browser/server-context.list-known-profile-names.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { BrowserServerState } from "./server-context.js"; -import { resolveBrowserConfig, resolveProfile } from "./config.js"; -import { listKnownProfileNames } from "./server-context.js"; - -describe("browser server-context listKnownProfileNames", () => { - it("includes configured and runtime-only profile names", () => { - const resolved = resolveBrowserConfig({ - defaultProfile: "openclaw", - profiles: { - openclaw: { cdpPort: 18800, color: "#FF4500" }, - }, - }); - const openclaw = resolveProfile(resolved, "openclaw"); - if (!openclaw) { - throw new Error("expected openclaw profile"); - } - - const state: BrowserServerState = { - server: null as unknown as BrowserServerState["server"], - port: 18791, - resolved, - profiles: new Map([ - [ - "stale-removed", - { - profile: { ...openclaw, name: "stale-removed" }, - running: null, - }, - ], - ]), - }; - - expect(listKnownProfileNames(state).toSorted()).toEqual([ - "chrome", - "openclaw", - "stale-removed", - ]); - }); -}); diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 61698f6e701..6e5a60a1420 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -10,7 +10,8 @@ import type { ProfileRuntimeState, ProfileStatus, } from "./server-context.types.js"; -import { appendCdpPath, createTargetViaCdp, getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js"; +import { fetchJson, fetchOk } from "./cdp.helpers.js"; +import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; import { isChromeCdpReady, isChromeReachable, @@ -62,35 +63,6 @@ function normalizeWsUrl(raw: string | undefined, cdpBaseUrl: string): string | u } } -async function fetchJson(url: string, timeoutMs = 1500, init?: RequestInit): Promise { - const ctrl = new AbortController(); - const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); - try { - const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); - const res = await fetch(url, { ...init, headers, signal: ctrl.signal }); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - return (await res.json()) as T; - } finally { - clearTimeout(t); - } -} - -async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promise { - const ctrl = new AbortController(); - const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); - try { - const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); - const res = await fetch(url, { ...init, headers, signal: ctrl.signal }); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - } finally { - clearTimeout(t); - } -} - /** * Create a profile-scoped context for browser operations. */ diff --git a/src/browser/target-id.test.ts b/src/browser/target-id.test.ts deleted file mode 100644 index a63b6aedbf3..00000000000 --- a/src/browser/target-id.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveTargetIdFromTabs } from "./target-id.js"; - -describe("browser target id resolution", () => { - it("resolves exact ids", () => { - const res = resolveTargetIdFromTabs("FULL", [{ targetId: "AAA" }, { targetId: "FULL" }]); - expect(res).toEqual({ ok: true, targetId: "FULL" }); - }); - - it("resolves unique prefixes (case-insensitive)", () => { - const res = resolveTargetIdFromTabs("57a01309", [ - { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, - ]); - expect(res).toEqual({ - ok: true, - targetId: "57A01309E14B5DEE0FB41F908515A2FC", - }); - }); - - it("fails on ambiguous prefixes", () => { - const res = resolveTargetIdFromTabs("57A0", [ - { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, - { targetId: "57A0BEEF000000000000000000000000" }, - ]); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.reason).toBe("ambiguous"); - expect(res.matches?.length).toBe(2); - } - }); - - it("fails when no tab matches", () => { - const res = resolveTargetIdFromTabs("NOPE", [{ targetId: "AAA" }]); - expect(res).toEqual({ ok: false, reason: "not_found" }); - }); -}); diff --git a/src/channel-web.barrel.test.ts b/src/channel-web.barrel.test.ts deleted file mode 100644 index 0c52598c3e2..00000000000 --- a/src/channel-web.barrel.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import * as mod from "./channel-web.js"; - -describe("channel-web barrel", () => { - it("exports the expected web helpers", () => { - expect(mod.createWaSocket).toBeTypeOf("function"); - expect(mod.loginWeb).toBeTypeOf("function"); - expect(mod.monitorWebChannel).toBeTypeOf("function"); - expect(mod.sendMessageWhatsApp).toBeTypeOf("function"); - expect(mod.monitorWebInbox).toBeTypeOf("function"); - expect(mod.pickWebChannel).toBeTypeOf("function"); - expect(mod.WA_WEB_AUTH_DIR).toBeTruthy(); - }); -}); diff --git a/src/channels/ack-reactions.test.ts b/src/channels/ack-reactions.test.ts index bd8698ef1a3..73891720867 100644 --- a/src/channels/ack-reactions.test.ts +++ b/src/channels/ack-reactions.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it, vi } from "vitest"; -import { sleep } from "../utils.ts"; import { removeAckReactionAfterReply, shouldAckReaction, shouldAckReactionForWhatsApp, } from "./ack-reactions.js"; +const flushMicrotasks = async () => { + await Promise.resolve(); +}; + describe("shouldAckReaction", () => { it("honors direct and group-all scopes", () => { expect( @@ -33,7 +36,7 @@ describe("shouldAckReaction", () => { ).toBe(true); }); - it("skips when scope is off or none", () => { + it("skips when scope is off", () => { expect( shouldAckReaction({ scope: "off", @@ -45,18 +48,6 @@ describe("shouldAckReaction", () => { effectiveWasMentioned: true, }), ).toBe(false); - - expect( - shouldAckReaction({ - scope: "none", - isDirect: true, - isGroup: true, - isMentionableGroup: true, - requireMention: true, - canDetectMention: true, - effectiveWasMentioned: true, - }), - ).toBe(false); }); it("defaults to group-mentions gating", () => { @@ -139,18 +130,6 @@ describe("shouldAckReaction", () => { describe("shouldAckReactionForWhatsApp", () => { it("respects direct and group modes", () => { - expect( - shouldAckReactionForWhatsApp({ - emoji: "πŸ‘€", - isDirect: true, - isGroup: false, - directEnabled: true, - groupMode: "mentions", - wasMentioned: false, - groupActivated: false, - }), - ).toBe(true); - expect( shouldAckReactionForWhatsApp({ emoji: "πŸ‘€", @@ -189,18 +168,6 @@ describe("shouldAckReactionForWhatsApp", () => { }); it("honors mentions or activation for group-mentions", () => { - expect( - shouldAckReactionForWhatsApp({ - emoji: "πŸ‘€", - isDirect: false, - isGroup: true, - directEnabled: true, - groupMode: "mentions", - wasMentioned: true, - groupActivated: false, - }), - ).toBe(true); - expect( shouldAckReactionForWhatsApp({ emoji: "πŸ‘€", @@ -238,7 +205,7 @@ describe("removeAckReactionAfterReply", () => { remove, onError, }); - await sleep(0); + await flushMicrotasks(); expect(remove).toHaveBeenCalledTimes(1); expect(onError).not.toHaveBeenCalled(); }); @@ -251,19 +218,7 @@ describe("removeAckReactionAfterReply", () => { ackReactionValue: "πŸ‘€", remove, }); - await sleep(0); - expect(remove).not.toHaveBeenCalled(); - }); - - it("skips when not configured", async () => { - const remove = vi.fn().mockResolvedValue(undefined); - removeAckReactionAfterReply({ - removeAfterReply: false, - ackReactionPromise: Promise.resolve(true), - ackReactionValue: "πŸ‘€", - remove, - }); - await sleep(0); + await flushMicrotasks(); expect(remove).not.toHaveBeenCalled(); }); }); diff --git a/src/channels/allowlists/resolve-utils.test.ts b/src/channels/allowlists/resolve-utils.test.ts new file mode 100644 index 00000000000..7d8cc212345 --- /dev/null +++ b/src/channels/allowlists/resolve-utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + addAllowlistUserEntriesFromConfigEntry, + buildAllowlistResolutionSummary, +} from "./resolve-utils.js"; + +describe("buildAllowlistResolutionSummary", () => { + it("returns mapping, additions, and unresolved (including missing ids)", () => { + const resolvedUsers = [ + { input: "a", resolved: true, id: "1" }, + { input: "b", resolved: false }, + { input: "c", resolved: true }, + ]; + const result = buildAllowlistResolutionSummary(resolvedUsers); + expect(result.mapping).toEqual(["aβ†’1"]); + expect(result.additions).toEqual(["1"]); + expect(result.unresolved).toEqual(["b", "c"]); + }); + + it("supports custom resolved formatting", () => { + const resolvedUsers = [{ input: "a", resolved: true, id: "1", note: "x" }]; + const result = buildAllowlistResolutionSummary(resolvedUsers, { + formatResolved: (entry) => + `${entry.input}β†’${entry.id}${(entry as { note?: string }).note ? " (note)" : ""}`, + }); + expect(result.mapping).toEqual(["aβ†’1 (note)"]); + }); +}); + +describe("addAllowlistUserEntriesFromConfigEntry", () => { + it("adds trimmed users and skips '*' and blanks", () => { + const target = new Set(); + addAllowlistUserEntriesFromConfigEntry(target, { users: [" a ", "*", "", "b"] }); + expect(Array.from(target).toSorted()).toEqual(["a", "b"]); + }); + + it("ignores non-objects", () => { + const target = new Set(["a"]); + addAllowlistUserEntriesFromConfigEntry(target, null); + expect(Array.from(target)).toEqual(["a"]); + }); +}); diff --git a/src/channels/allowlists/resolve-utils.ts b/src/channels/allowlists/resolve-utils.ts index a4a747f77dd..46b439093c9 100644 --- a/src/channels/allowlists/resolve-utils.ts +++ b/src/channels/allowlists/resolve-utils.ts @@ -35,17 +35,25 @@ export function mergeAllowlist(params: { export function buildAllowlistResolutionSummary( resolvedUsers: T[], + opts?: { formatResolved?: (entry: T) => string }, ): { resolvedMap: Map; mapping: string[]; unresolved: string[]; + additions: string[]; } { const resolvedMap = new Map(resolvedUsers.map((entry) => [entry.input, entry])); - const mapping = resolvedUsers - .filter((entry) => entry.resolved && entry.id) - .map((entry) => `${entry.input}β†’${entry.id}`); - const unresolved = resolvedUsers.filter((entry) => !entry.resolved).map((entry) => entry.input); - return { resolvedMap, mapping, unresolved }; + const resolvedOk = (entry: T) => Boolean(entry.resolved && entry.id); + const formatResolved = opts?.formatResolved ?? ((entry: T) => `${entry.input}β†’${entry.id}`); + const mapping = resolvedUsers.filter(resolvedOk).map(formatResolved); + const additions = resolvedUsers + .filter(resolvedOk) + .map((entry) => entry.id) + .filter((entry): entry is string => Boolean(entry)); + const unresolved = resolvedUsers + .filter((entry) => !resolvedOk(entry)) + .map((entry) => entry.input); + return { resolvedMap, mapping, unresolved, additions }; } export function resolveAllowlistIdAdditions(params: { @@ -88,6 +96,22 @@ export function patchAllowlistUsersInConfigEntries< return nextEntries as TEntries; } +export function addAllowlistUserEntriesFromConfigEntry(target: Set, entry: unknown): void { + if (!entry || typeof entry !== "object") { + return; + } + const users = (entry as { users?: Array }).users; + if (!Array.isArray(users)) { + return; + } + for (const value of users) { + const trimmed = String(value).trim(); + if (trimmed && trimmed !== "*") { + target.add(trimmed); + } + } +} + export function summarizeMapping( label: string, mapping: string[], diff --git a/src/channels/channel-config.test.ts b/src/channels/channel-config.test.ts index 9af6cedc135..807a254e62b 100644 --- a/src/channels/channel-config.test.ts +++ b/src/channels/channel-config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { MsgContext } from "../auto-reply/templating.js"; import { buildChannelKeyCandidates, normalizeChannelSlug, @@ -8,6 +9,7 @@ import { applyChannelMatchMeta, resolveChannelMatchConfig, } from "./channel-config.js"; +import { validateSenderIdentity } from "./sender-identity.js"; describe("buildChannelKeyCandidates", () => { it("dedupes and trims keys", () => { @@ -118,6 +120,33 @@ describe("resolveChannelMatchConfig", () => { }); }); +describe("validateSenderIdentity", () => { + it("allows direct messages without sender fields", () => { + const ctx: MsgContext = { ChatType: "direct" }; + expect(validateSenderIdentity(ctx)).toEqual([]); + }); + + it("requires some sender identity for non-direct chats", () => { + const ctx: MsgContext = { ChatType: "group" }; + expect(validateSenderIdentity(ctx)).toContain( + "missing sender identity (SenderId/SenderName/SenderUsername/SenderE164)", + ); + }); + + it("validates SenderE164 and SenderUsername shape", () => { + const ctx: MsgContext = { + ChatType: "group", + SenderE164: "123", + SenderUsername: "@ada lovelace", + }; + expect(validateSenderIdentity(ctx)).toEqual([ + "invalid SenderE164: 123", + 'SenderUsername should not include "@": @ada lovelace', + "SenderUsername should not include whitespace: @ada lovelace", + ]); + }); +}); + describe("resolveNestedAllowlistDecision", () => { it("allows when outer allowlist is disabled", () => { expect( diff --git a/src/channels/channel-helpers.test.ts b/src/channels/channel-helpers.test.ts new file mode 100644 index 00000000000..89837fe42ec --- /dev/null +++ b/src/channels/channel-helpers.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MsgContext } from "../auto-reply/templating.js"; +import { resolveConversationLabel } from "./conversation-label.js"; +import { + formatChannelSelectionLine, + listChatChannels, + normalizeChatChannelId, +} from "./registry.js"; +import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js"; +import { createTypingCallbacks } from "./typing.js"; + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("channel registry helpers", () => { + it("normalizes aliases + trims whitespace", () => { + expect(normalizeChatChannelId(" imsg ")).toBe("imessage"); + expect(normalizeChatChannelId("gchat")).toBe("googlechat"); + expect(normalizeChatChannelId("google-chat")).toBe("googlechat"); + expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc"); + expect(normalizeChatChannelId("telegram")).toBe("telegram"); + expect(normalizeChatChannelId("web")).toBeNull(); + expect(normalizeChatChannelId("nope")).toBeNull(); + }); + + it("keeps Telegram first in the default order", () => { + const channels = listChatChannels(); + expect(channels[0]?.id).toBe("telegram"); + }); + + it("does not include MS Teams by default", () => { + const channels = listChatChannels(); + expect(channels.some((channel) => channel.id === "msteams")).toBe(false); + }); + + it("formats selection lines with docs labels + website extras", () => { + const channels = listChatChannels(); + const first = channels[0]; + if (!first) { + throw new Error("Missing channel metadata."); + } + const line = formatChannelSelectionLine(first, (path, label) => + [label, path].filter(Boolean).join(":"), + ); + expect(line).not.toContain("Docs:"); + expect(line).toContain("/channels/telegram"); + expect(line).toContain("https://openclaw.ai"); + }); +}); + +describe("channel targets", () => { + it("ensureTargetId returns the candidate when it matches", () => { + expect( + ensureTargetId({ + candidate: "U123", + pattern: /^[A-Z0-9]+$/i, + errorMessage: "bad", + }), + ).toBe("U123"); + }); + + it("ensureTargetId throws with the provided message on mismatch", () => { + expect(() => + ensureTargetId({ + candidate: "not-ok", + pattern: /^[A-Z0-9]+$/i, + errorMessage: "Bad target", + }), + ).toThrow(/Bad target/); + }); + + it("requireTargetKind returns the target id when the kind matches", () => { + const target = buildMessagingTarget("channel", "C123", "C123"); + expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123"); + }); + + it("requireTargetKind throws when the kind is missing or mismatched", () => { + expect(() => + requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" }), + ).toThrow(/Slack channel id is required/); + const target = buildMessagingTarget("user", "U123", "U123"); + expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow( + /Slack channel id is required/, + ); + }); +}); + +describe("resolveConversationLabel", () => { + it("prefers ConversationLabel when present", () => { + const ctx: MsgContext = { ConversationLabel: "Pinned Label", ChatType: "group" }; + expect(resolveConversationLabel(ctx)).toBe("Pinned Label"); + }); + + it("prefers ThreadLabel over derived chat labels", () => { + const ctx: MsgContext = { + ThreadLabel: "Thread Alpha", + ChatType: "group", + GroupSubject: "Ops", + From: "telegram:group:42", + }; + expect(resolveConversationLabel(ctx)).toBe("Thread Alpha"); + }); + + it("uses SenderName for direct chats when available", () => { + const ctx: MsgContext = { ChatType: "direct", SenderName: "Ada", From: "telegram:99" }; + expect(resolveConversationLabel(ctx)).toBe("Ada"); + }); + + it("falls back to From for direct chats when SenderName is missing", () => { + const ctx: MsgContext = { ChatType: "direct", From: "telegram:99" }; + expect(resolveConversationLabel(ctx)).toBe("telegram:99"); + }); + + it("derives Telegram-like group labels with numeric id suffix", () => { + const ctx: MsgContext = { ChatType: "group", GroupSubject: "Ops", From: "telegram:group:42" }; + expect(resolveConversationLabel(ctx)).toBe("Ops id:42"); + }); + + it("does not append ids for #rooms/channels", () => { + const ctx: MsgContext = { + ChatType: "channel", + GroupSubject: "#general", + From: "slack:channel:C123", + }; + expect(resolveConversationLabel(ctx)).toBe("#general"); + }); + + it("does not append ids when the base already contains the id", () => { + const ctx: MsgContext = { + ChatType: "group", + GroupSubject: "Family id:123@g.us", + From: "whatsapp:group:123@g.us", + }; + expect(resolveConversationLabel(ctx)).toBe("Family id:123@g.us"); + }); + + it("appends ids for WhatsApp-like group ids when a subject exists", () => { + const ctx: MsgContext = { + ChatType: "group", + GroupSubject: "Family", + From: "whatsapp:group:123@g.us", + }; + expect(resolveConversationLabel(ctx)).toBe("Family id:123@g.us"); + }); +}); + +describe("createTypingCallbacks", () => { + it("invokes start on reply start", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(start).toHaveBeenCalledTimes(1); + expect(onStartError).not.toHaveBeenCalled(); + }); + + it("reports start errors", async () => { + const start = vi.fn().mockRejectedValue(new Error("fail")); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(onStartError).toHaveBeenCalledTimes(1); + }); + + it("invokes stop on idle and reports stop errors", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockRejectedValue(new Error("stop")); + const onStartError = vi.fn(); + const onStopError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError }); + + callbacks.onIdle?.(); + await flushMicrotasks(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(onStopError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/channels/channels-misc.test.ts b/src/channels/channels-misc.test.ts new file mode 100644 index 00000000000..3eb51c509ac --- /dev/null +++ b/src/channels/channels-misc.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import * as channelWeb from "../channel-web.js"; +import { normalizeChatType } from "./chat-type.js"; +import * as webEntry from "./web/index.js"; + +describe("channel-web barrel", () => { + it("exports the expected web helpers", () => { + expect(channelWeb.createWaSocket).toBeTypeOf("function"); + expect(channelWeb.loginWeb).toBeTypeOf("function"); + expect(channelWeb.monitorWebChannel).toBeTypeOf("function"); + expect(channelWeb.sendMessageWhatsApp).toBeTypeOf("function"); + expect(channelWeb.monitorWebInbox).toBeTypeOf("function"); + expect(channelWeb.pickWebChannel).toBeTypeOf("function"); + expect(channelWeb.WA_WEB_AUTH_DIR).toBeTruthy(); + }); +}); + +describe("normalizeChatType", () => { + it("normalizes common inputs", () => { + expect(normalizeChatType("direct")).toBe("direct"); + expect(normalizeChatType("dm")).toBe("direct"); + expect(normalizeChatType("group")).toBe("group"); + expect(normalizeChatType("channel")).toBe("channel"); + }); + + it("returns undefined for empty/unknown values", () => { + expect(normalizeChatType(undefined)).toBeUndefined(); + expect(normalizeChatType("")).toBeUndefined(); + expect(normalizeChatType("nope")).toBeUndefined(); + expect(normalizeChatType("room")).toBeUndefined(); + }); + + describe("backward compatibility", () => { + it("accepts legacy 'dm' value and normalizes to 'direct'", () => { + // Legacy config/input may use "dm" - ensure smooth upgrade path + expect(normalizeChatType("dm")).toBe("direct"); + expect(normalizeChatType("DM")).toBe("direct"); + expect(normalizeChatType(" dm ")).toBe("direct"); + }); + }); +}); + +describe("channels/web entrypoint", () => { + it("re-exports web channel helpers", () => { + expect(webEntry.createWaSocket).toBe(channelWeb.createWaSocket); + expect(webEntry.loginWeb).toBe(channelWeb.loginWeb); + expect(webEntry.logWebSelfId).toBe(channelWeb.logWebSelfId); + expect(webEntry.monitorWebInbox).toBe(channelWeb.monitorWebInbox); + expect(webEntry.monitorWebChannel).toBe(channelWeb.monitorWebChannel); + expect(webEntry.pickWebChannel).toBe(channelWeb.pickWebChannel); + expect(webEntry.sendMessageWhatsApp).toBe(channelWeb.sendMessageWhatsApp); + expect(webEntry.WA_WEB_AUTH_DIR).toBe(channelWeb.WA_WEB_AUTH_DIR); + expect(webEntry.waitForWaConnection).toBe(channelWeb.waitForWaConnection); + expect(webEntry.webAuthExists).toBe(channelWeb.webAuthExists); + }); +}); diff --git a/src/channels/chat-type.test.ts b/src/channels/chat-type.test.ts deleted file mode 100644 index 6775c8cac8e..00000000000 --- a/src/channels/chat-type.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeChatType } from "./chat-type.js"; - -describe("normalizeChatType", () => { - it("normalizes common inputs", () => { - expect(normalizeChatType("direct")).toBe("direct"); - expect(normalizeChatType("dm")).toBe("direct"); - expect(normalizeChatType("group")).toBe("group"); - expect(normalizeChatType("channel")).toBe("channel"); - }); - - it("returns undefined for empty/unknown values", () => { - expect(normalizeChatType(undefined)).toBeUndefined(); - expect(normalizeChatType("")).toBeUndefined(); - expect(normalizeChatType("nope")).toBeUndefined(); - expect(normalizeChatType("room")).toBeUndefined(); - }); - - describe("backward compatibility", () => { - it("accepts legacy 'dm' value and normalizes to 'direct'", () => { - // Legacy config/input may use "dm" - ensure smooth upgrade path - expect(normalizeChatType("dm")).toBe("direct"); - expect(normalizeChatType("DM")).toBe("direct"); - expect(normalizeChatType(" dm ")).toBe("direct"); - }); - }); -}); diff --git a/src/channels/conversation-label.test.ts b/src/channels/conversation-label.test.ts deleted file mode 100644 index 7e261e1c55a..00000000000 --- a/src/channels/conversation-label.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { MsgContext } from "../auto-reply/templating.js"; -import { resolveConversationLabel } from "./conversation-label.js"; - -describe("resolveConversationLabel", () => { - it("prefers ConversationLabel when present", () => { - const ctx: MsgContext = { ConversationLabel: "Pinned Label", ChatType: "group" }; - expect(resolveConversationLabel(ctx)).toBe("Pinned Label"); - }); - - it("uses SenderName for direct chats when available", () => { - const ctx: MsgContext = { ChatType: "direct", SenderName: "Ada", From: "telegram:99" }; - expect(resolveConversationLabel(ctx)).toBe("Ada"); - }); - - it("derives Telegram-like group labels with numeric id suffix", () => { - const ctx: MsgContext = { ChatType: "group", GroupSubject: "Ops", From: "telegram:group:42" }; - expect(resolveConversationLabel(ctx)).toBe("Ops id:42"); - }); - - it("does not append ids for #rooms/channels", () => { - const ctx: MsgContext = { - ChatType: "channel", - GroupSubject: "#general", - From: "slack:channel:C123", - }; - expect(resolveConversationLabel(ctx)).toBe("#general"); - }); - - it("appends ids for WhatsApp-like group ids when a subject exists", () => { - const ctx: MsgContext = { - ChatType: "group", - GroupSubject: "Family", - From: "whatsapp:group:123@g.us", - }; - expect(resolveConversationLabel(ctx)).toBe("Family id:123@g.us"); - }); -}); diff --git a/src/channels/dock.ts b/src/channels/dock.ts index e0aec226964..fa872a21620 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -8,7 +8,9 @@ import type { ChannelAgentPromptAdapter, ChannelMentionAdapter, ChannelPlugin, + ChannelThreadingContext, ChannelThreadingAdapter, + ChannelThreadingToolContext, } from "./plugins/types.js"; import { resolveChannelGroupRequireMention, @@ -79,6 +81,21 @@ const formatLower = (allowFrom: Array) => .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.toLowerCase()); + +function buildDirectOrGroupThreadToolContext(params: { + context: ChannelThreadingContext; + hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"]; +}): ChannelThreadingToolContext { + const isDirect = params.context.ChatType?.toLowerCase() === "direct"; + const channelId = + (isDirect ? (params.context.From ?? params.context.To) : params.context.To)?.trim() || + undefined; + return { + currentChannelId: channelId, + currentThreadTs: params.context.ReplyToId, + hasRepliedRef: params.hasRepliedRef, + }; +} // Channel docks: lightweight channel metadata/behavior for shared code paths. // // Rules: @@ -404,16 +421,8 @@ const DOCKS: Record = { .filter(Boolean), }, threading: { - buildToolContext: ({ context, hasRepliedRef }) => { - const isDirect = context.ChatType?.toLowerCase() === "direct"; - const channelId = - (isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined; - return { - currentChannelId: channelId, - currentThreadTs: context.ReplyToId, - hasRepliedRef, - }; - }, + buildToolContext: ({ context, hasRepliedRef }) => + buildDirectOrGroupThreadToolContext({ context, hasRepliedRef }), }, }, imessage: { @@ -437,16 +446,8 @@ const DOCKS: Record = { resolveToolPolicy: resolveIMessageGroupToolPolicy, }, threading: { - buildToolContext: ({ context, hasRepliedRef }) => { - const isDirect = context.ChatType?.toLowerCase() === "direct"; - const channelId = - (isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined; - return { - currentChannelId: channelId, - currentThreadTs: context.ReplyToId, - hasRepliedRef, - }; - }, + buildToolContext: ({ context, hasRepliedRef }) => + buildDirectOrGroupThreadToolContext({ context, hasRepliedRef }), }, }, }; diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts new file mode 100644 index 00000000000..1b33f41377c --- /dev/null +++ b/src/channels/plugins/actions/actions.test.ts @@ -0,0 +1,529 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; + +const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } })); +const handleTelegramAction = vi.fn(async () => ({ ok: true })); +const sendReactionSignal = vi.fn(async () => ({ ok: true })); +const removeReactionSignal = vi.fn(async () => ({ ok: true })); +const handleSlackAction = vi.fn(async () => ({ details: { ok: true } })); + +vi.mock("../../../agents/tools/discord-actions.js", () => ({ + handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args), +})); + +vi.mock("../../../agents/tools/telegram-actions.js", () => ({ + handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args), +})); + +vi.mock("../../../signal/send-reactions.js", () => ({ + sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args), + removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args), +})); + +vi.mock("../../../agents/tools/slack-actions.js", () => ({ + handleSlackAction: (...args: unknown[]) => handleSlackAction(...args), +})); + +const { discordMessageActions } = await import("./discord.js"); +const { handleDiscordMessageAction } = await import("./discord/handle-action.js"); +const { telegramMessageActions } = await import("./telegram.js"); +const { signalMessageActions } = await import("./signal.js"); +const { createSlackActions } = await import("../slack.actions.js"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("discord message actions", () => { + it("lists channel and upload actions by default", async () => { + const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig; + const actions = discordMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).toContain("emoji-upload"); + expect(actions).toContain("sticker-upload"); + expect(actions).toContain("channel-create"); + }); + + it("respects disabled channel actions", async () => { + const cfg = { + channels: { discord: { token: "d0", actions: { channels: false } } }, + } as OpenClawConfig; + const actions = discordMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).not.toContain("channel-create"); + }); +}); + +describe("handleDiscordMessageAction", () => { + it("forwards context accountId for send", async () => { + await handleDiscordMessageAction({ + action: "send", + params: { + to: "channel:123", + message: "hi", + }, + cfg: {} as OpenClawConfig, + accountId: "ops", + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + accountId: "ops", + to: "channel:123", + content: "hi", + }), + expect.any(Object), + ); + }); + + it("forwards legacy embeds for send", async () => { + const embeds = [{ title: "Legacy", description: "Use components v2." }]; + + await handleDiscordMessageAction({ + action: "send", + params: { + to: "channel:123", + message: "hi", + embeds, + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "channel:123", + content: "hi", + embeds, + }), + expect.any(Object), + ); + }); + + it("falls back to params accountId when context missing", async () => { + await handleDiscordMessageAction({ + action: "poll", + params: { + to: "channel:123", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + accountId: "marve", + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "poll", + accountId: "marve", + to: "channel:123", + question: "Ready?", + answers: ["Yes", "No"], + }), + expect.any(Object), + ); + }); + + it("forwards accountId for thread replies", async () => { + await handleDiscordMessageAction({ + action: "thread-reply", + params: { + channelId: "123", + message: "hi", + }, + cfg: {} as OpenClawConfig, + accountId: "ops", + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadReply", + accountId: "ops", + channelId: "123", + content: "hi", + }), + expect.any(Object), + ); + }); + + it("accepts threadId for thread replies (tool compatibility)", async () => { + await handleDiscordMessageAction({ + action: "thread-reply", + params: { + // The `message` tool uses `threadId`. + threadId: "999", + // Include a conflicting channelId to ensure threadId takes precedence. + channelId: "123", + message: "hi", + }, + cfg: {} as OpenClawConfig, + accountId: "ops", + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadReply", + accountId: "ops", + channelId: "999", + content: "hi", + }), + expect.any(Object), + ); + }); + + it("forwards thread-create message as content", async () => { + await handleDiscordMessageAction({ + action: "thread-create", + params: { + to: "channel:123456789", + threadName: "Forum thread", + message: "Initial forum post body", + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadCreate", + channelId: "123456789", + name: "Forum thread", + content: "Initial forum post body", + }), + expect.any(Object), + ); + }); + + it("forwards thread edit fields for channel-edit", async () => { + await handleDiscordMessageAction({ + action: "channel-edit", + params: { + channelId: "123456789", + archived: true, + locked: false, + autoArchiveDuration: 1440, + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "channelEdit", + channelId: "123456789", + archived: true, + locked: false, + autoArchiveDuration: 1440, + }), + expect.any(Object), + ); + }); +}); + +describe("telegramMessageActions", () => { + it("excludes sticker actions when not enabled", () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const actions = telegramMessageActions.listActions({ cfg }); + expect(actions).not.toContain("sticker"); + expect(actions).not.toContain("sticker-search"); + }); + + it("allows media-only sends and passes asVoice", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "send", + params: { + to: "123", + media: "https://example.com/voice.ogg", + asVoice: true, + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "123", + content: "", + mediaUrl: "https://example.com/voice.ogg", + asVoice: true, + }), + cfg, + ); + }); + + it("passes silent flag for silent sends", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "send", + params: { + to: "456", + message: "Silent notification test", + silent: true, + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "456", + content: "Silent notification test", + silent: true, + }), + cfg, + ); + }); + + it("maps edit action params into editMessage", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: 42, + message: "Updated", + buttons: [], + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + { + action: "editMessage", + chatId: "123", + messageId: 42, + content: "Updated", + buttons: [], + accountId: undefined, + }, + cfg, + ); + }); + + it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await expect( + telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: "nope", + message: "Updated", + }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(); + + expect(handleTelegramAction).not.toHaveBeenCalled(); + }); + + it("accepts numeric messageId and channelId for reactions", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "react", + params: { + channelId: 123, + messageId: 456, + emoji: "ok", + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0] as Record; + expect(call.action).toBe("react"); + expect(String(call.chatId)).toBe("123"); + expect(String(call.messageId)).toBe("456"); + expect(call.emoji).toBe("ok"); + }); +}); + +describe("signalMessageActions", () => { + it("returns no actions when no configured accounts exist", () => { + const cfg = {} as OpenClawConfig; + expect(signalMessageActions.listActions({ cfg })).toEqual([]); + }); + + it("hides react when reactions are disabled", () => { + const cfg = { + channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, + } as OpenClawConfig; + expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]); + }); + + it("enables react when at least one account allows reactions", () => { + const cfg = { + channels: { + signal: { + actions: { reactions: false }, + accounts: { + work: { account: "+15550001111", actions: { reactions: true } }, + }, + }, + }, + } as OpenClawConfig; + expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]); + }); + + it("skips send for plugin dispatch", () => { + expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false); + expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true); + }); + + it("blocks reactions when action gate is disabled", async () => { + const cfg = { + channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, + } as OpenClawConfig; + + await expect( + signalMessageActions.handleAction({ + action: "react", + params: { to: "+15550001111", messageId: "123", emoji: "βœ…" }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(/actions\.reactions/); + }); + + it("uses account-level actions when enabled", async () => { + const cfg = { + channels: { + signal: { + actions: { reactions: false }, + accounts: { + work: { account: "+15550001111", actions: { reactions: true } }, + }, + }, + }, + } as OpenClawConfig; + + await signalMessageActions.handleAction({ + action: "react", + params: { to: "+15550001111", messageId: "123", emoji: "πŸ‘" }, + cfg, + accountId: "work", + }); + + expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "πŸ‘", { + accountId: "work", + }); + }); + + it("normalizes uuid recipients", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await signalMessageActions.handleAction({ + action: "react", + params: { + recipient: "uuid:123e4567-e89b-12d3-a456-426614174000", + messageId: "123", + emoji: "πŸ”₯", + }, + cfg, + accountId: undefined, + }); + + expect(sendReactionSignal).toHaveBeenCalledWith( + "123e4567-e89b-12d3-a456-426614174000", + 123, + "πŸ”₯", + { accountId: undefined }, + ); + }); + + it("requires targetAuthor for group reactions", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await expect( + signalMessageActions.handleAction({ + action: "react", + params: { to: "signal:group:group-id", messageId: "123", emoji: "βœ…" }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(/targetAuthor/); + }); + + it("passes groupId and targetAuthor for group reactions", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await signalMessageActions.handleAction({ + action: "react", + params: { + to: "signal:group:group-id", + targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", + messageId: "123", + emoji: "βœ…", + }, + cfg, + accountId: undefined, + }); + + expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "βœ…", { + accountId: undefined, + groupId: "group-id", + targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", + targetAuthorUuid: undefined, + }); + }); +}); + +describe("slack actions adapter", () => { + it("forwards threadId for read", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + const actions = createSlackActions("slack"); + + await actions.handleAction?.({ + channel: "slack", + action: "read", + cfg, + params: { + channelId: "C1", + threadId: "171234.567", + }, + }); + + const [params] = handleSlackAction.mock.calls[0] ?? []; + expect(params).toMatchObject({ + action: "readMessages", + channelId: "C1", + threadId: "171234.567", + }); + }); + + it("forwards normalized limit for emoji-list", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + const actions = createSlackActions("slack"); + + await actions.handleAction?.({ + channel: "slack", + action: "emoji-list", + cfg, + params: { + limit: "2.9", + }, + }); + + const [params] = handleSlackAction.mock.calls[0] ?? []; + expect(params).toMatchObject({ + action: "emojiList", + limit: 2, + }); + }); +}); diff --git a/src/channels/plugins/actions/discord.test.ts b/src/channels/plugins/actions/discord.test.ts deleted file mode 100644 index fc30a0a7566..00000000000 --- a/src/channels/plugins/actions/discord.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -type SendMessageDiscord = typeof import("../../../discord/send.js").sendMessageDiscord; -type SendPollDiscord = typeof import("../../../discord/send.js").sendPollDiscord; - -const sendMessageDiscord = vi.fn, ReturnType>( - async () => ({ ok: true }) as Awaited>, -); -const sendPollDiscord = vi.fn, ReturnType>( - async () => ({ ok: true }) as Awaited>, -); - -vi.mock("../../../discord/send.js", async () => { - const actual = await vi.importActual( - "../../../discord/send.js", - ); - return { - ...actual, - sendMessageDiscord: (...args: Parameters) => sendMessageDiscord(...args), - sendPollDiscord: (...args: Parameters) => sendPollDiscord(...args), - }; -}); - -const { handleDiscordMessageAction } = await import("./discord/handle-action.js"); -const { discordMessageActions } = await import("./discord.js"); - -describe("discord message actions", () => { - it("lists channel and upload actions by default", async () => { - const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("emoji-upload"); - expect(actions).toContain("sticker-upload"); - expect(actions).toContain("channel-create"); - }); - - it("respects disabled channel actions", async () => { - const cfg = { - channels: { discord: { token: "d0", actions: { channels: false } } }, - } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("channel-create"); - }); -}); - -describe("handleDiscordMessageAction", () => { - it("forwards context accountId for send", async () => { - sendMessageDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "send", - params: { - to: "channel:123", - message: "hi", - }, - cfg: {} as OpenClawConfig, - accountId: "ops", - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:123", - "hi", - expect.objectContaining({ - accountId: "ops", - }), - ); - }); - - it("falls back to params accountId when context missing", async () => { - sendPollDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "poll", - params: { - to: "channel:123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - accountId: "marve", - }, - cfg: {} as OpenClawConfig, - }); - - expect(sendPollDiscord).toHaveBeenCalledWith( - "channel:123", - expect.objectContaining({ - question: "Ready?", - options: ["Yes", "No"], - }), - expect.objectContaining({ - accountId: "marve", - }), - ); - }); - - it("forwards accountId for thread replies", async () => { - sendMessageDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "thread-reply", - params: { - channelId: "123", - message: "hi", - }, - cfg: {} as OpenClawConfig, - accountId: "ops", - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:123", - "hi", - expect.objectContaining({ - accountId: "ops", - }), - ); - }); - - it("accepts threadId for thread replies (tool compatibility)", async () => { - sendMessageDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "thread-reply", - params: { - // The `message` tool uses `threadId`. - threadId: "999", - // Include a conflicting channelId to ensure threadId takes precedence. - channelId: "123", - message: "hi", - }, - cfg: {} as OpenClawConfig, - accountId: "ops", - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:999", - "hi", - expect.objectContaining({ - accountId: "ops", - }), - ); - }); -}); diff --git a/src/channels/plugins/actions/discord/handle-action.test.ts b/src/channels/plugins/actions/discord/handle-action.test.ts deleted file mode 100644 index 425c7d5a50e..00000000000 --- a/src/channels/plugins/actions/discord/handle-action.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { handleDiscordMessageAction } from "./handle-action.js"; - -const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } })); - -vi.mock("../../../../agents/tools/discord-actions.js", () => ({ - handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args), -})); - -describe("handleDiscordMessageAction", () => { - beforeEach(() => { - handleDiscordAction.mockClear(); - }); - - it("forwards thread-create message as content", async () => { - await handleDiscordMessageAction({ - action: "thread-create", - params: { - to: "channel:123456789", - threadName: "Forum thread", - message: "Initial forum post body", - }, - cfg: {}, - }); - expect(handleDiscordAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "threadCreate", - channelId: "123456789", - name: "Forum thread", - content: "Initial forum post body", - }), - expect.any(Object), - ); - }); - - it("forwards thread edit fields for channel-edit", async () => { - await handleDiscordMessageAction({ - action: "channel-edit", - params: { - channelId: "123456789", - archived: true, - locked: false, - autoArchiveDuration: 1440, - }, - cfg: {}, - }); - expect(handleDiscordAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "channelEdit", - channelId: "123456789", - archived: true, - locked: false, - autoArchiveDuration: 1440, - }), - expect.any(Object), - ); - }); -}); diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index a5797440af9..6c867d81c87 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -34,8 +34,14 @@ export async function handleDiscordMessageAction( if (action === "send") { const to = readStringParam(params, "to", { required: true }); + const asVoice = params.asVoice === true; + const rawComponents = params.components; + const hasComponents = + Boolean(rawComponents) && + (typeof rawComponents === "function" || typeof rawComponents === "object"); + const components = hasComponents ? rawComponents : undefined; const content = readStringParam(params, "message", { - required: true, + required: !asVoice && !hasComponents, allowEmpty: true, }); // Support media, path, and filePath for media URL @@ -43,10 +49,13 @@ export async function handleDiscordMessageAction( readStringParam(params, "media", { trim: false }) ?? readStringParam(params, "path", { trim: false }) ?? readStringParam(params, "filePath", { trim: false }); + const filename = readStringParam(params, "filename"); const replyTo = readStringParam(params, "replyTo"); - const embeds = Array.isArray(params.embeds) ? params.embeds : undefined; - const asVoice = params.asVoice === true; + const rawEmbeds = params.embeds; + const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; const silent = params.silent === true; + const sessionKey = readStringParam(params, "__sessionKey"); + const agentId = readStringParam(params, "__agentId"); return await handleDiscordAction( { action: "sendMessage", @@ -54,10 +63,14 @@ export async function handleDiscordMessageAction( to, content, mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, replyTo: replyTo ?? undefined, + components, embeds, asVoice, silent, + __sessionKey: sessionKey ?? undefined, + __agentId: agentId ?? undefined, }, cfg, ); diff --git a/src/channels/plugins/actions/signal.test.ts b/src/channels/plugins/actions/signal.test.ts deleted file mode 100644 index 613b725f77a..00000000000 --- a/src/channels/plugins/actions/signal.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { signalMessageActions } from "./signal.js"; - -const sendReactionSignal = vi.fn(async () => ({ ok: true })); -const removeReactionSignal = vi.fn(async () => ({ ok: true })); - -vi.mock("../../../signal/send-reactions.js", () => ({ - sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args), - removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args), -})); - -describe("signalMessageActions", () => { - it("returns no actions when no configured accounts exist", () => { - const cfg = {} as OpenClawConfig; - expect(signalMessageActions.listActions({ cfg })).toEqual([]); - }); - - it("hides react when reactions are disabled", () => { - const cfg = { - channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, - } as OpenClawConfig; - expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]); - }); - - it("enables react when at least one account allows reactions", () => { - const cfg = { - channels: { - signal: { - actions: { reactions: false }, - accounts: { - work: { account: "+15550001111", actions: { reactions: true } }, - }, - }, - }, - } as OpenClawConfig; - expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]); - }); - - it("skips send for plugin dispatch", () => { - expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false); - expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true); - }); - - it("blocks reactions when action gate is disabled", async () => { - const cfg = { - channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, - } as OpenClawConfig; - - await expect( - signalMessageActions.handleAction({ - action: "react", - params: { to: "+15550001111", messageId: "123", emoji: "βœ…" }, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(/actions\.reactions/); - }); - - it("uses account-level actions when enabled", async () => { - sendReactionSignal.mockClear(); - const cfg = { - channels: { - signal: { - actions: { reactions: false }, - accounts: { - work: { account: "+15550001111", actions: { reactions: true } }, - }, - }, - }, - } as OpenClawConfig; - - await signalMessageActions.handleAction({ - action: "react", - params: { to: "+15550001111", messageId: "123", emoji: "πŸ‘" }, - cfg, - accountId: "work", - }); - - expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "πŸ‘", { - accountId: "work", - }); - }); - - it("normalizes uuid recipients", async () => { - sendReactionSignal.mockClear(); - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - - await signalMessageActions.handleAction({ - action: "react", - params: { - recipient: "uuid:123e4567-e89b-12d3-a456-426614174000", - messageId: "123", - emoji: "πŸ”₯", - }, - cfg, - accountId: undefined, - }); - - expect(sendReactionSignal).toHaveBeenCalledWith( - "123e4567-e89b-12d3-a456-426614174000", - 123, - "πŸ”₯", - { accountId: undefined }, - ); - }); - - it("requires targetAuthor for group reactions", async () => { - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - - await expect( - signalMessageActions.handleAction({ - action: "react", - params: { to: "signal:group:group-id", messageId: "123", emoji: "βœ…" }, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(/targetAuthor/); - }); - - it("passes groupId and targetAuthor for group reactions", async () => { - sendReactionSignal.mockClear(); - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - - await signalMessageActions.handleAction({ - action: "react", - params: { - to: "signal:group:group-id", - targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", - messageId: "123", - emoji: "βœ…", - }, - cfg, - accountId: undefined, - }); - - expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "βœ…", { - accountId: undefined, - groupId: "group-id", - targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", - targetAuthorUuid: undefined, - }); - }); -}); diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts deleted file mode 100644 index 21922905e53..00000000000 --- a/src/channels/plugins/actions/telegram.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { telegramMessageActions } from "./telegram.js"; - -const handleTelegramAction = vi.fn(async () => ({ ok: true })); - -vi.mock("../../../agents/tools/telegram-actions.js", () => ({ - handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args), -})); - -describe("telegramMessageActions", () => { - it("excludes sticker actions when not enabled", () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - const actions = telegramMessageActions.listActions({ cfg }); - expect(actions).not.toContain("sticker"); - expect(actions).not.toContain("sticker-search"); - }); - - it("allows media-only sends and passes asVoice", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "send", - params: { - to: "123", - media: "https://example.com/voice.ogg", - asVoice: true, - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - to: "123", - content: "", - mediaUrl: "https://example.com/voice.ogg", - asVoice: true, - }), - cfg, - ); - }); - - it("passes silent flag for silent sends", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "send", - params: { - to: "456", - message: "Silent notification test", - silent: true, - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - to: "456", - content: "Silent notification test", - silent: true, - }), - cfg, - ); - }); - - it("maps edit action params into editMessage", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "edit", - params: { - chatId: "123", - messageId: 42, - message: "Updated", - buttons: [], - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - { - action: "editMessage", - chatId: "123", - messageId: 42, - content: "Updated", - buttons: [], - accountId: undefined, - }, - cfg, - ); - }); - - it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await expect( - telegramMessageActions.handleAction({ - action: "edit", - params: { - chatId: "123", - messageId: "nope", - message: "Updated", - }, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(); - - expect(handleTelegramAction).not.toHaveBeenCalled(); - }); - - it("accepts numeric messageId and channelId for reactions", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "react", - params: { - channelId: 123, - messageId: 456, - emoji: "ok", - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0] as Record; - expect(call.action).toBe("react"); - expect(String(call.chatId)).toBe("123"); - expect(String(call.messageId)).toBe("456"); - expect(call.emoji).toBe("ok"); - }); -}); diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts deleted file mode 100644 index d62fac8a8fc..00000000000 --- a/src/channels/plugins/catalog.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; - -describe("channel plugin catalog", () => { - it("includes Microsoft Teams", () => { - const entry = getChannelPluginCatalogEntry("msteams"); - expect(entry?.install.npmSpec).toBe("@openclaw/msteams"); - expect(entry?.meta.aliases).toContain("teams"); - }); - - it("lists plugin catalog entries", () => { - const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); - expect(ids).toContain("msteams"); - }); - - it("includes external catalog entries", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); - const catalogPath = path.join(dir, "catalog.json"); - fs.writeFileSync( - catalogPath, - JSON.stringify({ - entries: [ - { - name: "@openclaw/demo-channel", - openclaw: { - channel: { - id: "demo-channel", - label: "Demo Channel", - selectionLabel: "Demo Channel", - docsPath: "/channels/demo-channel", - blurb: "Demo entry", - order: 999, - }, - install: { - npmSpec: "@openclaw/demo-channel", - }, - }, - }, - ], - }), - ); - - const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( - (entry) => entry.id, - ); - expect(ids).toContain("demo-channel"); - }); -}); diff --git a/src/channels/plugins/config-writes.test.ts b/src/channels/plugins/config-writes.test.ts deleted file mode 100644 index 00fe9164f8e..00000000000 --- a/src/channels/plugins/config-writes.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveChannelConfigWrites } from "./config-writes.js"; - -describe("resolveChannelConfigWrites", () => { - it("defaults to allow when unset", () => { - const cfg = {}; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); - }); - - it("blocks when channel config disables writes", () => { - const cfg = { channels: { slack: { configWrites: false } } }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); - }); - - it("account override wins over channel default", () => { - const cfg = { - channels: { - slack: { - configWrites: true, - accounts: { - work: { configWrites: false }, - }, - }, - }, - }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); - }); - - it("matches account ids case-insensitively", () => { - const cfg = { - channels: { - slack: { - configWrites: true, - accounts: { - Work: { configWrites: false }, - }, - }, - }, - }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); - }); -}); diff --git a/src/channels/plugins/directory-config.test.ts b/src/channels/plugins/directory-config.test.ts deleted file mode 100644 index ab043e1b36d..00000000000 --- a/src/channels/plugins/directory-config.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - listDiscordDirectoryGroupsFromConfig, - listDiscordDirectoryPeersFromConfig, - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, - listTelegramDirectoryGroupsFromConfig, - listTelegramDirectoryPeersFromConfig, - listWhatsAppDirectoryGroupsFromConfig, - listWhatsAppDirectoryPeersFromConfig, -} from "./directory-config.js"; - -describe("directory (config-backed)", () => { - it("lists Slack peers/groups from config", async () => { - const cfg = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - dm: { allowFrom: ["U123", "user:U999"] }, - dms: { U234: {} }, - channels: { C111: { users: ["U777"] } }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listSlackDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual([ - "user:u123", - "user:u234", - "user:u777", - "user:u999", - ]); - - const groups = await listSlackDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]); - }); - - it("lists Discord peers/groups from config (numeric ids only)", async () => { - const cfg = { - channels: { - discord: { - token: "discord-test", - dm: { allowFrom: ["<@111>", "nope"] }, - dms: { "222": {} }, - guilds: { - "123": { - users: ["<@12345>", "not-an-id"], - channels: { - "555": {}, - "channel:666": {}, - general: {}, - }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listDiscordDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual(["user:111", "user:12345", "user:222"]); - - const groups = await listDiscordDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id).toSorted()).toEqual(["channel:555", "channel:666"]); - }); - - it("lists Telegram peers/groups from config", async () => { - const cfg = { - channels: { - telegram: { - botToken: "telegram-test", - allowFrom: ["123", "alice", "tg:@bob"], - dms: { "456": {} }, - groups: { "-1001": {}, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listTelegramDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual(["123", "456", "@alice", "@bob"]); - - const groups = await listTelegramDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["-1001"]); - }); - - it("lists WhatsApp peers/groups from config", async () => { - const cfg = { - channels: { - whatsapp: { - allowFrom: ["+15550000000", "*", "123@g.us"], - groups: { "999@g.us": { requireMention: true }, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listWhatsAppDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]); - - const groups = await listWhatsAppDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]); - }); -}); diff --git a/src/channels/plugins/directory-config.ts b/src/channels/plugins/directory-config.ts index 3b57bd082c9..2593878d204 100644 --- a/src/channels/plugins/directory-config.ts +++ b/src/channels/plugins/directory-config.ts @@ -14,6 +14,26 @@ export type DirectoryConfigParams = { limit?: number | null; }; +function addAllowFromAndDmsIds( + ids: Set, + allowFrom: readonly unknown[] | undefined, + dms: Record | undefined, +) { + for (const entry of allowFrom ?? []) { + const raw = String(entry).trim(); + if (!raw || raw === "*") { + continue; + } + ids.add(raw); + } + for (const id of Object.keys(dms ?? {})) { + const trimmed = id.trim(); + if (trimmed) { + ids.add(trimmed); + } + } +} + export async function listSlackDirectoryPeersFromConfig( params: DirectoryConfigParams, ): Promise { @@ -21,19 +41,7 @@ export async function listSlackDirectoryPeersFromConfig( const q = params.query?.trim().toLowerCase() || ""; const ids = new Set(); - for (const entry of account.config.allowFrom ?? account.dm?.allowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw); - } - for (const id of Object.keys(account.config.dms ?? {})) { - const trimmed = id.trim(); - if (trimmed) { - ids.add(trimmed); - } - } + addAllowFromAndDmsIds(ids, account.config.allowFrom ?? account.dm?.allowFrom, account.config.dms); for (const channel of Object.values(account.config.channels ?? {})) { for (const user of channel.users ?? []) { const raw = String(user).trim(); @@ -84,19 +92,11 @@ export async function listDiscordDirectoryPeersFromConfig( const q = params.query?.trim().toLowerCase() || ""; const ids = new Set(); - for (const entry of account.config.allowFrom ?? account.config.dm?.allowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw); - } - for (const id of Object.keys(account.config.dms ?? {})) { - const trimmed = id.trim(); - if (trimmed) { - ids.add(trimmed); - } - } + addAllowFromAndDmsIds( + ids, + account.config.allowFrom ?? account.config.dm?.allowFrom, + account.config.dms, + ); for (const guild of Object.values(account.config.guilds ?? {})) { for (const entry of guild.users ?? []) { const raw = String(entry).trim(); diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 708b4d3c190..e7369146480 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -120,6 +120,24 @@ function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: return guilds["*"] ?? null; } +function resolveDiscordChannelEntry( + channelEntries: Record | undefined, + params: { groupId?: string | null; groupChannel?: string | null }, +): TEntry | undefined { + if (!channelEntries || Object.keys(channelEntries).length === 0) { + return undefined; + } + const groupChannel = params.groupChannel; + const channelSlug = normalizeDiscordSlug(groupChannel); + return ( + (params.groupId ? channelEntries[params.groupId] : undefined) ?? + (channelSlug + ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) + : undefined) ?? + (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined) + ); +} + export function resolveTelegramGroupRequireMention( params: GroupMentionParams, ): boolean | undefined { @@ -165,14 +183,7 @@ export function resolveDiscordGroupRequireMention(params: GroupMentionParams): b ); const channelEntries = guildEntry?.channels; if (channelEntries && Object.keys(channelEntries).length > 0) { - const groupChannel = params.groupChannel; - const channelSlug = normalizeDiscordSlug(groupChannel); - const entry = - (params.groupId ? channelEntries[params.groupId] : undefined) ?? - (channelSlug - ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) - : undefined) ?? - (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined); + const entry = resolveDiscordChannelEntry(channelEntries, params); if (entry && typeof entry.requireMention === "boolean") { return entry.requireMention; } @@ -306,14 +317,7 @@ export function resolveDiscordGroupToolPolicy( ); const channelEntries = guildEntry?.channels; if (channelEntries && Object.keys(channelEntries).length > 0) { - const groupChannel = params.groupChannel; - const channelSlug = normalizeDiscordSlug(groupChannel); - const entry = - (params.groupId ? channelEntries[params.groupId] : undefined) ?? - (channelSlug - ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) - : undefined) ?? - (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined); + const entry = resolveDiscordChannelEntry(channelEntries, params); const senderPolicy = resolveToolsBySender({ toolsBySender: entry?.toolsBySender, senderId: params.senderId, diff --git a/src/channels/plugins/index.test.ts b/src/channels/plugins/index.test.ts deleted file mode 100644 index 63162f09018..00000000000 --- a/src/channels/plugins/index.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ChannelPlugin } from "./types.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { listChannelPlugins } from "./index.js"; - -describe("channel plugin registry", () => { - const emptyRegistry = createTestRegistry([]); - - const createPlugin = (id: string): ChannelPlugin => ({ - id, - meta: { - id, - label: id, - selectionLabel: id, - docsPath: `/channels/${id}`, - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - }); - - beforeEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - afterEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - it("sorts channel plugins by configured order", () => { - const registry = createTestRegistry( - ["slack", "telegram", "signal"].map((id) => ({ - pluginId: id, - plugin: createPlugin(id), - source: "test", - })), - ); - setActivePluginRegistry(registry); - const pluginIds = listChannelPlugins().map((plugin) => plugin.id); - expect(pluginIds).toEqual(["telegram", "slack", "signal"]); - }); -}); diff --git a/src/channels/plugins/load.test.ts b/src/channels/plugins/load.test.ts deleted file mode 100644 index f3daf0543c7..00000000000 --- a/src/channels/plugins/load.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { PluginRegistry } from "../../plugins/registry.js"; -import type { ChannelOutboundAdapter, ChannelPlugin } from "./types.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { loadChannelPlugin } from "./load.js"; -import { loadChannelOutboundAdapter } from "./outbound/load.js"; - -const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ - plugins: [], - tools: [], - channels, - providers: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - diagnostics: [], -}); - -const emptyRegistry = createRegistry([]); - -const msteamsOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - sendText: async () => ({ channel: "msteams", messageId: "m1" }), - sendMedia: async () => ({ channel: "msteams", messageId: "m2" }), -}; - -const msteamsPlugin: ChannelPlugin = { - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - blurb: "Bot Framework; enterprise support.", - aliases: ["teams"], - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - outbound: msteamsOutbound, -}; - -const registryWithMSTeams = createRegistry([ - { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, -]); - -describe("channel plugin loader", () => { - beforeEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - afterEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - it("loads channel plugins from the active registry", async () => { - setActivePluginRegistry(registryWithMSTeams); - const plugin = await loadChannelPlugin("msteams"); - expect(plugin).toBe(msteamsPlugin); - }); - - it("loads outbound adapters from registered plugins", async () => { - setActivePluginRegistry(registryWithMSTeams); - const outbound = await loadChannelOutboundAdapter("msteams"); - expect(outbound).toBe(msteamsOutbound); - }); -}); diff --git a/src/channels/plugins/normalize/imessage.test.ts b/src/channels/plugins/normalize/imessage.test.ts deleted file mode 100644 index a3cbf0501eb..00000000000 --- a/src/channels/plugins/normalize/imessage.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeIMessageMessagingTarget } from "./imessage.js"; - -describe("imessage target normalization", () => { - it("preserves service prefixes for handles", () => { - expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333"); - }); - - it("drops service prefixes for chat targets", () => { - expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123"); - expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc"); - expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chat_identifier:foo"); - }); -}); diff --git a/src/channels/plugins/normalize/signal.test.ts b/src/channels/plugins/normalize/signal.test.ts deleted file mode 100644 index 547a8f30d91..00000000000 --- a/src/channels/plugins/normalize/signal.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js"; - -describe("signal target normalization", () => { - it("normalizes uuid targets by stripping uuid:", () => { - expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("normalizes signal:uuid targets", () => { - expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("preserves case for group targets", () => { - expect( - normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="), - ).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); - }); - - it("accepts uuid prefixes for target detection", () => { - expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); - expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); - }); - - it("accepts compact UUIDs for target detection", () => { - expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); - expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); - }); - - it("rejects invalid uuid prefixes", () => { - expect(looksLikeSignalTargetId("uuid:")).toBe(false); - expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false); - }); -}); diff --git a/src/channels/plugins/onboarding/signal.test.ts b/src/channels/plugins/onboarding/signal.test.ts deleted file mode 100644 index 23f218bd4c4..00000000000 --- a/src/channels/plugins/onboarding/signal.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeSignalAccountInput } from "./signal.js"; - -describe("normalizeSignalAccountInput", () => { - it("accepts already normalized numbers", () => { - expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123"); - }); - - it("normalizes formatted input", () => { - expect(normalizeSignalAccountInput(" +1 (555) 000-1234 ")).toBe("+15550001234"); - }); - - it("rejects empty input", () => { - expect(normalizeSignalAccountInput(" ")).toBeNull(); - }); - - it("rejects non-numeric input", () => { - expect(normalizeSignalAccountInput("ok")).toBeNull(); - expect(normalizeSignalAccountInput("++--")).toBeNull(); - }); - - it("rejects inputs with stray + characters", () => { - expect(normalizeSignalAccountInput("++12345")).toBeNull(); - expect(normalizeSignalAccountInput("+1+2345")).toBeNull(); - }); - - it("rejects numbers that are too short or too long", () => { - expect(normalizeSignalAccountInput("+1234")).toBeNull(); - expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull(); - }); -}); diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts index daacc66fede..f1aa29b7c11 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/src/channels/plugins/onboarding/slack.ts @@ -124,6 +124,25 @@ async function noteSlackTokenHelp(prompter: WizardPrompter, botName: string): Pr ); } +async function promptSlackTokens(prompter: WizardPrompter): Promise<{ + botToken: string; + appToken: string; +}> { + const botToken = String( + await prompter.text({ + message: "Enter Slack bot token (xoxb-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const appToken = String( + await prompter.text({ + message: "Enter Slack app token (xapp-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { botToken, appToken }; +} + function patchSlackConfigForAccount( cfg: OpenClawConfig, accountId: string, @@ -370,18 +389,7 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } else { - botToken = String( - await prompter.text({ - message: "Enter Slack bot token (xoxb-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appToken = String( - await prompter.text({ - message: "Enter Slack app token (xapp-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + ({ botToken, appToken } = await promptSlackTokens(prompter)); } } else if (hasConfigTokens) { const keep = await prompter.confirm({ @@ -389,32 +397,10 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keep) { - botToken = String( - await prompter.text({ - message: "Enter Slack bot token (xoxb-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appToken = String( - await prompter.text({ - message: "Enter Slack app token (xapp-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + ({ botToken, appToken } = await promptSlackTokens(prompter)); } } else { - botToken = String( - await prompter.text({ - message: "Enter Slack bot token (xoxb-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appToken = String( - await prompter.text({ - message: "Enter Slack app token (xapp-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + ({ botToken, appToken } = await promptSlackTokens(prompter)); } if (botToken && appToken) { diff --git a/src/channels/plugins/onboarding/whatsapp.ts b/src/channels/plugins/onboarding/whatsapp.ts index 3a140ee49d7..e640c8a3989 100644 --- a/src/channels/plugins/onboarding/whatsapp.ts +++ b/src/channels/plugins/onboarding/whatsapp.ts @@ -79,6 +79,27 @@ async function promptWhatsAppOwnerAllowFrom(params: { return { normalized, allowFrom }; } +async function applyWhatsAppOwnerAllowlist(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + existingAllowFrom: string[]; + title: string; + messageLines: string[]; +}): Promise { + const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ + prompter: params.prompter, + existingAllowFrom: params.existingAllowFrom, + }); + let next = setWhatsAppSelfChatMode(params.cfg, true); + next = setWhatsAppDmPolicy(next, "allowlist"); + next = setWhatsAppAllowFrom(next, allowFrom); + await params.prompter.note( + [...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"), + params.title, + ); + return next; +} + async function promptWhatsAppAllowFrom( cfg: OpenClawConfig, _runtime: RuntimeEnv, @@ -90,18 +111,13 @@ async function promptWhatsAppAllowFrom( const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; if (options?.forceAllowlist) { - const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ + return await applyWhatsAppOwnerAllowlist({ + cfg, prompter, existingAllowFrom, + title: "WhatsApp allowlist", + messageLines: ["Allowlist mode enabled."], }); - let next = setWhatsAppSelfChatMode(cfg, true); - next = setWhatsAppDmPolicy(next, "allowlist"); - next = setWhatsAppAllowFrom(next, allowFrom); - await prompter.note( - ["Allowlist mode enabled.", `- allowFrom includes ${normalized}`].join("\n"), - "WhatsApp allowlist", - ); - return next; } await prompter.note( @@ -127,22 +143,16 @@ async function promptWhatsAppAllowFrom( }); if (phoneMode === "personal") { - const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ + return await applyWhatsAppOwnerAllowlist({ + cfg, prompter, existingAllowFrom, - }); - let next = setWhatsAppSelfChatMode(cfg, true); - next = setWhatsAppDmPolicy(next, "allowlist"); - next = setWhatsAppAllowFrom(next, allowFrom); - await prompter.note( - [ + title: "WhatsApp personal phone", + messageLines: [ "Personal phone mode enabled.", "- dmPolicy set to allowlist (pairing skipped)", - `- allowFrom includes ${normalized}`, - ].join("\n"), - "WhatsApp personal phone", - ); - return next; + ], + }); } const policy = (await prompter.select({ diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts index 8aab8c5f916..13d79849f12 100644 --- a/src/channels/plugins/outbound/imessage.ts +++ b/src/channels/plugins/outbound/imessage.ts @@ -3,6 +3,19 @@ import { chunkText } from "../../../auto-reply/chunk.js"; import { sendMessageIMessage } from "../../../imessage/send.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; +function resolveIMessageMaxBytes(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; +}) { + return resolveChannelMediaMaxBytes({ + cfg: params.cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.channels?.imessage?.mediaMaxMb, + accountId: params.accountId, + }); +} + export const imessageOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkText, @@ -10,13 +23,7 @@ export const imessageOutbound: ChannelOutboundAdapter = { textChunkLimit: 4000, sendText: async ({ cfg, to, text, accountId, deps }) => { const send = deps?.sendIMessage ?? sendMessageIMessage; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.imessage?.mediaMaxMb, - accountId, - }); + const maxBytes = resolveIMessageMaxBytes({ cfg, accountId }); const result = await send(to, text, { maxBytes, accountId: accountId ?? undefined, @@ -25,13 +32,7 @@ export const imessageOutbound: ChannelOutboundAdapter = { }, sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { const send = deps?.sendIMessage ?? sendMessageIMessage; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.imessage?.mediaMaxMb, - accountId, - }); + const maxBytes = resolveIMessageMaxBytes({ cfg, accountId }); const result = await send(to, text, { mediaUrl, maxBytes, diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index 45544b417da..b96615b1486 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -3,6 +3,18 @@ import { chunkText } from "../../../auto-reply/chunk.js"; import { sendMessageSignal } from "../../../signal/send.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; +function resolveSignalMaxBytes(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; +}) { + return resolveChannelMediaMaxBytes({ + cfg: params.cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb, + accountId: params.accountId, + }); +} + export const signalOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkText, @@ -10,12 +22,7 @@ export const signalOutbound: ChannelOutboundAdapter = { textChunkLimit: 4000, sendText: async ({ cfg, to, text, accountId, deps }) => { const send = deps?.sendSignal ?? sendMessageSignal; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb, - accountId, - }); + const maxBytes = resolveSignalMaxBytes({ cfg, accountId }); const result = await send(to, text, { maxBytes, accountId: accountId ?? undefined, @@ -24,12 +31,7 @@ export const signalOutbound: ChannelOutboundAdapter = { }, sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { const send = deps?.sendSignal ?? sendMessageSignal; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb, - accountId, - }); + const maxBytes = resolveSignalMaxBytes({ cfg, accountId }); const result = await send(to, text, { mediaUrl, maxBytes, diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index d7c05ea8d7f..b5bd4273fbd 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -46,36 +46,63 @@ async function applySlackMessageSendingHooks(params: { return { cancelled: false, text: hookResult?.content ?? params.text }; } +async function sendSlackOutboundMessage(params: { + to: string; + text: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + accountId?: string | null; + deps?: { sendSlack?: typeof sendMessageSlack } | null; + replyToId?: string | null; + threadId?: string | number | null; + identity?: OutboundIdentity; +}) { + const send = params.deps?.sendSlack ?? sendMessageSlack; + // Use threadId fallback so routed tool notifications stay in the Slack thread. + const threadTs = + params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined); + const hookResult = await applySlackMessageSendingHooks({ + to: params.to, + text: params.text, + threadTs, + mediaUrl: params.mediaUrl, + accountId: params.accountId ?? undefined, + }); + if (hookResult.cancelled) { + return { + channel: "slack" as const, + messageId: "cancelled-by-hook", + channelId: params.to, + meta: { cancelled: true }, + }; + } + + const slackIdentity = resolveSlackSendIdentity(params.identity); + const result = await send(params.to, hookResult.text, { + threadTs, + accountId: params.accountId ?? undefined, + ...(params.mediaUrl + ? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots } + : {}), + ...(slackIdentity ? { identity: slackIdentity } : {}), + }); + return { channel: "slack" as const, ...result }; +} + export const slackOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity }) => { - const send = deps?.sendSlack ?? sendMessageSlack; - // Use threadId fallback so routed tool notifications stay in the Slack thread. - const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined); - const hookResult = await applySlackMessageSendingHooks({ + return await sendSlackOutboundMessage({ to, text, - threadTs, - accountId: accountId ?? undefined, + accountId, + deps, + replyToId, + threadId, + identity, }); - if (hookResult.cancelled) { - return { - channel: "slack", - messageId: "cancelled-by-hook", - channelId: to, - meta: { cancelled: true }, - }; - } - - const slackIdentity = resolveSlackSendIdentity(identity); - const result = await send(to, hookResult.text, { - threadTs, - accountId: accountId ?? undefined, - ...(slackIdentity ? { identity: slackIdentity } : {}), - }); - return { channel: "slack", ...result }; }, sendMedia: async ({ to, @@ -88,33 +115,16 @@ export const slackOutbound: ChannelOutboundAdapter = { threadId, identity, }) => { - const send = deps?.sendSlack ?? sendMessageSlack; - // Use threadId fallback so routed tool notifications stay in the Slack thread. - const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined); - const hookResult = await applySlackMessageSendingHooks({ + return await sendSlackOutboundMessage({ to, text, - threadTs, - mediaUrl, - accountId: accountId ?? undefined, - }); - if (hookResult.cancelled) { - return { - channel: "slack", - messageId: "cancelled-by-hook", - channelId: to, - meta: { cancelled: true }, - }; - } - - const slackIdentity = resolveSlackSendIdentity(identity); - const result = await send(to, hookResult.text, { mediaUrl, mediaLocalRoots, - threadTs, - accountId: accountId ?? undefined, - ...(slackIdentity ? { identity: slackIdentity } : {}), + accountId, + deps, + replyToId, + threadId, + identity, }); - return { channel: "slack", ...result }; }, }; diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts deleted file mode 100644 index 7981addf566..00000000000 --- a/src/channels/plugins/outbound/telegram.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { telegramOutbound } from "./telegram.js"; - -describe("telegramOutbound.sendPayload", () => { - it("sends text payload with buttons", async () => { - const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); - - const result = await telegramOutbound.sendPayload?.({ - cfg: {} as OpenClawConfig, - to: "telegram:123", - text: "ignored", - payload: { - text: "Hello", - channelData: { - telegram: { - buttons: [[{ text: "Option", callback_data: "/option" }]], - }, - }, - }, - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledTimes(1); - expect(sendTelegram).toHaveBeenCalledWith( - "telegram:123", - "Hello", - expect.objectContaining({ - buttons: [[{ text: "Option", callback_data: "/option" }]], - textMode: "html", - }), - ); - expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); - }); - - it("sends media payloads and attaches buttons only to first", async () => { - const sendTelegram = vi - .fn() - .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) - .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); - - const result = await telegramOutbound.sendPayload?.({ - cfg: {} as OpenClawConfig, - to: "telegram:123", - text: "ignored", - payload: { - text: "Caption", - mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], - channelData: { - telegram: { - buttons: [[{ text: "Go", callback_data: "/go" }]], - }, - }, - }, - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledTimes(2); - expect(sendTelegram).toHaveBeenNthCalledWith( - 1, - "telegram:123", - "Caption", - expect.objectContaining({ - mediaUrl: "https://example.com/a.png", - buttons: [[{ text: "Go", callback_data: "/go" }]], - }), - ); - const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; - expect(sendTelegram).toHaveBeenNthCalledWith( - 2, - "telegram:123", - "", - expect.objectContaining({ - mediaUrl: "https://example.com/b.png", - }), - ); - expect(secondOpts?.buttons).toBeUndefined(); - expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); - }); -}); diff --git a/src/channels/plugins/outbound/whatsapp.test.ts b/src/channels/plugins/outbound/whatsapp.test.ts deleted file mode 100644 index 7922ed00795..00000000000 --- a/src/channels/plugins/outbound/whatsapp.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { whatsappOutbound } from "./whatsapp.js"; - -describe("whatsappOutbound.resolveTarget", () => { - it("returns error when no target is provided even with allowFrom", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: undefined, - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: false, - error: expect.any(Error), - }); - }); - - it("returns error when implicit target is not in allowFrom", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: "+15550000000", - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: false, - error: expect.any(Error), - }); - }); - - it("keeps group JID targets even when allowFrom does not contain them", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: "120363401234567890@g.us", - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: true, - to: "120363401234567890@g.us", - }); - }); -}); diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts new file mode 100644 index 00000000000..91277158d2e --- /dev/null +++ b/src/channels/plugins/plugins-channel.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeIMessageMessagingTarget } from "./normalize/imessage.js"; +import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js"; +import { normalizeSignalAccountInput } from "./onboarding/signal.js"; +import { telegramOutbound } from "./outbound/telegram.js"; +import { whatsappOutbound } from "./outbound/whatsapp.js"; + +describe("imessage target normalization", () => { + it("preserves service prefixes for handles", () => { + expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333"); + }); + + it("drops service prefixes for chat targets", () => { + expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123"); + expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc"); + expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chat_identifier:foo"); + }); +}); + +describe("signal target normalization", () => { + it("normalizes uuid targets by stripping uuid:", () => { + expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("normalizes signal:uuid targets", () => { + expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("preserves case for group targets", () => { + expect( + normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="), + ).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); + }); + + it("accepts uuid prefixes for target detection", () => { + expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + }); + + it("accepts compact UUIDs for target detection", () => { + expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); + expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); + }); + + it("rejects invalid uuid prefixes", () => { + expect(looksLikeSignalTargetId("uuid:")).toBe(false); + expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false); + }); +}); + +describe("telegramOutbound.sendPayload", () => { + it("sends text payload with buttons", async () => { + const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as OpenClawConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Hello", + channelData: { + telegram: { + buttons: [[{ text: "Option", callback_data: "/option" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "telegram:123", + "Hello", + expect.objectContaining({ + buttons: [[{ text: "Option", callback_data: "/option" }]], + textMode: "html", + }), + ); + expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); + }); + + it("sends media payloads and attaches buttons only to first", async () => { + const sendTelegram = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) + .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as OpenClawConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Caption", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + channelData: { + telegram: { + buttons: [[{ text: "Go", callback_data: "/go" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(2); + expect(sendTelegram).toHaveBeenNthCalledWith( + 1, + "telegram:123", + "Caption", + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + buttons: [[{ text: "Go", callback_data: "/go" }]], + }), + ); + const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; + expect(sendTelegram).toHaveBeenNthCalledWith( + 2, + "telegram:123", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/b.png", + }), + ); + expect(secondOpts?.buttons).toBeUndefined(); + expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); + }); +}); + +describe("whatsappOutbound.resolveTarget", () => { + it("returns error when no target is provided even with allowFrom", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: undefined, + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: false, + error: expect.any(Error), + }); + }); + + it("returns error when implicit target is not in allowFrom", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: "+15550000000", + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: false, + error: expect.any(Error), + }); + }); + + it("keeps group JID targets even when allowFrom does not contain them", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: "120363401234567890@g.us", + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: true, + to: "120363401234567890@g.us", + }); + }); +}); + +describe("normalizeSignalAccountInput", () => { + it("accepts already normalized numbers", () => { + expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123"); + }); + + it("normalizes formatted input", () => { + expect(normalizeSignalAccountInput(" +1 (555) 000-1234 ")).toBe("+15550001234"); + }); + + it("rejects empty input", () => { + expect(normalizeSignalAccountInput(" ")).toBeNull(); + }); + + it("rejects non-numeric input", () => { + expect(normalizeSignalAccountInput("ok")).toBeNull(); + expect(normalizeSignalAccountInput("++--")).toBeNull(); + }); + + it("rejects inputs with stray + characters", () => { + expect(normalizeSignalAccountInput("++12345")).toBeNull(); + expect(normalizeSignalAccountInput("+1+2345")).toBeNull(); + }); + + it("rejects numbers that are too short or too long", () => { + expect(normalizeSignalAccountInput("+1234")).toBeNull(); + expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull(); + }); +}); diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts new file mode 100644 index 00000000000..64daeb574a2 --- /dev/null +++ b/src/channels/plugins/plugins-core.test.ts @@ -0,0 +1,395 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; +import type { DiscordProbe } from "../../discord/probe.js"; +import type { DiscordTokenResolution } from "../../discord/token.js"; +import type { IMessageProbe } from "../../imessage/probe.js"; +import type { LineProbeResult } from "../../line/types.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { SignalProbe } from "../../signal/probe.js"; +import type { SlackProbe } from "../../slack/probe.js"; +import type { TelegramProbe } from "../../telegram/probe.js"; +import type { TelegramTokenResolution } from "../../telegram/token.js"; +import type { ChannelOutboundAdapter, ChannelPlugin } from "./types.js"; +import type { BaseProbeResult, BaseTokenResolution } from "./types.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; +import { resolveChannelConfigWrites } from "./config-writes.js"; +import { + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./directory-config.js"; +import { listChannelPlugins } from "./index.js"; +import { loadChannelPlugin } from "./load.js"; +import { loadChannelOutboundAdapter } from "./outbound/load.js"; + +describe("channel plugin registry", () => { + const emptyRegistry = createTestRegistry([]); + + const createPlugin = (id: string): ChannelPlugin => ({ + id, + meta: { + id, + label: id, + selectionLabel: id, + docsPath: `/channels/${id}`, + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }); + + beforeEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + it("sorts channel plugins by configured order", () => { + const registry = createTestRegistry( + ["slack", "telegram", "signal"].map((id) => ({ + pluginId: id, + plugin: createPlugin(id), + source: "test", + })), + ); + setActivePluginRegistry(registry); + const pluginIds = listChannelPlugins().map((plugin) => plugin.id); + expect(pluginIds).toEqual(["telegram", "slack", "signal"]); + }); +}); + +describe("channel plugin catalog", () => { + it("includes Microsoft Teams", () => { + const entry = getChannelPluginCatalogEntry("msteams"); + expect(entry?.install.npmSpec).toBe("@openclaw/msteams"); + expect(entry?.meta.aliases).toContain("teams"); + }); + + it("lists plugin catalog entries", () => { + const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); + expect(ids).toContain("msteams"); + }); + + it("includes external catalog entries", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); + const catalogPath = path.join(dir, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [ + { + name: "@openclaw/demo-channel", + openclaw: { + channel: { + id: "demo-channel", + label: "Demo Channel", + selectionLabel: "Demo Channel", + docsPath: "/channels/demo-channel", + blurb: "Demo entry", + order: 999, + }, + install: { + npmSpec: "@openclaw/demo-channel", + }, + }, + }, + ], + }), + ); + + const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( + (entry) => entry.id, + ); + expect(ids).toContain("demo-channel"); + }); +}); + +const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ + plugins: [], + tools: [], + channels, + providers: [], + gatewayHandlers: {}, + httpHandlers: [], + httpRoutes: [], + cliRegistrars: [], + services: [], + diagnostics: [], +}); + +const emptyRegistry = createRegistry([]); + +const msteamsOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async () => ({ channel: "msteams", messageId: "m1" }), + sendMedia: async () => ({ channel: "msteams", messageId: "m2" }), +}; + +const msteamsPlugin: ChannelPlugin = { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams (Bot Framework)", + docsPath: "/channels/msteams", + blurb: "Bot Framework; enterprise support.", + aliases: ["teams"], + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + outbound: msteamsOutbound, +}; + +const registryWithMSTeams = createRegistry([ + { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, +]); + +describe("channel plugin loader", () => { + beforeEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + it("loads channel plugins from the active registry", async () => { + setActivePluginRegistry(registryWithMSTeams); + const plugin = await loadChannelPlugin("msteams"); + expect(plugin).toBe(msteamsPlugin); + }); + + it("loads outbound adapters from registered plugins", async () => { + setActivePluginRegistry(registryWithMSTeams); + const outbound = await loadChannelOutboundAdapter("msteams"); + expect(outbound).toBe(msteamsOutbound); + }); +}); + +describe("BaseProbeResult assignability", () => { + it("TelegramProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("DiscordProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("SlackProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("SignalProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("IMessageProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("LineProbeResult satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); +}); + +describe("BaseTokenResolution assignability", () => { + it("TelegramTokenResolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("DiscordTokenResolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); +}); + +describe("resolveChannelConfigWrites", () => { + it("defaults to allow when unset", () => { + const cfg = {}; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); + }); + + it("blocks when channel config disables writes", () => { + const cfg = { channels: { slack: { configWrites: false } } }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); + }); + + it("account override wins over channel default", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + work: { configWrites: false }, + }, + }, + }, + }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + }); + + it("matches account ids case-insensitively", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + Work: { configWrites: false }, + }, + }, + }, + }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + }); +}); + +describe("directory (config-backed)", () => { + it("lists Slack peers/groups from config", async () => { + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + dm: { allowFrom: ["U123", "user:U999"] }, + dms: { U234: {} }, + channels: { C111: { users: ["U777"] } }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listSlackDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual([ + "user:u123", + "user:u234", + "user:u777", + "user:u999", + ]); + + const groups = await listSlackDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]); + }); + + it("lists Discord peers/groups from config (numeric ids only)", async () => { + const cfg = { + channels: { + discord: { + token: "discord-test", + dm: { allowFrom: ["<@111>", "nope"] }, + dms: { "222": {} }, + guilds: { + "123": { + users: ["<@12345>", "not-an-id"], + channels: { + "555": {}, + "channel:666": {}, + general: {}, + }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listDiscordDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual(["user:111", "user:12345", "user:222"]); + + const groups = await listDiscordDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id).toSorted()).toEqual(["channel:555", "channel:666"]); + }); + + it("lists Telegram peers/groups from config", async () => { + const cfg = { + channels: { + telegram: { + botToken: "telegram-test", + allowFrom: ["123", "alice", "tg:@bob"], + dms: { "456": {} }, + groups: { "-1001": {}, "*": {} }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listTelegramDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual(["123", "456", "@alice", "@bob"]); + + const groups = await listTelegramDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["-1001"]); + }); + + it("lists WhatsApp peers/groups from config", async () => { + const cfg = { + channels: { + whatsapp: { + allowFrom: ["+15550000000", "*", "123@g.us"], + groups: { "999@g.us": { requireMention: true }, "*": {} }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listWhatsAppDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]); + + const groups = await listWhatsAppDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]); + }); +}); diff --git a/src/channels/plugins/slack.actions.test.ts b/src/channels/plugins/slack.actions.test.ts deleted file mode 100644 index 844da4f09ad..00000000000 --- a/src/channels/plugins/slack.actions.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createSlackActions } from "./slack.actions.js"; - -const handleSlackAction = vi.fn(async () => ({ details: { ok: true } })); - -vi.mock("../../agents/tools/slack-actions.js", () => ({ - handleSlackAction: (...args: unknown[]) => handleSlackAction(...args), -})); - -describe("slack actions adapter", () => { - beforeEach(() => { - handleSlackAction.mockClear(); - }); - - it("forwards threadId for read", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "read", - cfg, - params: { - channelId: "C1", - threadId: "171234.567", - }, - }); - - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ - action: "readMessages", - channelId: "C1", - threadId: "171234.567", - }); - }); - - it("forwards normalized limit for emoji-list", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "emoji-list", - cfg, - params: { - limit: "2.9", - }, - }); - - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ - action: "emojiList", - limit: 2, - }); - }); -}); diff --git a/src/channels/plugins/status-issues/discord.ts b/src/channels/plugins/status-issues/discord.ts index d3e6b795ca0..f3e8765093f 100644 --- a/src/channels/plugins/status-issues/discord.ts +++ b/src/channels/plugins/status-issues/discord.ts @@ -1,5 +1,10 @@ import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; -import { appendMatchMetadata, asString, isRecord } from "./shared.js"; +import { + appendMatchMetadata, + asString, + isRecord, + resolveEnabledConfiguredAccountId, +} from "./shared.js"; type DiscordIntentSummary = { messageContent?: "enabled" | "limited" | "disabled"; @@ -111,10 +116,8 @@ export function collectDiscordStatusIssues( if (!account) { continue; } - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - const configured = account.configured === true; - if (!enabled || !configured) { + const accountId = resolveEnabledConfiguredAccountId(account); + if (!accountId) { continue; } diff --git a/src/channels/plugins/status-issues/shared.ts b/src/channels/plugins/status-issues/shared.ts index da3606c2e9f..d4f5be878c1 100644 --- a/src/channels/plugins/status-issues/shared.ts +++ b/src/channels/plugins/status-issues/shared.ts @@ -30,3 +30,14 @@ export function appendMatchMetadata( const meta = formatMatchMetadata(params); return meta ? `${message} (${meta})` : message; } + +export function resolveEnabledConfiguredAccountId(account: { + accountId?: unknown; + enabled?: unknown; + configured?: unknown; +}): string | null { + const accountId = asString(account.accountId) ?? "default"; + const enabled = account.enabled !== false; + const configured = account.configured === true; + return enabled && configured ? accountId : null; +} diff --git a/src/channels/plugins/status-issues/telegram.ts b/src/channels/plugins/status-issues/telegram.ts index 8853bf4b1c8..97998eb4da4 100644 --- a/src/channels/plugins/status-issues/telegram.ts +++ b/src/channels/plugins/status-issues/telegram.ts @@ -1,5 +1,10 @@ import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; -import { appendMatchMetadata, asString, isRecord } from "./shared.js"; +import { + appendMatchMetadata, + asString, + isRecord, + resolveEnabledConfiguredAccountId, +} from "./shared.js"; type TelegramAccountStatus = { accountId?: unknown; @@ -81,10 +86,8 @@ export function collectTelegramStatusIssues( if (!account) { continue; } - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - const configured = account.configured === true; - if (!enabled || !configured) { + const accountId = resolveEnabledConfiguredAccountId(account); + if (!accountId) { continue; } diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index a2195597e0c..2178acd5eee 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -347,3 +347,15 @@ export type ChannelPollContext = { silent?: boolean; isAnonymous?: boolean; }; + +/** Minimal base for all channel probe results. Channel-specific probes extend this. */ +export type BaseProbeResult = { + ok: boolean; + error?: TError; +}; + +/** Minimal base for token resolution results. */ +export type BaseTokenResolution = { + token: string; + source: string; +}; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index d7175f1765b..d3028e9970d 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -58,6 +58,8 @@ export type { ChannelThreadingContext, ChannelThreadingToolContext, ChannelToolSend, + BaseProbeResult, + BaseTokenResolution, } from "./types.core.js"; export type { ChannelPlugin } from "./types.plugin.js"; diff --git a/src/channels/registry.test.ts b/src/channels/registry.test.ts deleted file mode 100644 index cee891be70c..00000000000 --- a/src/channels/registry.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - formatChannelSelectionLine, - listChatChannels, - normalizeChatChannelId, -} from "./registry.js"; - -describe("channel registry", () => { - it("normalizes aliases", () => { - expect(normalizeChatChannelId("imsg")).toBe("imessage"); - expect(normalizeChatChannelId("gchat")).toBe("googlechat"); - expect(normalizeChatChannelId("google-chat")).toBe("googlechat"); - expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc"); - expect(normalizeChatChannelId("web")).toBeNull(); - }); - - it("keeps Telegram first in the default order", () => { - const channels = listChatChannels(); - expect(channels[0]?.id).toBe("telegram"); - }); - - it("does not include MS Teams by default", () => { - const channels = listChatChannels(); - expect(channels.some((channel) => channel.id === "msteams")).toBe(false); - }); - - it("formats selection lines with docs labels", () => { - const channels = listChatChannels(); - const first = channels[0]; - if (!first) { - throw new Error("Missing channel metadata."); - } - const line = formatChannelSelectionLine(first, (path, label) => - [label, path].filter(Boolean).join(":"), - ); - expect(line).not.toContain("Docs:"); - expect(line).toContain("/channels/telegram"); - expect(line).toContain("https://openclaw.ai"); - }); -}); diff --git a/src/channels/sender-identity.test.ts b/src/channels/sender-identity.test.ts deleted file mode 100644 index 7c93821efbe..00000000000 --- a/src/channels/sender-identity.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { MsgContext } from "../auto-reply/templating.js"; -import { validateSenderIdentity } from "./sender-identity.js"; - -describe("validateSenderIdentity", () => { - it("allows direct messages without sender fields", () => { - const ctx: MsgContext = { ChatType: "direct" }; - expect(validateSenderIdentity(ctx)).toEqual([]); - }); - - it("requires some sender identity for non-direct chats", () => { - const ctx: MsgContext = { ChatType: "group" }; - expect(validateSenderIdentity(ctx)).toContain( - "missing sender identity (SenderId/SenderName/SenderUsername/SenderE164)", - ); - }); - - it("validates SenderE164 and SenderUsername shape", () => { - const ctx: MsgContext = { - ChatType: "group", - SenderE164: "123", - SenderUsername: "@ada lovelace", - }; - expect(validateSenderIdentity(ctx)).toEqual([ - "invalid SenderE164: 123", - 'SenderUsername should not include "@": @ada lovelace', - "SenderUsername should not include whitespace: @ada lovelace", - ]); - }); -}); diff --git a/src/channels/targets.test.ts b/src/channels/targets.test.ts deleted file mode 100644 index 256c60bc435..00000000000 --- a/src/channels/targets.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js"; - -describe("ensureTargetId", () => { - it("returns the candidate when it matches", () => { - expect( - ensureTargetId({ - candidate: "U123", - pattern: /^[A-Z0-9]+$/i, - errorMessage: "bad", - }), - ).toBe("U123"); - }); - - it("throws with the provided message on mismatch", () => { - expect(() => - ensureTargetId({ - candidate: "not-ok", - pattern: /^[A-Z0-9]+$/i, - errorMessage: "Bad target", - }), - ).toThrow(/Bad target/); - }); -}); - -describe("requireTargetKind", () => { - it("returns the target id when the kind matches", () => { - const target = buildMessagingTarget("channel", "C123", "C123"); - expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123"); - }); - - it("throws when the kind is missing or mismatched", () => { - expect(() => - requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" }), - ).toThrow(/Slack channel id is required/); - const target = buildMessagingTarget("user", "U123", "U123"); - expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow( - /Slack channel id is required/, - ); - }); -}); diff --git a/src/channels/telegram/allow-from.ts b/src/channels/telegram/allow-from.ts new file mode 100644 index 00000000000..d1e0209bc23 --- /dev/null +++ b/src/channels/telegram/allow-from.ts @@ -0,0 +1,11 @@ +export function normalizeTelegramAllowFromEntry(raw: unknown): string { + const base = typeof raw === "string" ? raw : typeof raw === "number" ? String(raw) : ""; + return base + .trim() + .replace(/^(telegram|tg):/i, "") + .trim(); +} + +export function isNumericTelegramUserId(raw: string): boolean { + return /^\d+$/.test(raw); +} diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts deleted file mode 100644 index 5df7e02aa0b..00000000000 --- a/src/channels/typing.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createTypingCallbacks } from "./typing.js"; - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -describe("createTypingCallbacks", () => { - it("invokes start on reply start", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, onStartError }); - - await callbacks.onReplyStart(); - - expect(start).toHaveBeenCalledTimes(1); - expect(onStartError).not.toHaveBeenCalled(); - }); - - it("reports start errors", async () => { - const start = vi.fn().mockRejectedValue(new Error("fail")); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, onStartError }); - - await callbacks.onReplyStart(); - - expect(onStartError).toHaveBeenCalledTimes(1); - }); - - it("invokes stop on idle and reports stop errors", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockRejectedValue(new Error("stop")); - const onStartError = vi.fn(); - const onStopError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError }); - - callbacks.onIdle?.(); - await flush(); - - expect(stop).toHaveBeenCalledTimes(1); - expect(onStopError).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/channels/web/index.test.ts b/src/channels/web/index.test.ts deleted file mode 100644 index 8f628495798..00000000000 --- a/src/channels/web/index.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import * as impl from "../../channel-web.js"; -import * as entry from "./index.js"; - -describe("channels/web entrypoint", () => { - it("re-exports web channel helpers", () => { - expect(entry.createWaSocket).toBe(impl.createWaSocket); - expect(entry.loginWeb).toBe(impl.loginWeb); - expect(entry.logWebSelfId).toBe(impl.logWebSelfId); - expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox); - expect(entry.monitorWebChannel).toBe(impl.monitorWebChannel); - expect(entry.pickWebChannel).toBe(impl.pickWebChannel); - expect(entry.sendMessageWhatsApp).toBe(impl.sendMessageWhatsApp); - expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR); - expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection); - expect(entry.webAuthExists).toBe(impl.webAuthExists); - }); -}); diff --git a/src/cli/browser-cli-actions-input/register.navigation.ts b/src/cli/browser-cli-actions-input/register.navigation.ts index ca632dbd528..393ffb7e56f 100644 --- a/src/cli/browser-cli-actions-input/register.navigation.ts +++ b/src/cli/browser-cli-actions-input/register.navigation.ts @@ -1,11 +1,8 @@ import type { Command } from "commander"; import { danger } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; -import { - callBrowserRequest, - callBrowserResize, - type BrowserParentOpts, -} from "../browser-cli-shared.js"; +import { runBrowserResizeWithOutput } from "../browser-cli-resize.js"; +import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js"; import { requireRef, resolveBrowserActionContext } from "./shared.js"; export function registerBrowserNavigationCommands( @@ -52,27 +49,16 @@ export function registerBrowserNavigationCommands( .option("--target-id ", "CDP target id (or unique prefix)") .action(async (width: number, height: number, opts, cmd) => { const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - defaultRuntime.error(danger("width and height must be numbers")); - defaultRuntime.exit(1); - return; - } try { - const result = await callBrowserResize( + await runBrowserResizeWithOutput({ parent, - { - profile, - width, - height, - targetId: opts.targetId, - }, - { timeoutMs: 20000 }, - ); - if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } - defaultRuntime.log(`resized to ${width}x${height}`); + profile, + width, + height, + targetId: opts.targetId, + timeoutMs: 20000, + successMessage: `resized to ${width}x${height}`, + }); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); diff --git a/src/cli/browser-cli-extension.test.ts b/src/cli/browser-cli-extension.test.ts index 19b994d56c9..be5957f3141 100644 --- a/src/cli/browser-cli-extension.test.ts +++ b/src/cli/browser-cli-extension.test.ts @@ -1,7 +1,5 @@ -import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const copyToClipboard = vi.fn(); const runtime = { @@ -10,6 +8,91 @@ const runtime = { exit: vi.fn(), }; +type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; + +const state = vi.hoisted(() => ({ + entries: new Map(), + counter: 0, +})); + +const abs = (p: string) => path.resolve(p); + +function setFile(p: string, content = "") { + const resolved = abs(p); + state.entries.set(resolved, { kind: "file", content }); + setDir(path.dirname(resolved)); +} + +function setDir(p: string) { + const resolved = abs(p); + if (!state.entries.has(resolved)) { + state.entries.set(resolved, { kind: "dir" }); + } +} + +function copyTree(src: string, dest: string) { + const srcAbs = abs(src); + const destAbs = abs(dest); + const srcPrefix = `${srcAbs}${path.sep}`; + for (const [key, entry] of state.entries.entries()) { + if (key === srcAbs || key.startsWith(srcPrefix)) { + const rel = key === srcAbs ? "" : key.slice(srcPrefix.length); + const next = rel ? path.join(destAbs, rel) : destAbs; + state.entries.set(next, entry); + } + } +} + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const pathMod = await import("node:path"); + const absInMock = (p: string) => pathMod.resolve(p); + + const wrapped = { + ...actual, + existsSync: (p: string) => state.entries.has(absInMock(p)), + mkdirSync: (p: string, _opts?: unknown) => { + setDir(p); + }, + writeFileSync: (p: string, content: string) => { + setFile(p, content); + }, + renameSync: (from: string, to: string) => { + const fromAbs = absInMock(from); + const toAbs = absInMock(to); + const entry = state.entries.get(fromAbs); + if (!entry) { + throw new Error(`ENOENT: no such file or directory, rename '${from}' -> '${to}'`); + } + state.entries.delete(fromAbs); + state.entries.set(toAbs, entry); + }, + rmSync: (p: string) => { + const root = absInMock(p); + const prefix = `${root}${pathMod.sep}`; + const keys = Array.from(state.entries.keys()); + for (const key of keys) { + if (key === root || key.startsWith(prefix)) { + state.entries.delete(key); + } + } + }, + mkdtempSync: (prefix: string) => { + const dir = `${prefix}${state.counter++}`; + setDir(dir); + return dir; + }, + promises: { + ...actual.promises, + cp: async (src: string, dest: string, _opts?: unknown) => { + copyTree(src, dest); + }, + }, + }; + + return { ...wrapped, default: wrapped }; +}); + vi.mock("../infra/clipboard.js", () => ({ copyToClipboard, })); @@ -18,86 +101,83 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: runtime, })); +let resolveBundledExtensionRootDir: typeof import("./browser-cli-extension.js").resolveBundledExtensionRootDir; +let installChromeExtension: typeof import("./browser-cli-extension.js").installChromeExtension; +let registerBrowserExtensionCommands: typeof import("./browser-cli-extension.js").registerBrowserExtensionCommands; + +beforeAll(async () => { + ({ resolveBundledExtensionRootDir, installChromeExtension, registerBrowserExtensionCommands } = + await import("./browser-cli-extension.js")); +}); + +beforeEach(() => { + state.entries.clear(); + state.counter = 0; + copyToClipboard.mockReset(); + runtime.log.mockReset(); + runtime.error.mockReset(); + runtime.exit.mockReset(); + vi.clearAllMocks(); +}); + function writeManifest(dir: string) { - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify({ manifest_version: 3 })); + setDir(dir); + setFile(path.join(dir, "manifest.json"), JSON.stringify({ manifest_version: 3 })); } -describe("bundled extension resolver", () => { - it("walks up to find the assets directory", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-root-")); +describe("bundled extension resolver (fs-mocked)", () => { + it("walks up to find the assets directory", () => { + const root = abs("/tmp/openclaw-ext-root"); const here = path.join(root, "dist", "cli"); const assets = path.join(root, "assets", "chrome-extension"); - try { - writeManifest(assets); - fs.mkdirSync(here, { recursive: true }); + writeManifest(assets); + setDir(here); - const { resolveBundledExtensionRootDir } = await import("./browser-cli-extension.js"); - expect(resolveBundledExtensionRootDir(here)).toBe(assets); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } + expect(resolveBundledExtensionRootDir(here)).toBe(assets); }); - it("prefers the nearest assets directory", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-root-")); + it("prefers the nearest assets directory", () => { + const root = abs("/tmp/openclaw-ext-root-nearest"); const here = path.join(root, "dist", "cli"); const distAssets = path.join(root, "dist", "assets", "chrome-extension"); const rootAssets = path.join(root, "assets", "chrome-extension"); - try { - writeManifest(distAssets); - writeManifest(rootAssets); - fs.mkdirSync(here, { recursive: true }); + writeManifest(distAssets); + writeManifest(rootAssets); + setDir(here); - const { resolveBundledExtensionRootDir } = await import("./browser-cli-extension.js"); - expect(resolveBundledExtensionRootDir(here)).toBe(distAssets); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } + expect(resolveBundledExtensionRootDir(here)).toBe(distAssets); }); }); -describe("browser extension install", () => { +describe("browser extension install (fs-mocked)", () => { it("installs into the state dir (never node_modules)", async () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-")); + const tmp = abs("/tmp/openclaw-ext-install"); + const sourceDir = path.join(tmp, "source-ext"); + writeManifest(sourceDir); + setFile(path.join(sourceDir, "test.txt"), "ok"); - try { - const { installChromeExtension } = await import("./browser-cli-extension.js"); - // Keep this test hermetic + fast: use a tiny fixture instead of copying the - // full repo assets tree. - const sourceDir = path.join(tmp, "source-ext"); - writeManifest(sourceDir); - fs.writeFileSync(path.join(sourceDir, "test.txt"), "ok"); - const result = await installChromeExtension({ stateDir: tmp, sourceDir }); + const result = await installChromeExtension({ stateDir: tmp, sourceDir }); - expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension")); - expect(fs.existsSync(path.join(result.path, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(result.path, "test.txt"))).toBe(true); - expect(result.path.includes("node_modules")).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension")); + expect(state.entries.has(abs(path.join(result.path, "manifest.json")))).toBe(true); + expect(state.entries.has(abs(path.join(result.path, "test.txt")))).toBe(true); + expect(result.path.includes("node_modules")).toBe(false); }); it("copies extension path to clipboard", async () => { const prev = process.env.OPENCLAW_STATE_DIR; - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-path-")); + const tmp = abs("/tmp/openclaw-ext-path"); process.env.OPENCLAW_STATE_DIR = tmp; try { - copyToClipboard.mockReset(); copyToClipboard.mockResolvedValue(true); - runtime.log.mockReset(); - runtime.error.mockReset(); - runtime.exit.mockReset(); const dir = path.join(tmp, "browser", "chrome-extension"); writeManifest(dir); const { Command } = await import("commander"); - const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js"); const program = new Command(); const browser = program.command("browser").option("--json", false); @@ -107,7 +187,6 @@ describe("browser extension install", () => { ); await program.parseAsync(["browser", "extension", "path"], { from: "user" }); - expect(copyToClipboard).toHaveBeenCalledWith(dir); } finally { if (prev === undefined) { diff --git a/src/cli/browser-cli-resize.ts b/src/cli/browser-cli-resize.ts new file mode 100644 index 00000000000..1ba31cb29f2 --- /dev/null +++ b/src/cli/browser-cli-resize.ts @@ -0,0 +1,37 @@ +import { danger } from "../globals.js"; +import { defaultRuntime } from "../runtime.js"; +import { callBrowserResize, type BrowserParentOpts } from "./browser-cli-shared.js"; + +export async function runBrowserResizeWithOutput(params: { + parent: BrowserParentOpts; + profile?: string; + width: number; + height: number; + targetId?: string; + timeoutMs?: number; + successMessage: string; +}): Promise { + const { width, height } = params; + if (!Number.isFinite(width) || !Number.isFinite(height)) { + defaultRuntime.error(danger("width and height must be numbers")); + defaultRuntime.exit(1); + return; + } + + const result = await callBrowserResize( + params.parent, + { + profile: params.profile, + width, + height, + targetId: params.targetId, + }, + { timeoutMs: params.timeoutMs ?? 20000 }, + ); + + if (params.parent?.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + defaultRuntime.log(params.successMessage); +} diff --git a/src/cli/browser-cli-state.ts b/src/cli/browser-cli-state.ts index e2e19503ac1..ace701bd236 100644 --- a/src/cli/browser-cli-state.ts +++ b/src/cli/browser-cli-state.ts @@ -2,11 +2,8 @@ import type { Command } from "commander"; import { danger } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; import { parseBooleanValue } from "../utils/boolean.js"; -import { - callBrowserRequest, - callBrowserResize, - type BrowserParentOpts, -} from "./browser-cli-shared.js"; +import { runBrowserResizeWithOutput } from "./browser-cli-resize.js"; +import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js"; import { runCommandWithRuntime } from "./cli-utils.js"; @@ -39,27 +36,16 @@ export function registerBrowserStateCommands( .action(async (width: number, height: number, opts, cmd) => { const parent = parentOpts(cmd); const profile = parent?.browserProfile; - if (!Number.isFinite(width) || !Number.isFinite(height)) { - defaultRuntime.error(danger("width and height must be numbers")); - defaultRuntime.exit(1); - return; - } await runBrowserCommand(async () => { - const result = await callBrowserResize( + await runBrowserResizeWithOutput({ parent, - { - profile, - width, - height, - targetId: opts.targetId, - }, - { timeoutMs: 20000 }, - ); - if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } - defaultRuntime.log(`viewport set: ${width}x${height}`); + profile, + width, + height, + targetId: opts.targetId, + timeoutMs: 20000, + successMessage: `viewport set: ${width}x${height}`, + }); }); }); diff --git a/src/cli/cli-utils.test.ts b/src/cli/cli-utils.test.ts new file mode 100644 index 00000000000..5e8bfee99dd --- /dev/null +++ b/src/cli/cli-utils.test.ts @@ -0,0 +1,110 @@ +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; +import { parseCanvasSnapshotPayload } from "./nodes-canvas.js"; +import { parseByteSize } from "./parse-bytes.js"; +import { parseDurationMs } from "./parse-duration.js"; +import { shouldSkipRespawnForArgv } from "./respawn-policy.js"; +import { waitForever } from "./wait.js"; + +const { registerDnsCli } = await import("./dns-cli.js"); + +describe("waitForever", () => { + it("creates an unref'ed interval and returns a pending promise", () => { + const setIntervalSpy = vi.spyOn(global, "setInterval"); + const promise = waitForever(); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 1_000_000); + expect(promise).toBeInstanceOf(Promise); + setIntervalSpy.mockRestore(); + }); +}); + +describe("shouldSkipRespawnForArgv", () => { + it("skips respawn for help/version calls", () => { + expect(shouldSkipRespawnForArgv(["node", "openclaw", "--help"])).toBe(true); + expect(shouldSkipRespawnForArgv(["node", "openclaw", "-V"])).toBe(true); + }); + + it("keeps respawn path for normal commands", () => { + expect(shouldSkipRespawnForArgv(["node", "openclaw", "status"])).toBe(false); + }); +}); + +describe("nodes canvas helpers", () => { + it("parses canvas.snapshot payload", () => { + expect(parseCanvasSnapshotPayload({ format: "png", base64: "aGk=" })).toEqual({ + format: "png", + base64: "aGk=", + }); + }); + + it("rejects invalid canvas.snapshot payload", () => { + expect(() => parseCanvasSnapshotPayload({ format: "png" })).toThrow( + /invalid canvas\.snapshot payload/i, + ); + }); +}); + +describe("dns cli", () => { + it("prints setup info (no apply)", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const program = new Command(); + registerDnsCli(program); + await program.parseAsync(["dns", "setup", "--domain", "openclaw.internal"], { from: "user" }); + const output = log.mock.calls.map((call) => call.join(" ")).join("\\n"); + expect(output).toContain("DNS setup"); + expect(output).toContain("openclaw.internal"); + } finally { + log.mockRestore(); + } + }); +}); + +describe("parseByteSize", () => { + it("parses bytes with units", () => { + expect(parseByteSize("10kb")).toBe(10 * 1024); + expect(parseByteSize("1mb")).toBe(1024 * 1024); + expect(parseByteSize("2gb")).toBe(2 * 1024 * 1024 * 1024); + }); + + it("parses shorthand units", () => { + expect(parseByteSize("5k")).toBe(5 * 1024); + expect(parseByteSize("1m")).toBe(1024 * 1024); + }); + + it("uses default unit when omitted", () => { + expect(parseByteSize("123")).toBe(123); + }); + + it("rejects invalid values", () => { + expect(() => parseByteSize("")).toThrow(); + expect(() => parseByteSize("nope")).toThrow(); + expect(() => parseByteSize("-5kb")).toThrow(); + }); +}); + +describe("parseDurationMs", () => { + it("parses bare ms", () => { + expect(parseDurationMs("10000")).toBe(10_000); + }); + + it("parses seconds suffix", () => { + expect(parseDurationMs("10s")).toBe(10_000); + }); + + it("parses minutes suffix", () => { + expect(parseDurationMs("1m")).toBe(60_000); + }); + + it("parses hours suffix", () => { + expect(parseDurationMs("2h")).toBe(7_200_000); + }); + + it("parses days suffix", () => { + expect(parseDurationMs("2d")).toBe(172_800_000); + }); + + it("supports decimals", () => { + expect(parseDurationMs("0.5s")).toBe(500); + }); +}); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 112b4fa2743..c29462d4eca 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -16,7 +16,11 @@ import { resolveGatewayService } from "../../daemon/service.js"; import { resolveGatewayAuth } from "../../gateway/auth.js"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; -import { buildDaemonServiceSnapshot, createDaemonActionContext } from "./response.js"; +import { + buildDaemonServiceSnapshot, + createDaemonActionContext, + installDaemonServiceAndEmit, +} from "./response.js"; import { parsePort } from "./shared.js"; export async function runDaemonInstall(opts: DaemonInstallOptions) { @@ -154,29 +158,20 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { config: cfg, }); - try { - await service.install({ - env: process.env, - stdout, - programArguments, - workingDirectory, - environment, - }); - } catch (err) { - fail(`Gateway install failed: ${String(err)}`); - return; - } - - let installed = true; - try { - installed = await service.isLoaded({ env: process.env }); - } catch { - installed = true; - } - emit({ - ok: true, - result: "installed", - service: buildDaemonServiceSnapshot(service, installed), - warnings: warnings.length ? warnings : undefined, + await installDaemonServiceAndEmit({ + serviceNoun: "Gateway", + service, + warnings, + emit, + fail, + install: async () => { + await service.install({ + env: process.env, + stdout, + programArguments, + workingDirectory, + environment, + }); + }, }); } diff --git a/src/cli/daemon-cli/response.ts b/src/cli/daemon-cli/response.ts index 13dd4f2606d..7b6f6d2a07e 100644 --- a/src/cli/daemon-cli/response.ts +++ b/src/cli/daemon-cli/response.ts @@ -79,3 +79,32 @@ export function createDaemonActionContext(params: { action: DaemonAction; json: return { stdout, warnings, emit, fail }; } + +export async function installDaemonServiceAndEmit(params: { + serviceNoun: string; + service: GatewayService; + warnings: string[]; + emit: (payload: Omit) => void; + fail: (message: string, hints?: string[]) => void; + install: () => Promise; +}) { + try { + await params.install(); + } catch (err) { + params.fail(`${params.serviceNoun} install failed: ${String(err)}`); + return; + } + + let installed = true; + try { + installed = await params.service.isLoaded({ env: process.env }); + } catch { + installed = true; + } + params.emit({ + ok: true, + result: "installed", + service: buildDaemonServiceSnapshot(params.service, installed), + warnings: params.warnings.length ? params.warnings : undefined, + }); +} diff --git a/src/cli/daemon-cli/shared.ts b/src/cli/daemon-cli/shared.ts index 4c07458fb2d..ef37e855b24 100644 --- a/src/cli/daemon-cli/shared.ts +++ b/src/cli/daemon-cli/shared.ts @@ -7,12 +7,26 @@ import { resolveGatewayLogPaths } from "../../daemon/launchd.js"; import { formatRuntimeStatus } from "../../daemon/runtime-format.js"; import { pickPrimaryLanIPv4 } from "../../gateway/net.js"; import { getResolvedLoggerSettings } from "../../logging.js"; +import { colorize, isRich, theme } from "../../terminal/theme.js"; import { formatCliCommand } from "../command-format.js"; import { parsePort } from "../shared/parse-port.js"; export { formatRuntimeStatus }; export { parsePort }; +export function createCliStatusTextStyles() { + const rich = isRich(); + return { + rich, + label: (value: string) => colorize(rich, theme.muted, value), + accent: (value: string) => colorize(rich, theme.accent, value), + infoText: (value: string) => colorize(rich, theme.info, value), + okText: (value: string) => colorize(rich, theme.success, value), + warnText: (value: string) => colorize(rich, theme.warn, value), + errorText: (value: string) => colorize(rich, theme.error, value), + }; +} + export function parsePortFromArgs(programArguments: string[] | undefined): number | null { if (!programArguments?.length) { return null; diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 24249ab1dc1..bfa0cfa69c2 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -12,10 +12,11 @@ import { import { isWSLEnv } from "../../infra/wsl.js"; import { getResolvedLoggerSettings } from "../../logging.js"; import { defaultRuntime } from "../../runtime.js"; -import { colorize, isRich, theme } from "../../terminal/theme.js"; +import { colorize, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; import { formatCliCommand } from "../command-format.js"; import { + createCliStatusTextStyles, filterDaemonEnv, formatRuntimeStatus, renderRuntimeHints, @@ -53,13 +54,8 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) return; } - const rich = isRich(); - const label = (value: string) => colorize(rich, theme.muted, value); - const accent = (value: string) => colorize(rich, theme.accent, value); - const infoText = (value: string) => colorize(rich, theme.info, value); - const okText = (value: string) => colorize(rich, theme.success, value); - const warnText = (value: string) => colorize(rich, theme.warn, value); - const errorText = (value: string) => colorize(rich, theme.error, value); + const { rich, label, accent, infoText, okText, warnText, errorText } = + createCliStatusTextStyles(); const spacer = () => defaultRuntime.log(""); const { service, rpc, extraServices } = status; diff --git a/src/cli/dns-cli.test.ts b/src/cli/dns-cli.test.ts deleted file mode 100644 index 69d63dd28b1..00000000000 --- a/src/cli/dns-cli.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Command } from "commander"; -import { describe, expect, it, vi } from "vitest"; - -const { registerDnsCli } = await import("./dns-cli.js"); - -describe("dns cli", () => { - it("prints setup info (no apply)", async () => { - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - try { - const program = new Command(); - registerDnsCli(program); - await program.parseAsync(["dns", "setup", "--domain", "openclaw.internal"], { from: "user" }); - const output = log.mock.calls.map((call) => call.join(" ")).join("\n"); - expect(output).toContain("DNS setup"); - expect(output).toContain("openclaw.internal"); - } finally { - log.mockRestore(); - } - }); -}); diff --git a/src/cli/gateway-cli/discover.test.ts b/src/cli/gateway-cli/discover.test.ts deleted file mode 100644 index bda8b70920b..00000000000 --- a/src/cli/gateway-cli/discover.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { GatewayBonjourBeacon } from "../../infra/bonjour-discovery.js"; -import { pickBeaconHost, pickGatewayPort } from "./discover.js"; - -describe("gateway discover routing helpers", () => { - it("prefers resolved service host over TXT hints", () => { - const beacon: GatewayBonjourBeacon = { - instanceName: "Test", - host: "10.0.0.2", - lanHost: "evil.example.com", - tailnetDns: "evil.example.com", - }; - expect(pickBeaconHost(beacon)).toBe("10.0.0.2"); - }); - - it("prefers resolved service port over TXT gatewayPort", () => { - const beacon: GatewayBonjourBeacon = { - instanceName: "Test", - host: "10.0.0.2", - port: 18789, - gatewayPort: 12345, - }; - expect(pickGatewayPort(beacon)).toBe(18789); - }); - - it("falls back to TXT host/port when resolve data is missing", () => { - const beacon: GatewayBonjourBeacon = { - instanceName: "Test", - lanHost: "test-host.local", - gatewayPort: 18789, - }; - expect(pickBeaconHost(beacon)).toBe("test-host.local"); - expect(pickGatewayPort(beacon)).toBe(18789); - }); -}); diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 5bd0f73e480..3bea291e24f 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import type { GatewayBonjourBeacon } from "../../infra/bonjour-discovery.js"; +import { pickBeaconHost, pickGatewayPort } from "./discover.js"; const acquireGatewayLock = vi.fn(async () => ({ release: vi.fn(async () => {}), @@ -64,11 +66,27 @@ describe("runGatewayLoop", () => { const closeFirst = vi.fn(async () => {}); const closeSecond = vi.fn(async () => {}); - const start = vi - .fn() - .mockResolvedValueOnce({ close: closeFirst }) - .mockResolvedValueOnce({ close: closeSecond }) - .mockRejectedValueOnce(new Error("stop-loop")); + + const start = vi.fn(); + let resolveFirst: (() => void) | null = null; + const startedFirst = new Promise((resolve) => { + resolveFirst = resolve; + }); + start.mockImplementationOnce(async () => { + resolveFirst?.(); + return { close: closeFirst }; + }); + + let resolveSecond: (() => void) | null = null; + const startedSecond = new Promise((resolve) => { + resolveSecond = resolve; + }); + start.mockImplementationOnce(async () => { + resolveSecond?.(); + return { close: closeSecond }; + }); + + start.mockRejectedValueOnce(new Error("stop-loop")); const beforeSigterm = new Set( process.listeners("SIGTERM") as Array<(...args: unknown[]) => void>, @@ -80,25 +98,24 @@ describe("runGatewayLoop", () => { process.listeners("SIGUSR1") as Array<(...args: unknown[]) => void>, ); - const loopPromise = import("./run-loop.js").then(({ runGatewayLoop }) => - runGatewayLoop({ - start, - runtime: { - exit: vi.fn(), - } as { exit: (code: number) => never }, - }), - ); + const { runGatewayLoop } = await import("./run-loop.js"); + const loopPromise = runGatewayLoop({ + start, + runtime: { + exit: vi.fn(), + } as { exit: (code: number) => never }, + }); try { - await vi.waitFor(() => { - expect(start).toHaveBeenCalledTimes(1); - }); + await startedFirst; + expect(start).toHaveBeenCalledTimes(1); + await new Promise((resolve) => setImmediate(resolve)); process.emit("SIGUSR1"); - await vi.waitFor(() => { - expect(start).toHaveBeenCalledTimes(2); - }); + await startedSecond; + expect(start).toHaveBeenCalledTimes(2); + await new Promise((resolve) => setImmediate(resolve)); expect(waitForActiveTasks).toHaveBeenCalledWith(30_000); expect(gatewayLog.warn).toHaveBeenCalledWith(DRAIN_TIMEOUT_LOG); @@ -125,3 +142,35 @@ describe("runGatewayLoop", () => { } }); }); + +describe("gateway discover routing helpers", () => { + it("prefers resolved service host over TXT hints", () => { + const beacon: GatewayBonjourBeacon = { + instanceName: "Test", + host: "10.0.0.2", + lanHost: "evil.example.com", + tailnetDns: "evil.example.com", + }; + expect(pickBeaconHost(beacon)).toBe("10.0.0.2"); + }); + + it("prefers resolved service port over TXT gatewayPort", () => { + const beacon: GatewayBonjourBeacon = { + instanceName: "Test", + host: "10.0.0.2", + port: 18789, + gatewayPort: 12345, + }; + expect(pickGatewayPort(beacon)).toBe(18789); + }); + + it("falls back to TXT host/port when resolve data is missing", () => { + const beacon: GatewayBonjourBeacon = { + instanceName: "Test", + lanHost: "test-host.local", + gatewayPort: 18789, + }; + expect(pickBeaconHost(beacon)).toBe("test-host.local"); + expect(pickGatewayPort(beacon)).toBe(18789); + }); +}); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 7f7b0e9eb0a..42bb391e223 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -66,6 +66,50 @@ function buildHooksReport(config: OpenClawConfig): HookStatusReport { return buildWorkspaceHookStatus(workspaceDir, { config, entries }); } +function resolveHookForToggle( + report: HookStatusReport, + hookName: string, + opts?: { requireEligible?: boolean }, +): HookStatusEntry { + const hook = report.hooks.find((h) => h.name === hookName); + if (!hook) { + throw new Error(`Hook "${hookName}" not found`); + } + if (hook.managedByPlugin) { + throw new Error( + `Hook "${hookName}" is managed by plugin "${hook.pluginId ?? "unknown"}" and cannot be enabled/disabled.`, + ); + } + if (opts?.requireEligible && !hook.eligible) { + throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`); + } + return hook; +} + +function buildConfigWithHookEnabled(params: { + config: OpenClawConfig; + hookName: string; + enabled: boolean; + ensureHooksEnabled?: boolean; +}): OpenClawConfig { + const entries = { ...params.config.hooks?.internal?.entries }; + entries[params.hookName] = { ...entries[params.hookName], enabled: params.enabled }; + + const internal = { + ...params.config.hooks?.internal, + ...(params.ensureHooksEnabled ? { enabled: true } : {}), + entries, + }; + + return { + ...params.config, + hooks: { + ...params.config.hooks, + internal, + }, + }; +} + function formatHookStatus(hook: HookStatusEntry): string { if (hook.eligible) { return theme.success("βœ“ ready"); @@ -384,38 +428,13 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio export async function enableHook(hookName: string): Promise { const config = loadConfig(); - const report = buildHooksReport(config); - const hook = report.hooks.find((h) => h.name === hookName); - - if (!hook) { - throw new Error(`Hook "${hookName}" not found`); - } - - if (hook.managedByPlugin) { - throw new Error( - `Hook "${hookName}" is managed by plugin "${hook.pluginId ?? "unknown"}" and cannot be enabled/disabled.`, - ); - } - - if (!hook.eligible) { - throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`); - } - - // Update config - const entries = { ...config.hooks?.internal?.entries }; - entries[hookName] = { ...entries[hookName], enabled: true }; - - const nextConfig = { - ...config, - hooks: { - ...config.hooks, - internal: { - ...config.hooks?.internal, - enabled: true, - entries, - }, - }, - }; + const hook = resolveHookForToggle(buildHooksReport(config), hookName, { requireEligible: true }); + const nextConfig = buildConfigWithHookEnabled({ + config, + hookName, + enabled: true, + ensureHooksEnabled: true, + }); await writeConfigFile(nextConfig); defaultRuntime.log( @@ -425,33 +444,8 @@ export async function enableHook(hookName: string): Promise { export async function disableHook(hookName: string): Promise { const config = loadConfig(); - const report = buildHooksReport(config); - const hook = report.hooks.find((h) => h.name === hookName); - - if (!hook) { - throw new Error(`Hook "${hookName}" not found`); - } - - if (hook.managedByPlugin) { - throw new Error( - `Hook "${hookName}" is managed by plugin "${hook.pluginId ?? "unknown"}" and cannot be enabled/disabled.`, - ); - } - - // Update config - const entries = { ...config.hooks?.internal?.entries }; - entries[hookName] = { ...entries[hookName], enabled: false }; - - const nextConfig = { - ...config, - hooks: { - ...config.hooks, - internal: { - ...config.hooks?.internal, - entries, - }, - }, - }; + const hook = resolveHookForToggle(buildHooksReport(config), hookName); + const nextConfig = buildConfigWithHookEnabled({ config, hookName, enabled: false }); await writeConfigFile(nextConfig); defaultRuntime.log( diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index 073bc03ddee..2fa8345814a 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { setTimeout as delay } from "node:timers/promises"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { parseLogLine } from "../logging/parse-log-line.js"; +import { formatLocalIsoWithOffset } from "../logging/timestamps.js"; import { formatDocsLink } from "../terminal/links.js"; import { clearActiveProgressLine } from "../terminal/progress-line.js"; import { createSafeStreamWriter } from "../terminal/stream-writer.js"; @@ -73,21 +74,6 @@ export function formatLogTimestamp( return value; } - const formatLocalIsoWithOffset = (now: Date) => { - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const h = String(now.getHours()).padStart(2, "0"); - const m = String(now.getMinutes()).padStart(2, "0"); - const s = String(now.getSeconds()).padStart(2, "0"); - const ms = String(now.getMilliseconds()).padStart(3, "0"); - const tzOffset = now.getTimezoneOffset(); - const tzSign = tzOffset <= 0 ? "+" : "-"; - const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, "0"); - const tzMinutes = String(Math.abs(tzOffset) % 60).padStart(2, "0"); - return `${year}-${month}-${day}T${h}:${m}:${s}.${ms}${tzSign}${tzHours}:${tzMinutes}`; - }; - let timeString: string; if (localTime) { timeString = formatLocalIsoWithOffset(parsed); diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 72aa073bd8c..fb34e421fda 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -99,6 +99,10 @@ describe("models cli", () => { it("shows help for models auth without error exit", async () => { const program = new Command(); program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); registerModelsCli(program); try { diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 2c2418fb246..9c53af76178 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -14,7 +14,7 @@ import { resolveGatewayLogPaths } from "../../daemon/launchd.js"; import { resolveNodeService } from "../../daemon/node-service.js"; import { loadNodeHostConfig } from "../../node-host/config.js"; import { defaultRuntime } from "../../runtime.js"; -import { colorize, isRich, theme } from "../../terminal/theme.js"; +import { colorize, theme } from "../../terminal/theme.js"; import { formatCliCommand } from "../command-format.js"; import { runServiceRestart, @@ -22,8 +22,12 @@ import { runServiceStop, runServiceUninstall, } from "../daemon-cli/lifecycle-core.js"; -import { buildDaemonServiceSnapshot, createDaemonActionContext } from "../daemon-cli/response.js"; -import { formatRuntimeStatus, parsePort } from "../daemon-cli/shared.js"; +import { + buildDaemonServiceSnapshot, + createDaemonActionContext, + installDaemonServiceAndEmit, +} from "../daemon-cli/response.js"; +import { createCliStatusTextStyles, formatRuntimeStatus, parsePort } from "../daemon-cli/shared.js"; type NodeDaemonInstallOptions = { host?: string; @@ -160,31 +164,22 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { }, }); - try { - await service.install({ - env: process.env, - stdout, - programArguments, - workingDirectory, - environment, - description, - }); - } catch (err) { - fail(`Node install failed: ${String(err)}`); - return; - } - - let installed = true; - try { - installed = await service.isLoaded({ env: process.env }); - } catch { - installed = true; - } - emit({ - ok: true, - result: "installed", - service: buildDaemonServiceSnapshot(service, installed), - warnings: warnings.length ? warnings : undefined, + await installDaemonServiceAndEmit({ + serviceNoun: "Node", + service, + warnings, + emit, + fail, + install: async () => { + await service.install({ + env: process.env, + stdout, + programArguments, + workingDirectory, + environment, + description, + }); + }, }); } @@ -248,13 +243,8 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) { return; } - const rich = isRich(); - const label = (value: string) => colorize(rich, theme.muted, value); - const accent = (value: string) => colorize(rich, theme.accent, value); - const infoText = (value: string) => colorize(rich, theme.info, value); - const okText = (value: string) => colorize(rich, theme.success, value); - const warnText = (value: string) => colorize(rich, theme.warn, value); - const errorText = (value: string) => colorize(rich, theme.error, value); + const { rich, label, accent, infoText, okText, warnText, errorText } = + createCliStatusTextStyles(); const serviceStatus = loaded ? okText(service.loadedText) : warnText(service.notLoadedText); defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`); diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts index 63e1b1a4da8..e4a05f66f30 100644 --- a/src/cli/nodes-camera.test.ts +++ b/src/cli/nodes-camera.test.ts @@ -9,6 +9,7 @@ import { writeBase64ToFile, writeUrlToFile, } from "./nodes-camera.js"; +import { parseScreenRecordPayload, screenRecordTempPath } from "./nodes-screen.js"; describe("nodes camera helpers", () => { it("parses camera.snap payload", () => { @@ -104,3 +105,52 @@ describe("nodes camera helpers", () => { ); }); }); + +describe("nodes screen helpers", () => { + it("parses screen.record payload", () => { + const payload = parseScreenRecordPayload({ + format: "mp4", + base64: "Zm9v", + durationMs: 1000, + fps: 12, + screenIndex: 0, + hasAudio: true, + }); + expect(payload.format).toBe("mp4"); + expect(payload.base64).toBe("Zm9v"); + expect(payload.durationMs).toBe(1000); + expect(payload.fps).toBe(12); + expect(payload.screenIndex).toBe(0); + expect(payload.hasAudio).toBe(true); + }); + + it("drops invalid optional fields instead of throwing", () => { + const payload = parseScreenRecordPayload({ + format: "mp4", + base64: "Zm9v", + durationMs: "nope", + fps: null, + screenIndex: "0", + hasAudio: 1, + }); + expect(payload.durationMs).toBeUndefined(); + expect(payload.fps).toBeUndefined(); + expect(payload.screenIndex).toBeUndefined(); + expect(payload.hasAudio).toBeUndefined(); + }); + + it("rejects invalid screen.record payload", () => { + expect(() => parseScreenRecordPayload({ format: "mp4" })).toThrow( + /invalid screen\.record payload/i, + ); + }); + + it("builds screen record temp path", () => { + const p = screenRecordTempPath({ + ext: "mp4", + tmpDir: "/tmp", + id: "id1", + }); + expect(p).toBe(path.join("/tmp", "openclaw-screen-record-id1.mp4")); + }); +}); diff --git a/src/cli/nodes-canvas.test.ts b/src/cli/nodes-canvas.test.ts deleted file mode 100644 index a3b7a394582..00000000000 --- a/src/cli/nodes-canvas.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseCanvasSnapshotPayload } from "./nodes-canvas.js"; - -describe("nodes canvas helpers", () => { - it("parses canvas.snapshot payload", () => { - expect(parseCanvasSnapshotPayload({ format: "png", base64: "aGk=" })).toEqual({ - format: "png", - base64: "aGk=", - }); - }); - - it("rejects invalid canvas.snapshot payload", () => { - expect(() => parseCanvasSnapshotPayload({ format: "png" })).toThrow( - /invalid canvas\.snapshot payload/i, - ); - }); -}); diff --git a/src/cli/nodes-cli/pairing-render.ts b/src/cli/nodes-cli/pairing-render.ts new file mode 100644 index 00000000000..a24561912ee --- /dev/null +++ b/src/cli/nodes-cli/pairing-render.ts @@ -0,0 +1,38 @@ +import type { PendingRequest } from "./types.js"; +import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; +import { renderTable } from "../../terminal/table.js"; + +export function renderPendingPairingRequestsTable(params: { + pending: PendingRequest[]; + now: number; + tableWidth: number; + theme: { + heading: (text: string) => string; + warn: (text: string) => string; + muted: (text: string) => string; + }; +}) { + const { pending, now, tableWidth, theme } = params; + const rows = pending.map((r) => ({ + Request: r.requestId, + Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, + IP: r.remoteIp ?? "", + Requested: + typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : theme.muted("unknown"), + Repair: r.isRepair ? theme.warn("yes") : "", + })); + return { + heading: theme.heading("Pending"), + table: renderTable({ + width: tableWidth, + columns: [ + { key: "Request", header: "Request", minWidth: 8 }, + { key: "Node", header: "Node", minWidth: 14, flex: true }, + { key: "IP", header: "IP", minWidth: 10 }, + { key: "Requested", header: "Requested", minWidth: 12 }, + { key: "Repair", header: "Repair", minWidth: 6 }, + ], + rows, + }).trimEnd(), + }; +} diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index 76f839f6a74..3039189f0ff 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; import type { NodesRpcOpts } from "./types.js"; -import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { renderTable } from "../../terminal/table.js"; import { shortenHomePath } from "../../utils.js"; @@ -14,7 +13,7 @@ import { } from "../nodes-camera.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; -import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; const parseFacing = (value: string): CameraFacing => { const v = String(value ?? "") @@ -37,12 +36,15 @@ export function registerNodesCameraCommands(nodes: Command) { .action(async (opts: NodesRpcOpts) => { await runNodesCommand("camera list", async () => { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); - const raw = await callGatewayCli("node.invoke", opts, { - nodeId, - command: "camera.list", - params: {}, - idempotencyKey: randomIdempotencyKey(), - }); + const raw = await callGatewayCli( + "node.invoke", + opts, + buildNodeInvokeParams({ + nodeId, + command: "camera.list", + params: {}, + }), + ); const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; const payload = @@ -130,7 +132,7 @@ export function registerNodesCameraCommands(nodes: Command) { }> = []; for (const facing of facings) { - const invokeParams: Record = { + const invokeParams = buildNodeInvokeParams({ nodeId, command: "camera.snap", params: { @@ -141,11 +143,8 @@ export function registerNodesCameraCommands(nodes: Command) { delayMs: Number.isFinite(delayMs) ? delayMs : undefined, deviceId: deviceId || undefined, }, - idempotencyKey: randomIdempotencyKey(), - }; - if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { - invokeParams.timeoutMs = timeoutMs; - } + timeoutMs, + }); const raw = await callGatewayCli("node.invoke", opts, invokeParams); const res = @@ -204,7 +203,7 @@ export function registerNodesCameraCommands(nodes: Command) { : undefined; const deviceId = opts.deviceId ? String(opts.deviceId).trim() : undefined; - const invokeParams: Record = { + const invokeParams = buildNodeInvokeParams({ nodeId, command: "camera.clip", params: { @@ -214,11 +213,8 @@ export function registerNodesCameraCommands(nodes: Command) { format: "mp4", deviceId: deviceId || undefined, }, - idempotencyKey: randomIdempotencyKey(), - }; - if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { - invokeParams.timeoutMs = timeoutMs; - } + timeoutMs, + }); const raw = await callGatewayCli("node.invoke", opts, invokeParams); const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; diff --git a/src/cli/nodes-cli/register.canvas.ts b/src/cli/nodes-cli/register.canvas.ts index 5883953eb47..d9877f8ad15 100644 --- a/src/cli/nodes-cli/register.canvas.ts +++ b/src/cli/nodes-cli/register.canvas.ts @@ -1,7 +1,6 @@ import type { Command } from "commander"; import fs from "node:fs/promises"; import type { NodesRpcOpts } from "./types.js"; -import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { shortenHomePath } from "../../utils.js"; import { writeBase64ToFile } from "../nodes-camera.js"; @@ -9,21 +8,21 @@ import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-can import { parseTimeoutMs } from "../nodes-run.js"; import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; -import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record) { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); - const invokeParams: Record = { - nodeId, - command, - params, - idempotencyKey: randomIdempotencyKey(), - }; const timeoutMs = parseTimeoutMs(opts.invokeTimeout); - if (typeof timeoutMs === "number") { - invokeParams.timeoutMs = timeoutMs; - } - return await callGatewayCli("node.invoke", opts, invokeParams); + return await callGatewayCli( + "node.invoke", + opts, + buildNodeInvokeParams({ + nodeId, + command, + params, + timeoutMs: typeof timeoutMs === "number" ? timeoutMs : undefined, + }), + ); } export function registerNodesCanvasCommands(nodes: Command) { @@ -42,7 +41,6 @@ export function registerNodesCanvasCommands(nodes: Command) { .option("--invoke-timeout ", "Node invoke timeout in ms (default 20000)", "20000") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("canvas snapshot", async () => { - const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const formatOpt = String(opts.format ?? "jpg") .trim() .toLowerCase(); @@ -54,25 +52,11 @@ export function registerNodesCanvasCommands(nodes: Command) { const maxWidth = opts.maxWidth ? Number.parseInt(String(opts.maxWidth), 10) : undefined; const quality = opts.quality ? Number.parseFloat(String(opts.quality)) : undefined; - const timeoutMs = opts.invokeTimeout - ? Number.parseInt(String(opts.invokeTimeout), 10) - : undefined; - - const invokeParams: Record = { - nodeId, - command: "canvas.snapshot", - params: { - format: formatForParams, - maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined, - quality: Number.isFinite(quality) ? quality : undefined, - }, - idempotencyKey: randomIdempotencyKey(), - }; - if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { - invokeParams.timeoutMs = timeoutMs; - } - - const raw = await callGatewayCli("node.invoke", opts, invokeParams); + const raw = await invokeCanvas(opts, "canvas.snapshot", { + format: formatForParams, + maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined, + quality: Number.isFinite(quality) ? quality : undefined, + }); const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; const payload = parseCanvasSnapshotPayload(res.payload); const filePath = canvasSnapshotTempPath({ diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index 9241aeff782..daab00bdf32 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -1,10 +1,9 @@ import type { Command } from "commander"; import type { NodesRpcOpts } from "./types.js"; -import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { parsePairingList } from "./format.js"; +import { renderPendingPairingRequestsTable } from "./pairing-render.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; export function registerNodesPairingCommands(nodes: Command) { @@ -28,28 +27,14 @@ export function registerNodesPairingCommands(nodes: Command) { const { heading, warn, muted } = getNodesTheme(); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const now = Date.now(); - const rows = pending.map((r) => ({ - Request: r.requestId, - Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, - IP: r.remoteIp ?? "", - Requested: - typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : muted("unknown"), - Repair: r.isRepair ? warn("yes") : "", - })); - defaultRuntime.log(heading("Pending")); - defaultRuntime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Request", header: "Request", minWidth: 8 }, - { key: "Node", header: "Node", minWidth: 14, flex: true }, - { key: "IP", header: "IP", minWidth: 10 }, - { key: "Requested", header: "Requested", minWidth: 12 }, - { key: "Repair", header: "Repair", minWidth: 6 }, - ], - rows, - }).trimEnd(), - ); + const rendered = renderPendingPairingRequestsTable({ + pending, + now, + tableWidth, + theme: { heading, warn, muted }, + }); + defaultRuntime.log(rendered.heading); + defaultRuntime.log(rendered.table); }); }), ); diff --git a/src/cli/nodes-cli/register.screen.ts b/src/cli/nodes-cli/register.screen.ts index 60ff4ec9716..e2034be1699 100644 --- a/src/cli/nodes-cli/register.screen.ts +++ b/src/cli/nodes-cli/register.screen.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; import type { NodesRpcOpts } from "./types.js"; -import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { shortenHomePath } from "../../utils.js"; import { @@ -10,7 +9,7 @@ import { } from "../nodes-screen.js"; import { parseDurationMs } from "../parse-duration.js"; import { runNodesCommand } from "./cli-utils.js"; -import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; export function registerNodesScreenCommands(nodes: Command) { const screen = nodes @@ -38,7 +37,7 @@ export function registerNodesScreenCommands(nodes: Command) { ? Number.parseInt(String(opts.invokeTimeout), 10) : undefined; - const invokeParams: Record = { + const invokeParams = buildNodeInvokeParams({ nodeId, command: "screen.record", params: { @@ -48,11 +47,8 @@ export function registerNodesScreenCommands(nodes: Command) { format: "mp4", includeAudio: opts.audio !== false, }, - idempotencyKey: randomIdempotencyKey(), - }; - if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { - invokeParams.timeoutMs = timeoutMs; - } + timeoutMs, + }); const raw = await callGatewayCli("node.invoke", opts, invokeParams); const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index e29b79d0699..414106f130b 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -7,6 +7,7 @@ import { shortenHomeInString } from "../../utils.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { formatPermissions, parseNodeList, parsePairingList } from "./format.js"; +import { renderPendingPairingRequestsTable } from "./pairing-render.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; function formatVersionLabel(raw: string) { @@ -356,31 +357,15 @@ export function registerNodesStatusCommands(nodes: Command) { } if (pendingRows.length > 0) { - const pendingRowsRendered = pendingRows.map((r) => ({ - Request: r.requestId, - Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, - IP: r.remoteIp ?? "", - Requested: - typeof r.ts === "number" - ? formatTimeAgo(Math.max(0, now - r.ts)) - : muted("unknown"), - Repair: r.isRepair ? warn("yes") : "", - })); + const rendered = renderPendingPairingRequestsTable({ + pending: pendingRows, + now, + tableWidth, + theme: { heading, warn, muted }, + }); defaultRuntime.log(""); - defaultRuntime.log(heading("Pending")); - defaultRuntime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Request", header: "Request", minWidth: 8 }, - { key: "Node", header: "Node", minWidth: 14, flex: true }, - { key: "IP", header: "IP", minWidth: 10 }, - { key: "Requested", header: "Requested", minWidth: 12 }, - { key: "Repair", header: "Repair", minWidth: 6 }, - ], - rows: pendingRowsRendered, - }).trimEnd(), - ); + defaultRuntime.log(rendered.heading); + defaultRuntime.log(rendered.table); } if (filteredPaired.length > 0) { diff --git a/src/cli/nodes-cli/rpc.ts b/src/cli/nodes-cli/rpc.ts index a51e0fa2a9a..691da4dd7fa 100644 --- a/src/cli/nodes-cli/rpc.ts +++ b/src/cli/nodes-cli/rpc.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { NodeListNode, NodesRpcOpts } from "./types.js"; -import { callGateway } from "../../gateway/call.js"; +import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; import { resolveNodeIdFromCandidates } from "../../shared/node-match.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { withProgress } from "../progress.js"; @@ -37,6 +37,25 @@ export const callGatewayCli = async ( }), ); +export function buildNodeInvokeParams(params: { + nodeId: string; + command: string; + params?: Record; + timeoutMs?: number; + idempotencyKey?: string; +}): Record { + const invokeParams: Record = { + nodeId: params.nodeId, + command: params.command, + params: params.params, + idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(), + }; + if (typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)) { + invokeParams.timeoutMs = params.timeoutMs; + } + return invokeParams; +} + export function unauthorizedHintForMessage(message: string): string | null { const haystack = message.toLowerCase(); if ( diff --git a/src/cli/nodes-screen.test.ts b/src/cli/nodes-screen.test.ts deleted file mode 100644 index 5aa6dfe8361..00000000000 --- a/src/cli/nodes-screen.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { parseScreenRecordPayload, screenRecordTempPath } from "./nodes-screen.js"; - -describe("nodes screen helpers", () => { - it("parses screen.record payload", () => { - const payload = parseScreenRecordPayload({ - format: "mp4", - base64: "Zm9v", - durationMs: 1000, - fps: 12, - screenIndex: 0, - hasAudio: true, - }); - expect(payload.format).toBe("mp4"); - expect(payload.base64).toBe("Zm9v"); - expect(payload.durationMs).toBe(1000); - expect(payload.fps).toBe(12); - expect(payload.screenIndex).toBe(0); - expect(payload.hasAudio).toBe(true); - }); - - it("rejects invalid screen.record payload", () => { - expect(() => parseScreenRecordPayload({ format: "mp4" })).toThrow( - /invalid screen\.record payload/i, - ); - }); - - it("builds screen record temp path", () => { - const p = screenRecordTempPath({ - ext: "mp4", - tmpDir: "/tmp", - id: "id1", - }); - expect(p).toBe(path.join("/tmp", "openclaw-screen-record-id1.mp4")); - }); -}); diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index 5efb7f729d6..73b81386a0c 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -94,6 +94,20 @@ describe("pairing cli", () => { expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram"); }); + it("forwards --account for list", async () => { + const { registerPairingCli } = await import("./pairing-cli.js"); + listChannelPairingRequests.mockResolvedValueOnce([]); + + const program = new Command(); + program.name("test"); + registerPairingCli(program); + await program.parseAsync(["pairing", "list", "--channel", "telegram", "--account", "yy"], { + from: "user", + }); + + expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram", process.env, "yy"); + }); + it("normalizes channel aliases", async () => { const { registerPairingCli } = await import("./pairing-cli.js"); listChannelPairingRequests.mockResolvedValueOnce([]); @@ -170,4 +184,33 @@ describe("pairing cli", () => { }); expect(log).toHaveBeenCalledWith(expect.stringContaining("Approved")); }); + + it("forwards --account for approve", async () => { + const { registerPairingCli } = await import("./pairing-cli.js"); + approveChannelPairingCode.mockResolvedValueOnce({ + id: "123", + entry: { + id: "123", + code: "ABCDEFGH", + createdAt: "2026-01-08T00:00:00Z", + lastSeenAt: "2026-01-08T00:00:00Z", + }, + }); + + const program = new Command(); + program.name("test"); + registerPairingCli(program); + await program.parseAsync( + ["pairing", "approve", "--channel", "telegram", "--account", "yy", "ABCDEFGH"], + { + from: "user", + }, + ); + + expect(approveChannelPairingCode).toHaveBeenCalledWith({ + channel: "telegram", + code: "ABCDEFGH", + accountId: "yy", + }); + }); }); diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index e576d1b5723..f028b08fc33 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -64,6 +64,7 @@ export function registerPairingCli(program: Command) { .command("list") .description("List pending pairing requests") .option("--channel ", `Channel (${channels.join(", ")})`) + .option("--account ", "Account id (for multi-account channels)") .argument("[channel]", `Channel (${channels.join(", ")})`) .option("--json", "Print JSON", false) .action(async (channelArg, opts) => { @@ -74,7 +75,10 @@ export function registerPairingCli(program: Command) { ); } const channel = parseChannel(channelRaw, channels); - const requests = await listChannelPairingRequests(channel); + const accountId = String(opts.account ?? "").trim(); + const requests = accountId + ? await listChannelPairingRequests(channel, process.env, accountId) + : await listChannelPairingRequests(channel); if (opts.json) { defaultRuntime.log(JSON.stringify({ channel, requests }, null, 2)); return; @@ -111,6 +115,7 @@ export function registerPairingCli(program: Command) { .command("approve") .description("Approve a pairing code and allow that sender") .option("--channel ", `Channel (${channels.join(", ")})`) + .option("--account ", "Account id (for multi-account channels)") .argument("", "Pairing code (or channel when using 2 args)") .argument("[code]", "Pairing code (when channel is passed as the 1st arg)") .option("--notify", "Notify the requester on the same channel", false) @@ -128,10 +133,17 @@ export function registerPairingCli(program: Command) { ); } const channel = parseChannel(channelRaw, channels); - const approved = await approveChannelPairingCode({ - channel, - code: String(resolvedCode), - }); + const accountId = String(opts.account ?? "").trim(); + const approved = accountId + ? await approveChannelPairingCode({ + channel, + code: String(resolvedCode), + accountId, + }) + : await approveChannelPairingCode({ + channel, + code: String(resolvedCode), + }); if (!approved) { throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`); } diff --git a/src/cli/parse-bytes.test.ts b/src/cli/parse-bytes.test.ts deleted file mode 100644 index a0c1abcb0b0..00000000000 --- a/src/cli/parse-bytes.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseByteSize } from "./parse-bytes.js"; - -describe("parseByteSize", () => { - it("parses bytes with units", () => { - expect(parseByteSize("10kb")).toBe(10 * 1024); - expect(parseByteSize("1mb")).toBe(1024 * 1024); - expect(parseByteSize("2gb")).toBe(2 * 1024 * 1024 * 1024); - }); - - it("parses shorthand units", () => { - expect(parseByteSize("5k")).toBe(5 * 1024); - expect(parseByteSize("1m")).toBe(1024 * 1024); - }); - - it("uses default unit when omitted", () => { - expect(parseByteSize("123")).toBe(123); - }); - - it("rejects invalid values", () => { - expect(() => parseByteSize("")).toThrow(); - expect(() => parseByteSize("nope")).toThrow(); - expect(() => parseByteSize("-5kb")).toThrow(); - }); -}); diff --git a/src/cli/parse-duration.test.ts b/src/cli/parse-duration.test.ts deleted file mode 100644 index ad9d6a3a60c..00000000000 --- a/src/cli/parse-duration.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseDurationMs } from "./parse-duration.js"; - -describe("parseDurationMs", () => { - it("parses bare ms", () => { - expect(parseDurationMs("10000")).toBe(10_000); - }); - - it("parses seconds suffix", () => { - expect(parseDurationMs("10s")).toBe(10_000); - }); - - it("parses minutes suffix", () => { - expect(parseDurationMs("1m")).toBe(60_000); - }); - - it("parses hours suffix", () => { - expect(parseDurationMs("2h")).toBe(7_200_000); - }); - - it("parses days suffix", () => { - expect(parseDurationMs("2d")).toBe(172_800_000); - }); - - it("supports decimals", () => { - expect(parseDurationMs("0.5s")).toBe(500); - }); -}); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 3d3a3341115..5f897351c5f 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -9,6 +9,7 @@ import { resolveStateDir } from "../config/paths.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; +import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; import { buildPluginStatusReport } from "../plugins/status.js"; @@ -591,6 +592,9 @@ export function registerPluginsCli(program: Command) { defaultRuntime.error(result.error); process.exit(1); } + // Plugin CLI registrars may have warmed the manifest registry cache before install; + // force a rescan so config validation sees the freshly installed plugin. + clearPluginManifestRegistryCache(); let next = enablePluginInConfig(cfg, result.pluginId); const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; @@ -640,6 +644,8 @@ export function registerPluginsCli(program: Command) { defaultRuntime.error(result.error); process.exit(1); } + // Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup. + clearPluginManifestRegistryCache(); let next = enablePluginInConfig(cfg, result.pluginId); next = recordPluginInstall(next, { diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index 524c6b3a88e..ab0d6b497bf 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -43,6 +43,13 @@ export function installBaseProgramMocks() { ], configureCommand, configureCommandWithSections, + configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { + const resolved = Array.isArray(sections) ? sections : []; + if (resolved.length > 0) { + return configureCommandWithSections(resolved, runtime); + } + return configureCommand({}, runtime); + }, })); vi.mock("../commands/setup.js", () => ({ setupCommand })); vi.mock("../commands/onboard.js", () => ({ onboardCommand })); diff --git a/src/cli/respawn-policy.test.ts b/src/cli/respawn-policy.test.ts deleted file mode 100644 index 25e026b0a56..00000000000 --- a/src/cli/respawn-policy.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldSkipRespawnForArgv } from "./respawn-policy.js"; - -describe("shouldSkipRespawnForArgv", () => { - it("skips respawn for help/version calls", () => { - expect(shouldSkipRespawnForArgv(["node", "openclaw", "--help"])).toBe(true); - expect(shouldSkipRespawnForArgv(["node", "openclaw", "-V"])).toBe(true); - }); - - it("keeps respawn path for normal commands", () => { - expect(shouldSkipRespawnForArgv(["node", "openclaw", "status"])).toBe(false); - }); -}); diff --git a/src/cli/skills-cli.e2e.test.ts b/src/cli/skills-cli.e2e.test.ts new file mode 100644 index 00000000000..51b1640af1e --- /dev/null +++ b/src/cli/skills-cli.e2e.test.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { SkillEntry } from "../agents/skills.js"; +import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; +import { captureEnv } from "../test-utils/env.js"; +import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; + +describe("skills-cli (e2e)", () => { + let tempWorkspaceDir = ""; + let tempBundledDir = ""; + let envSnapshot: ReturnType; + + beforeAll(() => { + envSnapshot = captureEnv(["OPENCLAW_BUNDLED_SKILLS_DIR"]); + tempWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skills-test-")); + tempBundledDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-skills-test-")); + process.env.OPENCLAW_BUNDLED_SKILLS_DIR = tempBundledDir; + }); + + afterAll(() => { + if (tempWorkspaceDir) { + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + } + if (tempBundledDir) { + fs.rmSync(tempBundledDir, { recursive: true, force: true }); + } + envSnapshot.restore(); + }); + + function createEntries(): SkillEntry[] { + const baseDir = path.join(tempWorkspaceDir, "peekaboo"); + return [ + { + skill: { + name: "peekaboo", + description: "Capture UI screenshots", + source: "openclaw-bundled", + filePath: path.join(baseDir, "SKILL.md"), + baseDir, + } as SkillEntry["skill"], + frontmatter: {}, + metadata: { emoji: "πŸ“Έ" }, + }, + ]; + } + + it("loads bundled skills and formats them", () => { + const entries = createEntries(); + const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { + managedSkillsDir: "/nonexistent", + entries, + }); + + expect(report.skills.length).toBeGreaterThan(0); + + const listOutput = formatSkillsList(report, {}); + expect(listOutput).toContain("Skills"); + + const checkOutput = formatSkillsCheck(report, {}); + expect(checkOutput).toContain("Total:"); + + const jsonOutput = formatSkillsList(report, { json: true }); + const parsed = JSON.parse(jsonOutput); + expect(parsed.skills).toBeInstanceOf(Array); + }); + + it("formats info for a real bundled skill (peekaboo)", () => { + const entries = createEntries(); + const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { + managedSkillsDir: "/nonexistent", + entries, + }); + + const peekaboo = report.skills.find((s) => s.name === "peekaboo"); + if (!peekaboo) { + throw new Error("peekaboo fixture skill missing"); + } + + const output = formatSkillInfo(report, "peekaboo", {}); + expect(output).toContain("peekaboo"); + expect(output).toContain("Details:"); + }); +}); diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index 97f77a3530e..5f6dcfdcd2a 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -292,23 +292,8 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp lines.push(theme.heading("Missing requirements:")); for (const skill of missingReqs) { const emoji = skill.emoji ?? "πŸ“¦"; - const missing: string[] = []; - if (skill.missing.bins.length > 0) { - missing.push(`bins: ${skill.missing.bins.join(", ")}`); - } - if (skill.missing.anyBins.length > 0) { - missing.push(`anyBins: ${skill.missing.anyBins.join(", ")}`); - } - if (skill.missing.env.length > 0) { - missing.push(`env: ${skill.missing.env.join(", ")}`); - } - if (skill.missing.config.length > 0) { - missing.push(`config: ${skill.missing.config.join(", ")}`); - } - if (skill.missing.os.length > 0) { - missing.push(`os: ${skill.missing.os.join(", ")}`); - } - lines.push(` ${emoji} ${skill.name} ${theme.muted(`(${missing.join("; ")})`)}`); + const missing = formatSkillMissingSummary(skill); + lines.push(` ${emoji} ${skill.name} ${theme.muted(`(${missing})`)}`); } } diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index afc9336786d..b539caada9d 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -1,9 +1,5 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js"; -import type { SkillEntry } from "../agents/skills.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; // Unit tests: don't pay the runtime cost of loading/parsing the real skills loader. @@ -215,78 +211,4 @@ describe("skills-cli", () => { expect(parsed.summary.total).toBe(2); }); }); - - describe("integration: loads real skills from bundled directory", () => { - let tempWorkspaceDir = ""; - - beforeAll(() => { - tempWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skills-test-")); - }); - - afterAll(() => { - if (tempWorkspaceDir) { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); - } - }); - - const createEntries = (): SkillEntry[] => { - const baseDir = path.join(tempWorkspaceDir, "peekaboo"); - return [ - { - skill: { - name: "peekaboo", - description: "Capture UI screenshots", - source: "openclaw-bundled", - filePath: path.join(baseDir, "SKILL.md"), - baseDir, - } as SkillEntry["skill"], - frontmatter: {}, - metadata: { emoji: "πŸ“Έ" }, - }, - ]; - }; - - it("loads bundled skills and formats them", async () => { - const { buildWorkspaceSkillStatus } = await import("../agents/skills-status.js"); - const entries = createEntries(); - const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { - managedSkillsDir: "/nonexistent", - entries, - }); - - // Should have loaded some skills - expect(report.skills.length).toBeGreaterThan(0); - - // Format should work without errors - const listOutput = formatSkillsList(report, {}); - expect(listOutput).toContain("Skills"); - - const checkOutput = formatSkillsCheck(report, {}); - expect(checkOutput).toContain("Total:"); - - // JSON output should be valid - const jsonOutput = formatSkillsList(report, { json: true }); - const parsed = JSON.parse(jsonOutput); - expect(parsed.skills).toBeInstanceOf(Array); - }); - - it("formats info for a real bundled skill (peekaboo)", async () => { - const { buildWorkspaceSkillStatus } = await import("../agents/skills-status.js"); - const entries = createEntries(); - const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { - managedSkillsDir: "/nonexistent", - entries, - }); - - // peekaboo is a bundled skill that should always exist - const peekaboo = report.skills.find((s) => s.name === "peekaboo"); - if (!peekaboo) { - throw new Error("peekaboo fixture skill missing"); - } - - const output = formatSkillInfo(report, "peekaboo", {}); - expect(output).toContain("peekaboo"); - expect(output).toContain("Details:"); - }); - }); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 20b935e7ecf..550bbbf43ec 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { UpdateRunResult } from "../infra/update-runner.js"; +import { captureEnv } from "../test-utils/env.js"; const confirm = vi.fn(); const select = vi.fn(); @@ -144,7 +145,7 @@ describe("update-cli", () => { const createCaseDir = async (prefix: string) => { const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); - await fs.mkdir(dir, { recursive: true }); + // Tests only need a stable path; the directory does not have to exist because all I/O is mocked. return dir; }; @@ -597,7 +598,7 @@ describe("update-cli", () => { it("updateWizardCommand offers dev checkout and forwards selections", async () => { const tempDir = await createCaseDir("openclaw-update-wizard"); - const previousGitDir = process.env.OPENCLAW_GIT_DIR; + const envSnapshot = captureEnv(["OPENCLAW_GIT_DIR"]); try { setTty(true); process.env.OPENCLAW_GIT_DIR = tempDir; @@ -627,7 +628,7 @@ describe("update-cli", () => { const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; expect(call?.channel).toBe("dev"); } finally { - process.env.OPENCLAW_GIT_DIR = previousGitDir; + envSnapshot.restore(); } }); }); diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 507df6edc50..b137248f0b8 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { UpdateStepProgress, UpdateStepResult } from "../../infra/update-runner.js"; import { resolveStateDir } from "../../config/paths.js"; import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js"; +import { readPackageName, readPackageVersion } from "../../infra/package-json.js"; import { trimLogTail } from "../../infra/restart-sentinel.js"; import { parseSemver } from "../../infra/runtime-guard.js"; import { fetchNpmTagVersion } from "../../infra/update-check.js"; @@ -68,15 +69,7 @@ export function normalizeVersionTag(tag: string): string | null { return parseSemver(cleaned) ? cleaned : null; } -export async function readPackageVersion(root: string): Promise { - try { - const raw = await fs.readFile(path.join(root, "package.json"), "utf-8"); - const parsed = JSON.parse(raw) as { version?: string }; - return typeof parsed.version === "string" ? parsed.version : null; - } catch { - return null; - } -} +export { readPackageName, readPackageVersion }; export async function resolveTargetVersion( tag: string, @@ -99,17 +92,6 @@ export async function isGitCheckout(root: string): Promise { } } -export async function readPackageName(root: string): Promise { - try { - const raw = await fs.readFile(path.join(root, "package.json"), "utf-8"); - const parsed = JSON.parse(raw) as { name?: string }; - const name = parsed?.name?.trim(); - return name ? name : null; - } catch { - return null; - } -} - export async function isCorePackage(root: string): Promise { const name = await readPackageName(root); return Boolean(name && CORE_PACKAGE_NAMES.has(name)); diff --git a/src/cli/wait.test.ts b/src/cli/wait.test.ts deleted file mode 100644 index 5af1ba32a64..00000000000 --- a/src/cli/wait.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { waitForever } from "./wait.js"; - -describe("waitForever", () => { - it("creates an unref'ed interval and returns a pending promise", () => { - const setIntervalSpy = vi.spyOn(global, "setInterval"); - const promise = waitForever(); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 1_000_000); - expect(promise).toBeInstanceOf(Promise); - setIntervalSpy.mockRestore(); - }); -}); diff --git a/src/cli/webhooks-cli.ts b/src/cli/webhooks-cli.ts index c9c49a2b1ba..c5a551afd8f 100644 --- a/src/cli/webhooks-cli.ts +++ b/src/cli/webhooks-cli.ts @@ -110,32 +110,54 @@ function parseGmailSetupOptions(raw: Record): GmailSetupOptions if (!account) { throw new Error("--account is required"); } + const common = parseGmailCommonOptions(raw); return { account, project: stringOption(raw.project), - topic: stringOption(raw.topic), - subscription: stringOption(raw.subscription), - label: stringOption(raw.label), - hookUrl: stringOption(raw.hookUrl), - hookToken: stringOption(raw.hookToken), - pushToken: stringOption(raw.pushToken), - bind: stringOption(raw.bind), - port: numberOption(raw.port), - path: stringOption(raw.path), - includeBody: booleanOption(raw.includeBody), - maxBytes: numberOption(raw.maxBytes), - renewEveryMinutes: numberOption(raw.renewMinutes), - tailscale: stringOption(raw.tailscale) as GmailSetupOptions["tailscale"], - tailscalePath: stringOption(raw.tailscalePath), - tailscaleTarget: stringOption(raw.tailscaleTarget), + topic: common.topic, + subscription: common.subscription, + label: common.label, + hookUrl: common.hookUrl, + hookToken: common.hookToken, + pushToken: common.pushToken, + bind: common.bind, + port: common.port, + path: common.path, + includeBody: common.includeBody, + maxBytes: common.maxBytes, + renewEveryMinutes: common.renewEveryMinutes, + tailscale: common.tailscaleRaw as GmailSetupOptions["tailscale"], + tailscalePath: common.tailscalePath, + tailscaleTarget: common.tailscaleTarget, pushEndpoint: stringOption(raw.pushEndpoint), json: Boolean(raw.json), }; } function parseGmailRunOptions(raw: Record): GmailRunOptions { + const common = parseGmailCommonOptions(raw); return { account: stringOption(raw.account), + topic: common.topic, + subscription: common.subscription, + label: common.label, + hookUrl: common.hookUrl, + hookToken: common.hookToken, + pushToken: common.pushToken, + bind: common.bind, + port: common.port, + path: common.path, + includeBody: common.includeBody, + maxBytes: common.maxBytes, + renewEveryMinutes: common.renewEveryMinutes, + tailscale: common.tailscaleRaw as GmailRunOptions["tailscale"], + tailscalePath: common.tailscalePath, + tailscaleTarget: common.tailscaleTarget, + }; +} + +function parseGmailCommonOptions(raw: Record) { + return { topic: stringOption(raw.topic), subscription: stringOption(raw.subscription), label: stringOption(raw.label), @@ -148,7 +170,7 @@ function parseGmailRunOptions(raw: Record): GmailRunOptions { includeBody: booleanOption(raw.includeBody), maxBytes: numberOption(raw.maxBytes), renewEveryMinutes: numberOption(raw.renewMinutes), - tailscale: stringOption(raw.tailscale) as GmailRunOptions["tailscale"], + tailscaleRaw: stringOption(raw.tailscale), tailscalePath: stringOption(raw.tailscalePath), tailscaleTarget: stringOption(raw.tailscaleTarget), }; diff --git a/src/commands/agent.ts b/src/commands/agent.ts index adeaf865ad9..e8de4b4d86d 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -2,7 +2,7 @@ import type { AgentCommandOpts } from "./agent/types.js"; import { listAgentIds, resolveAgentDir, - resolveAgentModelFallbacksOverride, + resolveEffectiveModelFallbacks, resolveAgentModelPrimary, resolveAgentSkillsFilter, resolveAgentWorkspaceDir, @@ -63,6 +63,124 @@ import { resolveAgentRunContext } from "./agent/run-context.js"; import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js"; import { resolveSession } from "./agent/session.js"; +type PersistSessionEntryParams = { + sessionStore: Record; + sessionKey: string; + storePath: string; + entry: SessionEntry; +}; + +async function persistSessionEntry(params: PersistSessionEntryParams): Promise { + params.sessionStore[params.sessionKey] = params.entry; + await updateSessionStore(params.storePath, (store) => { + store[params.sessionKey] = params.entry; + }); +} + +function resolveFallbackRetryPrompt(params: { body: string; isFallbackRetry: boolean }): string { + if (!params.isFallbackRetry) { + return params.body; + } + return "Continue where you left off. The previous model attempt failed or timed out."; +} + +function runAgentAttempt(params: { + providerOverride: string; + modelOverride: string; + cfg: ReturnType; + sessionEntry: SessionEntry | undefined; + sessionId: string; + sessionKey: string | undefined; + sessionAgentId: string; + sessionFile: string; + workspaceDir: string; + body: string; + isFallbackRetry: boolean; + resolvedThinkLevel: ThinkLevel; + timeoutMs: number; + runId: string; + opts: AgentCommandOpts; + runContext: ReturnType; + spawnedBy: string | undefined; + messageChannel: ReturnType; + skillsSnapshot: ReturnType | undefined; + resolvedVerboseLevel: VerboseLevel | undefined; + agentDir: string; + onAgentEvent: (evt: { stream: string; data?: Record }) => void; + primaryProvider: string; +}) { + const effectivePrompt = resolveFallbackRetryPrompt({ + body: params.body, + isFallbackRetry: params.isFallbackRetry, + }); + if (isCliProvider(params.providerOverride, params.cfg)) { + const cliSessionId = getCliSessionId(params.sessionEntry, params.providerOverride); + return runCliAgent({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.sessionAgentId, + sessionFile: params.sessionFile, + workspaceDir: params.workspaceDir, + config: params.cfg, + prompt: effectivePrompt, + provider: params.providerOverride, + model: params.modelOverride, + thinkLevel: params.resolvedThinkLevel, + timeoutMs: params.timeoutMs, + runId: params.runId, + extraSystemPrompt: params.opts.extraSystemPrompt, + cliSessionId, + images: params.isFallbackRetry ? undefined : params.opts.images, + streamParams: params.opts.streamParams, + }); + } + + const authProfileId = + params.providerOverride === params.primaryProvider + ? params.sessionEntry?.authProfileOverride + : undefined; + return runEmbeddedPiAgent({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.sessionAgentId, + messageChannel: params.messageChannel, + agentAccountId: params.runContext.accountId, + messageTo: params.opts.replyTo ?? params.opts.to, + messageThreadId: params.opts.threadId, + groupId: params.runContext.groupId, + groupChannel: params.runContext.groupChannel, + groupSpace: params.runContext.groupSpace, + spawnedBy: params.spawnedBy, + currentChannelId: params.runContext.currentChannelId, + currentThreadTs: params.runContext.currentThreadTs, + replyToMode: params.runContext.replyToMode, + hasRepliedRef: params.runContext.hasRepliedRef, + senderIsOwner: true, + sessionFile: params.sessionFile, + workspaceDir: params.workspaceDir, + config: params.cfg, + skillsSnapshot: params.skillsSnapshot, + prompt: effectivePrompt, + images: params.isFallbackRetry ? undefined : params.opts.images, + clientTools: params.opts.clientTools, + provider: params.providerOverride, + model: params.modelOverride, + authProfileId, + authProfileIdSource: authProfileId ? params.sessionEntry?.authProfileOverrideSource : undefined, + thinkLevel: params.resolvedThinkLevel, + verboseLevel: params.resolvedVerboseLevel, + timeoutMs: params.timeoutMs, + runId: params.runId, + lane: params.opts.lane, + abortSignal: params.opts.abortSignal, + extraSystemPrompt: params.opts.extraSystemPrompt, + inputProvenance: params.opts.inputProvenance, + streamParams: params.opts.streamParams, + agentDir: params.agentDir, + onAgentEvent: params.onAgentEvent, + }); +} + export async function agentCommand( opts: AgentCommandOpts, runtime: RuntimeEnv = defaultRuntime, @@ -217,9 +335,11 @@ export async function agentCommand( updatedAt: Date.now(), skillsSnapshot, }; - sessionStore[sessionKey] = next; - await updateSessionStore(storePath, (store) => { - store[sessionKey] = next; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry: next, }); sessionEntry = next; } @@ -233,9 +353,11 @@ export async function agentCommand( next.thinkingLevel = thinkOverride; } applyVerboseOverride(next, verboseOverride); - sessionStore[sessionKey] = next; - await updateSessionStore(storePath, (store) => { - store[sessionKey] = next; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry: next, }); } @@ -307,9 +429,11 @@ export async function agentCommand( selection: { provider: defaultProvider, model: defaultModel, isDefault: true }, }); if (updated) { - sessionStore[sessionKey] = entry; - await updateSessionStore(storePath, (store) => { - store[sessionKey] = entry; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry, }); } } @@ -373,9 +497,11 @@ export async function agentCommand( const entry = sessionEntry; entry.thinkingLevel = "high"; entry.updatedAt = Date.now(); - sessionStore[sessionKey] = entry; - await updateSessionStore(storePath, (store) => { - store[sessionKey] = entry; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry, }); } } @@ -396,18 +522,13 @@ export async function agentCommand( opts.replyChannel ?? opts.channel, ); const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy; - // When a session has an explicit model override, keep the candidate chain - // anchored to that override (no implicit configured-primary append), while - // still preserving configured fallback lists unless the agent explicitly - // overrides fallbacks with its own list (including an empty list to disable). - const agentFallbacksOverride = resolveAgentModelFallbacksOverride(cfg, sessionAgentId); - const defaultFallbacks = - typeof cfg.agents?.defaults?.model === "object" - ? (cfg.agents.defaults.model.fallbacks ?? []) - : []; - const effectiveFallbacksOverride = storedModelOverride - ? (agentFallbacksOverride ?? defaultFallbacks) - : agentFallbacksOverride; + // Keep fallback candidate resolution centralized so session model overrides, + // per-agent overrides, and default fallbacks stay consistent across callers. + const effectiveFallbacksOverride = resolveEffectiveModelFallbacks({ + cfg, + agentId: sessionAgentId, + hasSessionModelOverride: Boolean(storedModelOverride), + }); // Track model fallback attempts so retries on an existing session don't // re-inject the original prompt as a duplicate user message. @@ -421,76 +542,29 @@ export async function agentCommand( run: (providerOverride, modelOverride) => { const isFallbackRetry = fallbackAttemptIndex > 0; fallbackAttemptIndex += 1; - // On fallback retries the session file already contains the original - // prompt from the first attempt. Re-injecting the full prompt would - // create a duplicate user message. Use a short continuation hint - // instead so the model picks up where it left off. - const effectivePrompt = isFallbackRetry - ? "Continue where you left off. The previous model attempt failed or timed out." - : body; - if (isCliProvider(providerOverride, cfg)) { - const cliSessionId = getCliSessionId(sessionEntry, providerOverride); - return runCliAgent({ - sessionId, - sessionKey, - agentId: sessionAgentId, - sessionFile, - workspaceDir, - config: cfg, - prompt: effectivePrompt, - provider: providerOverride, - model: modelOverride, - thinkLevel: resolvedThinkLevel, - timeoutMs, - runId, - extraSystemPrompt: opts.extraSystemPrompt, - cliSessionId, - images: isFallbackRetry ? undefined : opts.images, - streamParams: opts.streamParams, - }); - } - const authProfileId = - providerOverride === provider ? sessionEntry?.authProfileOverride : undefined; - return runEmbeddedPiAgent({ + return runAgentAttempt({ + providerOverride, + modelOverride, + cfg, + sessionEntry, sessionId, sessionKey, - agentId: sessionAgentId, - messageChannel, - agentAccountId: runContext.accountId, - messageTo: opts.replyTo ?? opts.to, - messageThreadId: opts.threadId, - groupId: runContext.groupId, - groupChannel: runContext.groupChannel, - groupSpace: runContext.groupSpace, - spawnedBy, - currentChannelId: runContext.currentChannelId, - currentThreadTs: runContext.currentThreadTs, - replyToMode: runContext.replyToMode, - hasRepliedRef: runContext.hasRepliedRef, - senderIsOwner: true, + sessionAgentId, sessionFile, workspaceDir, - config: cfg, - skillsSnapshot, - prompt: effectivePrompt, - images: isFallbackRetry ? undefined : opts.images, - clientTools: opts.clientTools, - provider: providerOverride, - model: modelOverride, - authProfileId, - authProfileIdSource: authProfileId - ? sessionEntry?.authProfileOverrideSource - : undefined, - thinkLevel: resolvedThinkLevel, - verboseLevel: resolvedVerboseLevel, + body, + isFallbackRetry, + resolvedThinkLevel, timeoutMs, runId, - lane: opts.lane, - abortSignal: opts.abortSignal, - extraSystemPrompt: opts.extraSystemPrompt, - inputProvenance: opts.inputProvenance, - streamParams: opts.streamParams, + opts, + runContext, + spawnedBy, + messageChannel, + skillsSnapshot, + resolvedVerboseLevel, agentDir, + primaryProvider: provider, onAgentEvent: (evt) => { // Track lifecycle end for fallback emission below. if ( diff --git a/src/commands/auth-choice.apply.huggingface.test.ts b/src/commands/auth-choice.apply.huggingface.test.ts index 3f6d995a908..aa0e2115235 100644 --- a/src/commands/auth-choice.apply.huggingface.test.ts +++ b/src/commands/auth-choice.apply.huggingface.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; const noopAsync = async () => {}; @@ -11,9 +12,7 @@ const noop = () => {}; const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json"); describe("applyAuthChoiceHuggingface", () => { - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousHfToken = process.env.HF_TOKEN; - const previousHubToken = process.env.HUGGINGFACE_HUB_TOKEN; + const envSnapshot = captureEnv(["OPENCLAW_AGENT_DIR", "HF_TOKEN", "HUGGINGFACE_HUB_TOKEN"]); let tempStateDir: string | null = null; afterEach(async () => { @@ -21,21 +20,7 @@ describe("applyAuthChoiceHuggingface", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousHfToken === undefined) { - delete process.env.HF_TOKEN; - } else { - process.env.HF_TOKEN = previousHfToken; - } - if (previousHubToken === undefined) { - delete process.env.HUGGINGFACE_HUB_TOKEN; - } else { - process.env.HUGGINGFACE_HUB_TOKEN = previousHubToken; - } + envSnapshot.restore(); }); it("returns null when authChoice is not huggingface-api-key", async () => { diff --git a/src/commands/auth-choice.e2e.test.ts b/src/commands/auth-choice.e2e.test.ts index c58494792b5..2977b750e50 100644 --- a/src/commands/auth-choice.e2e.test.ts +++ b/src/commands/auth-choice.e2e.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { AuthChoice } from "./onboard-types.js"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import { MINIMAX_CN_API_BASE_URL, @@ -38,18 +39,20 @@ const requireAgentDir = () => { }; describe("applyAuthChoice", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; - const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; - const previousHfToken = process.env.HF_TOKEN; - const previousHfHubToken = process.env.HUGGINGFACE_HUB_TOKEN; - const previousLitellmKey = process.env.LITELLM_API_KEY; - const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY; - const previousCloudflareGatewayKey = process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; - const previousSshTty = process.env.SSH_TTY; - const previousChutesClientId = process.env.CHUTES_CLIENT_ID; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "HF_TOKEN", + "HUGGINGFACE_HUB_TOKEN", + "LITELLM_API_KEY", + "AI_GATEWAY_API_KEY", + "CLOUDFLARE_AI_GATEWAY_API_KEY", + "SSH_TTY", + "CHUTES_CLIENT_ID", + ]); let tempStateDir: string | null = null; afterEach(async () => { @@ -61,66 +64,7 @@ describe("applyAuthChoice", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - if (previousAnthropicKey === undefined) { - delete process.env.ANTHROPIC_API_KEY; - } else { - process.env.ANTHROPIC_API_KEY = previousAnthropicKey; - } - if (previousOpenrouterKey === undefined) { - delete process.env.OPENROUTER_API_KEY; - } else { - process.env.OPENROUTER_API_KEY = previousOpenrouterKey; - } - if (previousHfToken === undefined) { - delete process.env.HF_TOKEN; - } else { - process.env.HF_TOKEN = previousHfToken; - } - if (previousHfHubToken === undefined) { - delete process.env.HUGGINGFACE_HUB_TOKEN; - } else { - process.env.HUGGINGFACE_HUB_TOKEN = previousHfHubToken; - } - if (previousLitellmKey === undefined) { - delete process.env.LITELLM_API_KEY; - } else { - process.env.LITELLM_API_KEY = previousLitellmKey; - } - if (previousAiGatewayKey === undefined) { - delete process.env.AI_GATEWAY_API_KEY; - } else { - process.env.AI_GATEWAY_API_KEY = previousAiGatewayKey; - } - if (previousCloudflareGatewayKey === undefined) { - delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; - } else { - process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = previousCloudflareGatewayKey; - } - if (previousSshTty === undefined) { - delete process.env.SSH_TTY; - } else { - process.env.SSH_TTY = previousSshTty; - } - if (previousChutesClientId === undefined) { - delete process.env.CHUTES_CLIENT_ID; - } else { - process.env.CHUTES_CLIENT_ID = previousChutesClientId; - } + envSnapshot.restore(); }); it("does not throw when openai-codex oauth fails", async () => { diff --git a/src/commands/auth-choice.moonshot.e2e.test.ts b/src/commands/auth-choice.moonshot.e2e.test.ts index 8bddbd7a6f6..d215125f357 100644 --- a/src/commands/auth-choice.moonshot.e2e.test.ts +++ b/src/commands/auth-choice.moonshot.e2e.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoice } from "./auth-choice.js"; const noopAsync = async () => {}; @@ -17,65 +18,61 @@ const requireAgentDir = () => { return agentDir; }; +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; +} + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + ...overrides, + }; +} + describe("applyAuthChoice (moonshot)", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousMoonshotKey = process.env.MOONSHOT_API_KEY; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "MOONSHOT_API_KEY", + ]); let tempStateDir: string | null = null; + async function setupTempState() { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; + delete process.env.MOONSHOT_API_KEY; + } + afterEach(async () => { if (tempStateDir) { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - if (previousMoonshotKey === undefined) { - delete process.env.MOONSHOT_API_KEY; - } else { - process.env.MOONSHOT_API_KEY = previousMoonshotKey; - } + envSnapshot.restore(); }); it("keeps the .cn baseUrl when setDefaultModel is false", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); - process.env.OPENCLAW_STATE_DIR = tempStateDir; - process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; - delete process.env.MOONSHOT_API_KEY; + await setupTempState(); const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test"); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select: vi.fn(async () => "" as never), - multiselect: vi.fn(async () => []), - text, - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] }); + const runtime = createRuntime(); const result = await applyAuthChoice({ authChoice: "moonshot-api-key-cn", @@ -107,30 +104,11 @@ describe("applyAuthChoice (moonshot)", () => { }); it("sets the default model when setDefaultModel is true", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); - process.env.OPENCLAW_STATE_DIR = tempStateDir; - process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; - delete process.env.MOONSHOT_API_KEY; + await setupTempState(); const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test"); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select: vi.fn(async () => "" as never), - multiselect: vi.fn(async () => []), - text, - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] }); + const runtime = createRuntime(); const result = await applyAuthChoice({ authChoice: "moonshot-api-key-cn", diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index be1e3bb9fac..28debb7e411 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -18,21 +18,49 @@ export type ChannelsStatusOptions = { timeout?: string; }; +function appendEnabledConfiguredLinkedBits(bits: string[], account: Record) { + if (typeof account.enabled === "boolean") { + bits.push(account.enabled ? "enabled" : "disabled"); + } + if (typeof account.configured === "boolean") { + bits.push(account.configured ? "configured" : "not configured"); + } + if (typeof account.linked === "boolean") { + bits.push(account.linked ? "linked" : "not linked"); + } +} + +function appendModeBit(bits: string[], account: Record) { + if (typeof account.mode === "string" && account.mode.length > 0) { + bits.push(`mode:${account.mode}`); + } +} + +function appendTokenSourceBits(bits: string[], account: Record) { + if (typeof account.tokenSource === "string" && account.tokenSource) { + bits.push(`token:${account.tokenSource}`); + } + if (typeof account.botTokenSource === "string" && account.botTokenSource) { + bits.push(`bot:${account.botTokenSource}`); + } + if (typeof account.appTokenSource === "string" && account.appTokenSource) { + bits.push(`app:${account.appTokenSource}`); + } +} + +function appendBaseUrlBit(bits: string[], account: Record) { + if (typeof account.baseUrl === "string" && account.baseUrl) { + bits.push(`url:${account.baseUrl}`); + } +} + export function formatGatewayChannelsStatusLines(payload: Record): string[] { const lines: string[] = []; lines.push(theme.success("Gateway reachable.")); const accountLines = (provider: ChatChannel, accounts: Array>) => accounts.map((account) => { const bits: string[] = []; - if (typeof account.enabled === "boolean") { - bits.push(account.enabled ? "enabled" : "disabled"); - } - if (typeof account.configured === "boolean") { - bits.push(account.configured ? "configured" : "not configured"); - } - if (typeof account.linked === "boolean") { - bits.push(account.linked ? "linked" : "not linked"); - } + appendEnabledConfiguredLinkedBits(bits, account); if (typeof account.running === "boolean") { bits.push(account.running ? "running" : "stopped"); } @@ -53,9 +81,7 @@ export function formatGatewayChannelsStatusLines(payload: Record 0) { - bits.push(`mode:${account.mode}`); - } + appendModeBit(bits, account); const botUsername = (() => { const bot = account.bot as { username?: string | null } | undefined; const probeBot = (account.probe as { bot?: { username?: string | null } } | undefined)?.bot; @@ -78,15 +104,7 @@ export function formatGatewayChannelsStatusLines(payload: Record 0) { bits.push(`allow:${account.allowFrom.slice(0, 2).join(",")}`); } - if (typeof account.tokenSource === "string" && account.tokenSource) { - bits.push(`token:${account.tokenSource}`); - } - if (typeof account.botTokenSource === "string" && account.botTokenSource) { - bits.push(`bot:${account.botTokenSource}`); - } - if (typeof account.appTokenSource === "string" && account.appTokenSource) { - bits.push(`app:${account.appTokenSource}`); - } + appendTokenSourceBits(bits, account); const application = account.application as | { intents?: { messageContent?: string } } | undefined; @@ -101,9 +119,7 @@ export function formatGatewayChannelsStatusLines(payload: Record>) => accounts.map((account) => { const bits: string[] = []; - if (typeof account.enabled === "boolean") { - bits.push(account.enabled ? "enabled" : "disabled"); - } - if (typeof account.configured === "boolean") { - bits.push(account.configured ? "configured" : "not configured"); - } - if (typeof account.linked === "boolean") { - bits.push(account.linked ? "linked" : "not linked"); - } - if (typeof account.mode === "string" && account.mode.length > 0) { - bits.push(`mode:${account.mode}`); - } - if (typeof account.tokenSource === "string" && account.tokenSource) { - bits.push(`token:${account.tokenSource}`); - } - if (typeof account.botTokenSource === "string" && account.botTokenSource) { - bits.push(`bot:${account.botTokenSource}`); - } - if (typeof account.appTokenSource === "string" && account.appTokenSource) { - bits.push(`app:${account.appTokenSource}`); - } - if (typeof account.baseUrl === "string" && account.baseUrl) { - bits.push(`url:${account.baseUrl}`); - } + appendEnabledConfiguredLinkedBits(bits, account); + appendModeBit(bits, account); + appendTokenSourceBits(bits, account); + appendBaseUrlBit(bits, account); const accountId = typeof account.accountId === "string" ? account.accountId : "default"; const name = typeof account.name === "string" ? account.name.trim() : ""; const labelText = formatChannelAccountLabel({ diff --git a/src/commands/cleanup-plan.ts b/src/commands/cleanup-plan.ts new file mode 100644 index 00000000000..534db21482c --- /dev/null +++ b/src/commands/cleanup-plan.ts @@ -0,0 +1,25 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + loadConfig, + resolveConfigPath, + resolveOAuthDir, + resolveStateDir, +} from "../config/config.js"; +import { buildCleanupPlan } from "./cleanup-utils.js"; + +export function resolveCleanupPlanFromDisk(): { + cfg: OpenClawConfig; + stateDir: string; + configPath: string; + oauthDir: string; + configInsideState: boolean; + oauthInsideState: boolean; + workspaceDirs: string[]; +} { + const cfg = loadConfig(); + const stateDir = resolveStateDir(); + const configPath = resolveConfigPath(); + const oauthDir = resolveOAuthDir(); + const plan = buildCleanupPlan({ cfg, stateDir, configPath, oauthDir }); + return { cfg, stateDir, configPath, oauthDir, ...plan }; +} diff --git a/src/commands/cleanup-utils.test.ts b/src/commands/cleanup-utils.test.ts new file mode 100644 index 00000000000..2d82753cca2 --- /dev/null +++ b/src/commands/cleanup-utils.test.ts @@ -0,0 +1,52 @@ +import path from "node:path"; +import { describe, expect, it, test } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { buildCleanupPlan } from "./cleanup-utils.js"; +import { applyAgentDefaultPrimaryModel } from "./model-default.js"; + +describe("buildCleanupPlan", () => { + test("resolves inside-state flags and workspace dirs", () => { + const tmpRoot = path.join(path.parse(process.cwd()).root, "tmp"); + const cfg = { + agents: { + defaults: { workspace: path.join(tmpRoot, "openclaw-workspace-1") }, + list: [{ workspace: path.join(tmpRoot, "openclaw-workspace-2") }], + }, + }; + const plan = buildCleanupPlan({ + cfg: cfg as unknown as OpenClawConfig, + stateDir: path.join(tmpRoot, "openclaw-state"), + configPath: path.join(tmpRoot, "openclaw-state", "openclaw.json"), + oauthDir: path.join(tmpRoot, "openclaw-oauth"), + }); + + expect(plan.configInsideState).toBe(true); + expect(plan.oauthInsideState).toBe(false); + expect(new Set(plan.workspaceDirs)).toEqual( + new Set([ + path.join(tmpRoot, "openclaw-workspace-1"), + path.join(tmpRoot, "openclaw-workspace-2"), + ]), + ); + }); +}); + +describe("applyAgentDefaultPrimaryModel", () => { + it("does not mutate when already set", () => { + const cfg = { agents: { defaults: { model: { primary: "a/b" } } } } as OpenClawConfig; + const result = applyAgentDefaultPrimaryModel({ cfg, model: "a/b" }); + expect(result.changed).toBe(false); + expect(result.next).toBe(cfg); + }); + + it("normalizes legacy models", () => { + const cfg = { agents: { defaults: { model: { primary: "legacy" } } } } as OpenClawConfig; + const result = applyAgentDefaultPrimaryModel({ + cfg, + model: "a/b", + legacyModels: new Set(["legacy"]), + }); + expect(result.changed).toBe(false); + expect(result.next).toBe(cfg); + }); +}); diff --git a/src/commands/cleanup-utils.ts b/src/commands/cleanup-utils.ts index a1c8dbb7c50..edca7757daa 100644 --- a/src/commands/cleanup-utils.ts +++ b/src/commands/cleanup-utils.ts @@ -29,6 +29,23 @@ export function collectWorkspaceDirs(cfg: OpenClawConfig | undefined): string[] return [...dirs]; } +export function buildCleanupPlan(params: { + cfg: OpenClawConfig | undefined; + stateDir: string; + configPath: string; + oauthDir: string; +}): { + configInsideState: boolean; + oauthInsideState: boolean; + workspaceDirs: string[]; +} { + return { + configInsideState: isPathWithin(params.configPath, params.stateDir), + oauthInsideState: isPathWithin(params.oauthDir, params.stateDir), + workspaceDirs: collectWorkspaceDirs(params.cfg), + }; +} + export function isPathWithin(child: string, parent: string): boolean { const relative = path.relative(parent, child); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index 84db572eb26..b0676f311a3 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -1,6 +1,11 @@ import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveGatewayPort } from "../config/config.js"; +import { + TAILSCALE_DOCS_LINES, + TAILSCALE_EXPOSURE_OPTIONS, + TAILSCALE_MISSING_BIN_NOTE_LINES, +} from "../gateway/gateway-config-prompts.shared.js"; import { findTailscaleBinary } from "../infra/tailscale.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import { note } from "../terminal/note.js"; @@ -100,19 +105,7 @@ export async function promptGatewayConfig( let tailscaleMode = guardCancel( await select({ message: "Tailscale exposure", - options: [ - { value: "off", label: "Off", hint: "No Tailscale exposure" }, - { - value: "serve", - label: "Serve", - hint: "Private HTTPS for your tailnet (devices on Tailscale)", - }, - { - value: "funnel", - label: "Funnel", - hint: "Public HTTPS via Tailscale Funnel (internet)", - }, - ], + options: [...TAILSCALE_EXPOSURE_OPTIONS], }), runtime, ); @@ -121,27 +114,13 @@ export async function promptGatewayConfig( if (tailscaleMode !== "off") { const tailscaleBin = await findTailscaleBinary(); if (!tailscaleBin) { - note( - [ - "Tailscale binary not found in PATH or /Applications.", - "Ensure Tailscale is installed from:", - " https://tailscale.com/download/mac", - "", - "You can continue setup, but serve/funnel will fail at runtime.", - ].join("\n"), - "Tailscale Warning", - ); + note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning"); } } let tailscaleResetOnExit = false; if (tailscaleMode !== "off") { - note( - ["Docs:", "https://docs.openclaw.ai/gateway/tailscale", "https://docs.openclaw.ai/web"].join( - "\n", - ), - "Tailscale", - ); + note(TAILSCALE_DOCS_LINES.join("\n"), "Tailscale"); tailscaleResetOnExit = Boolean( guardCancel( await confirm({ diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 511141e366d..287a2ac8ca6 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -3,6 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import type { DoctorOptions } from "./doctor-prompter.js"; +import { + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +} from "../channels/telegram/allow-from.js"; import { formatCliCommand } from "../cli/command-format.js"; import { OpenClawSchema, @@ -143,18 +147,6 @@ function noteOpencodeProviderOverrides(cfg: OpenClawConfig) { note(lines.join("\n"), "OpenCode Zen"); } -function normalizeTelegramAllowFromEntry(raw: unknown): string { - const base = typeof raw === "string" ? raw : typeof raw === "number" ? String(raw) : ""; - return base - .trim() - .replace(/^(telegram|tg):/i, "") - .trim(); -} - -function isNumericTelegramUserId(raw: string): boolean { - return /^\d+$/.test(raw); -} - type TelegramAllowFromUsernameHit = { path: string; entry: string }; function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllowFromUsernameHit[] { diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 334e0518214..9db631a6fe1 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; @@ -25,6 +26,7 @@ vi.mock("../agents/model-auth.js", () => ({ })); import { noteMemorySearchHealth } from "./doctor-memory-search.js"; +import { detectLegacyWorkspaceDirs } from "./doctor-workspace.js"; describe("noteMemorySearchHealth", () => { const cfg = {} as OpenClawConfig; @@ -85,3 +87,12 @@ describe("noteMemorySearchHealth", () => { expect(note).not.toHaveBeenCalled(); }); }); + +describe("detectLegacyWorkspaceDirs", () => { + it("returns active workspace and no legacy dirs", () => { + const workspaceDir = "/home/user/openclaw"; + const detection = detectLegacyWorkspaceDirs({ workspaceDir }); + expect(detection.activeWorkspace).toBe(path.resolve(workspaceDir)); + expect(detection.legacyDirs).toEqual([]); + }); +}); diff --git a/src/commands/doctor-state-migrations.e2e.test.ts b/src/commands/doctor-state-migrations.e2e.test.ts index ce8474f5468..5d43859173c 100644 --- a/src/commands/doctor-state-migrations.e2e.test.ts +++ b/src/commands/doctor-state-migrations.e2e.test.ts @@ -178,6 +178,42 @@ describe("doctor legacy state migrations", () => { expect(fs.existsSync(path.join(oauthDir, "creds.json"))).toBe(false); }); + it("migrates legacy Telegram pairing allowFrom store to account-scoped default file", async () => { + const root = await makeTempRoot(); + const cfg: OpenClawConfig = {}; + + const oauthDir = path.join(root, "credentials"); + fs.mkdirSync(oauthDir, { recursive: true }); + fs.writeFileSync( + path.join(oauthDir, "telegram-allowFrom.json"), + JSON.stringify( + { + version: 1, + allowFrom: ["123456"], + }, + null, + 2, + ) + "\n", + "utf-8", + ); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true); + + const result = await runLegacyStateMigrations({ detected, now: () => 123 }); + expect(result.warnings).toEqual([]); + + const target = path.join(oauthDir, "telegram-default-allowFrom.json"); + expect(fs.existsSync(target)).toBe(true); + expect(JSON.parse(fs.readFileSync(target, "utf-8"))).toEqual({ + version: 1, + allowFrom: ["123456"], + }); + }); + it("no-ops when nothing detected", async () => { const root = await makeTempRoot(); const cfg: OpenClawConfig = {}; diff --git a/src/commands/doctor-workspace.e2e.test.ts b/src/commands/doctor-workspace.e2e.test.ts deleted file mode 100644 index fb0d46a56e8..00000000000 --- a/src/commands/doctor-workspace.e2e.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { detectLegacyWorkspaceDirs } from "./doctor-workspace.js"; - -describe("detectLegacyWorkspaceDirs", () => { - it("returns active workspace and no legacy dirs", () => { - const workspaceDir = "/home/user/openclaw"; - const detection = detectLegacyWorkspaceDirs({ workspaceDir }); - expect(detection.activeWorkspace).toBe(path.resolve(workspaceDir)); - expect(detection.legacyDirs).toEqual([]); - }); -}); diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index beb20a8180d..126d912f486 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -116,6 +116,11 @@ export const detectLegacyStateMigrations = vi.fn().mockResolvedValue({ targetDir: "/tmp/oauth/whatsapp/default", hasLegacy: false, }, + pairingAllowFrom: { + legacyTelegramPath: "/tmp/oauth/telegram-allowFrom.json", + targetTelegramPath: "/tmp/oauth/telegram-default-allowFrom.json", + hasLegacyTelegram: false, + }, preview: [], }) as unknown as MockFn; @@ -306,6 +311,11 @@ export async function arrangeLegacyStateMigrationTest(): Promise<{ targetDir: "/tmp/oauth/whatsapp/default", hasLegacy: false, }, + pairingAllowFrom: { + legacyTelegramPath: "/tmp/oauth/telegram-allowFrom.json", + targetTelegramPath: "/tmp/oauth/telegram-default-allowFrom.json", + hasLegacyTelegram: false, + }, preview: ["- Legacy sessions detected"], }); runLegacyStateMigrations.mockResolvedValueOnce({ diff --git a/src/commands/doctor.falls-back-legacy-sandbox-image-missing.e2e.test.ts b/src/commands/doctor.falls-back-legacy-sandbox-image-missing.e2e.test.ts deleted file mode 100644 index 79c3b3ec1e1..00000000000 --- a/src/commands/doctor.falls-back-legacy-sandbox-image-missing.e2e.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { arrangeLegacyStateMigrationTest, confirm } from "./doctor.e2e-harness.js"; - -describe("doctor command", () => { - it("runs legacy state migrations in non-interactive mode without prompting", async () => { - const { doctorCommand, runtime, runLegacyStateMigrations } = - await arrangeLegacyStateMigrationTest(); - - await doctorCommand(runtime, { nonInteractive: true }); - - expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1); - expect(confirm).not.toHaveBeenCalled(); - }, 30_000); -}); diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts index 635cf8a05f7..1df276f3da6 100644 --- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts +++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts @@ -20,6 +20,16 @@ describe("doctor command", () => { expect(confirm).not.toHaveBeenCalled(); }, 30_000); + it("runs legacy state migrations in non-interactive mode without prompting", async () => { + const { doctorCommand, runtime, runLegacyStateMigrations } = + await arrangeLegacyStateMigrationTest(); + + await doctorCommand(runtime, { nonInteractive: true }); + + expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1); + expect(confirm).not.toHaveBeenCalled(); + }, 30_000); + it("skips gateway restarts in non-interactive mode", async () => { readConfigFileSnapshot.mockResolvedValue({ path: "/tmp/openclaw.json", diff --git a/src/commands/google-gemini-model-default.ts b/src/commands/google-gemini-model-default.ts index a343a29fb3b..385f1cc849d 100644 --- a/src/commands/google-gemini-model-default.ts +++ b/src/commands/google-gemini-model-default.ts @@ -1,44 +1,11 @@ import type { OpenClawConfig } from "../config/config.js"; -import type { AgentModelListConfig } from "../config/types.js"; +import { applyAgentDefaultPrimaryModel } from "./model-default.js"; export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3-pro-preview"; -function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined { - if (typeof model === "string") { - return model; - } - if (model && typeof model === "object" && typeof model.primary === "string") { - return model.primary; - } - return undefined; -} - export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { next: OpenClawConfig; changed: boolean; } { - const current = resolvePrimaryModel(cfg.agents?.defaults?.model)?.trim(); - if (current === GOOGLE_GEMINI_DEFAULT_MODEL) { - return { next: cfg, changed: false }; - } - - return { - next: { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: - cfg.agents?.defaults?.model && typeof cfg.agents.defaults.model === "object" - ? { - ...cfg.agents.defaults.model, - primary: GOOGLE_GEMINI_DEFAULT_MODEL, - } - : { primary: GOOGLE_GEMINI_DEFAULT_MODEL }, - }, - }, - }, - changed: true, - }; + return applyAgentDefaultPrimaryModel({ cfg, model: GOOGLE_GEMINI_DEFAULT_MODEL }); } diff --git a/src/commands/health-format.e2e.test.ts b/src/commands/health-format.e2e.test.ts deleted file mode 100644 index 7381743f1f2..00000000000 --- a/src/commands/health-format.e2e.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { stripAnsi } from "../terminal/ansi.js"; -import { formatHealthCheckFailure } from "./health-format.js"; - -describe("formatHealthCheckFailure", () => { - it("keeps non-rich output stable", () => { - const err = new Error("gateway closed (1006 abnormal closure): no close reason"); - expect(formatHealthCheckFailure(err, { rich: false })).toBe( - `Health check failed: ${String(err)}`, - ); - }); - - it("formats gateway connection details as indented key/value lines", () => { - const err = new Error( - [ - "gateway closed (1006 abnormal closure (no close frame)): no close reason", - "Gateway target: ws://127.0.0.1:19001", - "Source: local loopback", - "Config: /Users/steipete/.openclaw-dev/openclaw.json", - "Bind: loopback", - ].join("\n"), - ); - - expect(stripAnsi(formatHealthCheckFailure(err, { rich: true }))).toBe( - [ - "Health check failed: gateway closed (1006 abnormal closure (no close frame)): no close reason", - " Gateway target: ws://127.0.0.1:19001", - " Source: local loopback", - " Config: /Users/steipete/.openclaw-dev/openclaw.json", - " Bind: loopback", - ].join("\n"), - ); - }); -}); diff --git a/src/commands/health.e2e.test.ts b/src/commands/health.e2e.test.ts index 289af11bb79..e452b1389a8 100644 --- a/src/commands/health.e2e.test.ts +++ b/src/commands/health.e2e.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { HealthSummary } from "./health.js"; +import { stripAnsi } from "../terminal/ansi.js"; +import { formatHealthCheckFailure } from "./health-format.js"; import { formatHealthChannelLines, healthCommand } from "./health.js"; const runtime = { @@ -173,3 +175,34 @@ describe("healthCommand", () => { ); }); }); + +describe("formatHealthCheckFailure", () => { + it("keeps non-rich output stable", () => { + const err = new Error("gateway closed (1006 abnormal closure): no close reason"); + expect(formatHealthCheckFailure(err, { rich: false })).toBe( + `Health check failed: ${String(err)}`, + ); + }); + + it("formats gateway connection details as indented key/value lines", () => { + const err = new Error( + [ + "gateway closed (1006 abnormal closure (no close frame)): no close reason", + "Gateway target: ws://127.0.0.1:19001", + "Source: local loopback", + "Config: /Users/steipete/.openclaw-dev/openclaw.json", + "Bind: loopback", + ].join("\n"), + ); + + expect(stripAnsi(formatHealthCheckFailure(err, { rich: true }))).toBe( + [ + "Health check failed: gateway closed (1006 abnormal closure (no close frame)): no close reason", + " Gateway target: ws://127.0.0.1:19001", + " Source: local loopback", + " Config: /Users/steipete/.openclaw-dev/openclaw.json", + " Bind: loopback", + ].join("\n"), + ); + }); +}); diff --git a/src/commands/model-default.ts b/src/commands/model-default.ts new file mode 100644 index 00000000000..ce121973da3 --- /dev/null +++ b/src/commands/model-default.ts @@ -0,0 +1,45 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentModelListConfig } from "../config/types.js"; + +export function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined { + if (typeof model === "string") { + return model; + } + if (model && typeof model === "object" && typeof model.primary === "string") { + return model.primary; + } + return undefined; +} + +export function applyAgentDefaultPrimaryModel(params: { + cfg: OpenClawConfig; + model: string; + legacyModels?: Set; +}): { next: OpenClawConfig; changed: boolean } { + const current = resolvePrimaryModel(params.cfg.agents?.defaults?.model)?.trim(); + const normalizedCurrent = current && params.legacyModels?.has(current) ? params.model : current; + if (normalizedCurrent === params.model) { + return { next: params.cfg, changed: false }; + } + + return { + next: { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...params.cfg.agents?.defaults, + model: + params.cfg.agents?.defaults?.model && + typeof params.cfg.agents.defaults.model === "object" + ? { + ...params.cfg.agents.defaults.model, + primary: params.model, + } + : { primary: params.model }, + }, + }, + }, + changed: true, + }; +} diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index 0afd7b2e6f2..22956dcec61 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -57,6 +57,25 @@ function hasAuthForProvider( return false; } +function createProviderAuthChecker(params: { + cfg: OpenClawConfig; + agentDir?: string; +}): (provider: string) => boolean { + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const authCache = new Map(); + return (provider: string) => { + const cached = authCache.get(provider); + if (cached !== undefined) { + return cached; + } + const value = hasAuthForProvider(provider, params.cfg, authStore); + authCache.set(provider, value); + return value; + }; +} + function resolveConfiguredModelRaw(cfg: OpenClawConfig): string { const raw = cfg.agents?.defaults?.model as { primary?: string } | string | undefined; if (typeof raw === "string") { @@ -235,19 +254,7 @@ export async function promptDefaultModel( } const agentDir = params.agentDir; - const authStore = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, - }); - const authCache = new Map(); - const hasAuth = (provider: string) => { - const cached = authCache.get(provider); - if (cached !== undefined) { - return cached; - } - const value = hasAuthForProvider(provider, cfg, authStore); - authCache.set(provider, value); - return value; - }; + const hasAuth = createProviderAuthChecker({ cfg, agentDir }); const options: WizardSelectOption[] = []; if (allowKeep) { @@ -383,19 +390,7 @@ export async function promptModelAllowlist(params: { cfg, defaultProvider: DEFAULT_PROVIDER, }); - const authStore = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const authCache = new Map(); - const hasAuth = (provider: string) => { - const cached = authCache.get(provider); - if (cached !== undefined) { - return cached; - } - const value = hasAuthForProvider(provider, cfg, authStore); - authCache.set(provider, value); - return value; - }; + const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); const options: WizardSelectOption[] = []; const seen = new Set(); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 27e2b1af0f4..932ccec7a03 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -153,30 +153,6 @@ describe("models list/status", () => { ({ modelsListCommand } = await import("./models/list.list-command.js")); }); - it("models list outputs canonical zai key for configured z.ai model", async () => { - loadConfig.mockReturnValue({ - agents: { defaults: { model: "z.ai/glm-4.7" } }, - }); - const runtime = makeRuntime(); - - const model = { - provider: "zai", - id: "glm-4.7", - name: "GLM-4.7", - input: ["text"], - baseUrl: "https://api.z.ai/v1", - contextWindow: 128000, - }; - - modelRegistryState.models = [model]; - modelRegistryState.available = [model]; - await modelsListCommand({ json: true }, runtime); - - expect(runtime.log).toHaveBeenCalledTimes(1); - const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); - expect(payload.models[0]?.key).toBe("zai/glm-4.7"); - }); - it("models list plain outputs canonical zai key", async () => { loadConfig.mockReturnValue({ agents: { defaults: { model: "z.ai/glm-4.7" } }, @@ -200,18 +176,10 @@ describe("models list/status", () => { expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7"); }); - it("models list provider filter normalizes z.ai alias", async () => { - await expectZaiProviderFilter("z.ai"); - }); - it("models list provider filter normalizes Z.AI alias casing", async () => { await expectZaiProviderFilter("Z.AI"); }); - it("models list provider filter normalizes z-ai alias", async () => { - await expectZaiProviderFilter("z-ai"); - }); - it("models list marks auth as unavailable when ZAI key is missing", async () => { loadConfig.mockReturnValue({ agents: { defaults: { model: "z.ai/glm-4.7" } }, @@ -348,42 +316,6 @@ describe("models list/status", () => { expect(payload.models[0]?.available).toBe(true); }); - it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6", - models: { - "google-antigravity/claude-opus-4-6": {}, - }, - }, - }, - }); - const runtime = makeRuntime(); - - const template = { - provider: "google-antigravity", - id: "claude-opus-4-5", - name: "Claude Opus 4.5", - api: "google-gemini-cli", - input: ["text", "image"], - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }; - modelRegistryState.models = [template]; - modelRegistryState.available = [template]; - await modelsListCommand({ json: true }, runtime); - - expect(runtime.log).toHaveBeenCalledTimes(1); - const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); - expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6"); - expect(payload.models[0]?.missing).toBe(false); - expect(payload.models[0]?.available).toBe(true); - }); - it("models list prefers registry availability over provider auth heuristics", async () => { loadConfig.mockReturnValue({ agents: { @@ -426,55 +358,6 @@ describe("models list/status", () => { listProfilesForProvider.mockReturnValue([]); }); - it("models list falls back to auth heuristics when registry availability is unavailable", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - model: "google-antigravity/claude-opus-4-6-thinking", - models: { - "google-antigravity/claude-opus-4-6-thinking": {}, - }, - }, - }, - }); - listProfilesForProvider.mockImplementation((_: unknown, provider: string) => - provider === "google-antigravity" - ? ([{ id: "profile-1" }] as Array>) - : [], - ); - modelRegistryState.getAvailableError = Object.assign( - new Error("availability unsupported: getAvailable failed"), - { code: "MODEL_AVAILABILITY_UNAVAILABLE" }, - ); - const runtime = makeRuntime(); - - modelRegistryState.models = [ - { - provider: "google-antigravity", - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking", - api: "google-gemini-cli", - input: ["text", "image"], - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }, - ]; - modelRegistryState.available = []; - await modelsListCommand({ json: true }, runtime); - - expect(runtime.error).toHaveBeenCalledTimes(1); - expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); - expect(runtime.error.mock.calls[0]?.[0]).toContain("getAvailable failed"); - expect(runtime.log).toHaveBeenCalledTimes(1); - const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); - expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); - expect(payload.models[0]?.missing).toBe(false); - expect(payload.models[0]?.available).toBe(true); - }); - it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => { loadConfig.mockReturnValue({ agents: { @@ -627,29 +510,6 @@ describe("models list/status", () => { expect(process.exitCode).toBe(1); }); - it("loadModelRegistry throws when model discovery is unavailable", async () => { - modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), { - code: "MODEL_DISCOVERY_UNAVAILABLE", - }); - modelRegistryState.available = [ - { - provider: "google-antigravity", - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking", - api: "google-gemini-cli", - input: ["text", "image"], - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }, - ]; - - const { loadModelRegistry } = await import("./models/list.registry.js"); - await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable"); - }); - it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { const { toModelRow } = await import("./models/list.registry.js"); diff --git a/src/commands/models/auth-order.ts b/src/commands/models/auth-order.ts index b8f373eaaa3..9dc820c8e9e 100644 --- a/src/commands/models/auth-order.ts +++ b/src/commands/models/auth-order.ts @@ -28,18 +28,22 @@ function describeOrder(store: AuthProfileStore, provider: string): string[] { return Array.isArray(order) ? order : []; } -export async function modelsAuthOrderGetCommand( - opts: { provider: string; agent?: string; json?: boolean }, - runtime: RuntimeEnv, -) { +function resolveAuthOrderContext(opts: { provider: string; agent?: string }) { const rawProvider = opts.provider?.trim(); if (!rawProvider) { throw new Error("Missing --provider."); } const provider = normalizeProviderId(rawProvider); - const cfg = loadConfig(); const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); + return { cfg, agentId, agentDir, provider }; +} + +export async function modelsAuthOrderGetCommand( + opts: { provider: string; agent?: string; json?: boolean }, + runtime: RuntimeEnv, +) { + const { agentId, agentDir, provider } = resolveAuthOrderContext(opts); const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false, }); @@ -72,14 +76,7 @@ export async function modelsAuthOrderClearCommand( opts: { provider: string; agent?: string }, runtime: RuntimeEnv, ) { - const rawProvider = opts.provider?.trim(); - if (!rawProvider) { - throw new Error("Missing --provider."); - } - const provider = normalizeProviderId(rawProvider); - - const cfg = loadConfig(); - const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); + const { agentId, agentDir, provider } = resolveAuthOrderContext(opts); const updated = await setAuthProfileOrder({ agentDir, provider, @@ -98,19 +95,12 @@ export async function modelsAuthOrderSetCommand( opts: { provider: string; agent?: string; order: string[] }, runtime: RuntimeEnv, ) { - const rawProvider = opts.provider?.trim(); - if (!rawProvider) { - throw new Error("Missing --provider."); - } - const provider = normalizeProviderId(rawProvider); - - const cfg = loadConfig(); - const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); + const { agentId, agentDir, provider } = resolveAuthOrderContext(opts); const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false, }); - const providerKey = normalizeProviderId(provider); + const providerKey = provider; const requested = (opts.order ?? []).map((entry) => String(entry).trim()).filter(Boolean); if (requested.length === 0) { throw new Error("Missing profile ids. Provide one or more profile ids."); diff --git a/src/commands/models/fallbacks-shared.ts b/src/commands/models/fallbacks-shared.ts new file mode 100644 index 00000000000..1070249ddd5 --- /dev/null +++ b/src/commands/models/fallbacks-shared.ts @@ -0,0 +1,158 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js"; +import { loadConfig } from "../../config/config.js"; +import { logConfigUpdated } from "../../config/logging.js"; +import { + DEFAULT_PROVIDER, + ensureFlagCompatibility, + mergePrimaryFallbackConfig, + type PrimaryFallbackConfig, + modelKey, + resolveModelTarget, + resolveModelKeysFromEntries, + updateConfig, +} from "./shared.js"; + +type DefaultsFallbackKey = "model" | "imageModel"; + +function getFallbacks(cfg: OpenClawConfig, key: DefaultsFallbackKey): string[] { + const entry = cfg.agents?.defaults?.[key] as unknown as PrimaryFallbackConfig | undefined; + return entry?.fallbacks ?? []; +} + +function patchDefaultsFallbacks( + cfg: OpenClawConfig, + params: { key: DefaultsFallbackKey; fallbacks: string[]; models?: Record }, +): OpenClawConfig { + const existing = cfg.agents?.defaults?.[params.key] as unknown as + | PrimaryFallbackConfig + | undefined; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + [params.key]: mergePrimaryFallbackConfig(existing, { fallbacks: params.fallbacks }), + ...(params.models ? { models: params.models as never } : undefined), + }, + }, + }; +} + +export async function listFallbacksCommand( + params: { label: string; key: DefaultsFallbackKey }, + opts: { json?: boolean; plain?: boolean }, + runtime: RuntimeEnv, +) { + ensureFlagCompatibility(opts); + const cfg = loadConfig(); + const fallbacks = getFallbacks(cfg, params.key); + + if (opts.json) { + runtime.log(JSON.stringify({ fallbacks }, null, 2)); + return; + } + if (opts.plain) { + for (const entry of fallbacks) { + runtime.log(entry); + } + return; + } + + runtime.log(`${params.label} (${fallbacks.length}):`); + if (fallbacks.length === 0) { + runtime.log("- none"); + return; + } + for (const entry of fallbacks) { + runtime.log(`- ${entry}`); + } +} + +export async function addFallbackCommand( + params: { + label: string; + key: DefaultsFallbackKey; + logPrefix: string; + }, + modelRaw: string, + runtime: RuntimeEnv, +) { + const updated = await updateConfig((cfg) => { + const resolved = resolveModelTarget({ raw: modelRaw, cfg }); + const targetKey = modelKey(resolved.provider, resolved.model); + const nextModels = { ...cfg.agents?.defaults?.models } as Record; + if (!nextModels[targetKey]) { + nextModels[targetKey] = {}; + } + const existing = getFallbacks(cfg, params.key); + const existingKeys = resolveModelKeysFromEntries({ cfg, entries: existing }); + if (existingKeys.includes(targetKey)) { + return cfg; + } + + return patchDefaultsFallbacks(cfg, { + key: params.key, + fallbacks: [...existing, targetKey], + models: nextModels, + }); + }); + + logConfigUpdated(runtime); + runtime.log(`${params.logPrefix}: ${getFallbacks(updated, params.key).join(", ")}`); +} + +export async function removeFallbackCommand( + params: { + label: string; + key: DefaultsFallbackKey; + notFoundLabel: string; + logPrefix: string; + }, + modelRaw: string, + runtime: RuntimeEnv, +) { + const updated = await updateConfig((cfg) => { + const resolved = resolveModelTarget({ raw: modelRaw, cfg }); + const targetKey = modelKey(resolved.provider, resolved.model); + const aliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + const existing = getFallbacks(cfg, params.key); + const filtered = existing.filter((entry) => { + const resolvedEntry = resolveModelRefFromString({ + raw: String(entry ?? ""), + defaultProvider: DEFAULT_PROVIDER, + aliasIndex, + }); + if (!resolvedEntry) { + return true; + } + return modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !== targetKey; + }); + + if (filtered.length === existing.length) { + throw new Error(`${params.notFoundLabel} not found: ${targetKey}`); + } + + return patchDefaultsFallbacks(cfg, { key: params.key, fallbacks: filtered }); + }); + + logConfigUpdated(runtime); + runtime.log(`${params.logPrefix}: ${getFallbacks(updated, params.key).join(", ")}`); +} + +export async function clearFallbacksCommand( + params: { key: DefaultsFallbackKey; clearedMessage: string }, + runtime: RuntimeEnv, +) { + await updateConfig((cfg) => { + return patchDefaultsFallbacks(cfg, { key: params.key, fallbacks: [] }); + }); + + logConfigUpdated(runtime); + runtime.log(params.clearedMessage); +} diff --git a/src/commands/models/fallbacks.ts b/src/commands/models/fallbacks.ts index afd14667b21..f588dbc2452 100644 --- a/src/commands/models/fallbacks.ts +++ b/src/commands/models/fallbacks.ts @@ -1,164 +1,42 @@ import type { RuntimeEnv } from "../../runtime.js"; -import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js"; -import { loadConfig } from "../../config/config.js"; -import { logConfigUpdated } from "../../config/logging.js"; import { - DEFAULT_PROVIDER, - ensureFlagCompatibility, - modelKey, - resolveModelTarget, - updateConfig, -} from "./shared.js"; + addFallbackCommand, + clearFallbacksCommand, + listFallbacksCommand, + removeFallbackCommand, +} from "./fallbacks-shared.js"; export async function modelsFallbacksListCommand( opts: { json?: boolean; plain?: boolean }, runtime: RuntimeEnv, ) { - ensureFlagCompatibility(opts); - const cfg = loadConfig(); - const fallbacks = cfg.agents?.defaults?.model?.fallbacks ?? []; - - if (opts.json) { - runtime.log(JSON.stringify({ fallbacks }, null, 2)); - return; - } - if (opts.plain) { - for (const entry of fallbacks) { - runtime.log(entry); - } - return; - } - - runtime.log(`Fallbacks (${fallbacks.length}):`); - if (fallbacks.length === 0) { - runtime.log("- none"); - return; - } - for (const entry of fallbacks) { - runtime.log(`- ${entry}`); - } + return await listFallbacksCommand({ label: "Fallbacks", key: "model" }, opts, runtime); } export async function modelsFallbacksAddCommand(modelRaw: string, runtime: RuntimeEnv) { - const updated = await updateConfig((cfg) => { - const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const targetKey = modelKey(resolved.provider, resolved.model); - const nextModels = { ...cfg.agents?.defaults?.models }; - if (!nextModels[targetKey]) { - nextModels[targetKey] = {}; - } - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - const existing = cfg.agents?.defaults?.model?.fallbacks ?? []; - const existingKeys = existing - .map((entry) => - resolveModelRefFromString({ - raw: String(entry ?? ""), - defaultProvider: DEFAULT_PROVIDER, - aliasIndex, - }), - ) - .filter((entry): entry is NonNullable => Boolean(entry)) - .map((entry) => modelKey(entry.ref.provider, entry.ref.model)); - - if (existingKeys.includes(targetKey)) { - return cfg; - } - - const existingModel = cfg.agents?.defaults?.model as - | { primary?: string; fallbacks?: string[] } - | undefined; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: { - ...(existingModel?.primary ? { primary: existingModel.primary } : undefined), - fallbacks: [...existing, targetKey], - }, - models: nextModels, - }, - }, - }; - }); - - logConfigUpdated(runtime); - runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`); + return await addFallbackCommand( + { label: "Fallbacks", key: "model", logPrefix: "Fallbacks" }, + modelRaw, + runtime, + ); } export async function modelsFallbacksRemoveCommand(modelRaw: string, runtime: RuntimeEnv) { - const updated = await updateConfig((cfg) => { - const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const targetKey = modelKey(resolved.provider, resolved.model); - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - const existing = cfg.agents?.defaults?.model?.fallbacks ?? []; - const filtered = existing.filter((entry) => { - const resolvedEntry = resolveModelRefFromString({ - raw: String(entry ?? ""), - defaultProvider: DEFAULT_PROVIDER, - aliasIndex, - }); - if (!resolvedEntry) { - return true; - } - return modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !== targetKey; - }); - - if (filtered.length === existing.length) { - throw new Error(`Fallback not found: ${targetKey}`); - } - - const existingModel = cfg.agents?.defaults?.model as - | { primary?: string; fallbacks?: string[] } - | undefined; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: { - ...(existingModel?.primary ? { primary: existingModel.primary } : undefined), - fallbacks: filtered, - }, - }, - }, - }; - }); - - logConfigUpdated(runtime); - runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`); + return await removeFallbackCommand( + { + label: "Fallbacks", + key: "model", + notFoundLabel: "Fallback", + logPrefix: "Fallbacks", + }, + modelRaw, + runtime, + ); } export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) { - await updateConfig((cfg) => { - const existingModel = cfg.agents?.defaults?.model as - | { primary?: string; fallbacks?: string[] } - | undefined; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: { - ...(existingModel?.primary ? { primary: existingModel.primary } : undefined), - fallbacks: [], - }, - }, - }, - }; - }); - - logConfigUpdated(runtime); - runtime.log("Fallback list cleared."); + return await clearFallbacksCommand( + { key: "model", clearedMessage: "Fallback list cleared." }, + runtime, + ); } diff --git a/src/commands/models/image-fallbacks.ts b/src/commands/models/image-fallbacks.ts index e4beb1adf26..99ae401972b 100644 --- a/src/commands/models/image-fallbacks.ts +++ b/src/commands/models/image-fallbacks.ts @@ -1,168 +1,42 @@ import type { RuntimeEnv } from "../../runtime.js"; -import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js"; -import { loadConfig } from "../../config/config.js"; -import { logConfigUpdated } from "../../config/logging.js"; import { - DEFAULT_PROVIDER, - ensureFlagCompatibility, - modelKey, - resolveModelTarget, - updateConfig, -} from "./shared.js"; + addFallbackCommand, + clearFallbacksCommand, + listFallbacksCommand, + removeFallbackCommand, +} from "./fallbacks-shared.js"; export async function modelsImageFallbacksListCommand( opts: { json?: boolean; plain?: boolean }, runtime: RuntimeEnv, ) { - ensureFlagCompatibility(opts); - const cfg = loadConfig(); - const fallbacks = cfg.agents?.defaults?.imageModel?.fallbacks ?? []; - - if (opts.json) { - runtime.log(JSON.stringify({ fallbacks }, null, 2)); - return; - } - if (opts.plain) { - for (const entry of fallbacks) { - runtime.log(entry); - } - return; - } - - runtime.log(`Image fallbacks (${fallbacks.length}):`); - if (fallbacks.length === 0) { - runtime.log("- none"); - return; - } - for (const entry of fallbacks) { - runtime.log(`- ${entry}`); - } + return await listFallbacksCommand({ label: "Image fallbacks", key: "imageModel" }, opts, runtime); } export async function modelsImageFallbacksAddCommand(modelRaw: string, runtime: RuntimeEnv) { - const updated = await updateConfig((cfg) => { - const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const targetKey = modelKey(resolved.provider, resolved.model); - const nextModels = { ...cfg.agents?.defaults?.models }; - if (!nextModels[targetKey]) { - nextModels[targetKey] = {}; - } - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - const existing = cfg.agents?.defaults?.imageModel?.fallbacks ?? []; - const existingKeys = existing - .map((entry) => - resolveModelRefFromString({ - raw: String(entry ?? ""), - defaultProvider: DEFAULT_PROVIDER, - aliasIndex, - }), - ) - .filter((entry): entry is NonNullable => Boolean(entry)) - .map((entry) => modelKey(entry.ref.provider, entry.ref.model)); - - if (existingKeys.includes(targetKey)) { - return cfg; - } - - const existingModel = cfg.agents?.defaults?.imageModel as - | { primary?: string; fallbacks?: string[] } - | undefined; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - imageModel: { - ...(existingModel?.primary ? { primary: existingModel.primary } : undefined), - fallbacks: [...existing, targetKey], - }, - models: nextModels, - }, - }, - }; - }); - - logConfigUpdated(runtime); - runtime.log( - `Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`, + return await addFallbackCommand( + { label: "Image fallbacks", key: "imageModel", logPrefix: "Image fallbacks" }, + modelRaw, + runtime, ); } export async function modelsImageFallbacksRemoveCommand(modelRaw: string, runtime: RuntimeEnv) { - const updated = await updateConfig((cfg) => { - const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const targetKey = modelKey(resolved.provider, resolved.model); - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - const existing = cfg.agents?.defaults?.imageModel?.fallbacks ?? []; - const filtered = existing.filter((entry) => { - const resolvedEntry = resolveModelRefFromString({ - raw: String(entry ?? ""), - defaultProvider: DEFAULT_PROVIDER, - aliasIndex, - }); - if (!resolvedEntry) { - return true; - } - return modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !== targetKey; - }); - - if (filtered.length === existing.length) { - throw new Error(`Image fallback not found: ${targetKey}`); - } - - const existingModel = cfg.agents?.defaults?.imageModel as - | { primary?: string; fallbacks?: string[] } - | undefined; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - imageModel: { - ...(existingModel?.primary ? { primary: existingModel.primary } : undefined), - fallbacks: filtered, - }, - }, - }, - }; - }); - - logConfigUpdated(runtime); - runtime.log( - `Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`, + return await removeFallbackCommand( + { + label: "Image fallbacks", + key: "imageModel", + notFoundLabel: "Image fallback", + logPrefix: "Image fallbacks", + }, + modelRaw, + runtime, ); } export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) { - await updateConfig((cfg) => { - const existingModel = cfg.agents?.defaults?.imageModel as - | { primary?: string; fallbacks?: string[] } - | undefined; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - imageModel: { - ...(existingModel?.primary ? { primary: existingModel.primary } : undefined), - fallbacks: [], - }, - }, - }, - }; - }); - - logConfigUpdated(runtime); - runtime.log("Image fallback list cleared."); + return await clearFallbacksCommand( + { key: "imageModel", clearedMessage: "Image fallback list cleared." }, + runtime, + ); } diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index c371e85a308..cdad37bbd21 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -2,11 +2,8 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "../../agents/pi-model-discovery.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { ModelRow } from "./list.types.js"; -import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; import { resolveForwardCompatModel } from "../../agents/model-forward-compat.js"; import { parseModelRef } from "../../agents/model-selection.js"; -import { resolveModel } from "../../agents/pi-embedded-runner/model.js"; -import { loadConfig } from "../../config/config.js"; import { resolveConfiguredEntries } from "./list.configured.js"; import { formatErrorWithStack } from "./list.errors.js"; import { loadModelRegistry, toModelRow } from "./list.registry.js"; @@ -24,6 +21,8 @@ export async function modelsListCommand( runtime: RuntimeEnv, ) { ensureFlagCompatibility(opts); + const { loadConfig } = await import("../../config/config.js"); + const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js"); const cfg = loadConfig(); const authStore = ensureAuthProfileStore(); const providerFilter = (() => { @@ -111,6 +110,7 @@ export async function modelsListCommand( } } if (!model) { + const { resolveModel } = await import("../../agents/pi-embedded-runner/model.js"); model = resolveModel(entry.ref.provider, entry.ref.model, undefined, cfg).model; } if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) { diff --git a/src/commands/models/set-image.ts b/src/commands/models/set-image.ts index 82531414ef2..920418e4f89 100644 --- a/src/commands/models/set-image.ts +++ b/src/commands/models/set-image.ts @@ -1,32 +1,10 @@ import type { RuntimeEnv } from "../../runtime.js"; import { logConfigUpdated } from "../../config/logging.js"; -import { resolveModelTarget, updateConfig } from "./shared.js"; +import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js"; export async function modelsSetImageCommand(modelRaw: string, runtime: RuntimeEnv) { const updated = await updateConfig((cfg) => { - const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const key = `${resolved.provider}/${resolved.model}`; - const nextModels = { ...cfg.agents?.defaults?.models }; - if (!nextModels[key]) { - nextModels[key] = {}; - } - const existingModel = cfg.agents?.defaults?.imageModel as - | { primary?: string; fallbacks?: string[] } - | undefined; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - imageModel: { - ...(existingModel?.fallbacks ? { fallbacks: existingModel.fallbacks } : undefined), - primary: key, - }, - models: nextModels, - }, - }, - }; + return applyDefaultModelPrimaryUpdate({ cfg, modelRaw, field: "imageModel" }); }); logConfigUpdated(runtime); diff --git a/src/commands/models/set.ts b/src/commands/models/set.ts index 83db6723fbd..d0506acbdf6 100644 --- a/src/commands/models/set.ts +++ b/src/commands/models/set.ts @@ -1,32 +1,10 @@ import type { RuntimeEnv } from "../../runtime.js"; import { logConfigUpdated } from "../../config/logging.js"; -import { resolveModelTarget, updateConfig } from "./shared.js"; +import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js"; export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) { const updated = await updateConfig((cfg) => { - const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const key = `${resolved.provider}/${resolved.model}`; - const nextModels = { ...cfg.agents?.defaults?.models }; - if (!nextModels[key]) { - nextModels[key] = {}; - } - const existingModel = cfg.agents?.defaults?.model as - | { primary?: string; fallbacks?: string[] } - | undefined; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: { - ...(existingModel?.fallbacks ? { fallbacks: existingModel.fallbacks } : undefined), - primary: key, - }, - models: nextModels, - }, - }, - }; + return applyDefaultModelPrimaryUpdate({ cfg, modelRaw, field: "model" }); }); logConfigUpdated(runtime); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index b25be3a8926..64439ef60c7 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -91,6 +91,26 @@ export function resolveModelTarget(params: { raw: string; cfg: OpenClawConfig }) return resolved.ref; } +export function resolveModelKeysFromEntries(params: { + cfg: OpenClawConfig; + entries: readonly string[]; +}): string[] { + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + return params.entries + .map((entry) => + resolveModelRefFromString({ + raw: entry, + defaultProvider: DEFAULT_PROVIDER, + aliasIndex, + }), + ) + .filter((entry): entry is NonNullable => Boolean(entry)) + .map((entry) => modelKey(entry.ref.provider, entry.ref.model)); +} + export function buildAllowlistSet(cfg: OpenClawConfig): Set { const allowed = new Set(); const models = cfg.agents?.defaults?.models ?? {}; @@ -133,6 +153,53 @@ export function resolveKnownAgentId(params: { return agentId; } +export type PrimaryFallbackConfig = { primary?: string; fallbacks?: string[] }; + +export function mergePrimaryFallbackConfig( + existing: PrimaryFallbackConfig | undefined, + patch: { primary?: string; fallbacks?: string[] }, +): PrimaryFallbackConfig { + const next: PrimaryFallbackConfig = { ...existing }; + if (patch.primary !== undefined) { + next.primary = patch.primary; + } + if (patch.fallbacks !== undefined) { + next.fallbacks = patch.fallbacks; + } + return next; +} + +export function applyDefaultModelPrimaryUpdate(params: { + cfg: OpenClawConfig; + modelRaw: string; + field: "model" | "imageModel"; +}): OpenClawConfig { + const resolved = resolveModelTarget({ raw: params.modelRaw, cfg: params.cfg }); + const key = `${resolved.provider}/${resolved.model}`; + + const nextModels = { ...params.cfg.agents?.defaults?.models }; + if (!nextModels[key]) { + nextModels[key] = {}; + } + + const defaults = params.cfg.agents?.defaults ?? {}; + const existing = (defaults as Record)[params.field] as + | PrimaryFallbackConfig + | undefined; + + return { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...defaults, + [params.field]: mergePrimaryFallbackConfig(existing, { primary: key }), + models: nextModels, + }, + }, + }; +} + export { modelKey }; export { DEFAULT_MODEL, DEFAULT_PROVIDER }; diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 037c28e3721..4db007191b4 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -135,25 +135,7 @@ export function applyZaiConfig( const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; const modelRef = modelId === ZAI_DEFAULT_MODEL_ID ? ZAI_DEFAULT_MODEL_REF : `zai/${modelId}`; const next = applyZaiProviderConfig(cfg, params); - - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: modelRef, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, modelRef); } export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConfig { @@ -177,24 +159,7 @@ export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConf export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyOpenrouterProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: OPENROUTER_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, OPENROUTER_DEFAULT_MODEL_REF); } export function applyMoonshotProviderConfig(cfg: OpenClawConfig): OpenClawConfig { @@ -258,24 +223,7 @@ export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig export function applyKimiCodeConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyKimiCodeProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: KIMI_CODING_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, KIMI_CODING_MODEL_REF); } export function applySyntheticProviderConfig(cfg: OpenClawConfig): OpenClawConfig { @@ -314,24 +262,7 @@ export function applySyntheticProviderConfig(cfg: OpenClawConfig): OpenClawConfi export function applySyntheticConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applySyntheticProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: SYNTHETIC_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, SYNTHETIC_DEFAULT_MODEL_REF); } export function applyXiaomiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { @@ -354,24 +285,7 @@ export function applyXiaomiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { export function applyXiaomiConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyXiaomiProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: XIAOMI_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, XIAOMI_DEFAULT_MODEL_REF); } /** @@ -401,24 +315,7 @@ export function applyVeniceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { */ export function applyVeniceConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyVeniceProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: VENICE_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, VENICE_DEFAULT_MODEL_REF); } /** @@ -448,24 +345,7 @@ export function applyTogetherProviderConfig(cfg: OpenClawConfig): OpenClawConfig */ export function applyTogetherConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyTogetherProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: TOGETHER_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, TOGETHER_DEFAULT_MODEL_REF); } /** @@ -493,24 +373,7 @@ export function applyHuggingfaceProviderConfig(cfg: OpenClawConfig): OpenClawCon */ export function applyHuggingfaceConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyHuggingfaceProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: HUGGINGFACE_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, HUGGINGFACE_DEFAULT_MODEL_REF); } export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { @@ -534,24 +397,7 @@ export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyXaiProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: XAI_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, XAI_DEFAULT_MODEL_REF); } export function applyAuthProfileConfig( @@ -636,22 +482,5 @@ export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyQianfanProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: QIANFAN_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, QIANFAN_DEFAULT_MODEL_REF); } diff --git a/src/commands/onboard-auth.config-gateways.ts b/src/commands/onboard-auth.config-gateways.ts index 1e4ffc62cfa..b89acca00f0 100644 --- a/src/commands/onboard-auth.config-gateways.ts +++ b/src/commands/onboard-auth.config-gateways.ts @@ -3,7 +3,10 @@ import { buildCloudflareAiGatewayModelDefinition, resolveCloudflareAiGatewayBaseUrl, } from "../agents/cloudflare-ai-gateway.js"; -import { applyProviderConfigWithDefaultModel } from "./onboard-auth.config-shared.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "./onboard-auth.config-shared.js"; import { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, @@ -76,24 +79,7 @@ export function applyCloudflareAiGatewayProviderConfig( export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyVercelAiGatewayProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF); } export function applyCloudflareAiGatewayConfig( @@ -101,22 +87,5 @@ export function applyCloudflareAiGatewayConfig( params?: { accountId?: string; gatewayId?: string }, ): OpenClawConfig { const next = applyCloudflareAiGatewayProviderConfig(cfg, params); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - }, - }, - }, - }; + return applyAgentDefaultModelPrimary(next, CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF); } diff --git a/src/commands/onboard-auth.e2e.test.ts b/src/commands/onboard-auth.e2e.test.ts index eb6858f87d5..a26c544e133 100644 --- a/src/commands/onboard-auth.e2e.test.ts +++ b/src/commands/onboard-auth.e2e.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthProfileConfig, applyLitellmProviderConfig, @@ -40,9 +41,12 @@ const requireAgentDir = () => { }; describe("writeOAuthCredentials", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "OPENCLAW_OAUTH_DIR", + ]); let tempStateDir: string | null = null; afterEach(async () => { @@ -50,22 +54,7 @@ describe("writeOAuthCredentials", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - delete process.env.OPENCLAW_OAUTH_DIR; + envSnapshot.restore(); }); it("writes auth-profiles.json under OPENCLAW_AGENT_DIR when set", async () => { @@ -100,9 +89,11 @@ describe("writeOAuthCredentials", () => { }); describe("setMinimaxApiKey", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); let tempStateDir: string | null = null; afterEach(async () => { @@ -110,21 +101,7 @@ describe("setMinimaxApiKey", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); }); it("writes to OPENCLAW_AGENT_DIR when set", async () => { diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 25c1c6fc220..210ef5b7ad1 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -12,6 +12,32 @@ import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { setupChannels } from "./onboard-channels.js"; +const noopAsync = async () => {}; + +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; +} + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "__done__" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + vi.mock("node:fs/promises", () => ({ default: { access: vi.fn(async () => { @@ -56,24 +82,13 @@ describe("setupChannels", () => { throw new Error(`unexpected text prompt: ${message}`); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), + const prompter = createPrompter({ select, multiselect, text: text as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels({} as OpenClawConfig, runtime, prompter, { skipConfirm: true, @@ -97,24 +112,14 @@ describe("setupChannels", () => { throw new Error(`unexpected text prompt: ${message}`); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), + const prompter = createPrompter({ note, select, multiselect, text: text as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels({} as OpenClawConfig, runtime, prompter, { skipConfirm: true, @@ -146,24 +151,13 @@ describe("setupChannels", () => { throw new Error(`unexpected text prompt: ${message}`); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), + const prompter = createPrompter({ select, multiselect, text: text as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels( { @@ -209,24 +203,13 @@ describe("setupChannels", () => { const multiselect = vi.fn(async () => { throw new Error("unexpected multiselect"); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), + const prompter = createPrompter({ select, multiselect, - text: vi.fn(async () => ""), - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels( { diff --git a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts index 188bfae6aa1..ea2a4199307 100644 --- a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL } from "./onboard-auth.js"; import { OPENAI_DEFAULT_MODEL } from "./openai-model-default.js"; @@ -12,20 +13,6 @@ type RuntimeMock = { exit: (code: number) => never; }; -type EnvSnapshot = { - home: string | undefined; - stateDir: string | undefined; - configPath: string | undefined; - skipChannels: string | undefined; - skipGmail: string | undefined; - skipCron: string | undefined; - skipCanvas: string | undefined; - token: string | undefined; - password: string | undefined; - customApiKey: string | undefined; - disableConfigCache: string | undefined; -}; - type OnboardEnv = { configPath: string; runtime: RuntimeMock; @@ -47,49 +34,23 @@ async function removeDirWithRetry(dir: string): Promise { } } -function captureEnv(): EnvSnapshot { - return { - home: process.env.HOME, - stateDir: process.env.OPENCLAW_STATE_DIR, - configPath: process.env.OPENCLAW_CONFIG_PATH, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - password: process.env.OPENCLAW_GATEWAY_PASSWORD, - customApiKey: process.env.CUSTOM_API_KEY, - disableConfigCache: process.env.OPENCLAW_DISABLE_CONFIG_CACHE, - }; -} - -function restoreEnvVar(key: keyof NodeJS.ProcessEnv, value: string | undefined): void { - if (value == null) { - delete process.env[key]; - return; - } - process.env[key] = value; -} - -function restoreEnv(prev: EnvSnapshot): void { - restoreEnvVar("HOME", prev.home); - restoreEnvVar("OPENCLAW_STATE_DIR", prev.stateDir); - restoreEnvVar("OPENCLAW_CONFIG_PATH", prev.configPath); - restoreEnvVar("OPENCLAW_SKIP_CHANNELS", prev.skipChannels); - restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", prev.skipGmail); - restoreEnvVar("OPENCLAW_SKIP_CRON", prev.skipCron); - restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", prev.skipCanvas); - restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", prev.token); - restoreEnvVar("OPENCLAW_GATEWAY_PASSWORD", prev.password); - restoreEnvVar("CUSTOM_API_KEY", prev.customApiKey); - restoreEnvVar("OPENCLAW_DISABLE_CONFIG_CACHE", prev.disableConfigCache); -} - async function withOnboardEnv( prefix: string, run: (ctx: OnboardEnv) => Promise, ): Promise { - const prev = captureEnv(); + const prev = captureEnv([ + "HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_SKIP_CHANNELS", + "OPENCLAW_SKIP_GMAIL_WATCHER", + "OPENCLAW_SKIP_CRON", + "OPENCLAW_SKIP_CANVAS_HOST", + "OPENCLAW_GATEWAY_TOKEN", + "OPENCLAW_GATEWAY_PASSWORD", + "CUSTOM_API_KEY", + "OPENCLAW_DISABLE_CONFIG_CACHE", + ]); process.env.OPENCLAW_SKIP_CHANNELS = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; @@ -120,7 +81,7 @@ async function withOnboardEnv( await run({ configPath, runtime }); } finally { await removeDirWithRetry(tempHome); - restoreEnv(prev); + prev.restore(); } } diff --git a/src/commands/opencode-zen-model-default.ts b/src/commands/opencode-zen-model-default.ts index 9f3d4b45654..9efb9c17ade 100644 --- a/src/commands/opencode-zen-model-default.ts +++ b/src/commands/opencode-zen-model-default.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; -import type { AgentModelListConfig } from "../config/types.js"; +import { applyAgentDefaultPrimaryModel } from "./model-default.js"; export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6"; const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ @@ -7,46 +7,13 @@ const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ "opencode-zen/claude-opus-4-5", ]); -function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined { - if (typeof model === "string") { - return model; - } - if (model && typeof model === "object" && typeof model.primary === "string") { - return model.primary; - } - return undefined; -} - export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): { next: OpenClawConfig; changed: boolean; } { - const current = resolvePrimaryModel(cfg.agents?.defaults?.model)?.trim(); - const normalizedCurrent = - current && LEGACY_OPENCODE_ZEN_DEFAULT_MODELS.has(current) - ? OPENCODE_ZEN_DEFAULT_MODEL - : current; - if (normalizedCurrent === OPENCODE_ZEN_DEFAULT_MODEL) { - return { next: cfg, changed: false }; - } - - return { - next: { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: - cfg.agents?.defaults?.model && typeof cfg.agents.defaults.model === "object" - ? { - ...cfg.agents.defaults.model, - primary: OPENCODE_ZEN_DEFAULT_MODEL, - } - : { primary: OPENCODE_ZEN_DEFAULT_MODEL }, - }, - }, - }, - changed: true, - }; + return applyAgentDefaultPrimaryModel({ + cfg, + model: OPENCODE_ZEN_DEFAULT_MODEL, + legacyModels: LEGACY_OPENCODE_ZEN_DEFAULT_MODELS, + }); } diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 0717544a431..fd6203c0af8 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -1,21 +1,11 @@ import { cancel, confirm, isCancel, select } from "@clack/prompts"; import type { RuntimeEnv } from "../runtime.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { - isNixMode, - loadConfig, - resolveConfigPath, - resolveOAuthDir, - resolveStateDir, -} from "../config/config.js"; +import { isNixMode } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js"; -import { - collectWorkspaceDirs, - isPathWithin, - listAgentSessionDirs, - removePath, -} from "./cleanup-utils.js"; +import { resolveCleanupPlanFromDisk } from "./cleanup-plan.js"; +import { listAgentSessionDirs, removePath } from "./cleanup-utils.js"; export type ResetScope = "config" | "config+creds+sessions" | "full"; @@ -119,13 +109,8 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { } const dryRun = Boolean(opts.dryRun); - const cfg = loadConfig(); - const stateDir = resolveStateDir(); - const configPath = resolveConfigPath(); - const oauthDir = resolveOAuthDir(); - const configInsideState = isPathWithin(configPath, stateDir); - const oauthInsideState = isPathWithin(oauthDir, stateDir); - const workspaceDirs = collectWorkspaceDirs(cfg); + const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } = + resolveCleanupPlanFromDisk(); if (scope !== "config") { if (dryRun) { diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index deb22a3814c..03a912e63e3 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -9,6 +9,7 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; +import { classifySessionKey } from "../gateway/session-utils.js"; import { info } from "../globals.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { isRich, theme } from "../terminal/theme.js"; @@ -129,29 +130,13 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => { return label.length === 0 ? "" : rich ? theme.muted(label) : label; }; -function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] { - if (key === "global") { - return "global"; - } - if (key === "unknown") { - return "unknown"; - } - if (entry?.chatType === "group" || entry?.chatType === "channel") { - return "group"; - } - if (key.includes(":group:") || key.includes(":channel:")) { - return "group"; - } - return "direct"; -} - function toRows(store: Record): SessionRow[] { return Object.entries(store) .map(([key, entry]) => { const updatedAt = entry?.updatedAt ?? null; return { key, - kind: classifyKey(key, entry), + kind: classifySessionKey(key, entry), updatedAt, ageMs: updatedAt ? Date.now() - updatedAt : null, sessionId: entry?.sessionId, diff --git a/src/commands/status.e2e.test.ts b/src/commands/status.e2e.test.ts index d5a8dcb0944..ae866bbd2ac 100644 --- a/src/commands/status.e2e.test.ts +++ b/src/commands/status.e2e.test.ts @@ -1,18 +1,15 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; -let previousProfile: string | undefined; +let envSnapshot: ReturnType; beforeAll(() => { - previousProfile = process.env.OPENCLAW_PROFILE; + envSnapshot = captureEnv(["OPENCLAW_PROFILE"]); process.env.OPENCLAW_PROFILE = "isolated"; }); afterAll(() => { - if (previousProfile === undefined) { - delete process.env.OPENCLAW_PROFILE; - } else { - process.env.OPENCLAW_PROFILE = previousProfile; - } + envSnapshot.restore(); }); const mocks = vi.hoisted(() => ({ @@ -249,6 +246,7 @@ vi.mock("../infra/update-check.js", () => ({ }, registry: { latestVersion: "0.0.0" }, }), + formatGitInstallLabel: vi.fn(() => "main Β· @ deadbeef"), compareSemverStrings: vi.fn(() => 0), })); vi.mock("../config/config.js", async (importOriginal) => { diff --git a/src/commands/status.update.test.ts b/src/commands/status.update.test.ts new file mode 100644 index 00000000000..8c29a37839f --- /dev/null +++ b/src/commands/status.update.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import type { UpdateCheckResult } from "../infra/update-check.js"; +import { VERSION } from "../version.js"; +import { + formatUpdateAvailableHint, + formatUpdateOneLiner, + resolveUpdateAvailability, +} from "./status.update.js"; + +function buildUpdate(partial: Partial): UpdateCheckResult { + return { + root: null, + installKind: "unknown", + packageManager: "unknown", + ...partial, + }; +} + +function nextMajorVersion(version: string): string { + const [majorPart] = version.split("."); + const major = Number.parseInt(majorPart ?? "", 10); + if (Number.isFinite(major) && major >= 0) { + return `${major + 1}.0.0`; + } + return "999999.0.0"; +} + +describe("resolveUpdateAvailability", () => { + it("flags git update when behind upstream", () => { + const update = buildUpdate({ + installKind: "git", + git: { + root: "/tmp/repo", + sha: null, + tag: null, + branch: "main", + upstream: "origin/main", + dirty: false, + ahead: 0, + behind: 3, + fetchOk: true, + }, + }); + expect(resolveUpdateAvailability(update)).toEqual({ + available: true, + hasGitUpdate: true, + hasRegistryUpdate: false, + latestVersion: null, + gitBehind: 3, + }); + }); + + it("flags registry update when latest version is newer", () => { + const latestVersion = nextMajorVersion(VERSION); + const update = buildUpdate({ + installKind: "package", + packageManager: "pnpm", + registry: { latestVersion }, + }); + const availability = resolveUpdateAvailability(update); + expect(availability.available).toBe(true); + expect(availability.hasGitUpdate).toBe(false); + expect(availability.hasRegistryUpdate).toBe(true); + expect(availability.latestVersion).toBe(latestVersion); + }); +}); + +describe("formatUpdateOneLiner", () => { + it("renders git status and registry latest summary", () => { + const update = buildUpdate({ + installKind: "git", + git: { + root: "/tmp/repo", + sha: "abc123456789", + tag: null, + branch: "main", + upstream: "origin/main", + dirty: true, + ahead: 0, + behind: 2, + fetchOk: true, + }, + registry: { latestVersion: VERSION }, + deps: { + manager: "pnpm", + status: "ok", + lockfilePath: "pnpm-lock.yaml", + markerPath: "node_modules/.modules.yaml", + }, + }); + + expect(formatUpdateOneLiner(update)).toBe( + `Update: git main Β· ↔ origin/main Β· dirty Β· behind 2 Β· npm latest ${VERSION} Β· deps ok`, + ); + }); + + it("renders package-manager mode with registry error", () => { + const update = buildUpdate({ + installKind: "package", + packageManager: "npm", + registry: { latestVersion: null, error: "offline" }, + deps: { + manager: "npm", + status: "missing", + lockfilePath: "package-lock.json", + markerPath: "node_modules", + }, + }); + + expect(formatUpdateOneLiner(update)).toBe("Update: npm Β· npm latest unknown Β· deps missing"); + }); +}); + +describe("formatUpdateAvailableHint", () => { + it("returns null when no update is available", () => { + const update = buildUpdate({ + installKind: "package", + packageManager: "pnpm", + registry: { latestVersion: VERSION }, + }); + + expect(formatUpdateAvailableHint(update)).toBeNull(); + }); + + it("renders git and registry update details", () => { + const latestVersion = nextMajorVersion(VERSION); + const update = buildUpdate({ + installKind: "git", + git: { + root: "/tmp/repo", + sha: null, + tag: null, + branch: "main", + upstream: "origin/main", + dirty: false, + ahead: 0, + behind: 2, + fetchOk: true, + }, + registry: { latestVersion }, + }); + + expect(formatUpdateAvailableHint(update)).toBe( + `Update available (git behind 2 Β· npm ${latestVersion}). Run: openclaw update`, + ); + }); +}); diff --git a/src/commands/status.update.ts b/src/commands/status.update.ts index 9d3215995de..9fd7809989b 100644 --- a/src/commands/status.update.ts +++ b/src/commands/status.update.ts @@ -71,6 +71,24 @@ export function formatUpdateAvailableHint(update: UpdateCheckResult): string | n export function formatUpdateOneLiner(update: UpdateCheckResult): string { const parts: string[] = []; + + const appendRegistryUpdateSummary = () => { + if (update.registry?.latestVersion) { + const cmp = compareSemverStrings(VERSION, update.registry.latestVersion); + if (cmp === 0) { + parts.push(`npm latest ${update.registry.latestVersion}`); + } else if (cmp != null && cmp < 0) { + parts.push(`npm update ${update.registry.latestVersion}`); + } else { + parts.push(`npm latest ${update.registry.latestVersion} (local newer)`); + } + return; + } + if (update.registry?.error) { + parts.push("npm latest unknown"); + } + }; + if (update.installKind === "git" && update.git) { const branch = update.git.branch ? `git ${update.git.branch}` : "git"; parts.push(branch); @@ -94,33 +112,10 @@ export function formatUpdateOneLiner(update: UpdateCheckResult): string { if (update.git.fetchOk === false) { parts.push("fetch failed"); } - - if (update.registry?.latestVersion) { - const cmp = compareSemverStrings(VERSION, update.registry.latestVersion); - if (cmp === 0) { - parts.push(`npm latest ${update.registry.latestVersion}`); - } else if (cmp != null && cmp < 0) { - parts.push(`npm update ${update.registry.latestVersion}`); - } else { - parts.push(`npm latest ${update.registry.latestVersion} (local newer)`); - } - } else if (update.registry?.error) { - parts.push("npm latest unknown"); - } + appendRegistryUpdateSummary(); } else { parts.push(update.packageManager !== "unknown" ? update.packageManager : "pkg"); - if (update.registry?.latestVersion) { - const cmp = compareSemverStrings(VERSION, update.registry.latestVersion); - if (cmp === 0) { - parts.push(`npm latest ${update.registry.latestVersion}`); - } else if (cmp != null && cmp < 0) { - parts.push(`npm update ${update.registry.latestVersion}`); - } else { - parts.push(`npm latest ${update.registry.latestVersion} (local newer)`); - } - } else if (update.registry?.error) { - parts.push("npm latest unknown"); - } + appendRegistryUpdateSummary(); } if (update.deps) { diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 6f9e9941e3c..333d664112c 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -1,17 +1,12 @@ import { cancel, confirm, isCancel, multiselect } from "@clack/prompts"; import path from "node:path"; import type { RuntimeEnv } from "../runtime.js"; -import { - isNixMode, - loadConfig, - resolveConfigPath, - resolveOAuthDir, - resolveStateDir, -} from "../config/config.js"; +import { isNixMode } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js"; import { resolveHomeDir } from "../utils.js"; -import { collectWorkspaceDirs, isPathWithin, removePath } from "./cleanup-utils.js"; +import { resolveCleanupPlanFromDisk } from "./cleanup-plan.js"; +import { removePath } from "./cleanup-utils.js"; type UninstallScope = "service" | "state" | "workspace" | "app"; @@ -157,13 +152,8 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio } const dryRun = Boolean(opts.dryRun); - const cfg = loadConfig(); - const stateDir = resolveStateDir(); - const configPath = resolveConfigPath(); - const oauthDir = resolveOAuthDir(); - const configInsideState = isPathWithin(configPath, stateDir); - const oauthInsideState = isPathWithin(oauthDir, stateDir); - const workspaceDirs = collectWorkspaceDirs(cfg); + const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } = + resolveCleanupPlanFromDisk(); if (scopes.has("service")) { if (dryRun) { diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts new file mode 100644 index 00000000000..4f6eaf41bdb --- /dev/null +++ b/src/config/config-misc.test.ts @@ -0,0 +1,293 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + getConfigValueAtPath, + parseConfigPath, + setConfigValueAtPath, + unsetConfigValueAtPath, +} from "./config-paths.js"; +import { readConfigFileSnapshot, validateConfigObject } from "./config.js"; +import { withTempHome } from "./test-helpers.js"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("$schema key in config (#14998)", () => { + it("accepts config with $schema string", () => { + const result = OpenClawSchema.safeParse({ + $schema: "https://openclaw.ai/config.json", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.$schema).toBe("https://openclaw.ai/config.json"); + } + }); + + it("accepts config without $schema", () => { + const result = OpenClawSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("rejects non-string $schema", () => { + const result = OpenClawSchema.safeParse({ $schema: 123 }); + expect(result.success).toBe(false); + }); +}); + +describe("ui.seamColor", () => { + it("accepts hex colors", () => { + const res = validateConfigObject({ ui: { seamColor: "#FF4500" } }); + expect(res.ok).toBe(true); + }); + + it("rejects non-hex colors", () => { + const res = validateConfigObject({ ui: { seamColor: "lobster" } }); + expect(res.ok).toBe(false); + }); + + it("rejects invalid hex length", () => { + const res = validateConfigObject({ ui: { seamColor: "#FF4500FF" } }); + expect(res.ok).toBe(false); + }); +}); + +describe("web search provider config", () => { + it("accepts perplexity provider and config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + enabled: true, + provider: "perplexity", + perplexity: { + apiKey: "test-key", + baseUrl: "https://api.perplexity.ai", + model: "perplexity/sonar-pro", + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); +}); + +describe("talk.voiceAliases", () => { + it("accepts a string map of voice aliases", () => { + const res = validateConfigObject({ + talk: { + voiceAliases: { + Clawd: "EXAVITQu4vr4xnSDxMaL", + Roger: "CwhRBWXzGAHq8TQ4Fs17", + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects non-string voice alias values", () => { + const res = validateConfigObject({ + talk: { + voiceAliases: { + Clawd: 123, + }, + }, + }); + expect(res.ok).toBe(false); + }); +}); + +describe("gateway.remote.transport", () => { + it("accepts direct transport", () => { + const res = validateConfigObject({ + gateway: { + remote: { + transport: "direct", + url: "wss://gateway.example.ts.net", + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects unknown transport", () => { + const res = validateConfigObject({ + gateway: { + remote: { + transport: "udp", + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("gateway.remote.transport"); + } + }); +}); + +describe("gateway.tools config", () => { + it("accepts gateway.tools allow and deny lists", () => { + const res = validateConfigObject({ + gateway: { + tools: { + allow: ["gateway"], + deny: ["sessions_spawn", "sessions_send"], + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects invalid gateway.tools values", () => { + const res = validateConfigObject({ + gateway: { + tools: { + allow: "gateway", + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("gateway.tools.allow"); + } + }); +}); + +describe("cron webhook schema", () => { + it("accepts cron.webhookToken and legacy cron.webhook", () => { + const res = OpenClawSchema.safeParse({ + cron: { + enabled: true, + webhook: "https://example.invalid/legacy-cron-webhook", + webhookToken: "secret-token", + }, + }); + + expect(res.success).toBe(true); + }); + + it("rejects non-http cron.webhook URLs", () => { + const res = OpenClawSchema.safeParse({ + cron: { + webhook: "ftp://example.invalid/legacy-cron-webhook", + }, + }); + + expect(res.success).toBe(false); + }); +}); + +describe("broadcast", () => { + it("accepts a broadcast peer map with strategy", () => { + const res = validateConfigObject({ + agents: { + list: [{ id: "alfred" }, { id: "baerbel" }], + }, + broadcast: { + strategy: "parallel", + "120363403215116621@g.us": ["alfred", "baerbel"], + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects invalid broadcast strategy", () => { + const res = validateConfigObject({ + broadcast: { strategy: "nope" }, + }); + expect(res.ok).toBe(false); + }); + + it("rejects non-array broadcast entries", () => { + const res = validateConfigObject({ + broadcast: { "120363403215116621@g.us": 123 }, + }); + expect(res.ok).toBe(false); + }); +}); + +describe("model compat config schema", () => { + it("accepts full openai-completions compat fields", () => { + const res = validateConfigObject({ + models: { + providers: { + local: { + baseUrl: "http://127.0.0.1:1234/v1", + api: "openai-completions", + models: [ + { + id: "qwen3-32b", + name: "Qwen3 32B", + compat: { + supportsUsageInStreaming: true, + supportsStrictMode: false, + thinkingFormat: "qwen", + requiresToolResultName: true, + requiresAssistantAfterToolResult: false, + requiresThinkingAsText: false, + requiresMistralToolIds: false, + }, + }, + ], + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); +}); + +describe("config paths", () => { + it("rejects empty and blocked paths", () => { + expect(parseConfigPath("")).toEqual({ + ok: false, + error: "Invalid path. Use dot notation (e.g. foo.bar).", + }); + expect(parseConfigPath("__proto__.polluted").ok).toBe(false); + expect(parseConfigPath("constructor.polluted").ok).toBe(false); + expect(parseConfigPath("prototype.polluted").ok).toBe(false); + }); + + it("sets, gets, and unsets nested values", () => { + const root: Record = {}; + const parsed = parseConfigPath("foo.bar"); + if (!parsed.ok || !parsed.path) { + throw new Error("path parse failed"); + } + setConfigValueAtPath(root, parsed.path, 123); + expect(getConfigValueAtPath(root, parsed.path)).toBe(123); + expect(unsetConfigValueAtPath(root, parsed.path)).toBe(true); + expect(getConfigValueAtPath(root, parsed.path)).toBeUndefined(); + }); +}); + +describe("config strict validation", () => { + it("rejects unknown fields", async () => { + const res = validateConfigObject({ + agents: { list: [{ id: "pi" }] }, + customUnknownField: { nested: "value" }, + }); + expect(res.ok).toBe(false); + }); + + it("flags legacy config entries without auto-migrating", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ + agents: { list: [{ id: "pi" }] }, + routing: { allowFrom: ["+15555550123"] }, + }), + "utf-8", + ); + + const snap = await readConfigFileSnapshot(); + + expect(snap.valid).toBe(false); + expect(snap.legacyIssues).not.toHaveLength(0); + }); + }); +}); diff --git a/src/config/config-paths.test.ts b/src/config/config-paths.test.ts deleted file mode 100644 index a4dc7192ecd..00000000000 --- a/src/config/config-paths.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getConfigValueAtPath, - parseConfigPath, - setConfigValueAtPath, - unsetConfigValueAtPath, -} from "./config-paths.js"; - -describe("config paths", () => { - it("rejects empty and blocked paths", () => { - expect(parseConfigPath("")).toEqual({ - ok: false, - error: "Invalid path. Use dot notation (e.g. foo.bar).", - }); - expect(parseConfigPath("__proto__.polluted").ok).toBe(false); - expect(parseConfigPath("constructor.polluted").ok).toBe(false); - expect(parseConfigPath("prototype.polluted").ok).toBe(false); - }); - - it("sets, gets, and unsets nested values", () => { - const root: Record = {}; - const parsed = parseConfigPath("foo.bar"); - if (!parsed.ok || !parsed.path) { - throw new Error("path parse failed"); - } - setConfigValueAtPath(root, parsed.path, 123); - expect(getConfigValueAtPath(root, parsed.path)).toBe(123); - expect(unsetConfigValueAtPath(root, parsed.path)).toBe(true); - expect(getConfigValueAtPath(root, parsed.path)).toBeUndefined(); - }); -}); diff --git a/src/config/config.agent-concurrency-defaults.test.ts b/src/config/config.agent-concurrency-defaults.test.ts index accedc42a2b..d2fc3853914 100644 --- a/src/config/config.agent-concurrency-defaults.test.ts +++ b/src/config/config.agent-concurrency-defaults.test.ts @@ -17,19 +17,6 @@ describe("agent concurrency defaults", () => { expect(resolveSubagentMaxConcurrent({})).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT); }); - it("resolves configured values", () => { - const cfg = { - agents: { - defaults: { - maxConcurrent: 6, - subagents: { maxConcurrent: 9 }, - }, - }, - }; - expect(resolveAgentMaxConcurrent(cfg)).toBe(6); - expect(resolveSubagentMaxConcurrent(cfg)).toBe(9); - }); - it("clamps invalid values to at least 1", () => { const cfg = { agents: { diff --git a/src/config/config.broadcast.test.ts b/src/config/config.broadcast.test.ts deleted file mode 100644 index cab0cdf12b1..00000000000 --- a/src/config/config.broadcast.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("broadcast", () => { - it("accepts a broadcast peer map with strategy", () => { - const res = validateConfigObject({ - agents: { - list: [{ id: "alfred" }, { id: "baerbel" }], - }, - broadcast: { - strategy: "parallel", - "120363403215116621@g.us": ["alfred", "baerbel"], - }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects invalid broadcast strategy", () => { - const res = validateConfigObject({ - broadcast: { strategy: "nope" }, - }); - expect(res.ok).toBe(false); - }); - - it("rejects non-array broadcast entries", () => { - const res = validateConfigObject({ - broadcast: { "120363403215116621@g.us": 123 }, - }); - expect(res.ok).toBe(false); - }); -}); diff --git a/src/config/config.gateway-remote-transport.test.ts b/src/config/config.gateway-remote-transport.test.ts deleted file mode 100644 index a729a163772..00000000000 --- a/src/config/config.gateway-remote-transport.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("gateway.remote.transport", () => { - it("accepts direct transport", () => { - const res = validateConfigObject({ - gateway: { - remote: { - transport: "direct", - url: "wss://gateway.example.ts.net", - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects unknown transport", () => { - const res = validateConfigObject({ - gateway: { - remote: { - transport: "udp", - }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("gateway.remote.transport"); - } - }); -}); diff --git a/src/config/config.gateway-tools-config.test.ts b/src/config/config.gateway-tools-config.test.ts deleted file mode 100644 index 022dfc79abd..00000000000 --- a/src/config/config.gateway-tools-config.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("gateway.tools config", () => { - it("accepts gateway.tools allow and deny lists", () => { - const res = validateConfigObject({ - gateway: { - tools: { - allow: ["gateway"], - deny: ["sessions_spawn", "sessions_send"], - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects invalid gateway.tools values", () => { - const res = validateConfigObject({ - gateway: { - tools: { - allow: "gateway", - }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("gateway.tools.allow"); - } - }); -}); diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index a2d67e3f5e8..6c3d15f9bed 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -171,26 +171,4 @@ describe("config identity defaults", () => { expect(cfg.messages?.responsePrefix).toBe(""); }); }); - - it("does not derive responsePrefix from identity emoji", async () => { - await withTempHome("openclaw-config-identity-", async (home) => { - const cfg = await writeAndLoadConfig(home, { - agents: { - list: [ - { - id: "main", - identity: { - name: "OpenClaw", - theme: "space lobster", - emoji: "🦞", - }, - }, - ], - }, - messages: {}, - }); - - expect(cfg.messages?.responsePrefix).toBeUndefined(); - }); - }); }); diff --git a/src/config/config.model-compat-schema.test.ts b/src/config/config.model-compat-schema.test.ts deleted file mode 100644 index 7039e44f34c..00000000000 --- a/src/config/config.model-compat-schema.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./validation.js"; - -describe("model compat config schema", () => { - it("accepts full openai-completions compat fields", () => { - const res = validateConfigObject({ - models: { - providers: { - local: { - baseUrl: "http://127.0.0.1:1234/v1", - api: "openai-completions", - models: [ - { - id: "qwen3-32b", - name: "Qwen3 32B", - compat: { - supportsUsageInStreaming: true, - supportsStrictMode: false, - thinkingFormat: "qwen", - requiresToolResultName: true, - requiresAssistantAfterToolResult: false, - requiresThinkingAsText: false, - requiresMistralToolIds: false, - }, - }, - ], - }, - }, - }, - }); - - expect(res.ok).toBe(true); - }); -}); diff --git a/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts b/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts index da574e9a4d8..371b1da121c 100644 --- a/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts +++ b/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts @@ -12,7 +12,8 @@ import { import { withTempHome } from "./test-helpers.js"; function envWith(overrides: Record): NodeJS.ProcessEnv { - return { ...process.env, ...overrides }; + // Hermetic env: don't inherit process.env because other tests may mutate it. + return { ...overrides }; } function loadConfigForHome(home: string) { diff --git a/src/config/config.preservation-on-validation-failure.test.ts b/src/config/config.preservation-on-validation-failure.test.ts deleted file mode 100644 index b82b861d289..00000000000 --- a/src/config/config.preservation-on-validation-failure.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { readConfigFileSnapshot, validateConfigObject } from "./config.js"; -import { withTempHome } from "./test-helpers.js"; - -describe("config strict validation", () => { - it("rejects unknown fields", async () => { - const res = validateConfigObject({ - agents: { list: [{ id: "pi" }] }, - customUnknownField: { nested: "value" }, - }); - expect(res.ok).toBe(false); - }); - - it("flags legacy config entries without auto-migrating", async () => { - await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify({ - agents: { list: [{ id: "pi" }] }, - routing: { allowFrom: ["+15555550123"] }, - }), - "utf-8", - ); - - const snap = await readConfigFileSnapshot(); - - expect(snap.valid).toBe(false); - expect(snap.legacyIssues).not.toHaveLength(0); - }); - }); -}); diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 92903ff32f7..7add1d3c293 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -3,13 +3,13 @@ import { resolveSandboxBrowserConfig } from "../agents/sandbox/config.js"; import { validateConfigObject } from "./config.js"; describe("sandbox docker config", () => { - it("accepts binds array in sandbox.docker config", () => { + it("accepts safe binds array in sandbox.docker config", () => { const res = validateConfigObject({ agents: { defaults: { sandbox: { docker: { - binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"], + binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"], }, }, }, @@ -29,8 +29,8 @@ describe("sandbox docker config", () => { expect(res.ok).toBe(true); if (res.ok) { expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([ - "/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw", + "/var/data/myapp:/data:ro", ]); expect(res.config.agents?.list?.[0]?.sandbox?.docker?.binds).toEqual([ "/home/user/projects:/projects:ro", @@ -38,6 +38,51 @@ describe("sandbox docker config", () => { } }); + it("rejects network host mode via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + network: "host", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("rejects seccomp unconfined via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + seccompProfile: "unconfined", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("rejects apparmor unconfined via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + apparmorProfile: "unconfined", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + it("rejects non-string values in binds array", () => { const res = validateConfigObject({ agents: { diff --git a/src/config/config.schema-key.test.ts b/src/config/config.schema-key.test.ts deleted file mode 100644 index effa08347fa..00000000000 --- a/src/config/config.schema-key.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { OpenClawSchema } from "./zod-schema.js"; - -describe("$schema key in config (#14998)", () => { - it("accepts config with $schema string", () => { - const result = OpenClawSchema.safeParse({ - $schema: "https://openclaw.ai/config.json", - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.$schema).toBe("https://openclaw.ai/config.json"); - } - }); - - it("accepts config without $schema", () => { - const result = OpenClawSchema.safeParse({}); - expect(result.success).toBe(true); - }); - - it("rejects non-string $schema", () => { - const result = OpenClawSchema.safeParse({ $schema: 123 }); - expect(result.success).toBe(false); - }); -}); diff --git a/src/config/config.talk-voicealiases.test.ts b/src/config/config.talk-voicealiases.test.ts deleted file mode 100644 index e7e32c5b698..00000000000 --- a/src/config/config.talk-voicealiases.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("talk.voiceAliases", () => { - it("accepts a string map of voice aliases", () => { - const res = validateConfigObject({ - talk: { - voiceAliases: { - Clawd: "EXAVITQu4vr4xnSDxMaL", - Roger: "CwhRBWXzGAHq8TQ4Fs17", - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects non-string voice alias values", () => { - const res = validateConfigObject({ - talk: { - voiceAliases: { - Clawd: 123, - }, - }, - }); - expect(res.ok).toBe(false); - }); -}); diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts deleted file mode 100644 index a0f1c6acded..00000000000 --- a/src/config/config.web-search-provider.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("web search provider config", () => { - it("accepts perplexity provider and config", () => { - const res = validateConfigObject({ - tools: { - web: { - search: { - enabled: true, - provider: "perplexity", - perplexity: { - apiKey: "test-key", - baseUrl: "https://api.perplexity.ai", - model: "perplexity/sonar-pro", - }, - }, - }, - }, - }); - - expect(res.ok).toBe(true); - }); -}); diff --git a/src/config/home-env.test-harness.ts b/src/config/home-env.test-harness.ts index 02808461b0f..78abde370dc 100644 --- a/src/config/home-env.test-harness.ts +++ b/src/config/home-env.test-harness.ts @@ -1,39 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; - -type HomeEnvSnapshot = { - home: string | undefined; - userProfile: string | undefined; - homeDrive: string | undefined; - homePath: string | undefined; - stateDir: string | undefined; -}; - -function snapshotHomeEnv(): HomeEnvSnapshot { - return { - home: process.env.HOME, - userProfile: process.env.USERPROFILE, - homeDrive: process.env.HOMEDRIVE, - homePath: process.env.HOMEPATH, - stateDir: process.env.OPENCLAW_STATE_DIR, - }; -} - -function restoreHomeEnv(snapshot: HomeEnvSnapshot) { - const restoreKey = (key: string, value: string | undefined) => { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreKey("HOME", snapshot.home); - restoreKey("USERPROFILE", snapshot.userProfile); - restoreKey("HOMEDRIVE", snapshot.homeDrive); - restoreKey("HOMEPATH", snapshot.homePath); - restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir); -} +import { captureEnv } from "../test-utils/env.js"; export async function withTempHome( prefix: string, @@ -42,7 +10,13 @@ export async function withTempHome( const home = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); - const snapshot = snapshotHomeEnv(); + const snapshot = captureEnv([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_STATE_DIR", + ]); process.env.HOME = home; process.env.USERPROFILE = home; process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); @@ -58,7 +32,7 @@ export async function withTempHome( try { return await fn(home); } finally { - restoreHomeEnv(snapshot); + snapshot.restore(); await fs.rm(home, { recursive: true, force: true }); } } diff --git a/src/config/io.ts b/src/config/io.ts index 8ce2ae1d25c..e922e005a4b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -933,6 +933,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (suspiciousReasons.length === 0) { return; } + // Tests often write minimal configs (missing meta, etc); keep output quiet unless requested. + const isVitest = deps.env.VITEST === "true"; + const shouldLogInVitest = deps.env.OPENCLAW_TEST_CONFIG_WRITE_ANOMALY_LOG === "1"; + if (isVitest && !shouldLogInVitest) { + return; + } deps.logger.warn(`Config write anomaly: ${configPath} (${suspiciousReasons.join(", ")})`); }; const auditRecordBase = { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 75eeaaac0c4..04f5e34a77f 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { withTempHome } from "./home-env.test-harness.js"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { createConfigIO } from "./io.js"; describe("config io write", () => { @@ -10,6 +11,51 @@ describe("config io write", () => { error: () => {}, }; + let fixtureRoot = ""; + let caseId = 0; + + async function withTempHome(prefix: string, fn: (home: string) => Promise): Promise { + const safePrefix = prefix.trim().replace(/[^a-zA-Z0-9._-]+/g, "-") || "tmp"; + const home = path.join(fixtureRoot, `${safePrefix}${caseId++}`); + await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); + + const snapshot = captureEnv([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_STATE_DIR", + ]); + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); + + if (process.platform === "win32") { + const match = home.match(/^([A-Za-z]:)(.*)$/); + if (match) { + process.env.HOMEDRIVE = match[1]; + process.env.HOMEPATH = match[2] || "\\"; + } + } + + try { + await fn(home); + } finally { + snapshot.restore(); + } + } + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-io-")); + }); + + afterAll(async () => { + if (!fixtureRoot) { + return; + } + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + it("persists caller changes onto resolved config without leaking runtime defaults", async () => { await withTempHome("openclaw-config-io-", async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts index 036a0c5a227..3c4d187610e 100644 --- a/src/config/legacy.migrations.part-1.ts +++ b/src/config/legacy.migrations.part-1.ts @@ -6,80 +6,85 @@ import { mergeMissing, } from "./legacy.shared.js"; +function migrateBindings( + raw: Record, + changes: string[], + changeNote: string, + mutator: (match: Record) => boolean, +) { + const bindings = Array.isArray(raw.bindings) ? raw.bindings : null; + if (!bindings) { + return; + } + + let touched = false; + for (const entry of bindings) { + if (!isRecord(entry)) { + continue; + } + const match = getRecord(entry.match); + if (!match) { + continue; + } + if (!mutator(match)) { + continue; + } + entry.match = match; + touched = true; + } + + if (touched) { + raw.bindings = bindings; + changes.push(changeNote); + } +} + export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ { id: "bindings.match.provider->bindings.match.channel", describe: "Move bindings[].match.provider to bindings[].match.channel", apply: (raw, changes) => { - const bindings = Array.isArray(raw.bindings) ? raw.bindings : null; - if (!bindings) { - return; - } - - let touched = false; - for (const entry of bindings) { - if (!isRecord(entry)) { - continue; - } - const match = getRecord(entry.match); - if (!match) { - continue; - } - if (typeof match.channel === "string" && match.channel.trim()) { - continue; - } - const provider = typeof match.provider === "string" ? match.provider.trim() : ""; - if (!provider) { - continue; - } - match.channel = provider; - delete match.provider; - entry.match = match; - touched = true; - } - - if (touched) { - raw.bindings = bindings; - changes.push("Moved bindings[].match.provider β†’ bindings[].match.channel."); - } + migrateBindings( + raw, + changes, + "Moved bindings[].match.provider β†’ bindings[].match.channel.", + (match) => { + if (typeof match.channel === "string" && match.channel.trim()) { + return false; + } + const provider = typeof match.provider === "string" ? match.provider.trim() : ""; + if (!provider) { + return false; + } + match.channel = provider; + delete match.provider; + return true; + }, + ); }, }, { id: "bindings.match.accountID->bindings.match.accountId", describe: "Move bindings[].match.accountID to bindings[].match.accountId", apply: (raw, changes) => { - const bindings = Array.isArray(raw.bindings) ? raw.bindings : null; - if (!bindings) { - return; - } - - let touched = false; - for (const entry of bindings) { - if (!isRecord(entry)) { - continue; - } - const match = getRecord(entry.match); - if (!match) { - continue; - } - if (match.accountId !== undefined) { - continue; - } - const accountID = - typeof match.accountID === "string" ? match.accountID.trim() : match.accountID; - if (!accountID) { - continue; - } - match.accountId = accountID; - delete match.accountID; - entry.match = match; - touched = true; - } - - if (touched) { - raw.bindings = bindings; - changes.push("Moved bindings[].match.accountID β†’ bindings[].match.accountId."); - } + migrateBindings( + raw, + changes, + "Moved bindings[].match.accountID β†’ bindings[].match.accountId.", + (match) => { + if (match.accountId !== undefined) { + return false; + } + const accountID = + typeof match.accountID === "string" ? match.accountID.trim() : match.accountID; + if (!accountID) { + return false; + } + match.accountId = accountID; + delete match.accountID; + return true; + }, + ); }, }, { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 62a6d05e95b..b246c1ea6d4 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -71,6 +71,8 @@ export const FIELD_HELP: Record = { "Allow stdin-only safe binaries to run without explicit allowlist entries.", "tools.fs.workspaceOnly": "Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).", + "tools.sessions.visibility": + 'Controls which sessions can be targeted by sessions_list/sessions_history/sessions_send. ("tree" default = current session + spawned subagent sessions; "self" = only current; "agent" = any session in the current agent id; "all" = any session; cross-agent still requires tools.agentToAgent).', "tools.message.allowCrossContextSend": "Legacy override: allow cross-context sends across all providers.", "tools.message.crossContext.allowWithinProvider": @@ -373,6 +375,8 @@ export const FIELD_HELP: Record = { "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", + "channels.discord.ui.components.accentColor": + "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.", "channels.discord.intents.presence": "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", "channels.discord.intents.guildMembers": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index c27ca6d5311..e7fc90854ca 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -74,6 +74,7 @@ export const FIELD_LABELS: Record = { "tools.exec.applyPatch.workspaceOnly": "apply_patch Workspace-Only", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", "tools.fs.workspaceOnly": "Workspace-only FS tools", + "tools.sessions.visibility": "Session Tools Visibility", "tools.exec.notifyOnExit": "Exec Notify On Exit", "tools.exec.notifyOnExitEmptySuccess": "Exec Notify On Empty Success", "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", @@ -261,6 +262,7 @@ export const FIELD_LABELS: Record = { "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", "channels.discord.retry.jitter": "Discord Retry Jitter", "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.ui.components.accentColor": "Discord Component Accent Color", "channels.discord.intents.presence": "Discord Presence Intent", "channels.discord.intents.guildMembers": "Discord Guild Members Intent", "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts index 92922576bb5..cc3c6cb75a4 100644 --- a/src/config/sessions.cache.test.ts +++ b/src/config/sessions.cache.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { clearSessionStoreCacheForTest, loadSessionStore, @@ -10,12 +10,23 @@ import { } from "./sessions.js"; describe("Session Store Cache", () => { + let fixtureRoot = ""; + let caseId = 0; let testDir: string; let storePath: string; + beforeAll(() => { + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "session-cache-test-")); + }); + + afterAll(() => { + if (fixtureRoot) { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + beforeEach(() => { - // Create a temporary directory for test - testDir = path.join(os.tmpdir(), `session-cache-test-${Date.now()}`); + testDir = path.join(fixtureRoot, `case-${caseId++}`); fs.mkdirSync(testDir, { recursive: true }); storePath = path.join(testDir, "sessions.json"); @@ -27,10 +38,6 @@ describe("Session Store Cache", () => { }); afterEach(() => { - // Clean up test directory - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } clearSessionStoreCacheForTest(); delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS; }); diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 686a46d3740..4bce24426a4 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -2,7 +2,6 @@ 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 { sleep } from "../utils.js"; import { buildGroupDisplayName, deriveSessionKey, @@ -490,24 +489,39 @@ describe("sessions", () => { "utf-8", ); - await Promise.all([ - updateSessionStoreEntry({ - storePath, - sessionKey: mainSessionKey, - update: async () => { - await sleep(10); - return { modelOverride: "anthropic/claude-opus-4-5" }; - }, - }), - updateSessionStoreEntry({ - storePath, - sessionKey: mainSessionKey, - update: async () => { - await sleep(1); - return { thinkingLevel: "high" }; - }, - }), - ]); + const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; + const firstStarted = createDeferred(); + const releaseFirst = createDeferred(); + + const p1 = updateSessionStoreEntry({ + storePath, + sessionKey: mainSessionKey, + update: async () => { + firstStarted.resolve(); + await releaseFirst.promise; + return { modelOverride: "anthropic/claude-opus-4-5" }; + }, + }); + const p2 = updateSessionStoreEntry({ + storePath, + sessionKey: mainSessionKey, + update: async () => { + await firstStarted.promise; + return { thinkingLevel: "high" }; + }, + }); + + await firstStarted.promise; + releaseFirst.resolve(); + await Promise.all([p1, p2]); const store = loadSessionStore(storePath); expect(store[mainSessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5"); diff --git a/src/config/sessions/metadata.test.ts b/src/config/sessions/metadata.test.ts deleted file mode 100644 index c85624f0cbb..00000000000 --- a/src/config/sessions/metadata.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { deriveSessionMetaPatch } from "./metadata.js"; - -describe("deriveSessionMetaPatch", () => { - it("captures origin + group metadata", () => { - const patch = deriveSessionMetaPatch({ - ctx: { - Provider: "whatsapp", - ChatType: "group", - GroupSubject: "Family", - From: "123@g.us", - }, - sessionKey: "agent:main:whatsapp:group:123@g.us", - }); - - expect(patch?.origin?.label).toBe("Family id:123@g.us"); - expect(patch?.origin?.provider).toBe("whatsapp"); - expect(patch?.subject).toBe("Family"); - expect(patch?.channel).toBe("whatsapp"); - expect(patch?.groupId).toBe("123@g.us"); - }); -}); diff --git a/src/config/sessions/paths.test.ts b/src/config/sessions/paths.test.ts deleted file mode 100644 index 443b7791b8f..00000000000 --- a/src/config/sessions/paths.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - resolveSessionFilePath, - resolveSessionFilePathOptions, - resolveSessionTranscriptPath, - resolveSessionTranscriptPathInDir, - resolveStorePath, - validateSessionId, -} from "./paths.js"; - -describe("resolveStorePath", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("uses OPENCLAW_HOME for tilde expansion", () => { - vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); - vi.stubEnv("HOME", "/home/other"); - - const resolved = resolveStorePath("~/.openclaw/agents/{agentId}/sessions/sessions.json", { - agentId: "research", - }); - - expect(resolved).toBe( - path.resolve("/srv/openclaw-home/.openclaw/agents/research/sessions/sessions.json"), - ); - }); -}); - -describe("session path safety", () => { - it("validates safe session IDs", () => { - expect(validateSessionId("sess-1")).toBe("sess-1"); - expect(validateSessionId("ABC_123.hello")).toBe("ABC_123.hello"); - }); - - it("rejects unsafe session IDs", () => { - expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/); - expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/); - expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/); - expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/); - }); - - it("resolves transcript path inside an explicit sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b"); - - expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl")); - }); - - it("rejects unsafe sessionFile candidates that escape the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - expect(() => - resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }), - ).toThrow(/within sessions directory/); - - expect(() => - resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }), - ).toThrow(/within sessions directory/); - }); - - it("accepts sessionFile candidates within the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: "subdir/threaded-session.jsonl" }, - { sessionsDir }, - ); - - expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl")); - }); - - it("accepts absolute sessionFile paths that resolve within the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123.jsonl" }, - { sessionsDir }, - ); - - expect(resolved).toBe(path.resolve(sessionsDir, "abc-123.jsonl")); - }); - - it("accepts absolute sessionFile with topic suffix within the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123-topic-42.jsonl" }, - { sessionsDir }, - ); - - expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl")); - }); - - it("rejects absolute sessionFile paths outside known agent sessions dirs", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - expect(() => - resolveSessionFilePath( - "sess-1", - { sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" }, - { sessionsDir }, - ), - ).toThrow(/within sessions directory/); - }); - - it("uses explicit agentId fallback for absolute sessionFile outside sessionsDir", () => { - const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); - const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); - const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir, agentId: "ops" }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses absolute path fallback when sessionFile includes a different agent dir", () => { - const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); - const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); - const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses sibling fallback for custom per-agent store roots", () => { - const mainSessionsDir = "/srv/custom/agents/main/sessions"; - const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir, agentId: "ops" }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses extracted agent fallback for custom per-agent store roots", () => { - const mainSessionsDir = "/srv/custom/agents/main/sessions"; - const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses agent sessions dir fallback for transcript path", () => { - const resolved = resolveSessionTranscriptPath("sess-1", "main"); - expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true); - }); - - it("keeps storePath and agentId when resolving session file options", () => { - const opts = resolveSessionFilePathOptions({ - storePath: "/tmp/custom/agent-store/sessions.json", - agentId: "ops", - }); - expect(opts).toEqual({ - sessionsDir: path.resolve("/tmp/custom/agent-store"), - agentId: "ops", - }); - }); - - it("keeps custom per-agent store roots when agentId is provided", () => { - const opts = resolveSessionFilePathOptions({ - storePath: "/srv/custom/agents/ops/sessions/sessions.json", - agentId: "ops", - }); - expect(opts).toEqual({ - sessionsDir: path.resolve("/srv/custom/agents/ops/sessions"), - agentId: "ops", - }); - }); - - it("falls back to agentId when storePath is absent", () => { - const opts = resolveSessionFilePathOptions({ agentId: "ops" }); - expect(opts).toEqual({ agentId: "ops" }); - }); -}); diff --git a/src/config/sessions/reset.test.ts b/src/config/sessions/reset.test.ts deleted file mode 100644 index 01962a887e5..00000000000 --- a/src/config/sessions/reset.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SessionConfig } from "../types.base.js"; -import { resolveSessionResetPolicy } from "./reset.js"; - -describe("resolveSessionResetPolicy", () => { - describe("backward compatibility: resetByType.dm β†’ direct", () => { - it("uses resetByType.direct when available", () => { - const sessionCfg = { - resetByType: { - direct: { mode: "idle" as const, idleMinutes: 30 }, - }, - } satisfies SessionConfig; - - const policy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "direct", - }); - - expect(policy.mode).toBe("idle"); - expect(policy.idleMinutes).toBe(30); - }); - - it("falls back to resetByType.dm (legacy) when direct is missing", () => { - // Simulating legacy config with "dm" key instead of "direct" - const sessionCfg = { - resetByType: { - dm: { mode: "idle" as const, idleMinutes: 45 }, - }, - } as unknown as SessionConfig; - - const policy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "direct", - }); - - expect(policy.mode).toBe("idle"); - expect(policy.idleMinutes).toBe(45); - }); - - it("prefers resetByType.direct over resetByType.dm when both present", () => { - const sessionCfg = { - resetByType: { - direct: { mode: "daily" as const }, - dm: { mode: "idle" as const, idleMinutes: 99 }, - }, - } as unknown as SessionConfig; - - const policy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "direct", - }); - - expect(policy.mode).toBe("daily"); - }); - - it("does not use dm fallback for group/thread types", () => { - const sessionCfg = { - resetByType: { - dm: { mode: "idle" as const, idleMinutes: 45 }, - }, - } as unknown as SessionConfig; - - const groupPolicy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "group", - }); - - // Should use default mode since group has no config and dm doesn't apply - expect(groupPolicy.mode).toBe("daily"); - }); - }); -}); diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts new file mode 100644 index 00000000000..f29ff5a7f2b --- /dev/null +++ b/src/config/sessions/sessions.test.ts @@ -0,0 +1,201 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { SessionConfig } from "../types.base.js"; +import type { SessionEntry } from "./types.js"; +import { + clearSessionStoreCacheForTest, + loadSessionStore, + updateSessionStore, +} from "../sessions.js"; +import { + resolveSessionFilePath, + resolveSessionTranscriptPathInDir, + validateSessionId, +} from "./paths.js"; +import { resolveSessionResetPolicy } from "./reset.js"; +import { appendAssistantMessageToSessionTranscript } from "./transcript.js"; + +describe("session path safety", () => { + it("rejects unsafe session IDs", () => { + expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/); + }); + + it("resolves transcript path inside an explicit sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b"); + + expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl")); + }); + + it("rejects absolute sessionFile paths outside known agent sessions dirs", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + expect(() => + resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" }, + { sessionsDir }, + ), + ).toThrow(/within sessions directory/); + }); +}); + +describe("resolveSessionResetPolicy", () => { + describe("backward compatibility: resetByType.dm -> direct", () => { + it("does not use dm fallback for group/thread types", () => { + const sessionCfg = { + resetByType: { + dm: { mode: "idle" as const, idleMinutes: 45 }, + }, + } as unknown as SessionConfig; + + const groupPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "group", + }); + + expect(groupPolicy.mode).toBe("daily"); + }); + }); +}); + +describe("session store lock (Promise chain mutex)", () => { + let lockFixtureRoot = ""; + let lockCaseId = 0; + let lockTmpDirs: string[] = []; + + async function makeTmpStore( + initial: Record = {}, + ): Promise<{ dir: string; storePath: string }> { + const dir = path.join(lockFixtureRoot, `case-${lockCaseId++}`); + await fsPromises.mkdir(dir); + lockTmpDirs.push(dir); + const storePath = path.join(dir, "sessions.json"); + if (Object.keys(initial).length > 0) { + await fsPromises.writeFile(storePath, JSON.stringify(initial, null, 2), "utf-8"); + } + return { dir, storePath }; + } + + beforeAll(async () => { + lockFixtureRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-test-")); + }); + + afterAll(async () => { + if (lockFixtureRoot) { + await fsPromises.rm(lockFixtureRoot, { recursive: true, force: true }).catch(() => undefined); + } + }); + + afterEach(async () => { + clearSessionStoreCacheForTest(); + lockTmpDirs = []; + }); + + it("serializes concurrent updateSessionStore calls without data loss", async () => { + const key = "agent:main:test"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100, counter: 0 }, + }); + + const N = 4; + await Promise.all( + Array.from({ length: N }, (_, i) => + updateSessionStore(storePath, async (store) => { + const entry = store[key] as Record; + await Promise.resolve(); + entry.counter = (entry.counter as number) + 1; + entry.tag = `writer-${i}`; + }), + ), + ); + + const store = loadSessionStore(storePath); + expect((store[key] as Record).counter).toBe(N); + }); + + it("multiple consecutive errors do not permanently poison the queue", async () => { + const key = "agent:main:multi-err"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100 }, + }); + + const errors = Array.from({ length: 3 }, (_, i) => + updateSessionStore(storePath, async () => { + throw new Error(`fail-${i}`); + }), + ); + + const success = updateSessionStore(storePath, async (store) => { + store[key] = { ...store[key], modelOverride: "recovered" } as unknown as SessionEntry; + }); + + for (const p of errors) { + await expect(p).rejects.toThrow(); + } + await success; + + const store = loadSessionStore(storePath); + expect(store[key]?.modelOverride).toBe("recovered"); + }); +}); + +describe("appendAssistantMessageToSessionTranscript", () => { + let tempDir: string; + let storePath: string; + let sessionsDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-")); + sessionsDir = path.join(tempDir, "agents", "main", "sessions"); + fs.mkdirSync(sessionsDir, { recursive: true }); + storePath = path.join(sessionsDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("creates transcript file and appends message for valid session", async () => { + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(storePath, JSON.stringify(store), "utf-8"); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + storePath, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(fs.existsSync(result.sessionFile)).toBe(true); + + const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + + const header = JSON.parse(lines[0]); + expect(header.type).toBe("session"); + expect(header.id).toBe(sessionId); + + const messageLine = JSON.parse(lines[1]); + expect(messageLine.type).toBe("message"); + expect(messageLine.message.role).toBe("assistant"); + expect(messageLine.message.content[0].type).toBe("text"); + expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); + } + }); +}); diff --git a/src/config/sessions/store.lock.test.ts b/src/config/sessions/store.lock.test.ts deleted file mode 100644 index 826b3715e33..00000000000 --- a/src/config/sessions/store.lock.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { SessionEntry } from "./types.js"; -import { - clearSessionStoreCacheForTest, - getSessionStoreLockQueueSizeForTest, - loadSessionStore, - updateSessionStore, - updateSessionStoreEntry, - withSessionStoreLockForTest, -} from "../sessions.js"; - -describe("session store lock (Promise chain mutex)", () => { - let fixtureRoot = ""; - let caseId = 0; - let tmpDirs: string[] = []; - - function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; - } - - async function waitForFile(filePath: string, maxTicks = 50): Promise { - for (let tick = 0; tick < maxTicks; tick += 1) { - try { - await fs.access(filePath); - return; - } catch { - // Works under both real + fake timers (setImmediate is faked). - await new Promise((resolve) => process.nextTick(resolve)); - } - } - throw new Error(`timed out waiting for file: ${filePath}`); - } - - async function makeTmpStore( - initial: Record = {}, - ): Promise<{ dir: string; storePath: string }> { - const dir = path.join(fixtureRoot, `case-${caseId++}`); - await fs.mkdir(dir); - tmpDirs.push(dir); - const storePath = path.join(dir, "sessions.json"); - if (Object.keys(initial).length > 0) { - await fs.writeFile(storePath, JSON.stringify(initial, null, 2), "utf-8"); - } - return { dir, storePath }; - } - - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-test-")); - }); - - afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); - } - }); - - afterEach(async () => { - clearSessionStoreCacheForTest(); - tmpDirs = []; - }); - - // ── 1. Concurrent access does not corrupt data ────────────────────── - - it("serializes concurrent updateSessionStore calls without data loss", async () => { - const key = "agent:main:test"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100, counter: 0 }, - }); - - // Launch 10 concurrent read-modify-write cycles. - const N = 10; - await Promise.all( - Array.from({ length: N }, (_, i) => - updateSessionStore(storePath, async (store) => { - const entry = store[key] as Record; - // Keep an async boundary so stale-read races would surface without serialization. - await Promise.resolve(); - entry.counter = (entry.counter as number) + 1; - entry.tag = `writer-${i}`; - }), - ), - ); - - const store = loadSessionStore(storePath); - expect((store[key] as Record).counter).toBe(N); - }); - - it("concurrent updateSessionStoreEntry patches all merge correctly", async () => { - const key = "agent:main:merge"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - await Promise.all([ - updateSessionStoreEntry({ - storePath, - sessionKey: key, - update: async () => { - await Promise.resolve(); - return { modelOverride: "model-a" }; - }, - }), - updateSessionStoreEntry({ - storePath, - sessionKey: key, - update: async () => { - await Promise.resolve(); - return { thinkingLevel: "high" as const }; - }, - }), - updateSessionStoreEntry({ - storePath, - sessionKey: key, - update: async () => { - await Promise.resolve(); - return { systemPromptOverride: "custom" }; - }, - }), - ]); - - const store = loadSessionStore(storePath); - const entry = store[key]; - expect(entry.modelOverride).toBe("model-a"); - expect(entry.thinkingLevel).toBe("high"); - expect(entry.systemPromptOverride).toBe("custom"); - }); - - // ── 2. Error in fn() does not break queue ─────────────────────────── - - it("continues processing queued tasks after a preceding task throws", async () => { - const key = "agent:main:err"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - const errorPromise = updateSessionStore(storePath, async () => { - throw new Error("boom"); - }); - - // Queue a second write immediately after the failing one. - const successPromise = updateSessionStore(storePath, async (store) => { - store[key] = { ...store[key], modelOverride: "after-error" } as unknown as SessionEntry; - }); - - await expect(errorPromise).rejects.toThrow("boom"); - await successPromise; // must resolve, not hang or reject - - const store = loadSessionStore(storePath); - expect(store[key]?.modelOverride).toBe("after-error"); - }); - - it("multiple consecutive errors do not permanently poison the queue", async () => { - const key = "agent:main:multi-err"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - const errors = Array.from({ length: 3 }, (_, i) => - updateSessionStore(storePath, async () => { - throw new Error(`fail-${i}`); - }), - ); - - const success = updateSessionStore(storePath, async (store) => { - store[key] = { ...store[key], modelOverride: "recovered" } as unknown as SessionEntry; - }); - - // All error promises reject. - for (const p of errors) { - await expect(p).rejects.toThrow(); - } - // The trailing write succeeds. - await success; - - const store = loadSessionStore(storePath); - expect(store[key]?.modelOverride).toBe("recovered"); - }); - - // ── 3. Different storePaths run independently / in parallel ───────── - - it("operations on different storePaths execute concurrently", async () => { - const { storePath: pathA } = await makeTmpStore({ - a: { sessionId: "a", updatedAt: 100 }, - }); - const { storePath: pathB } = await makeTmpStore({ - b: { sessionId: "b", updatedAt: 100 }, - }); - - const order: string[] = []; - let started = 0; - let releaseBoth: (() => void) | undefined; - const gate = new Promise((resolve) => { - releaseBoth = resolve; - }); - const markStarted = () => { - started += 1; - if (started === 2) { - releaseBoth?.(); - } - }; - - const opA = updateSessionStore(pathA, async (store) => { - order.push("a-start"); - markStarted(); - await gate; - store.a = { ...store.a, modelOverride: "done-a" } as unknown as SessionEntry; - order.push("a-end"); - }); - - const opB = updateSessionStore(pathB, async (store) => { - order.push("b-start"); - markStarted(); - await gate; - store.b = { ...store.b, modelOverride: "done-b" } as unknown as SessionEntry; - order.push("b-end"); - }); - - await Promise.all([opA, opB]); - - // Parallel behavior: both ops start before either one finishes. - const aStart = order.indexOf("a-start"); - const bStart = order.indexOf("b-start"); - const aEnd = order.indexOf("a-end"); - const bEnd = order.indexOf("b-end"); - const firstEnd = Math.min(aEnd, bEnd); - expect(aStart).toBeGreaterThanOrEqual(0); - expect(bStart).toBeGreaterThanOrEqual(0); - expect(aEnd).toBeGreaterThanOrEqual(0); - expect(bEnd).toBeGreaterThanOrEqual(0); - expect(aStart).toBeLessThan(firstEnd); - expect(bStart).toBeLessThan(firstEnd); - - expect(loadSessionStore(pathA).a?.modelOverride).toBe("done-a"); - expect(loadSessionStore(pathB).b?.modelOverride).toBe("done-b"); - }); - - // ── 4. LOCK_QUEUES cleanup ───────────────────────────────────────── - - it("cleans up LOCK_QUEUES entry after all tasks complete", async () => { - const { storePath } = await makeTmpStore({ - x: { sessionId: "x", updatedAt: 100 }, - }); - - await updateSessionStore(storePath, async (store) => { - store.x = { ...store.x, modelOverride: "done" } as unknown as SessionEntry; - }); - - // Allow microtask (finally) to run. - await Promise.resolve(); - - expect(getSessionStoreLockQueueSizeForTest()).toBe(0); - }); - - it("cleans up LOCK_QUEUES entry even after errors", async () => { - const { storePath } = await makeTmpStore({}); - - await updateSessionStore(storePath, async () => { - throw new Error("fail"); - }).catch(() => undefined); - - await Promise.resolve(); - - expect(getSessionStoreLockQueueSizeForTest()).toBe(0); - }); - - // ── 5. FIFO order guarantee ────────────────────────────────────────── - - it("executes queued operations in FIFO order", async () => { - const key = "agent:main:fifo"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100, order: "" }, - }); - - const executionOrder: number[] = []; - - // Queue 5 operations sequentially (no awaiting in between). - const promises = Array.from({ length: 5 }, (_, i) => - updateSessionStore(storePath, async (store) => { - executionOrder.push(i); - const entry = store[key] as Record; - entry.order = ((entry.order as string) || "") + String(i); - }), - ); - - await Promise.all(promises); - - // Execution order must be 0, 1, 2, 3, 4 (FIFO). - expect(executionOrder).toEqual([0, 1, 2, 3, 4]); - - // The store should reflect sequential application. - const store = loadSessionStore(storePath); - expect((store[key] as Record).order).toBe("01234"); - }); - - it("times out queued operations strictly and does not run them later", async () => { - vi.useFakeTimers(); - try { - const { storePath } = await makeTmpStore({ - x: { sessionId: "x", updatedAt: 100 }, - }); - let timedOutRan = false; - - const lockPath = `${storePath}.lock`; - const releaseLock = createDeferred(); - const lockHolder = withSessionStoreLockForTest( - storePath, - async () => { - await releaseLock.promise; - }, - { timeoutMs: 1_000 }, - ); - await waitForFile(lockPath); - const timedOut = withSessionStoreLockForTest( - storePath, - async () => { - timedOutRan = true; - }, - { timeoutMs: 5 }, - ); - - // Attach rejection handler before advancing fake timers to avoid unhandled rejections. - const timedOutExpectation = expect(timedOut).rejects.toThrow( - "timeout waiting for session store lock", - ); - await vi.advanceTimersByTimeAsync(5); - await timedOutExpectation; - releaseLock.resolve(); - await lockHolder; - await vi.runOnlyPendingTimersAsync(); - expect(timedOutRan).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it("creates and removes lock file while operation runs", async () => { - const key = "agent:main:no-lock-file"; - const { dir, storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - const lockPath = `${storePath}.lock`; - const allowWrite = createDeferred(); - const write = updateSessionStore(storePath, async (store) => { - await allowWrite.promise; - store[key] = { ...store[key], modelOverride: "v" } as unknown as SessionEntry; - }); - - await waitForFile(lockPath); - allowWrite.resolve(); - await write; - - const files = await fs.readdir(dir); - const lockFiles = files.filter((f) => f.endsWith(".lock")); - expect(lockFiles).toHaveLength(0); - }); -}); diff --git a/src/config/sessions/store.pruning.e2e.test.ts b/src/config/sessions/store.pruning.e2e.test.ts new file mode 100644 index 00000000000..92cd0da77fd --- /dev/null +++ b/src/config/sessions/store.pruning.e2e.test.ts @@ -0,0 +1,114 @@ +import crypto from "node:crypto"; +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 type { SessionEntry } from "./types.js"; +import { clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } from "./store.js"; + +// Keep integration tests deterministic: never read a real openclaw.json. +vi.mock("../config.js", () => ({ + loadConfig: vi.fn().mockReturnValue({}), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; + +let fixtureRoot = ""; +let fixtureCount = 0; + +function makeEntry(updatedAt: number): SessionEntry { + return { sessionId: crypto.randomUUID(), updatedAt }; +} + +async function createCaseDir(prefix: string): Promise { + const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); + await fs.mkdir(dir, { recursive: true }); + return dir; +} + +describe("Integration: saveSessionStore with pruning", () => { + let testDir: string; + let storePath: string; + let savedCacheTtl: string | undefined; + let mockLoadConfig: ReturnType; + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pruning-integ-")); + }); + + afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + + beforeEach(async () => { + testDir = await createCaseDir("pruning-integ"); + storePath = path.join(testDir, "sessions.json"); + savedCacheTtl = process.env.OPENCLAW_SESSION_CACHE_TTL_MS; + process.env.OPENCLAW_SESSION_CACHE_TTL_MS = "0"; + clearSessionStoreCacheForTest(); + + const configModule = await import("../config.js"); + mockLoadConfig = configModule.loadConfig as ReturnType; + }); + + afterEach(() => { + vi.restoreAllMocks(); + clearSessionStoreCacheForTest(); + if (savedCacheTtl === undefined) { + delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS; + } else { + process.env.OPENCLAW_SESSION_CACHE_TTL_MS = savedCacheTtl; + } + }); + + it("saveSessionStore prunes stale entries on write", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "7d", + maxEntries: 500, + rotateBytes: 10_485_760, + }, + }, + }); + + const now = Date.now(); + const store: Record = { + stale: makeEntry(now - 30 * DAY_MS), + fresh: makeEntry(now), + }; + + await saveSessionStore(storePath, store); + + const loaded = loadSessionStore(storePath); + expect(loaded.stale).toBeUndefined(); + expect(loaded.fresh).toBeDefined(); + }); + + it("saveSessionStore skips enforcement when maintenance mode is warn", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "warn", + pruneAfter: "7d", + maxEntries: 1, + rotateBytes: 10_485_760, + }, + }, + }); + + const now = Date.now(); + const store: Record = { + stale: makeEntry(now - 30 * DAY_MS), + fresh: makeEntry(now), + }; + + await saveSessionStore(storePath, store); + + const loaded = loadSessionStore(storePath); + expect(loaded.stale).toBeDefined(); + expect(loaded.fresh).toBeDefined(); + expect(Object.keys(loaded)).toHaveLength(2); + }); +}); diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index 973763b55c5..a31a97b8f91 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -2,23 +2,9 @@ import crypto from "node:crypto"; 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 { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "./types.js"; -import { - capEntryCount, - clearSessionStoreCacheForTest, - loadSessionStore, - pruneStaleEntries, - rotateSessionFile, - saveSessionStore, -} from "./store.js"; - -// Mock loadConfig so resolveMaintenanceConfig() never reads a real openclaw.json. -// Unit tests always pass explicit overrides so this mock is inert for them. -// Integration tests set return values to control the config. -vi.mock("../config.js", () => ({ - loadConfig: vi.fn().mockReturnValue({}), -})); +import { capEntryCount, pruneStaleEntries, rotateSessionFile } from "./store.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -66,94 +52,6 @@ describe("pruneStaleEntries", () => { expect(store.old).toBeUndefined(); expect(store.fresh).toBeDefined(); }); - - it("keeps entries newer than maxAgeDays", () => { - const now = Date.now(); - const store = makeStore([ - ["a", makeEntry(now - 1 * DAY_MS)], - ["b", makeEntry(now - 6 * DAY_MS)], - ["c", makeEntry(now)], - ]); - - const pruned = pruneStaleEntries(store, 7 * DAY_MS); - - expect(pruned).toBe(0); - expect(Object.keys(store)).toHaveLength(3); - }); - - it("keeps entries with no updatedAt", () => { - const store: Record = { - noDate: { sessionId: crypto.randomUUID() } as SessionEntry, - fresh: makeEntry(Date.now()), - }; - - const pruned = pruneStaleEntries(store, 1 * DAY_MS); - - expect(pruned).toBe(0); - expect(store.noDate).toBeDefined(); - }); - - it("empty store is a no-op", () => { - const store: Record = {}; - const pruned = pruneStaleEntries(store, 30 * DAY_MS); - - expect(pruned).toBe(0); - expect(Object.keys(store)).toHaveLength(0); - }); - - it("all entries stale results in empty store", () => { - const now = Date.now(); - const store = makeStore([ - ["a", makeEntry(now - 10 * DAY_MS)], - ["b", makeEntry(now - 20 * DAY_MS)], - ["c", makeEntry(now - 100 * DAY_MS)], - ]); - - const pruned = pruneStaleEntries(store, 5 * DAY_MS); - - expect(pruned).toBe(3); - expect(Object.keys(store)).toHaveLength(0); - }); - - it("returns count of pruned entries", () => { - const now = Date.now(); - const store = makeStore([ - ["stale1", makeEntry(now - 15 * DAY_MS)], - ["stale2", makeEntry(now - 30 * DAY_MS)], - ["fresh1", makeEntry(now - 5 * DAY_MS)], - ["fresh2", makeEntry(now)], - ]); - - const pruned = pruneStaleEntries(store, 10 * DAY_MS); - - expect(pruned).toBe(2); - expect(Object.keys(store)).toHaveLength(2); - }); - - it("entry exactly at the boundary is kept", () => { - const now = Date.now(); - const store = makeStore([["borderline", makeEntry(now - 30 * DAY_MS + 1000)]]); - - const pruned = pruneStaleEntries(store, 30 * DAY_MS); - - expect(pruned).toBe(0); - expect(store.borderline).toBeDefined(); - }); - - it("falls back to built-in default (30 days) when no override given", () => { - const now = Date.now(); - const store = makeStore([ - ["old", makeEntry(now - 31 * DAY_MS)], - ["fresh", makeEntry(now - 29 * DAY_MS)], - ]); - - // loadConfig mock returns {} β†’ maintenance is undefined β†’ default 30 days - const pruned = pruneStaleEntries(store); - - expect(pruned).toBe(1); - expect(store.old).toBeUndefined(); - expect(store.fresh).toBeDefined(); - }); }); describe("capEntryCount", () => { @@ -177,90 +75,6 @@ describe("capEntryCount", () => { expect(store.oldest).toBeUndefined(); expect(store.old).toBeUndefined(); }); - - it("under limit: no-op", () => { - const store = makeStore([ - ["a", makeEntry(Date.now())], - ["b", makeEntry(Date.now() - DAY_MS)], - ]); - - const evicted = capEntryCount(store, 10); - - expect(evicted).toBe(0); - expect(Object.keys(store)).toHaveLength(2); - }); - - it("exactly at limit: no-op", () => { - const now = Date.now(); - const store = makeStore([ - ["a", makeEntry(now)], - ["b", makeEntry(now - DAY_MS)], - ["c", makeEntry(now - 2 * DAY_MS)], - ]); - - const evicted = capEntryCount(store, 3); - - expect(evicted).toBe(0); - expect(Object.keys(store)).toHaveLength(3); - }); - - it("entries without updatedAt are evicted first (lowest priority)", () => { - const now = Date.now(); - const store: Record = { - noDate1: { sessionId: crypto.randomUUID() } as SessionEntry, - noDate2: { sessionId: crypto.randomUUID() } as SessionEntry, - recent: makeEntry(now), - older: makeEntry(now - DAY_MS), - }; - - const evicted = capEntryCount(store, 2); - - expect(evicted).toBe(2); - expect(store.recent).toBeDefined(); - expect(store.older).toBeDefined(); - expect(store.noDate1).toBeUndefined(); - expect(store.noDate2).toBeUndefined(); - }); - - it("returns count of evicted entries", () => { - const now = Date.now(); - const store = makeStore([ - ["a", makeEntry(now)], - ["b", makeEntry(now - DAY_MS)], - ["c", makeEntry(now - 2 * DAY_MS)], - ]); - - const evicted = capEntryCount(store, 1); - - expect(evicted).toBe(2); - expect(Object.keys(store)).toHaveLength(1); - expect(store.a).toBeDefined(); - }); - - it("falls back to built-in default (500) when no override given", () => { - const now = Date.now(); - const entries: Array<[string, SessionEntry]> = []; - for (let i = 0; i < 501; i++) { - entries.push([`key-${i}`, makeEntry(now - i * 1000)]); - } - const store = makeStore(entries); - - // loadConfig mock returns {} β†’ maintenance is undefined β†’ default 500 - const evicted = capEntryCount(store); - - expect(evicted).toBe(1); - expect(Object.keys(store)).toHaveLength(500); - expect(store["key-0"]).toBeDefined(); - expect(store["key-500"]).toBeUndefined(); - }); - - it("empty store is a no-op", () => { - const store: Record = {}; - - const evicted = capEntryCount(store, 5); - - expect(evicted).toBe(0); - }); }); describe("rotateSessionFile", () => { @@ -272,16 +86,6 @@ describe("rotateSessionFile", () => { storePath = path.join(testDir, "sessions.json"); }); - it("file under maxBytes: no rotation (returns false)", async () => { - await fs.writeFile(storePath, "x".repeat(500), "utf-8"); - - const rotated = await rotateSessionFile(storePath, 1000); - - expect(rotated).toBe(false); - const content = await fs.readFile(storePath, "utf-8"); - expect(content).toBe("x".repeat(500)); - }); - it("file over maxBytes: renamed to .bak.{timestamp}, returns true", async () => { const bigContent = "x".repeat(200); await fs.writeFile(storePath, bigContent, "utf-8"); @@ -314,266 +118,4 @@ describe("rotateSessionFile", () => { expect(bakFiles.length).toBeLessThanOrEqual(3); }); - - it("non-existent file: no rotation (returns false)", async () => { - const missingPath = path.join(testDir, "missing.json"); - - const rotated = await rotateSessionFile(missingPath, 100); - - expect(rotated).toBe(false); - }); - - it("file exactly at maxBytes: no rotation (returns false)", async () => { - await fs.writeFile(storePath, "x".repeat(100), "utf-8"); - - const rotated = await rotateSessionFile(storePath, 100); - - expect(rotated).toBe(false); - }); - - it("backup file name includes a timestamp", async () => { - await fs.writeFile(storePath, "x".repeat(100), "utf-8"); - const before = Date.now(); - - await rotateSessionFile(storePath, 50); - - const after = Date.now(); - const files = await fs.readdir(testDir); - const bakFiles = files.filter((f) => f.startsWith("sessions.json.bak.")); - expect(bakFiles).toHaveLength(1); - const timestamp = Number(bakFiles[0].replace("sessions.json.bak.", "")); - expect(timestamp).toBeGreaterThanOrEqual(before); - expect(timestamp).toBeLessThanOrEqual(after); - }); -}); - -// --------------------------------------------------------------------------- -// Integration tests β€” exercise saveSessionStore end-to-end. -// The file-level vi.mock("../config.js") stubs loadConfig; per-test -// mockReturnValue controls what resolveMaintenanceConfig() returns. -// --------------------------------------------------------------------------- - -describe("Integration: saveSessionStore with pruning", () => { - let testDir: string; - let storePath: string; - let savedCacheTtl: string | undefined; - let mockLoadConfig: ReturnType; - - beforeEach(async () => { - testDir = await createCaseDir("pruning-integ"); - storePath = path.join(testDir, "sessions.json"); - savedCacheTtl = process.env.OPENCLAW_SESSION_CACHE_TTL_MS; - process.env.OPENCLAW_SESSION_CACHE_TTL_MS = "0"; - clearSessionStoreCacheForTest(); - - const configModule = await import("../config.js"); - mockLoadConfig = configModule.loadConfig as ReturnType; - }); - - afterEach(async () => { - vi.restoreAllMocks(); - clearSessionStoreCacheForTest(); - if (savedCacheTtl === undefined) { - delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS; - } else { - process.env.OPENCLAW_SESSION_CACHE_TTL_MS = savedCacheTtl; - } - }); - - it("saveSessionStore prunes stale entries on write", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "enforce", - pruneAfter: "7d", - maxEntries: 500, - rotateBytes: 10_485_760, - }, - }, - }); - - const now = Date.now(); - const store: Record = { - stale: makeEntry(now - 30 * DAY_MS), - fresh: makeEntry(now), - }; - - await saveSessionStore(storePath, store); - - const loaded = loadSessionStore(storePath); - expect(loaded.stale).toBeUndefined(); - expect(loaded.fresh).toBeDefined(); - }); - - it("saveSessionStore caps entries over limit", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "enforce", - pruneAfter: "30d", - maxEntries: 5, - rotateBytes: 10_485_760, - }, - }, - }); - - const now = Date.now(); - const store: Record = {}; - for (let i = 0; i < 10; i++) { - store[`key-${i}`] = makeEntry(now - i * 1000); - } - - await saveSessionStore(storePath, store); - - const loaded = loadSessionStore(storePath); - expect(Object.keys(loaded)).toHaveLength(5); - for (let i = 0; i < 5; i++) { - expect(loaded[`key-${i}`]).toBeDefined(); - } - for (let i = 5; i < 10; i++) { - expect(loaded[`key-${i}`]).toBeUndefined(); - } - }); - - it("saveSessionStore rotates file when over size limit and creates .bak", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "enforce", - pruneAfter: "30d", - maxEntries: 500, - rotateBytes: "100b", - }, - }, - }); - - const now = Date.now(); - const largeStore: Record = {}; - for (let i = 0; i < 50; i++) { - largeStore[`agent:main:session-${crypto.randomUUID()}`] = makeEntry(now - i * 1000); - } - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(largeStore, null, 2), "utf-8"); - - const statBefore = await fs.stat(storePath); - expect(statBefore.size).toBeGreaterThan(100); - - const smallStore: Record = { - only: makeEntry(now), - }; - await saveSessionStore(storePath, smallStore); - - const files = await fs.readdir(testDir); - const bakFiles = files.filter((f) => f.startsWith("sessions.json.bak.")); - expect(bakFiles.length).toBeGreaterThanOrEqual(1); - - const loaded = loadSessionStore(storePath); - expect(loaded.only).toBeDefined(); - }); - - it("saveSessionStore applies both pruning and capping together", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "enforce", - pruneAfter: "10d", - maxEntries: 3, - rotateBytes: 10_485_760, - }, - }, - }); - - const now = Date.now(); - const store: Record = { - stale1: makeEntry(now - 15 * DAY_MS), - stale2: makeEntry(now - 20 * DAY_MS), - fresh1: makeEntry(now), - fresh2: makeEntry(now - 1 * DAY_MS), - fresh3: makeEntry(now - 2 * DAY_MS), - fresh4: makeEntry(now - 5 * DAY_MS), - }; - - await saveSessionStore(storePath, store); - - const loaded = loadSessionStore(storePath); - expect(loaded.stale1).toBeUndefined(); - expect(loaded.stale2).toBeUndefined(); - expect(Object.keys(loaded).length).toBeLessThanOrEqual(3); - expect(loaded.fresh1).toBeDefined(); - expect(loaded.fresh2).toBeDefined(); - expect(loaded.fresh3).toBeDefined(); - expect(loaded.fresh4).toBeUndefined(); - }); - - it("saveSessionStore skips enforcement when maintenance mode is warn", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "warn", - pruneAfter: "7d", - maxEntries: 1, - rotateBytes: 10_485_760, - }, - }, - }); - - const now = Date.now(); - const store: Record = { - stale: makeEntry(now - 30 * DAY_MS), - fresh: makeEntry(now), - }; - - await saveSessionStore(storePath, store); - - const loaded = loadSessionStore(storePath); - expect(loaded.stale).toBeDefined(); - expect(loaded.fresh).toBeDefined(); - expect(Object.keys(loaded)).toHaveLength(2); - }); - - it("resolveMaintenanceConfig reads from loadConfig().session.maintenance", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { pruneAfter: "7d", maxEntries: 100, rotateBytes: "5mb" }, - }, - }); - - const { resolveMaintenanceConfig } = await import("./store.js"); - const config = resolveMaintenanceConfig(); - - expect(config).toEqual({ - mode: "warn", - pruneAfterMs: 7 * DAY_MS, - maxEntries: 100, - rotateBytes: 5 * 1024 * 1024, - }); - }); - - it("resolveMaintenanceConfig uses defaults for missing fields", async () => { - mockLoadConfig.mockReturnValue({ session: { maintenance: { pruneAfter: "14d" } } }); - - const { resolveMaintenanceConfig } = await import("./store.js"); - const config = resolveMaintenanceConfig(); - - expect(config).toEqual({ - mode: "warn", - pruneAfterMs: 14 * DAY_MS, - maxEntries: 500, - rotateBytes: 10_485_760, - }); - }); - - it("resolveMaintenanceConfig falls back to deprecated pruneDays", async () => { - mockLoadConfig.mockReturnValue({ session: { maintenance: { pruneDays: 2 } } }); - - const { resolveMaintenanceConfig } = await import("./store.js"); - const config = resolveMaintenanceConfig(); - - expect(config).toEqual({ - mode: "warn", - pruneAfterMs: 2 * DAY_MS, - maxEntries: 500, - rotateBytes: 10_485_760, - }); - }); }); diff --git a/src/config/sessions/store.undefined-path.test.ts b/src/config/sessions/store.undefined-path.test.ts deleted file mode 100644 index 8d0bc1b05be..00000000000 --- a/src/config/sessions/store.undefined-path.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Regression test for #14717: path.dirname(undefined) crash in withSessionStoreLock - * - * When a channel plugin passes undefined as storePath to recordSessionMetaFromInbound, - * the call chain reaches withSessionStoreLock β†’ path.dirname(undefined) β†’ TypeError crash. - * After fix, a clear Error is thrown instead of an unhandled TypeError. - */ -import { describe, expect, it } from "vitest"; -import { updateSessionStore } from "./store.js"; - -describe("withSessionStoreLock storePath guard (#14717)", () => { - it("throws descriptive error when storePath is undefined", async () => { - await expect( - updateSessionStore(undefined as unknown as string, (store) => store), - ).rejects.toThrow("withSessionStoreLock: storePath must be a non-empty string"); - }); - - it("throws descriptive error when storePath is empty string", async () => { - await expect(updateSessionStore("", (store) => store)).rejects.toThrow( - "withSessionStoreLock: storePath must be a non-empty string", - ); - }); -}); diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts deleted file mode 100644 index 540ebd04752..00000000000 --- a/src/config/sessions/transcript.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - appendAssistantMessageToSessionTranscript, - resolveMirroredTranscriptText, -} from "./transcript.js"; - -describe("resolveMirroredTranscriptText", () => { - it("prefers media filenames over text", () => { - const result = resolveMirroredTranscriptText({ - text: "caption here", - mediaUrls: ["https://example.com/files/report.pdf?sig=123"], - }); - expect(result).toBe("report.pdf"); - }); - - it("returns trimmed text when no media", () => { - const result = resolveMirroredTranscriptText({ text: " hello " }); - expect(result).toBe("hello"); - }); -}); - -describe("appendAssistantMessageToSessionTranscript", () => { - let tempDir: string; - let storePath: string; - let sessionsDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-")); - sessionsDir = path.join(tempDir, "agents", "main", "sessions"); - fs.mkdirSync(sessionsDir, { recursive: true }); - storePath = path.join(sessionsDir, "sessions.json"); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it("returns error for missing sessionKey", async () => { - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "", - text: "test", - storePath, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe("missing sessionKey"); - } - }); - - it("returns error for empty text", async () => { - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "test-session", - text: " ", - storePath, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe("empty text"); - } - }); - - it("returns error for unknown sessionKey", async () => { - fs.writeFileSync(storePath, JSON.stringify({}), "utf-8"); - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "nonexistent", - text: "test message", - storePath, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toContain("unknown sessionKey"); - } - }); - - it("creates transcript file and appends message for valid session", async () => { - const sessionId = "test-session-id"; - const sessionKey = "test-session"; - const store = { - [sessionKey]: { - sessionId, - chatType: "direct", - channel: "discord", - }, - }; - fs.writeFileSync(storePath, JSON.stringify(store), "utf-8"); - - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey, - text: "Hello from delivery mirror!", - storePath, - }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(fs.existsSync(result.sessionFile)).toBe(true); - - const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n"); - expect(lines.length).toBe(2); // header + message - - const header = JSON.parse(lines[0]); - expect(header.type).toBe("session"); - expect(header.id).toBe(sessionId); - - const messageLine = JSON.parse(lines[1]); - expect(messageLine.type).toBe("message"); - expect(messageLine.message.role).toBe("assistant"); - expect(messageLine.message.content[0].type).toBe("text"); - expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); - } - }); -}); diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 1d0012a749f..012d59f728d 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -144,6 +144,8 @@ export type GroupKeyResolution = { export type SessionSkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; + /** Normalized agent-level filter used to build this snapshot; undefined means unrestricted. */ + skillFilter?: string[]; resolvedSkills?: Skill[]; version?: number; }; diff --git a/src/config/slack-token-validation.test.ts b/src/config/slack-token-validation.test.ts index 8a678afdc10..6ebfcabb858 100644 --- a/src/config/slack-token-validation.test.ts +++ b/src/config/slack-token-validation.test.ts @@ -33,4 +33,39 @@ describe("Slack token config fields", () => { }); expect(res.ok).toBe(true); }); + + it("rejects invalid userTokenReadOnly types", () => { + const res = validateConfigObject({ + channels: { + slack: { + botToken: "xoxb-any", + appToken: "xapp-any", + userToken: "xoxp-any", + // oxlint-disable-next-line typescript/no-explicit-any + userTokenReadOnly: "no" as any, + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((iss) => iss.path.includes("userTokenReadOnly"))).toBe(true); + } + }); + + it("rejects invalid userToken types", () => { + const res = validateConfigObject({ + channels: { + slack: { + botToken: "xoxb-any", + appToken: "xapp-any", + // oxlint-disable-next-line typescript/no-explicit-any + userToken: 123 as any, + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((iss) => iss.path.includes("userToken"))).toBe(true); + } + }); }); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index b58a8039064..2774105fb2b 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -91,6 +91,34 @@ export type CliBackendConfig = { imageMode?: "repeat" | "list"; /** Serialize runs for this CLI. */ serialize?: boolean; + /** Runtime reliability tuning for this backend's process lifecycle. */ + reliability?: { + /** No-output watchdog tuning (fresh vs resumed runs). */ + watchdog?: { + /** Fresh/new sessions (non-resume). */ + fresh?: { + /** Fixed watchdog timeout in ms (overrides ratio when set). */ + noOutputTimeoutMs?: number; + /** Fraction of overall timeout used when fixed timeout is not set. */ + noOutputTimeoutRatio?: number; + /** Lower bound for computed watchdog timeout. */ + minMs?: number; + /** Upper bound for computed watchdog timeout. */ + maxMs?: number; + }; + /** Resume sessions. */ + resume?: { + /** Fixed watchdog timeout in ms (overrides ratio when set). */ + noOutputTimeoutMs?: number; + /** Fraction of overall timeout used when fixed timeout is not set. */ + noOutputTimeoutRatio?: number; + /** Lower bound for computed watchdog timeout. */ + minMs?: number; + /** Upper bound for computed watchdog timeout. */ + maxMs?: number; + }; + }; + }; }; export type AgentDefaultsConfig = { @@ -230,7 +258,7 @@ export type AgentDefaultsConfig = { workspaceAccess?: "none" | "ro" | "rw"; /** * Session tools visibility for sandboxed sessions. - * - "spawned": only allow session tools to target sessions spawned from this session (default) + * - "spawned": only allow session tools to target the current session and sessions spawned from it (default) * - "all": allow session tools to target any session */ sessionToolsVisibility?: "spawned" | "all"; diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts index 62a9c1da139..45a8b715103 100644 --- a/src/config/types.cron.ts +++ b/src/config/types.cron.ts @@ -2,6 +2,13 @@ export type CronConfig = { enabled?: boolean; store?: string; maxConcurrentRuns?: number; + /** + * Deprecated legacy fallback webhook URL used only for stored jobs with notify=true. + * Prefer per-job delivery.mode="webhook" with delivery.to. + */ + webhook?: string; + /** Bearer token for cron webhook POST delivery. */ + webhookToken?: string; /** * How long to retain completed cron run sessions before automatic pruning. * Accepts a duration string (e.g. "24h", "7d", "1h30m") or `false` to disable pruning. diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index b2f248459f4..b2e1652907c 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -113,6 +113,15 @@ export type DiscordAgentComponentsConfig = { enabled?: boolean; }; +export type DiscordUiComponentsConfig = { + /** Accent color used by Discord component containers (hex). */ + accentColor?: string; +}; + +export type DiscordUiConfig = { + components?: DiscordUiComponentsConfig; +}; + export type DiscordAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -183,12 +192,19 @@ export type DiscordAccountConfig = { execApprovals?: DiscordExecApprovalConfig; /** Agent-controlled interactive components (buttons, select menus). */ agentComponents?: DiscordAgentComponentsConfig; + /** Discord UI customization (components, modals, etc.). */ + ui?: DiscordUiConfig; /** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */ intents?: DiscordIntentsConfig; /** PluralKit identity resolution for proxied messages. */ pluralkit?: DiscordPluralKitConfig; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; + /** + * Per-channel ack reaction override. + * Discord supports both unicode emoji and custom emoji names. + */ + ackReaction?: string; /** Bot activity status text (e.g. "Watching X"). */ activity?: string; /** Bot status (online|dnd|idle|invisible). Defaults to online when presence is configured. */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index a7f7bef2c80..ead656cce29 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -156,6 +156,11 @@ export type SlackAccountConfig = { heartbeat?: ChannelHeartbeatVisibilityConfig; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; + /** + * Per-channel ack reaction override. + * Slack uses shortcodes (e.g., "eyes") rather than unicode emoji. + */ + ackReaction?: string; }; export type SlackConfig = { diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 99b28cf793c..d8e189e756a 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -141,6 +141,11 @@ export type TelegramAccountConfig = { * Use `"auto"` to derive `[{identity.name}]` from the routed agent. */ responsePrefix?: string; + /** + * Per-channel ack reaction override. + * Telegram expects unicode emoji (e.g., "πŸ‘€") rather than shortcodes. + */ + ackReaction?: string; }; export type TelegramTopicConfig = { diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index e6fa1eec10b..4f02166d7aa 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -138,6 +138,8 @@ export type MediaToolsConfig = { export type ToolProfileId = "minimal" | "coding" | "messaging" | "full"; +export type SessionsToolsVisibility = "self" | "tree" | "agent" | "all"; + export type ToolPolicyConfig = { allow?: string[]; /** @@ -453,6 +455,21 @@ export type ToolsConfig = { /** Allowlist of agent ids or patterns (implementation-defined). */ allow?: string[]; }; + /** + * Session tool visibility controls which sessions can be targeted by session tools + * (sessions_list, sessions_history, sessions_send). + * + * Default: "tree" (current session + spawned subagent sessions). + */ + sessions?: { + /** + * - "self": only the current session + * - "tree": current session + sessions spawned by this session (default) + * - "agent": any session belonging to the current agent id (can include other users) + * - "all": any session (cross-agent still requires tools.agentToAgent) + */ + visibility?: SessionsToolsVisibility; + }; /** Elevated exec permissions for the host machine. */ elevated?: { /** Enable or disable elevated mode (default: true). */ diff --git a/src/config/ui-seam-color.test.ts b/src/config/ui-seam-color.test.ts deleted file mode 100644 index 6483a0c8129..00000000000 --- a/src/config/ui-seam-color.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("ui.seamColor", () => { - it("accepts hex colors", () => { - const res = validateConfigObject({ ui: { seamColor: "#FF4500" } }); - expect(res.ok).toBe(true); - }); - - it("rejects non-hex colors", () => { - const res = validateConfigObject({ ui: { seamColor: "lobster" } }); - expect(res.ok).toBe(false); - }); - - it("rejects invalid hex length", () => { - const res = validateConfigObject({ ui: { seamColor: "#FF4500FF" } }); - expect(res.ok).toBe(false); - }); -}); diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index d655a797ad2..2508179707c 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -1,10 +1,9 @@ import { z } from "zod"; import { HeartbeatSchema, + AgentSandboxSchema, + AgentModelSchema, MemorySearchSchema, - SandboxBrowserSchema, - SandboxDockerSchema, - SandboxPruneSchema, } from "./zod-schema.agent-runtime.js"; import { BlockStreamingChunkSchema, @@ -160,35 +159,12 @@ export const AgentDefaultsSchema = z "Maximum number of active children a single agent session can spawn (default: 5).", ), archiveAfterMinutes: z.number().int().positive().optional(), - model: z - .union([ - z.string(), - z - .object({ - primary: z.string().optional(), - fallbacks: z.array(z.string()).optional(), - }) - .strict(), - ]) - .optional(), + model: AgentModelSchema.optional(), thinking: z.string().optional(), }) .strict() .optional(), - sandbox: z - .object({ - mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), - workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(), - sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(), - scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(), - perSession: z.boolean().optional(), - workspaceRoot: z.string().optional(), - docker: SandboxDockerSchema, - browser: SandboxBrowserSchema, - prune: SandboxPruneSchema, - }) - .strict() - .optional(), + sandbox: AgentSandboxSchema, }) .strict() .optional(); diff --git a/src/config/zod-schema.agent-model.ts b/src/config/zod-schema.agent-model.ts new file mode 100644 index 00000000000..3a6bac05c24 --- /dev/null +++ b/src/config/zod-schema.agent-model.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const AgentModelSchema = z.union([ + z.string(), + z + .object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }) + .strict(), +]); diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index bab2b645115..52f768ff24f 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { parseDurationMs } from "../cli/parse-duration.js"; +import { AgentModelSchema } from "./zod-schema.agent-model.js"; import { GroupChatSchema, HumanDelaySchema, @@ -124,6 +125,58 @@ export const SandboxDockerSchema = z binds: z.array(z.string()).optional(), }) .strict() + .superRefine((data, ctx) => { + if (data.binds) { + for (let i = 0; i < data.binds.length; i += 1) { + const bind = data.binds[i]?.trim() ?? ""; + if (!bind) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["binds", i], + message: "Sandbox security: bind mount entry must be a non-empty string.", + }); + continue; + } + const firstColon = bind.indexOf(":"); + const source = (firstColon <= 0 ? bind : bind.slice(0, firstColon)).trim(); + if (!source.startsWith("/")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["binds", i], + message: + `Sandbox security: bind mount "${bind}" uses a non-absolute source path "${source}". ` + + "Only absolute POSIX paths are supported for sandbox binds.", + }); + } + } + } + if (data.network?.trim().toLowerCase() === "host") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["network"], + message: + 'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.', + }); + } + if (data.seccompProfile?.trim().toLowerCase() === "unconfined") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["seccompProfile"], + message: + 'Sandbox security: seccomp profile "unconfined" is blocked. ' + + "Use a custom seccomp profile file or omit this setting.", + }); + } + if (data.apparmorProfile?.trim().toLowerCase() === "unconfined") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["apparmorProfile"], + message: + 'Sandbox security: apparmor profile "unconfined" is blocked. ' + + "Use a named AppArmor profile or omit this setting.", + }); + } + }) .optional(); export const SandboxBrowserSchema = z @@ -450,15 +503,7 @@ export const MemorySearchSchema = z }) .strict() .optional(); -export const AgentModelSchema = z.union([ - z.string(), - z - .object({ - primary: z.string().optional(), - fallbacks: z.array(z.string()).optional(), - }) - .strict(), -]); +export { AgentModelSchema }; export const AgentEntrySchema = z .object({ id: z.string(), @@ -506,6 +551,12 @@ export const ToolsSchema = z web: ToolsWebSchema, media: ToolsMediaSchema, links: ToolsLinksSchema, + sessions: z + .object({ + visibility: z.enum(["self", "tree", "agent", "all"]).optional(), + }) + .strict() + .optional(), message: z .object({ allowCrossContextSend: z.boolean().optional(), diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 213ed9bedba..dbcbf80652f 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -275,6 +275,34 @@ export const CliBackendSchema = z imageArg: z.string().optional(), imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), serialize: z.boolean().optional(), + reliability: z + .object({ + watchdog: z + .object({ + fresh: z + .object({ + noOutputTimeoutMs: z.number().int().min(1000).optional(), + noOutputTimeoutRatio: z.number().min(0.05).max(0.95).optional(), + minMs: z.number().int().min(1000).optional(), + maxMs: z.number().int().min(1000).optional(), + }) + .strict() + .optional(), + resume: z + .object({ + noOutputTimeoutMs: z.number().int().min(1000).optional(), + noOutputTimeoutRatio: z.number().min(0.05).max(0.95).optional(), + minMs: z.number().int().min(1000).optional(), + maxMs: z.number().int().min(1000).optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1f5866eadf0..ed40d5e62bc 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -13,6 +13,7 @@ import { DmPolicySchema, ExecutableTokenSchema, GroupPolicySchema, + HexColorSchema, MarkdownConfigSchema, MSTeamsReplyStyleSchema, ProviderCommandsSchema, @@ -142,6 +143,7 @@ export const TelegramAccountSchemaBase = z heartbeat: ChannelHeartbeatVisibilitySchema, linkPreview: z.boolean().optional(), responsePrefix: z.string().optional(), + ackReaction: z.string().optional(), }) .strict(); @@ -247,6 +249,18 @@ export const DiscordGuildSchema = z }) .strict(); +const DiscordUiSchema = z + .object({ + components: z + .object({ + accentColor: HexColorSchema.optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(); + export const DiscordAccountSchema = z .object({ name: z.string().optional(), @@ -312,6 +326,7 @@ export const DiscordAccountSchema = z }) .strict() .optional(), + ui: DiscordUiSchema, intents: z .object({ presence: z.boolean().optional(), @@ -327,6 +342,7 @@ export const DiscordAccountSchema = z .strict() .optional(), responsePrefix: z.string().optional(), + ackReaction: z.string().optional(), activity: z.string().optional(), status: z.enum(["online", "dnd", "idle", "invisible"]).optional(), activityType: z @@ -558,6 +574,7 @@ export const SlackAccountSchema = z channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, responsePrefix: z.string().optional(), + ackReaction: z.string().optional(), }) .strict() .superRefine((value, ctx) => { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7f43b4b1a08..3d718f2f1a5 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -93,6 +93,14 @@ const MemorySchema = z .strict() .optional(); +const HttpUrlSchema = z + .string() + .url() + .refine((value) => { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; + }, "Expected http:// or https:// URL"); + export const OpenClawSchema = z .object({ $schema: z.string().optional(), @@ -295,6 +303,8 @@ export const OpenClawSchema = z enabled: z.boolean().optional(), store: z.string().optional(), maxConcurrentRuns: z.number().int().positive().optional(), + webhook: HttpUrlSchema.optional(), + webhookToken: z.string().optional().register(sensitive), sessionRetention: z.union([z.string(), z.literal(false)]).optional(), }) .strict() diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index 99a4b05de51..51fe8f4767c 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -5,16 +5,28 @@ import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; import { CronDeliverySchema } from "../gateway/protocol/schema.js"; type SchemaLike = { - anyOf?: Array<{ properties?: Record; const?: unknown }>; + anyOf?: Array; properties?: Record; const?: unknown; }; function extractDeliveryModes(schema: SchemaLike): string[] { const modeSchema = schema.properties?.mode as SchemaLike | undefined; - return (modeSchema?.anyOf ?? []) + const directModes = (modeSchema?.anyOf ?? []) .map((entry) => entry?.const) .filter((value): value is string => typeof value === "string"); + if (directModes.length > 0) { + return directModes; + } + + const unionModes = (schema.anyOf ?? []) + .map((entry) => { + const mode = entry.properties?.mode as SchemaLike | undefined; + return mode?.const; + }) + .filter((value): value is string => typeof value === "string"); + + return Array.from(new Set(unionModes)); } const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; diff --git a/src/cron/delivery.test.ts b/src/cron/delivery.test.ts index fcbe9e99a3b..783d0532b34 100644 --- a/src/cron/delivery.test.ts +++ b/src/cron/delivery.test.ts @@ -42,4 +42,16 @@ describe("resolveCronDeliveryPlan", () => { expect(plan.mode).toBe("none"); expect(plan.requested).toBe(false); }); + + it("resolves webhook mode without channel routing", () => { + const plan = resolveCronDeliveryPlan( + makeJob({ + delivery: { mode: "webhook", to: "https://example.invalid/cron" }, + }), + ); + expect(plan.mode).toBe("webhook"); + expect(plan.requested).toBe(false); + expect(plan.channel).toBeUndefined(); + expect(plan.to).toBe("https://example.invalid/cron"); + }); }); diff --git a/src/cron/delivery.ts b/src/cron/delivery.ts index f0ba2c2b072..377cdb49b2f 100644 --- a/src/cron/delivery.ts +++ b/src/cron/delivery.ts @@ -2,7 +2,7 @@ import type { CronDeliveryMode, CronJob, CronMessageChannel } from "./types.js"; export type CronDeliveryPlan = { mode: CronDeliveryMode; - channel: CronMessageChannel; + channel?: CronMessageChannel; to?: string; source: "delivery" | "payload"; requested: boolean; @@ -36,11 +36,13 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { const mode = normalizedMode === "announce" ? "announce" - : normalizedMode === "none" - ? "none" - : normalizedMode === "deliver" - ? "announce" - : undefined; + : normalizedMode === "webhook" + ? "webhook" + : normalizedMode === "none" + ? "none" + : normalizedMode === "deliver" + ? "announce" + : undefined; const payloadChannel = normalizeChannel(payload?.channel); const payloadTo = normalizeTo(payload?.to); @@ -55,7 +57,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { const resolvedMode = mode ?? "announce"; return { mode: resolvedMode, - channel, + channel: resolvedMode === "announce" ? channel : undefined, to, source: "delivery", requested: resolvedMode === "announce", diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts new file mode 100644 index 00000000000..407918c74ac --- /dev/null +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -0,0 +1,345 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ---------- mocks ---------- + +const buildWorkspaceSkillSnapshotMock = vi.fn(); +const resolveAgentSkillsFilterMock = vi.fn(); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentConfig: vi.fn().mockReturnValue(undefined), + resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"), + resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined), + resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"), + resolveDefaultAgentId: vi.fn().mockReturnValue("default"), + resolveAgentSkillsFilter: resolveAgentSkillsFilterMock, +})); + +vi.mock("../../agents/skills.js", () => ({ + buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock, +})); + +vi.mock("../../agents/skills/refresh.js", () => ({ + getSkillsSnapshotVersion: vi.fn().mockReturnValue(42), +})); + +vi.mock("../../agents/workspace.js", () => ({ + ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }), +})); + +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }), +})); + +vi.mock("../../agents/model-selection.js", () => ({ + getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }), + isCliProvider: vi.fn().mockReturnValue(false), + resolveAllowedModelRef: vi.fn().mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }), + resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }), + resolveHooksGmailModel: vi.fn().mockReturnValue(null), + resolveThinkingDefault: vi.fn().mockReturnValue(undefined), +})); + +vi.mock("../../agents/model-fallback.js", () => ({ + runWithModelFallback: vi.fn().mockResolvedValue({ + result: { + payloads: [{ text: "test output" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }, + provider: "openai", + model: "gpt-4", + }), +})); + +vi.mock("../../agents/pi-embedded.js", () => ({ + runEmbeddedPiAgent: vi.fn().mockResolvedValue({ + payloads: [{ text: "test output" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }), +})); + +vi.mock("../../agents/context.js", () => ({ + lookupContextTokens: vi.fn().mockReturnValue(128000), +})); + +vi.mock("../../agents/date-time.js", () => ({ + formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"), + resolveUserTimeFormat: vi.fn().mockReturnValue("24h"), + resolveUserTimezone: vi.fn().mockReturnValue("UTC"), +})); + +vi.mock("../../agents/timeout.js", () => ({ + resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000), +})); + +vi.mock("../../agents/usage.js", () => ({ + deriveSessionTotalTokens: vi.fn().mockReturnValue(30), + hasNonzeroUsage: vi.fn().mockReturnValue(false), +})); + +vi.mock("../../agents/subagent-announce.js", () => ({ + runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true), +})); + +vi.mock("../../agents/cli-runner.js", () => ({ + runCliAgent: vi.fn(), +})); + +vi.mock("../../agents/cli-session.js", () => ({ + getCliSessionId: vi.fn().mockReturnValue(undefined), + setCliSessionId: vi.fn(), +})); + +vi.mock("../../auto-reply/thinking.js", () => ({ + normalizeThinkLevel: vi.fn().mockReturnValue(undefined), + normalizeVerboseLevel: vi.fn().mockReturnValue("off"), + supportsXHighThinking: vi.fn().mockReturnValue(false), +})); + +vi.mock("../../cli/outbound-send-deps.js", () => ({ + createOutboundSendDeps: vi.fn().mockReturnValue({}), +})); + +vi.mock("../../config/sessions.js", () => ({ + resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"), + resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"), + updateSessionStore: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../routing/session-key.js", () => ({ + buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"), + normalizeAgentId: vi.fn((id: string) => id), +})); + +vi.mock("../../infra/agent-events.js", () => ({ + registerAgentRunContext: vi.fn(), +})); + +vi.mock("../../infra/outbound/deliver.js", () => ({ + deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../infra/skills-remote.js", () => ({ + getRemoteSkillEligibility: vi.fn().mockReturnValue({}), +})); + +vi.mock("../../logger.js", () => ({ + logWarn: vi.fn(), +})); + +vi.mock("../../security/external-content.js", () => ({ + buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"), + detectSuspiciousPatterns: vi.fn().mockReturnValue([]), + getHookType: vi.fn().mockReturnValue("unknown"), + isExternalHookSession: vi.fn().mockReturnValue(false), +})); + +vi.mock("../delivery.js", () => ({ + resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }), +})); + +vi.mock("./delivery-target.js", () => ({ + resolveDeliveryTarget: vi.fn().mockResolvedValue({ + channel: "discord", + to: undefined, + accountId: undefined, + error: undefined, + }), +})); + +vi.mock("./helpers.js", () => ({ + isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false), + pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined), + pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"), + pickSummaryFromOutput: vi.fn().mockReturnValue("summary"), + pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"), + resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100), +})); + +const resolveCronSessionMock = vi.fn(); +vi.mock("./session.js", () => ({ + resolveCronSession: resolveCronSessionMock, +})); + +vi.mock("../../agents/defaults.js", () => ({ + DEFAULT_CONTEXT_TOKENS: 128000, + DEFAULT_MODEL: "gpt-4", + DEFAULT_PROVIDER: "openai", +})); + +const { runCronIsolatedAgentTurn } = await import("./run.js"); + +// ---------- helpers ---------- + +function makeJob(overrides?: Record) { + return { + id: "test-job", + name: "Test Job", + schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + sessionTarget: "isolated", + payload: { kind: "agentTurn", message: "test" }, + ...overrides, + } as never; +} + +function makeParams(overrides?: Record) { + return { + cfg: {}, + deps: {} as never, + job: makeJob(), + message: "test", + sessionKey: "cron:test", + ...overrides, + }; +} + +// ---------- tests ---------- + +describe("runCronIsolatedAgentTurn β€” skill filter", () => { + let previousFastTestEnv: string | undefined; + beforeEach(() => { + vi.clearAllMocks(); + previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; + delete process.env.OPENCLAW_TEST_FAST; + buildWorkspaceSkillSnapshotMock.mockReturnValue({ + prompt: "", + resolvedSkills: [], + version: 42, + }); + resolveAgentSkillsFilterMock.mockReturnValue(undefined); + // Fresh session object per test β€” prevents mutation leaking between tests + resolveCronSessionMock.mockReturnValue({ + storePath: "/tmp/store.json", + store: {}, + sessionEntry: { + sessionId: "test-session-id", + updatedAt: 0, + systemSent: false, + skillsSnapshot: undefined, + }, + systemSent: false, + isNewSession: true, + }); + }); + + afterEach(() => { + if (previousFastTestEnv == null) { + delete process.env.OPENCLAW_TEST_FAST; + return; + } + process.env.OPENCLAW_TEST_FAST = previousFastTestEnv; + }); + + it("passes agent-level skillFilter to buildWorkspaceSkillSnapshot", async () => { + resolveAgentSkillsFilterMock.mockReturnValue(["meme-factory", "weather"]); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + cfg: { agents: { list: [{ id: "scout", skills: ["meme-factory", "weather"] }] } }, + agentId: "scout", + }), + ); + + expect(result.status).toBe("ok"); + expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); + expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", [ + "meme-factory", + "weather", + ]); + }); + + it("omits skillFilter when agent has no skills config", async () => { + resolveAgentSkillsFilterMock.mockReturnValue(undefined); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + cfg: { agents: { list: [{ id: "general" }] } }, + agentId: "general", + }), + ); + + expect(result.status).toBe("ok"); + expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); + // When no skills config, skillFilter should be undefined (no filtering applied) + expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1].skillFilter).toBeUndefined(); + }); + + it("passes empty skillFilter when agent explicitly disables all skills", async () => { + resolveAgentSkillsFilterMock.mockReturnValue([]); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + cfg: { agents: { list: [{ id: "silent", skills: [] }] } }, + agentId: "silent", + }), + ); + + expect(result.status).toBe("ok"); + expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); + // Explicit empty skills list should forward [] to filter out all skills + expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", []); + }); + + it("refreshes cached snapshot when skillFilter changes without version bump", async () => { + resolveAgentSkillsFilterMock.mockReturnValue(["weather"]); + resolveCronSessionMock.mockReturnValue({ + storePath: "/tmp/store.json", + store: {}, + sessionEntry: { + sessionId: "test-session-id", + updatedAt: 0, + systemSent: false, + skillsSnapshot: { + prompt: "meme-factory", + skills: [{ name: "meme-factory" }], + version: 42, + }, + }, + systemSent: false, + isNewSession: true, + }); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather"] }] } }, + agentId: "weather-bot", + }), + ); + + expect(result.status).toBe("ok"); + expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); + expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", [ + "weather", + ]); + }); + + it("reuses cached snapshot when version and normalized skillFilter are unchanged", async () => { + resolveAgentSkillsFilterMock.mockReturnValue([" weather ", "meme-factory", "weather"]); + resolveCronSessionMock.mockReturnValue({ + storePath: "/tmp/store.json", + store: {}, + sessionEntry: { + sessionId: "test-session-id", + updatedAt: 0, + systemSent: false, + skillsSnapshot: { + prompt: "weather", + skills: [{ name: "weather" }], + skillFilter: ["meme-factory", "weather"], + version: 42, + }, + }, + systemSent: false, + isNewSession: true, + }); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather", "meme-factory"] }] } }, + agentId: "weather-bot", + }), + ); + + expect(result.status).toBe("ok"); + expect(buildWorkspaceSkillSnapshotMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 116507f8e4e..8aba703dfbd 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -25,15 +25,9 @@ import { resolveThinkingDefault, } from "../../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; -import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; -import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { runSubagentAnnounceFlow } from "../../agents/subagent-announce.js"; -import { - countActiveDescendantRuns, - listDescendantRunsForRequester, -} from "../../agents/subagent-registry.js"; +import { countActiveDescendantRuns } from "../../agents/subagent-registry.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; -import { readLatestAssistantReply } from "../../agents/tools/agent-step.js"; import { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js"; import { ensureAgentWorkspace } from "../../agents/workspace.js"; import { @@ -51,7 +45,6 @@ import { import { registerAgentRunContext } from "../../infra/agent-events.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { resolveAgentOutboundIdentity } from "../../infra/outbound/identity.js"; -import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { logWarn } from "../../logger.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js"; import { @@ -71,6 +64,13 @@ import { resolveHeartbeatAckMaxChars, } from "./helpers.js"; import { resolveCronSession } from "./session.js"; +import { resolveCronSkillsSnapshot } from "./skills-snapshot.js"; +import { + expectsSubagentFollowup, + isLikelyInterimCronMessage, + readDescendantSubagentFallbackReply, + waitForDescendantSubagentSummary, +} from "./subagent-followup.js"; function matchesMessagingToolDeliveryTarget( target: MessagingToolSend, @@ -100,152 +100,6 @@ function resolveCronDeliveryBestEffort(job: CronJob): boolean { return false; } -const CRON_SUBAGENT_WAIT_POLL_MS = 500; -const CRON_SUBAGENT_WAIT_MIN_MS = 30_000; -const CRON_SUBAGENT_FINAL_REPLY_GRACE_MS = 5_000; - -function isLikelyInterimCronMessage(value: string): boolean { - const text = value.trim(); - if (!text) { - return true; - } - const normalized = text.toLowerCase().replace(/\s+/g, " "); - const words = normalized.split(" ").filter(Boolean).length; - const interimHints = [ - "on it", - "pulling everything together", - "give me a few", - "give me a few min", - "few minutes", - "let me compile", - "i'll gather", - "i will gather", - "working on it", - "retrying now", - "should be about", - "should have your summary", - "subagent spawned", - "spawned a subagent", - "it'll auto-announce when done", - "it will auto-announce when done", - "auto-announce when done", - "both subagents are running", - "wait for them to report back", - ]; - return words <= 45 && interimHints.some((hint) => normalized.includes(hint)); -} - -function expectsSubagentFollowup(value: string): boolean { - const normalized = value.trim().toLowerCase().replace(/\s+/g, " "); - if (!normalized) { - return false; - } - const hints = [ - "subagent spawned", - "spawned a subagent", - "auto-announce when done", - "both subagents are running", - "wait for them to report back", - ]; - return hints.some((hint) => normalized.includes(hint)); -} - -async function readDescendantSubagentFallbackReply(params: { - sessionKey: string; - runStartedAt: number; -}): Promise { - const descendants = listDescendantRunsForRequester(params.sessionKey) - .filter( - (entry) => - typeof entry.endedAt === "number" && - entry.endedAt >= params.runStartedAt && - entry.childSessionKey.trim().length > 0, - ) - .toSorted((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0)); - if (descendants.length === 0) { - return undefined; - } - - const latestByChild = new Map(); - for (const entry of descendants) { - const childKey = entry.childSessionKey.trim(); - if (!childKey) { - continue; - } - const current = latestByChild.get(childKey); - if (!current || (entry.endedAt ?? 0) >= (current.endedAt ?? 0)) { - latestByChild.set(childKey, entry); - } - } - - const replies: string[] = []; - const latestRuns = [...latestByChild.values()] - .toSorted((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0)) - .slice(-4); - for (const entry of latestRuns) { - const reply = (await readLatestAssistantReply({ sessionKey: entry.childSessionKey }))?.trim(); - if (!reply || reply.toUpperCase() === SILENT_REPLY_TOKEN.toUpperCase()) { - continue; - } - replies.push(reply); - } - if (replies.length === 0) { - return undefined; - } - if (replies.length === 1) { - return replies[0]; - } - return replies.join("\n\n"); -} - -async function waitForDescendantSubagentSummary(params: { - sessionKey: string; - initialReply?: string; - timeoutMs: number; - observedActiveDescendants?: boolean; -}): 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(); - } - const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); - if ( - latest && - latest.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() && - (latest !== initialReply || !isLikelyInterimCronMessage(latest)) - ) { - 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)); - } - const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); - if ( - latest && - latest.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() && - (latest !== initialReply || !isLikelyInterimCronMessage(latest)) - ) { - return latest; - } - return undefined; -} - export type RunCronAgentTurnResult = { status: "ok" | "error" | "skipped"; summary?: string; @@ -520,30 +374,21 @@ export async function runCronIsolatedAgentTurn(params: { `${commandBody}\n\nReturn your summary as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim(); } - let skillsSnapshot = cronSession.sessionEntry.skillsSnapshot; - if (isFastTestEnv) { - // Fast unit-test mode: avoid scanning the workspace and writing session stores. - skillsSnapshot = skillsSnapshot ?? { prompt: "", skills: [] }; - } else { - const existingSnapshot = cronSession.sessionEntry.skillsSnapshot; - const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); - const needsSkillsSnapshot = - !existingSnapshot || existingSnapshot.version !== skillsSnapshotVersion; - if (needsSkillsSnapshot) { - skillsSnapshot = buildWorkspaceSkillSnapshot(workspaceDir, { - config: cfgWithAgentDefaults, - eligibility: { remote: getRemoteSkillEligibility() }, - snapshotVersion: skillsSnapshotVersion, - }); - if (skillsSnapshot) { - cronSession.sessionEntry = { - ...cronSession.sessionEntry, - updatedAt: Date.now(), - skillsSnapshot, - }; - await persistSessionEntry(); - } - } + const existingSkillsSnapshot = cronSession.sessionEntry.skillsSnapshot; + const skillsSnapshot = resolveCronSkillsSnapshot({ + workspaceDir, + config: cfgWithAgentDefaults, + agentId, + existingSnapshot: existingSkillsSnapshot, + isFastTestEnv, + }); + if (!isFastTestEnv && skillsSnapshot !== existingSkillsSnapshot) { + cronSession.sessionEntry = { + ...cronSession.sessionEntry, + updatedAt: Date.now(), + skillsSnapshot, + }; + await persistSessionEntry(); } // Persist systemSent before the run, mirroring the inbound auto-reply behavior. diff --git a/src/cron/isolated-agent/skills-snapshot.ts b/src/cron/isolated-agent/skills-snapshot.ts new file mode 100644 index 00000000000..2a491717a2c --- /dev/null +++ b/src/cron/isolated-agent/skills-snapshot.ts @@ -0,0 +1,37 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { resolveAgentSkillsFilter } from "../../agents/agent-scope.js"; +import { buildWorkspaceSkillSnapshot, type SkillSnapshot } from "../../agents/skills.js"; +import { matchesSkillFilter } from "../../agents/skills/filter.js"; +import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; +import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; + +export function resolveCronSkillsSnapshot(params: { + workspaceDir: string; + config: OpenClawConfig; + agentId: string; + existingSnapshot?: SkillSnapshot; + isFastTestEnv: boolean; +}): SkillSnapshot { + if (params.isFastTestEnv) { + // Fast unit-test mode skips filesystem scans and snapshot refresh writes. + return params.existingSnapshot ?? { prompt: "", skills: [] }; + } + + const snapshotVersion = getSkillsSnapshotVersion(params.workspaceDir); + const skillFilter = resolveAgentSkillsFilter(params.config, params.agentId); + const existingSnapshot = params.existingSnapshot; + const shouldRefresh = + !existingSnapshot || + existingSnapshot.version !== snapshotVersion || + !matchesSkillFilter(existingSnapshot.skillFilter, skillFilter); + if (!shouldRefresh) { + return existingSnapshot; + } + + return buildWorkspaceSkillSnapshot(params.workspaceDir, { + config: params.config, + skillFilter, + eligibility: { remote: getRemoteSkillEligibility() }, + snapshotVersion, + }); +} diff --git a/src/cron/isolated-agent/subagent-followup.ts b/src/cron/isolated-agent/subagent-followup.ts new file mode 100644 index 00000000000..9bf92925019 --- /dev/null +++ b/src/cron/isolated-agent/subagent-followup.ts @@ -0,0 +1,152 @@ +import { + countActiveDescendantRuns, + 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; + +export function isLikelyInterimCronMessage(value: string): boolean { + const text = value.trim(); + if (!text) { + return true; + } + const normalized = text.toLowerCase().replace(/\s+/g, " "); + const words = normalized.split(" ").filter(Boolean).length; + const interimHints = [ + "on it", + "pulling everything together", + "give me a few", + "give me a few min", + "few minutes", + "let me compile", + "i'll gather", + "i will gather", + "working on it", + "retrying now", + "should be about", + "should have your summary", + "subagent spawned", + "spawned a subagent", + "it'll auto-announce when done", + "it will auto-announce when done", + "auto-announce when done", + "both subagents are running", + "wait for them to report back", + ]; + return words <= 45 && interimHints.some((hint) => normalized.includes(hint)); +} + +export function expectsSubagentFollowup(value: string): boolean { + const normalized = value.trim().toLowerCase().replace(/\s+/g, " "); + if (!normalized) { + return false; + } + const hints = [ + "subagent spawned", + "spawned a subagent", + "auto-announce when done", + "both subagents are running", + "wait for them to report back", + ]; + return hints.some((hint) => normalized.includes(hint)); +} + +export async function readDescendantSubagentFallbackReply(params: { + sessionKey: string; + runStartedAt: number; +}): Promise { + const descendants = listDescendantRunsForRequester(params.sessionKey) + .filter( + (entry) => + typeof entry.endedAt === "number" && + entry.endedAt >= params.runStartedAt && + entry.childSessionKey.trim().length > 0, + ) + .toSorted((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0)); + if (descendants.length === 0) { + return undefined; + } + + const latestByChild = new Map(); + for (const entry of descendants) { + const childKey = entry.childSessionKey.trim(); + if (!childKey) { + continue; + } + const current = latestByChild.get(childKey); + if (!current || (entry.endedAt ?? 0) >= (current.endedAt ?? 0)) { + latestByChild.set(childKey, entry); + } + } + + const replies: string[] = []; + const latestRuns = [...latestByChild.values()] + .toSorted((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0)) + .slice(-4); + for (const entry of latestRuns) { + const reply = (await readLatestAssistantReply({ sessionKey: entry.childSessionKey }))?.trim(); + if (!reply || reply.toUpperCase() === SILENT_REPLY_TOKEN.toUpperCase()) { + continue; + } + replies.push(reply); + } + if (replies.length === 0) { + return undefined; + } + if (replies.length === 1) { + return replies[0]; + } + return replies.join("\n\n"); +} + +export async function waitForDescendantSubagentSummary(params: { + sessionKey: string; + initialReply?: string; + timeoutMs: number; + observedActiveDescendants?: boolean; +}): 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(); + } + const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); + if ( + latest && + latest.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() && + (latest !== initialReply || !isLikelyInterimCronMessage(latest)) + ) { + 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)); + } + const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); + if ( + latest && + latest.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() && + (latest !== initialReply || !isLikelyInterimCronMessage(latest)) + ) { + return latest; + } + return undefined; +} diff --git a/src/cron/legacy-delivery.ts b/src/cron/legacy-delivery.ts new file mode 100644 index 00000000000..326f000b7a4 --- /dev/null +++ b/src/cron/legacy-delivery.ts @@ -0,0 +1,48 @@ +export function hasLegacyDeliveryHints(payload: Record) { + if (typeof payload.deliver === "boolean") { + return true; + } + if (typeof payload.bestEffortDeliver === "boolean") { + return true; + } + if (typeof payload.to === "string" && payload.to.trim()) { + return true; + } + return false; +} + +export function buildDeliveryFromLegacyPayload( + payload: Record, +): Record { + const deliver = payload.deliver; + const mode = deliver === false ? "none" : "announce"; + const channelRaw = + typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : ""; + const toRaw = typeof payload.to === "string" ? payload.to.trim() : ""; + const next: Record = { mode }; + if (channelRaw) { + next.channel = channelRaw; + } + if (toRaw) { + next.to = toRaw; + } + if (typeof payload.bestEffortDeliver === "boolean") { + next.bestEffort = payload.bestEffortDeliver; + } + return next; +} + +export function stripLegacyDeliveryFields(payload: Record) { + if ("deliver" in payload) { + delete payload.deliver; + } + if ("channel" in payload) { + delete payload.channel; + } + if ("to" in payload) { + delete payload.to; + } + if ("bestEffortDeliver" in payload) { + delete payload.bestEffortDeliver; + } +} diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index f5d7910044e..bcc2849ae57 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -163,6 +163,25 @@ describe("normalizeCronJobCreate", () => { expect(delivery.to).toBe("7200373102"); }); + it("normalizes webhook delivery mode and target URL", () => { + const normalized = normalizeCronJobCreate({ + name: "webhook delivery", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "hello" }, + delivery: { + mode: " WeBhOoK ", + to: " https://example.invalid/cron ", + }, + }) as unknown as Record; + + const delivery = normalized.delivery as Record; + expect(delivery.mode).toBe("webhook"); + expect(delivery.to).toBe("https://example.invalid/cron"); + }); + it("defaults isolated agentTurn delivery to announce", () => { const normalized = normalizeCronJobCreate({ name: "default-announce", diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 00e56164631..2e09aefd576 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -1,6 +1,11 @@ import type { CronJobCreate, CronJobPatch } from "./types.js"; import { sanitizeAgentId } from "../routing/session-key.js"; import { isRecord } from "../utils.js"; +import { + buildDeliveryFromLegacyPayload, + hasLegacyDeliveryHints, + stripLegacyDeliveryFields, +} from "./legacy-delivery.js"; import { parseAbsoluteTimeMs } from "./parse.js"; import { migrateLegacyCronPayload } from "./payload-migration.js"; import { inferLegacyName } from "./service/normalize.js"; @@ -146,7 +151,7 @@ function coerceDelivery(delivery: UnknownRecord) { const mode = delivery.mode.trim().toLowerCase(); if (mode === "deliver") { next.mode = "announce"; - } else if (mode === "announce" || mode === "none") { + } else if (mode === "announce" || mode === "none" || mode === "webhook") { next.mode = mode; } else { delete next.mode; @@ -173,53 +178,6 @@ function coerceDelivery(delivery: UnknownRecord) { return next; } -function hasLegacyDeliveryHints(payload: UnknownRecord) { - if (typeof payload.deliver === "boolean") { - return true; - } - if (typeof payload.bestEffortDeliver === "boolean") { - return true; - } - if (typeof payload.to === "string" && payload.to.trim()) { - return true; - } - return false; -} - -function buildDeliveryFromLegacyPayload(payload: UnknownRecord): UnknownRecord { - const deliver = payload.deliver; - const mode = deliver === false ? "none" : "announce"; - const channelRaw = - typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : ""; - const toRaw = typeof payload.to === "string" ? payload.to.trim() : ""; - const next: UnknownRecord = { mode }; - if (channelRaw) { - next.channel = channelRaw; - } - if (toRaw) { - next.to = toRaw; - } - if (typeof payload.bestEffortDeliver === "boolean") { - next.bestEffort = payload.bestEffortDeliver; - } - return next; -} - -function stripLegacyDeliveryFields(payload: UnknownRecord) { - if ("deliver" in payload) { - delete payload.deliver; - } - if ("channel" in payload) { - delete payload.channel; - } - if ("to" in payload) { - delete payload.to; - } - if ("bestEffortDeliver" in payload) { - delete payload.bestEffortDeliver; - } -} - function unwrapJob(raw: UnknownRecord) { if (isRecord(raw.data)) { return raw.data; diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index 9a38121aecb..b70c3654f7b 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -190,12 +190,14 @@ describe("CronService interval/cron jobs fire on time", () => { }); await cron.start(); - for (let minute = 1; minute <= 6; minute++) { + // Perf: a few recomputation cycles are enough to catch legacy "every" drift. + for (let minute = 1; minute <= 3; minute++) { vi.setSystemTime(new Date(nowMs + minute * 60_000)); const minuteRun = await cron.run("minute-cron", "force"); expect(minuteRun).toEqual({ ok: true, ran: true }); } + // "every" cadence is 2m; verify it stays due at the 6-minute boundary. vi.setSystemTime(new Date(nowMs + 6 * 60_000)); const sfRun = await cron.run("legacy-every", "due"); expect(sfRun).toEqual({ ok: true, ran: true }); diff --git a/src/cron/service.get-job.test.ts b/src/cron/service.get-job.test.ts new file mode 100644 index 00000000000..a3e56004d46 --- /dev/null +++ b/src/cron/service.get-job.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { CronService } from "./service.js"; +import { + createCronStoreHarness, + createNoopLogger, + installCronTestHooks, +} from "./service.test-harness.js"; + +const logger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-get-job-" }); +installCronTestHooks({ logger }); + +function createCronService(storePath: string) { + return new CronService({ + storePath, + cronEnabled: true, + log: logger, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); +} + +describe("CronService.getJob", () => { + it("returns added jobs and undefined for missing ids", async () => { + const { storePath } = await makeStorePath(); + const cron = createCronService(storePath); + await cron.start(); + + try { + const added = await cron.add({ + name: "lookup-test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + + expect(cron.getJob(added.id)?.id).toBe(added.id); + expect(cron.getJob("missing-job-id")).toBeUndefined(); + } finally { + cron.stop(); + } + }); + + it("preserves webhook delivery on create", async () => { + const { storePath } = await makeStorePath(); + const cron = createCronService(storePath); + await cron.start(); + + try { + const webhookJob = await cron.add({ + name: "webhook-job", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + delivery: { mode: "webhook", to: "https://example.invalid/cron" }, + }); + expect(cron.getJob(webhookJob.id)?.delivery).toEqual({ + mode: "webhook", + to: "https://example.invalid/cron", + }); + } finally { + cron.stop(); + } + }); +}); diff --git a/src/cron/service.issue-16156-list-skips-cron.test.ts b/src/cron/service.issue-16156-list-skips-cron.test.ts index 85b960dd333..dd9363a5194 100644 --- a/src/cron/service.issue-16156-list-skips-cron.test.ts +++ b/src/cron/service.issue-16156-list-skips-cron.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { CronEvent } from "./service.js"; import { CronService } from "./service.js"; @@ -12,14 +12,14 @@ const noopLogger = { error: vi.fn(), }; +let fixtureRoot = ""; +let caseId = 0; + async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-16156-")); - return { - storePath: path.join(dir, "cron", "jobs.json"), - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; + const dir = path.join(fixtureRoot, `case-${caseId++}`); + const storePath = path.join(dir, "cron", "jobs.json"); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + return { storePath }; } function createFinishedBarrier() { @@ -44,6 +44,16 @@ function createFinishedBarrier() { } describe("#16156: cron.list() must not silently advance past-due recurring jobs", () => { + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-16156-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); + } + }); + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z")); @@ -119,7 +129,6 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs" expect(updated?.state.nextRunAtMs).toBeGreaterThan(firstDueAt); cron.stop(); - await store.cleanup(); }); it("does not skip a cron job when status() is called while the job is past-due", async () => { @@ -172,7 +181,6 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs" expect(updated?.state.lastStatus).toBe("ok"); cron.stop(); - await store.cleanup(); }); it("still fills missing nextRunAtMs via list() for enabled jobs", async () => { @@ -226,6 +234,5 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs" expect(job?.state.nextRunAtMs).toBeGreaterThan(nowMs); cron.stop(); - await store.cleanup(); }); }); diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index df1f867cf5f..e7dd5285519 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -24,9 +24,6 @@ async function makeStorePath() { const storePath = path.join(dir, "jobs.json"); return { storePath, - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, }; } @@ -152,7 +149,6 @@ describe("Cron issue regressions", () => { } cron.stop(); - await store.cleanup(); }); it("repairs missing nextRunAtMs on non-schedule updates without touching other jobs", async () => { @@ -183,7 +179,6 @@ describe("Cron issue regressions", () => { expect(updated.state.nextRunAtMs).toBe(created.state.nextRunAtMs); cron.stop(); - await store.cleanup(); }); it("does not advance unrelated due jobs when updating another job", async () => { @@ -230,7 +225,6 @@ describe("Cron issue regressions", () => { expect(persistedDueJob?.state?.nextRunAtMs).toBe(originalDueNextRunAtMs); cron.stop(); - await store.cleanup(); }); it("treats persisted jobs with missing enabled as enabled during update()", async () => { @@ -282,7 +276,6 @@ describe("Cron issue regressions", () => { expect(updated.state.nextRunAtMs).toBeGreaterThan(now); cron.stop(); - await store.cleanup(); }); it("treats persisted due jobs with missing enabled as runnable", async () => { @@ -333,7 +326,6 @@ describe("Cron issue regressions", () => { ); cron.stop(); - await store.cleanup(); }); it("caps timer delay to 60s for far-future schedules", async () => { @@ -366,7 +358,6 @@ describe("Cron issue regressions", () => { cron.stop(); timeoutSpy.mockRestore(); - await store.cleanup(); }); it("re-arms timer without hot-looping when a run is already in progress", async () => { @@ -400,7 +391,6 @@ describe("Cron issue regressions", () => { .filter((d): d is number => typeof d === "number"); expect(delays).toContain(60_000); timeoutSpy.mockRestore(); - await store.cleanup(); }); it("skips forced manual runs while a timer-triggered run is in progress", async () => { @@ -467,7 +457,6 @@ describe("Cron issue regressions", () => { await cron.list({ includeDisabled: true }); cron.stop(); - await store.cleanup(); }); it("#13845: one-shot jobs with terminal statuses do not re-fire on restart", async () => { @@ -523,7 +512,6 @@ describe("Cron issue regressions", () => { expect(enqueueSystemEvent).not.toHaveBeenCalled(); cron.stop(); } - await store.cleanup(); }); it("records per-job start time and duration for batched due jobs", async () => { @@ -569,7 +557,5 @@ describe("Cron issue regressions", () => { expect(secondDone?.state.lastRunAtMs).toBe(dueAt + 50); expect(secondDone?.state.lastDurationMs).toBe(20); expect(startedAtEvents).toEqual([dueAt, dueAt + 50]); - - await store.cleanup(); }); }); diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index b11ca9854b1..ed28d592757 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -30,6 +30,32 @@ describe("applyJobPatch", () => { expect(job.delivery).toBeUndefined(); }); + it("keeps webhook delivery when switching to main session", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-webhook", + name: "job-webhook", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it" }, + delivery: { mode: "webhook", to: "https://example.invalid/cron" }, + state: {}, + }; + + const patch: CronJobPatch = { + sessionTarget: "main", + payload: { kind: "systemEvent", text: "ping" }, + }; + + expect(() => applyJobPatch(job, patch)).not.toThrow(); + expect(job.sessionTarget).toBe("main"); + expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron" }); + }); + it("maps legacy payload delivery updates onto delivery", () => { const now = Date.now(); const job: CronJob = { @@ -100,4 +126,56 @@ describe("applyJobPatch", () => { bestEffort: undefined, }); }); + + it("rejects webhook delivery without a valid http(s) target URL", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-webhook-invalid", + name: "job-webhook-invalid", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "ping" }, + delivery: { mode: "webhook" }, + state: {}, + }; + + expect(() => applyJobPatch(job, { enabled: true })).toThrow( + "cron webhook delivery requires delivery.to to be a valid http(s) URL", + ); + expect(() => applyJobPatch(job, { delivery: { mode: "webhook", to: "" } })).toThrow( + "cron webhook delivery requires delivery.to to be a valid http(s) URL", + ); + expect(() => + applyJobPatch(job, { delivery: { mode: "webhook", to: "ftp://example.invalid" } }), + ).toThrow("cron webhook delivery requires delivery.to to be a valid http(s) URL"); + expect(() => applyJobPatch(job, { delivery: { mode: "webhook", to: "not-a-url" } })).toThrow( + "cron webhook delivery requires delivery.to to be a valid http(s) URL", + ); + }); + + it("trims webhook delivery target URLs", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-webhook-trim", + name: "job-webhook-trim", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "ping" }, + delivery: { mode: "webhook", to: "https://example.invalid/original" }, + state: {}, + }; + + expect(() => + applyJobPatch(job, { delivery: { mode: "webhook", to: " https://example.invalid/trim " } }), + ).not.toThrow(); + expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/trim" }); + }); }); diff --git a/src/cron/service.read-ops-nonblocking.test.ts b/src/cron/service.read-ops-nonblocking.test.ts index 048246cc5cb..3a649d7ce90 100644 --- a/src/cron/service.read-ops-nonblocking.test.ts +++ b/src/cron/service.read-ops-nonblocking.test.ts @@ -16,6 +16,21 @@ async function makeStorePath() { return { storePath: path.join(dir, "cron", "jobs.json"), cleanup: async () => { + // On macOS, teardown can race with trailing async fs writes and leave + // transient ENOTEMPTY errors. Retry briefly for stability. + for (let i = 0; i < 10; i += 1) { + try { + await fs.rm(dir, { recursive: true, force: true }); + return; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOTEMPTY") { + throw err; + } + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } await fs.rm(dir, { recursive: true, force: true }); }, }; @@ -23,24 +38,35 @@ async function makeStorePath() { describe("CronService read ops while job is running", () => { it("keeps list and status responsive during a long isolated run", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z")); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); + let resolveFinished: (() => void) | undefined; + const finished = new Promise((resolve) => { + resolveFinished = resolve; + }); let resolveRun: | ((value: { status: "ok" | "error" | "skipped"; summary?: string; error?: string }) => void) | undefined; - const runIsolatedAgentJob = vi.fn( - async () => - await new Promise<{ - status: "ok" | "error" | "skipped"; - summary?: string; - error?: string; - }>((resolve) => { - resolveRun = resolve; - }), - ); + let resolveRunStarted: (() => void) | undefined; + const runStarted = new Promise((resolve) => { + resolveRunStarted = resolve; + }); + + const runIsolatedAgentJob = vi.fn(async () => { + resolveRunStarted?.(); + return await new Promise<{ + status: "ok" | "error" | "skipped"; + summary?: string; + error?: string; + }>((resolve) => { + resolveRun = resolve; + }); + }); const cron = new CronService({ storePath: store.storePath, @@ -49,70 +75,67 @@ describe("CronService read ops while job is running", () => { enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob, + onEvent: (evt) => { + if (evt.action === "finished" && evt.status === "ok") { + resolveFinished?.(); + } + }, }); - const timeout = async (promise: Promise, ms: number): Promise => { - let t: NodeJS.Timeout; - const timeoutPromise = new Promise((_, reject) => { - t = setTimeout(() => reject(new Error("timeout")), ms); - }); - return await Promise.race([promise.finally(() => clearTimeout(t!)), timeoutPromise]); - }; - try { await cron.start(); - // Schedule the job in the past so the cron timer fires immediately. + // Schedule the job a second in the future; then jump time to trigger the tick. await cron.add({ name: "slow isolated", enabled: true, deleteAfterRun: false, - schedule: { kind: "at", at: new Date(Date.now() - 1).toISOString() }, + schedule: { + kind: "at", + at: new Date("2025-12-13T00:00:01.000Z").toISOString(), + }, sessionTarget: "isolated", wakeMode: "next-heartbeat", payload: { kind: "agentTurn", message: "long task" }, delivery: { mode: "none" }, }); - // Let the scheduler tick and start the job. - await timeout( - (async () => { - for (;;) { - if (runIsolatedAgentJob.mock.calls.length > 0) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 0)); - } - })(), - 2000, - ); + vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + await vi.runOnlyPendingTimersAsync(); + await runStarted; expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); - await expect(timeout(cron.list({ includeDisabled: true }), 1000)).resolves.toBeTypeOf( - "object", - ); - await expect(timeout(cron.status(), 1000)).resolves.toBeTypeOf("object"); + await expect(cron.list({ includeDisabled: true })).resolves.toBeTypeOf("object"); + await expect(cron.status()).resolves.toBeTypeOf("object"); const running = await cron.list({ includeDisabled: true }); expect(running[0]?.state.runningAtMs).toBeTypeOf("number"); resolveRun?.({ status: "ok", summary: "done" }); - await timeout( - (async () => { - for (;;) { - const finished = await cron.list({ includeDisabled: true }); - if (finished[0]?.state.lastStatus === "ok") { - return; - } - await new Promise((resolve) => setTimeout(resolve, 0)); - } - })(), - 2000, - ); + // Wait until the scheduler writes the result back to the store. + await finished; + // Ensure any trailing store writes have finished before cleanup. + await cron.status(); + + const completed = await cron.list({ includeDisabled: true }); + expect(completed[0]?.state.lastStatus).toBe("ok"); + + // Ensure the scheduler loop has fully settled before deleting the store directory. + const internal = cron as unknown as { state?: { running?: boolean } }; + for (let i = 0; i < 100; i += 1) { + if (!internal.state?.running) { + break; + } + // eslint-disable-next-line no-await-in-loop + await Promise.resolve(); + } + expect(internal.state?.running).toBe(false); } finally { cron.stop(); + vi.clearAllTimers(); + vi.useRealTimers(); await store.cleanup(); } }); diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index f9aa49c8975..912ea9d1a00 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -1,19 +1,201 @@ -import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js"; import type { CronEvent } from "./service.js"; import { CronService } from "./service.js"; -import { - createCronStoreHarness, - createNoopLogger, - installCronTestHooks, -} from "./service.test-harness.js"; +import { createNoopLogger, installCronTestHooks } from "./service.test-harness.js"; const noopLogger = createNoopLogger(); -const { makeStorePath } = createCronStoreHarness(); installCronTestHooks({ logger: noopLogger }); +type FakeFsEntry = + | { kind: "file"; content: string; mtimeMs: number } + | { kind: "dir"; mtimeMs: number }; + +const fsState = vi.hoisted(() => ({ + entries: new Map(), + nowMs: 0, + fixtureCount: 0, +})); + +const abs = (p: string) => path.resolve(p); +const fixturesRoot = abs(path.join("__openclaw_vitest__", "cron", "runs-one-shot")); +const isFixturePath = (p: string) => { + const resolved = abs(p); + const rootPrefix = `${fixturesRoot}${path.sep}`; + return resolved === fixturesRoot || resolved.startsWith(rootPrefix); +}; + +function bumpMtimeMs() { + fsState.nowMs += 1; + return fsState.nowMs; +} + +function ensureDir(dirPath: string) { + let current = abs(dirPath); + while (true) { + if (!fsState.entries.has(current)) { + fsState.entries.set(current, { kind: "dir", mtimeMs: bumpMtimeMs() }); + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } +} + +function setFile(filePath: string, content: string) { + const resolved = abs(filePath); + ensureDir(path.dirname(resolved)); + fsState.entries.set(resolved, { kind: "file", content, mtimeMs: bumpMtimeMs() }); +} + +async function makeStorePath() { + const dir = path.join(fixturesRoot, `case-${fsState.fixtureCount++}`); + ensureDir(dir); + const storePath = path.join(dir, "cron", "jobs.json"); + ensureDir(path.dirname(storePath)); + return { storePath, cleanup: async () => {} }; +} + +function writeStoreFile(storePath: string, payload: unknown) { + setFile(storePath, JSON.stringify(payload, null, 2)); +} + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const pathMod = await import("node:path"); + const absInMock = (p: string) => pathMod.resolve(p); + const isFixtureInMock = (p: string) => { + const resolved = absInMock(p); + const rootPrefix = `${absInMock(fixturesRoot)}${pathMod.sep}`; + return resolved === absInMock(fixturesRoot) || resolved.startsWith(rootPrefix); + }; + + const mkErr = (code: string, message: string) => Object.assign(new Error(message), { code }); + + const promises = { + ...actual.promises, + mkdir: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.mkdir as any)(p, { recursive: true }); + } + ensureDir(p); + }, + readFile: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.readFile as any)(p, "utf-8"); + } + const entry = fsState.entries.get(absInMock(p)); + if (!entry || entry.kind !== "file") { + throw mkErr("ENOENT", `ENOENT: no such file or directory, open '${p}'`); + } + return entry.content; + }, + writeFile: async (p: string, data: string | Uint8Array) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.writeFile as any)(p, data, "utf-8"); + } + const content = typeof data === "string" ? data : Buffer.from(data).toString("utf-8"); + setFile(p, content); + }, + rename: async (from: string, to: string) => { + if (!isFixtureInMock(from) || !isFixtureInMock(to)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.rename as any)(from, to); + } + const fromAbs = absInMock(from); + const toAbs = absInMock(to); + const entry = fsState.entries.get(fromAbs); + if (!entry || entry.kind !== "file") { + throw mkErr("ENOENT", `ENOENT: no such file or directory, rename '${from}' -> '${to}'`); + } + ensureDir(pathMod.dirname(toAbs)); + fsState.entries.delete(fromAbs); + fsState.entries.set(toAbs, { ...entry, mtimeMs: bumpMtimeMs() }); + }, + copyFile: async (from: string, to: string) => { + if (!isFixtureInMock(from) || !isFixtureInMock(to)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.copyFile as any)(from, to); + } + const entry = fsState.entries.get(absInMock(from)); + if (!entry || entry.kind !== "file") { + throw mkErr("ENOENT", `ENOENT: no such file or directory, copyfile '${from}' -> '${to}'`); + } + setFile(to, entry.content); + }, + stat: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.stat as any)(p); + } + const entry = fsState.entries.get(absInMock(p)); + if (!entry) { + throw mkErr("ENOENT", `ENOENT: no such file or directory, stat '${p}'`); + } + return { + mtimeMs: entry.mtimeMs, + isDirectory: () => entry.kind === "dir", + isFile: () => entry.kind === "file", + }; + }, + access: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.access as any)(p); + } + const entry = fsState.entries.get(absInMock(p)); + if (!entry) { + throw mkErr("ENOENT", `ENOENT: no such file or directory, access '${p}'`); + } + }, + unlink: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.unlink as any)(p); + } + fsState.entries.delete(absInMock(p)); + }, + } satisfies typeof actual.promises; + + const wrapped = { ...actual, promises }; + return { ...wrapped, default: wrapped }; +}); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + const wrapped = { + ...actual, + mkdir: async (p: string, _opts?: unknown) => { + if (!isFixturePath(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.mkdir as any)(p, { recursive: true }); + } + ensureDir(p); + }, + writeFile: async (p: string, data: string, _enc?: unknown) => { + if (!isFixturePath(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.writeFile as any)(p, data, "utf-8"); + } + setFile(p, data); + }, + }; + return { ...wrapped, default: wrapped }; +}); + +beforeEach(() => { + fsState.entries.clear(); + fsState.nowMs = 0; + fsState.fixtureCount = 0; + ensureDir(fixturesRoot); +}); + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -57,35 +239,8 @@ function createCronEventHarness() { } describe("CronService", () => { - async function loadLegacyJobFromStore(rawJob: Record) { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify({ version: 1, jobs: [rawJob] }, null, 2), - "utf-8", - ); - - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - }); - - await cron.start(); - const jobs = await cron.list({ includeDisabled: true }); - const job = jobs.find((j) => j.id === rawJob.id); - - return { cron, store, enqueueSystemEvent, requestHeartbeatNow, job }; - } - it("runs a one-shot main job and disables it after success when requested", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -134,6 +289,7 @@ describe("CronService", () => { }); it("runs a one-shot job and deletes it after success by default", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -177,6 +333,7 @@ describe("CronService", () => { }); it("wakeMode now waits for heartbeat completion when available", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -244,6 +401,7 @@ describe("CronService", () => { }); it("passes agentId to runHeartbeatOnce for main-session wakeMode now jobs", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -293,6 +451,7 @@ describe("CronService", () => { }); it("wakeMode now falls back to queued heartbeat when main lane stays busy", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -343,6 +502,7 @@ describe("CronService", () => { }); it("runs an isolated job and posts summary to main", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -391,6 +551,7 @@ describe("CronService", () => { }); it("does not post isolated summary to main when run already delivered output", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -437,6 +598,11 @@ describe("CronService", () => { }); it("migrates legacy payload.provider to payload.channel on load", async () => { + ensureDir(fixturesRoot); + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const rawJob = { id: "legacy-1", name: "legacy", @@ -456,7 +622,20 @@ describe("CronService", () => { state: {}, }; - const { cron, store, job } = await loadLegacyJobFromStore(rawJob); + writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] }); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + const jobs = await cron.list({ includeDisabled: true }); + const job = jobs.find((j) => j.id === rawJob.id); // Legacy delivery fields are migrated to the top-level delivery object const delivery = job?.delivery as unknown as Record; expect(delivery?.channel).toBe("telegram"); @@ -469,6 +648,11 @@ describe("CronService", () => { }); it("canonicalizes payload.channel casing on load", async () => { + ensureDir(fixturesRoot); + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const rawJob = { id: "legacy-2", name: "legacy", @@ -488,7 +672,20 @@ describe("CronService", () => { state: {}, }; - const { cron, store, job } = await loadLegacyJobFromStore(rawJob); + writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] }); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + const jobs = await cron.list({ includeDisabled: true }); + const job = jobs.find((j) => j.id === rawJob.id); // Legacy delivery fields are migrated to the top-level delivery object const delivery = job?.delivery as unknown as Record; expect(delivery?.channel).toBe("telegram"); @@ -498,6 +695,7 @@ describe("CronService", () => { }); it("posts last output to main even when isolated job errors", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -546,6 +744,7 @@ describe("CronService", () => { }); it("rejects unsupported session/payload combinations", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const cron = new CronService({ @@ -586,32 +785,29 @@ describe("CronService", () => { }); it("skips invalid main jobs with agentTurn payloads from disk", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); const events = createCronEventHarness(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify({ - version: 1, - jobs: [ - { - id: "job-1", - enabled: true, - createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"), - updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"), - schedule: { kind: "at", at: new Date(atMs).toISOString() }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "agentTurn", message: "bad" }, - state: {}, - }, - ], - }), - ); + writeStoreFile(store.storePath, { + version: 1, + jobs: [ + { + id: "job-1", + enabled: true, + createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"), + updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"), + schedule: { kind: "at", at: new Date(atMs).toISOString() }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "agentTurn", message: "bad" }, + state: {}, + }, + ], + }); const cron = new CronService({ storePath: store.storePath, diff --git a/src/cron/service.ts b/src/cron/service.ts index 8f82a2e6947..8891ee9915b 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -1,4 +1,4 @@ -import type { CronJobCreate, CronJobPatch } from "./types.js"; +import type { CronJob, CronJobCreate, CronJobPatch } from "./types.js"; import * as ops from "./service/ops.js"; import { type CronServiceDeps, createCronServiceState } from "./service/state.js"; @@ -42,6 +42,10 @@ export class CronService { return await ops.run(this.state, id, mode); } + getJob(id: string): CronJob | undefined { + return this.state.store?.jobs.find((job) => job.id === id); + } + wake(opts: { mode: "now" | "next-heartbeat"; text: string }) { return ops.wakeNow(this.state, opts); } diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 71a11af7bca..4185987f5a7 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -11,6 +11,7 @@ import type { import type { CronServiceState } from "./state.js"; import { parseAbsoluteTimeMs } from "../parse.js"; import { computeNextRunAtMs } from "../schedule.js"; +import { normalizeHttpWebhookUrl } from "../webhook-url.js"; import { normalizeOptionalAgentId, normalizeOptionalText, @@ -41,8 +42,19 @@ export function assertSupportedJobSpec(job: Pick) { - if (job.delivery && job.sessionTarget !== "isolated") { - throw new Error('cron delivery config is only supported for sessionTarget="isolated"'); + if (!job.delivery) { + return; + } + if (job.delivery.mode === "webhook") { + const target = normalizeHttpWebhookUrl(job.delivery.to); + if (!target) { + throw new Error("cron webhook delivery requires delivery.to to be a valid http(s) URL"); + } + job.delivery.to = target; + return; + } + if (job.sessionTarget !== "isolated") { + throw new Error('cron channel delivery config is only supported for sessionTarget="isolated"'); } } @@ -127,7 +139,10 @@ function normalizeJobTickState(params: { state: CronServiceState; job: CronJob; return { changed, skip: false }; } -export function recomputeNextRuns(state: CronServiceState): boolean { +function walkSchedulableJobs( + state: CronServiceState, + fn: (params: { job: CronJob; nowMs: number }) => boolean, +): boolean { if (!state.store) { return false; } @@ -141,6 +156,16 @@ export function recomputeNextRuns(state: CronServiceState): boolean { if (tick.skip) { continue; } + if (fn({ job, nowMs: now })) { + changed = true; + } + } + return changed; +} + +export function recomputeNextRuns(state: CronServiceState): boolean { + return walkSchedulableJobs(state, ({ job, nowMs: now }) => { + let changed = false; // Only recompute if nextRunAtMs is missing or already past-due. // Preserving a still-future nextRunAtMs avoids accidentally advancing // a job that hasn't fired yet (e.g. during restart recovery). @@ -179,8 +204,8 @@ export function recomputeNextRuns(state: CronServiceState): boolean { } } } - } - return changed; + return changed; + }); } /** @@ -191,19 +216,8 @@ export function recomputeNextRuns(state: CronServiceState): boolean { * (see #13992). */ export function recomputeNextRunsForMaintenance(state: CronServiceState): boolean { - if (!state.store) { - return false; - } - let changed = false; - const now = state.deps.nowMs(); - for (const job of state.store.jobs) { - const tick = normalizeJobTickState({ state, job, nowMs: now }); - if (tick.changed) { - changed = true; - } - if (tick.skip) { - continue; - } + return walkSchedulableJobs(state, ({ job, nowMs: now }) => { + let changed = false; // Only compute missing nextRunAtMs, do NOT recompute existing ones. // If a job was past-due but not found by findDueJobs, recomputing would // cause it to be silently skipped. @@ -214,8 +228,8 @@ export function recomputeNextRunsForMaintenance(state: CronServiceState): boolea changed = true; } } - } - return changed; + return changed; + }); } export function nextWakeAtMs(state: CronServiceState) { @@ -313,7 +327,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) { if (patch.delivery) { job.delivery = mergeCronDelivery(job.delivery, patch.delivery); } - if (job.sessionTarget === "main" && job.delivery) { + if (job.sessionTarget === "main" && job.delivery?.mode !== "webhook") { job.delivery = undefined; } if (patch.state) { diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 1df1dfc95e3..2545a84d210 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -14,6 +14,19 @@ import { locked } from "./locked.js"; import { ensureLoaded, persist, warnIfDisabled } from "./store.js"; import { armTimer, emit, executeJob, runMissedJobs, stopTimer, wake } from "./timer.js"; +async function ensureLoadedForRead(state: CronServiceState) { + await ensureLoaded(state, { skipRecompute: true }); + if (!state.store) { + return; + } + // Use the maintenance-only version so that read-only operations never + // advance a past-due nextRunAtMs without executing the job (#16156). + const changed = recomputeNextRunsForMaintenance(state); + if (changed) { + await persist(state); + } +} + export async function start(state: CronServiceState) { await locked(state, async () => { if (!state.deps.cronEnabled) { @@ -54,15 +67,7 @@ export function stop(state: CronServiceState) { export async function status(state: CronServiceState) { return await locked(state, async () => { - await ensureLoaded(state, { skipRecompute: true }); - if (state.store) { - // Use the maintenance-only version so that read-only operations never - // advance a past-due nextRunAtMs without executing the job (#16156). - const changed = recomputeNextRunsForMaintenance(state); - if (changed) { - await persist(state); - } - } + await ensureLoadedForRead(state); return { enabled: state.deps.cronEnabled, storePath: state.deps.storePath, @@ -74,15 +79,7 @@ export async function status(state: CronServiceState) { export async function list(state: CronServiceState, opts?: { includeDisabled?: boolean }) { return await locked(state, async () => { - await ensureLoaded(state, { skipRecompute: true }); - if (state.store) { - // Use the maintenance-only version so that read-only operations never - // advance a past-due nextRunAtMs without executing the job (#16156). - const changed = recomputeNextRunsForMaintenance(state); - if (changed) { - await persist(state); - } - } + await ensureLoadedForRead(state); const includeDisabled = opts?.includeDisabled === true; const jobs = (state.store?.jobs ?? []).filter((j) => includeDisabled || j.enabled); return jobs.toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index 3da848f3e38..9a171f9e078 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -1,44 +1,17 @@ import fs from "node:fs"; import type { CronJob } from "../types.js"; import type { CronServiceState } from "./state.js"; +import { + buildDeliveryFromLegacyPayload, + hasLegacyDeliveryHints, + stripLegacyDeliveryFields, +} from "../legacy-delivery.js"; import { parseAbsoluteTimeMs } from "../parse.js"; import { migrateLegacyCronPayload } from "../payload-migration.js"; import { loadCronStore, saveCronStore } from "../store.js"; import { recomputeNextRuns } from "./jobs.js"; import { inferLegacyName, normalizeOptionalText } from "./normalize.js"; -function hasLegacyDeliveryHints(payload: Record) { - if (typeof payload.deliver === "boolean") { - return true; - } - if (typeof payload.bestEffortDeliver === "boolean") { - return true; - } - if (typeof payload.to === "string" && payload.to.trim()) { - return true; - } - return false; -} - -function buildDeliveryFromLegacyPayload(payload: Record) { - const deliver = payload.deliver; - const mode = deliver === false ? "none" : "announce"; - const channelRaw = - typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : ""; - const toRaw = typeof payload.to === "string" ? payload.to.trim() : ""; - const next: Record = { mode }; - if (channelRaw) { - next.channel = channelRaw; - } - if (toRaw) { - next.to = toRaw; - } - if (typeof payload.bestEffortDeliver === "boolean") { - next.bestEffort = payload.bestEffortDeliver; - } - return next; -} - function buildDeliveryPatchFromLegacyPayload(payload: Record) { const deliver = payload.deliver; const channelRaw = @@ -102,21 +75,6 @@ function mergeLegacyDeliveryInto( return { delivery: next, mutated }; } -function stripLegacyDeliveryFields(payload: Record) { - if ("deliver" in payload) { - delete payload.deliver; - } - if ("channel" in payload) { - delete payload.channel; - } - if ("to" in payload) { - delete payload.to; - } - if ("bestEffortDeliver" in payload) { - delete payload.bestEffortDeliver; - } -} - function normalizePayloadKind(payload: Record) { const raw = typeof payload.kind === "string" ? payload.kind.trim().toLowerCase() : ""; if (raw === "agentturn") { diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 3c18a5e03fd..674f191a875 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -144,12 +144,13 @@ export function armTimer(state: CronServiceState) { // Wake at least once a minute to avoid schedule drift and recover quickly // when the process was paused or wall-clock time jumps. const clampedDelay = Math.min(delay, MAX_TIMER_DELAY_MS); - state.timer = setTimeout(async () => { - try { - await onTimer(state); - } catch (err) { + // Intentionally avoid an `async` timer callback: + // Vitest's fake-timer helpers can await async callbacks, which would block + // tests that simulate long-running jobs. Runtime behavior is unchanged. + state.timer = setTimeout(() => { + void onTimer(state).catch((err) => { state.deps.log.error({ err: String(err) }, "cron: timer tick failed"); - } + }); }, clampedDelay); state.deps.log.debug( { nextAt, delayMs: clampedDelay, clamped: delay > MAX_TIMER_DELAY_MS }, @@ -172,12 +173,10 @@ export async function onTimer(state: CronServiceState) { if (state.timer) { clearTimeout(state.timer); } - state.timer = setTimeout(async () => { - try { - await onTimer(state); - } catch (err) { + state.timer = setTimeout(() => { + void onTimer(state).catch((err) => { state.deps.log.error({ err: String(err) }, "cron: timer tick failed"); - } + }); }, MAX_TIMER_DELAY_MS); return; } @@ -276,18 +275,7 @@ export async function onTimer(state: CronServiceState) { endedAt: result.endedAt, }); - emit(state, { - jobId: job.id, - action: "finished", - status: result.status, - error: result.error, - summary: result.summary, - sessionId: result.sessionId, - sessionKey: result.sessionKey, - runAtMs: result.startedAt, - durationMs: job.state.lastDurationMs, - nextRunAtMs: job.state.nextRunAtMs, - }); + emitJobFinished(state, job, result, result.startedAt); if (shouldDelete && state.store) { state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id); @@ -342,19 +330,54 @@ function findDueJobs(state: CronServiceState): CronJob[] { return []; } const now = state.deps.nowMs(); - return state.store.jobs.filter((j) => { - if (!j.state) { - j.state = {}; - } - if (!j.enabled) { - return false; - } - if (typeof j.state.runningAtMs === "number") { - return false; - } - const next = j.state.nextRunAtMs; - return typeof next === "number" && now >= next; - }); + return collectRunnableJobs(state, now); +} + +function isRunnableJob(params: { + job: CronJob; + nowMs: number; + skipJobIds?: ReadonlySet; + skipAtIfAlreadyRan?: boolean; +}): boolean { + const { job, nowMs } = params; + if (!job.state) { + job.state = {}; + } + if (!job.enabled) { + return false; + } + if (params.skipJobIds?.has(job.id)) { + return false; + } + if (typeof job.state.runningAtMs === "number") { + return false; + } + if (params.skipAtIfAlreadyRan && job.schedule.kind === "at" && job.state.lastStatus) { + // Any terminal status (ok, error, skipped) means the job already ran at least once. + // Don't re-fire it on restart β€” applyJobResult disables one-shot jobs, but guard + // here defensively (#13845). + return false; + } + const next = job.state.nextRunAtMs; + return typeof next === "number" && nowMs >= next; +} + +function collectRunnableJobs( + state: CronServiceState, + nowMs: number, + opts?: { skipJobIds?: ReadonlySet; skipAtIfAlreadyRan?: boolean }, +): CronJob[] { + if (!state.store) { + return []; + } + return state.store.jobs.filter((job) => + isRunnableJob({ + job, + nowMs, + skipJobIds: opts?.skipJobIds, + skipAtIfAlreadyRan: opts?.skipAtIfAlreadyRan, + }), + ); } export async function runMissedJobs( @@ -366,28 +389,7 @@ export async function runMissedJobs( } const now = state.deps.nowMs(); const skipJobIds = opts?.skipJobIds; - const missed = state.store.jobs.filter((j) => { - if (!j.state) { - j.state = {}; - } - if (!j.enabled) { - return false; - } - if (skipJobIds?.has(j.id)) { - return false; - } - if (typeof j.state.runningAtMs === "number") { - return false; - } - const next = j.state.nextRunAtMs; - if (j.schedule.kind === "at" && j.state.lastStatus) { - // Any terminal status (ok, error, skipped) means the job already - // ran at least once. Don't re-fire it on restart β€” applyJobResult - // disables one-shot jobs, but guard here defensively (#13845). - return false; - } - return typeof next === "number" && now >= next; - }); + const missed = collectRunnableJobs(state, now, { skipJobIds, skipAtIfAlreadyRan: true }); if (missed.length > 0) { state.deps.log.info( @@ -405,19 +407,7 @@ export async function runDueJobs(state: CronServiceState) { return; } const now = state.deps.nowMs(); - const due = state.store.jobs.filter((j) => { - if (!j.state) { - j.state = {}; - } - if (!j.enabled) { - return false; - } - if (typeof j.state.runningAtMs === "number") { - return false; - } - const next = j.state.nextRunAtMs; - return typeof next === "number" && now >= next; - }); + const due = collectRunnableJobs(state, now); for (const job of due) { await executeJob(state, job, now, { forced: false }); } @@ -563,18 +553,7 @@ export async function executeJob( endedAt, }); - emit(state, { - jobId: job.id, - action: "finished", - status: coreResult.status, - error: coreResult.error, - summary: coreResult.summary, - sessionId: coreResult.sessionId, - sessionKey: coreResult.sessionKey, - runAtMs: startedAt, - durationMs: job.state.lastDurationMs, - nextRunAtMs: job.state.nextRunAtMs, - }); + emitJobFinished(state, job, coreResult, startedAt); if (shouldDelete && state.store) { state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id); @@ -582,6 +561,32 @@ export async function executeJob( } } +function emitJobFinished( + state: CronServiceState, + job: CronJob, + result: { + status: "ok" | "error" | "skipped"; + error?: string; + summary?: string; + sessionId?: string; + sessionKey?: string; + }, + runAtMs: number, +) { + emit(state, { + jobId: job.id, + action: "finished", + status: result.status, + error: result.error, + summary: result.summary, + sessionId: result.sessionId, + sessionKey: result.sessionKey, + runAtMs, + durationMs: job.state.lastDurationMs, + nextRunAtMs: job.state.nextRunAtMs, + }); +} + export function wake( state: CronServiceState, opts: { mode: "now" | "next-heartbeat"; text: string }, diff --git a/src/cron/types.ts b/src/cron/types.ts index c3168346fb4..6c7e7bec02f 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -10,7 +10,7 @@ export type CronWakeMode = "next-heartbeat" | "now"; export type CronMessageChannel = ChannelId | "last"; -export type CronDeliveryMode = "none" | "announce"; +export type CronDeliveryMode = "none" | "announce" | "webhook"; export type CronDelivery = { mode: CronDeliveryMode; diff --git a/src/cron/webhook-url.ts b/src/cron/webhook-url.ts new file mode 100644 index 00000000000..7cd6c154173 --- /dev/null +++ b/src/cron/webhook-url.ts @@ -0,0 +1,22 @@ +function isAllowedWebhookProtocol(protocol: string) { + return protocol === "http:" || protocol === "https:"; +} + +export function normalizeHttpWebhookUrl(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + try { + const parsed = new URL(trimmed); + if (!isAllowedWebhookProtocol(parsed.protocol)) { + return null; + } + return trimmed; + } catch { + return null; + } +} diff --git a/src/daemon/arg-split.test.ts b/src/daemon/arg-split.test.ts deleted file mode 100644 index f9b8c89448e..00000000000 --- a/src/daemon/arg-split.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { splitArgsPreservingQuotes } from "./arg-split.js"; - -describe("splitArgsPreservingQuotes", () => { - it("splits on whitespace outside quotes", () => { - expect(splitArgsPreservingQuotes('/usr/bin/openclaw gateway start --name "My Bot"')).toEqual([ - "/usr/bin/openclaw", - "gateway", - "start", - "--name", - "My Bot", - ]); - }); - - it("supports systemd-style backslash escaping", () => { - expect( - splitArgsPreservingQuotes('openclaw --name "My \\"Bot\\"" --foo bar', { - escapeMode: "backslash", - }), - ).toEqual(["openclaw", "--name", 'My "Bot"', "--foo", "bar"]); - }); - - it("supports schtasks-style escaped quotes while preserving other backslashes", () => { - expect( - splitArgsPreservingQuotes('openclaw --path "C:\\\\Program Files\\\\OpenClaw"', { - escapeMode: "backslash-quote-only", - }), - ).toEqual(["openclaw", "--path", "C:\\\\Program Files\\\\OpenClaw"]); - - expect( - splitArgsPreservingQuotes('openclaw --label "My \\"Quoted\\" Name"', { - escapeMode: "backslash-quote-only", - }), - ).toEqual(["openclaw", "--label", 'My "Quoted" Name']); - }); -}); diff --git a/src/daemon/constants.test.ts b/src/daemon/constants.test.ts index c215ae1a88d..469832e41b0 100644 --- a/src/daemon/constants.test.ts +++ b/src/daemon/constants.test.ts @@ -4,12 +4,29 @@ import { GATEWAY_LAUNCH_AGENT_LABEL, GATEWAY_SYSTEMD_SERVICE_NAME, GATEWAY_WINDOWS_TASK_NAME, + normalizeGatewayProfile, resolveGatewayLaunchAgentLabel, resolveGatewayProfileSuffix, + resolveGatewayServiceDescription, resolveGatewaySystemdServiceName, resolveGatewayWindowsTaskName, } from "./constants.js"; +describe("normalizeGatewayProfile", () => { + it("returns null for empty/default profiles", () => { + expect(normalizeGatewayProfile()).toBeNull(); + expect(normalizeGatewayProfile("")).toBeNull(); + expect(normalizeGatewayProfile(" ")).toBeNull(); + expect(normalizeGatewayProfile("default")).toBeNull(); + expect(normalizeGatewayProfile(" Default ")).toBeNull(); + }); + + it("returns trimmed custom profiles", () => { + expect(normalizeGatewayProfile("dev")).toBe("dev"); + expect(normalizeGatewayProfile(" staging ")).toBe("staging"); + }); +}); + describe("resolveGatewayLaunchAgentLabel", () => { it("returns default label when no profile is set", () => { const result = resolveGatewayLaunchAgentLabel(); @@ -17,45 +34,10 @@ describe("resolveGatewayLaunchAgentLabel", () => { expect(result).toBe("ai.openclaw.gateway"); }); - it("returns default label when profile is undefined", () => { - const result = resolveGatewayLaunchAgentLabel(undefined); - expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL); - }); - - it("returns default label when profile is 'default'", () => { - const result = resolveGatewayLaunchAgentLabel("default"); - expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL); - }); - - it("returns default label when profile is 'Default' (case-insensitive)", () => { - const result = resolveGatewayLaunchAgentLabel("Default"); - expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL); - }); - it("returns profile-specific label when profile is set", () => { const result = resolveGatewayLaunchAgentLabel("dev"); expect(result).toBe("ai.openclaw.dev"); }); - - it("returns profile-specific label for custom profile", () => { - const result = resolveGatewayLaunchAgentLabel("work"); - expect(result).toBe("ai.openclaw.work"); - }); - - it("trims whitespace from profile", () => { - const result = resolveGatewayLaunchAgentLabel(" staging "); - expect(result).toBe("ai.openclaw.staging"); - }); - - it("returns default label for empty string profile", () => { - const result = resolveGatewayLaunchAgentLabel(""); - expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL); - }); - - it("returns default label for whitespace-only profile", () => { - const result = resolveGatewayLaunchAgentLabel(" "); - expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL); - }); }); describe("resolveGatewaySystemdServiceName", () => { @@ -65,45 +47,10 @@ describe("resolveGatewaySystemdServiceName", () => { expect(result).toBe("openclaw-gateway"); }); - it("returns default service name when profile is undefined", () => { - const result = resolveGatewaySystemdServiceName(undefined); - expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME); - }); - - it("returns default service name when profile is 'default'", () => { - const result = resolveGatewaySystemdServiceName("default"); - expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME); - }); - - it("returns default service name when profile is 'DEFAULT' (case-insensitive)", () => { - const result = resolveGatewaySystemdServiceName("DEFAULT"); - expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME); - }); - it("returns profile-specific service name when profile is set", () => { const result = resolveGatewaySystemdServiceName("dev"); expect(result).toBe("openclaw-gateway-dev"); }); - - it("returns profile-specific service name for custom profile", () => { - const result = resolveGatewaySystemdServiceName("production"); - expect(result).toBe("openclaw-gateway-production"); - }); - - it("trims whitespace from profile", () => { - const result = resolveGatewaySystemdServiceName(" test "); - expect(result).toBe("openclaw-gateway-test"); - }); - - it("returns default service name for empty string profile", () => { - const result = resolveGatewaySystemdServiceName(""); - expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME); - }); - - it("returns default service name for whitespace-only profile", () => { - const result = resolveGatewaySystemdServiceName(" "); - expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME); - }); }); describe("resolveGatewayWindowsTaskName", () => { @@ -113,45 +60,10 @@ describe("resolveGatewayWindowsTaskName", () => { expect(result).toBe("OpenClaw Gateway"); }); - it("returns default task name when profile is undefined", () => { - const result = resolveGatewayWindowsTaskName(undefined); - expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME); - }); - - it("returns default task name when profile is 'default'", () => { - const result = resolveGatewayWindowsTaskName("default"); - expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME); - }); - - it("returns default task name when profile is 'DeFaUlT' (case-insensitive)", () => { - const result = resolveGatewayWindowsTaskName("DeFaUlT"); - expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME); - }); - it("returns profile-specific task name when profile is set", () => { const result = resolveGatewayWindowsTaskName("dev"); expect(result).toBe("OpenClaw Gateway (dev)"); }); - - it("returns profile-specific task name for custom profile", () => { - const result = resolveGatewayWindowsTaskName("work"); - expect(result).toBe("OpenClaw Gateway (work)"); - }); - - it("trims whitespace from profile", () => { - const result = resolveGatewayWindowsTaskName(" ci "); - expect(result).toBe("OpenClaw Gateway (ci)"); - }); - - it("returns default task name for empty string profile", () => { - const result = resolveGatewayWindowsTaskName(""); - expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME); - }); - - it("returns default task name for whitespace-only profile", () => { - const result = resolveGatewayWindowsTaskName(" "); - expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME); - }); }); describe("resolveGatewayProfileSuffix", () => { @@ -196,3 +108,23 @@ describe("formatGatewayServiceDescription", () => { ); }); }); + +describe("resolveGatewayServiceDescription", () => { + it("prefers explicit description override", () => { + expect( + resolveGatewayServiceDescription({ + env: { OPENCLAW_PROFILE: "work", OPENCLAW_SERVICE_VERSION: "1.0.0" }, + description: "Custom", + }), + ).toBe("Custom"); + }); + + it("resolves version from explicit environment map", () => { + expect( + resolveGatewayServiceDescription({ + env: { OPENCLAW_PROFILE: "work", OPENCLAW_SERVICE_VERSION: "local" }, + environment: { OPENCLAW_SERVICE_VERSION: "remote" }, + }), + ).toBe("OpenClaw Gateway (profile: work, vremote)"); + }); +}); diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 212eb93a2a9..3ee523b1535 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -75,6 +75,20 @@ export function formatGatewayServiceDescription(params?: { return `OpenClaw Gateway (${parts.join(", ")})`; } +export function resolveGatewayServiceDescription(params: { + env: Record; + environment?: Record; + description?: string; +}): string { + return ( + params.description ?? + formatGatewayServiceDescription({ + profile: params.env.OPENCLAW_PROFILE, + version: params.environment?.OPENCLAW_SERVICE_VERSION ?? params.env.OPENCLAW_SERVICE_VERSION, + }) + ); +} + export function resolveNodeLaunchAgentLabel(): string { return NODE_LAUNCH_AGENT_LABEL; } diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index beb67e7cefe..43222a0e298 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -1,8 +1,5 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { PassThrough } from "node:stream"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { installLaunchAgent, isLaunchAgentListed, @@ -11,116 +8,69 @@ import { resolveLaunchAgentPlistPath, } from "./launchd.js"; -function parseLaunchctlCalls(raw: string): string[][] { - return raw - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => line.split(/\s+/)); -} +const state = vi.hoisted(() => ({ + launchctlCalls: [] as string[][], + listOutput: "", + dirs: new Set(), + files: new Map(), +})); -async function writeLaunchctlStub(binDir: string) { - if (process.platform === "win32") { - const stubJsPath = path.join(binDir, "launchctl.js"); - await fs.writeFile( - stubJsPath, - [ - 'import fs from "node:fs";', - "const args = process.argv.slice(2);", - "const logPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;", - "if (logPath) {", - ' fs.appendFileSync(logPath, args.join("\\t") + "\\n", "utf8");', - "}", - 'if (args[0] === "list") {', - ' const output = process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT || "";', - " process.stdout.write(output);", - "}", - "process.exit(0);", - "", - ].join("\n"), - "utf8", - ); - await fs.writeFile( - path.join(binDir, "launchctl.cmd"), - `@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`, - "utf8", - ); - return; +function normalizeLaunchctlArgs(file: string, args: string[]): string[] { + if (file === "launchctl") { + return args; } - - const shPath = path.join(binDir, "launchctl"); - await fs.writeFile( - shPath, - [ - "#!/bin/sh", - 'log_path="${OPENCLAW_TEST_LAUNCHCTL_LOG:-}"', - 'if [ -n "$log_path" ]; then', - ' line=""', - ' for arg in "$@"; do', - ' if [ -n "$line" ]; then', - ' line="$line $arg"', - " else", - ' line="$arg"', - " fi", - " done", - ' printf \'%s\\n\' "$line" >> "$log_path"', - "fi", - 'if [ "$1" = "list" ]; then', - " printf '%s' \"${OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT:-}\"", - "fi", - "exit 0", - "", - ].join("\n"), - "utf8", - ); - await fs.chmod(shPath, 0o755); -} - -async function withLaunchctlStub( - options: { listOutput?: string }, - run: (context: { env: Record; logPath: string }) => Promise, -) { - const originalPath = process.env.PATH; - const originalLogPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG; - const originalListOutput = process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT; - - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launchctl-test-")); - try { - const binDir = path.join(tmpDir, "bin"); - const homeDir = path.join(tmpDir, "home"); - const logPath = path.join(tmpDir, "launchctl.log"); - await fs.mkdir(binDir, { recursive: true }); - await fs.mkdir(homeDir, { recursive: true }); - - await writeLaunchctlStub(binDir); - - process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = logPath; - process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT = options.listOutput ?? ""; - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; - - await run({ - env: { - HOME: homeDir, - OPENCLAW_PROFILE: "default", - }, - logPath, - }); - } finally { - process.env.PATH = originalPath; - if (originalLogPath === undefined) { - delete process.env.OPENCLAW_TEST_LAUNCHCTL_LOG; - } else { - process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = originalLogPath; - } - if (originalListOutput === undefined) { - delete process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT; - } else { - process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT = originalListOutput; - } - await fs.rm(tmpDir, { recursive: true, force: true }); + const idx = args.indexOf("launchctl"); + if (idx >= 0) { + return args.slice(idx + 1); } + return args; } +vi.mock("./exec-file.js", () => ({ + execFileUtf8: vi.fn(async (file: string, args: string[]) => { + const call = normalizeLaunchctlArgs(file, args); + state.launchctlCalls.push(call); + if (call[0] === "list") { + return { stdout: state.listOutput, stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }), +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + const wrapped = { + ...actual, + access: vi.fn(async (p: string) => { + const key = String(p); + if (state.files.has(key) || state.dirs.has(key)) { + return; + } + throw new Error(`ENOENT: no such file or directory, access '${key}'`); + }), + mkdir: vi.fn(async (p: string) => { + state.dirs.add(String(p)); + }), + unlink: vi.fn(async (p: string) => { + state.files.delete(String(p)); + }), + writeFile: vi.fn(async (p: string, data: string) => { + const key = String(p); + state.files.set(key, data); + state.dirs.add(String(key.split("/").slice(0, -1).join("/"))); + }), + }; + return { ...wrapped, default: wrapped }; +}); + +beforeEach(() => { + state.launchctlCalls.length = 0; + state.listOutput = ""; + state.dirs.clear(); + state.files.clear(); + vi.clearAllMocks(); +}); + describe("launchd runtime parsing", () => { it("parses state, pid, and exit status", () => { const output = [ @@ -140,103 +90,70 @@ describe("launchd runtime parsing", () => { describe("launchctl list detection", () => { it("detects the resolved label in launchctl list", async () => { - await withLaunchctlStub({ listOutput: "123 0 ai.openclaw.gateway\n" }, async ({ env }) => { - const listed = await isLaunchAgentListed({ env }); - expect(listed).toBe(true); + state.listOutput = "123 0 ai.openclaw.gateway\n"; + const listed = await isLaunchAgentListed({ + env: { HOME: "/Users/test", OPENCLAW_PROFILE: "default" }, }); + expect(listed).toBe(true); }); it("returns false when the label is missing", async () => { - await withLaunchctlStub({ listOutput: "123 0 com.other.service\n" }, async ({ env }) => { - const listed = await isLaunchAgentListed({ env }); - expect(listed).toBe(false); + state.listOutput = "123 0 com.other.service\n"; + const listed = await isLaunchAgentListed({ + env: { HOME: "/Users/test", OPENCLAW_PROFILE: "default" }, }); + expect(listed).toBe(false); }); }); describe("launchd bootstrap repair", () => { it("bootstraps and kickstarts the resolved label", async () => { - await withLaunchctlStub({}, async ({ env, logPath }) => { - const repair = await repairLaunchAgentBootstrap({ env }); - expect(repair.ok).toBe(true); + const env: Record = { + HOME: "/Users/test", + OPENCLAW_PROFILE: "default", + }; + const repair = await repairLaunchAgentBootstrap({ env }); + expect(repair.ok).toBe(true); - const calls = parseLaunchctlCalls(await fs.readFile(logPath, "utf8")); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const label = "ai.openclaw.gateway"; + const plistPath = resolveLaunchAgentPlistPath(env); - const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; - const label = "ai.openclaw.gateway"; - const plistPath = resolveLaunchAgentPlistPath(env); - - expect(calls).toContainEqual(["bootstrap", domain, plistPath]); - expect(calls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]); - }); + expect(state.launchctlCalls).toContainEqual(["bootstrap", domain, plistPath]); + expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]); }); }); describe("launchd install", () => { it("enables service before bootstrap (clears persisted disabled state)", async () => { - const originalPath = process.env.PATH; - const originalLogPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG; + const env: Record = { + HOME: "/Users/test", + OPENCLAW_PROFILE: "default", + }; + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: ["node", "-e", "process.exit(0)"], + }); - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launchctl-test-")); - try { - const binDir = path.join(tmpDir, "bin"); - const homeDir = path.join(tmpDir, "home"); - const logPath = path.join(tmpDir, "launchctl.log"); - await fs.mkdir(binDir, { recursive: true }); - await fs.mkdir(homeDir, { recursive: true }); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const label = "ai.openclaw.gateway"; + const plistPath = resolveLaunchAgentPlistPath(env); + const serviceId = `${domain}/${label}`; - await writeLaunchctlStub(binDir); - - process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = logPath; - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; - - const env: Record = { - HOME: homeDir, - OPENCLAW_PROFILE: "default", - }; - await installLaunchAgent({ - env, - stdout: new PassThrough(), - programArguments: ["node", "-e", "process.exit(0)"], - }); - - const calls = parseLaunchctlCalls(await fs.readFile(logPath, "utf8")); - - const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; - const label = "ai.openclaw.gateway"; - const plistPath = resolveLaunchAgentPlistPath(env); - const serviceId = `${domain}/${label}`; - - const enableCalls = calls.filter((c) => c[0] === "enable" && c[1] === serviceId); - expect(enableCalls).toHaveLength(1); - - const enableIndex = calls.findIndex((c) => c[0] === "enable" && c[1] === serviceId); - const bootstrapIndex = calls.findIndex( - (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, - ); - expect(enableIndex).toBeGreaterThanOrEqual(0); - expect(bootstrapIndex).toBeGreaterThanOrEqual(0); - expect(enableIndex).toBeLessThan(bootstrapIndex); - } finally { - process.env.PATH = originalPath; - if (originalLogPath === undefined) { - delete process.env.OPENCLAW_TEST_LAUNCHCTL_LOG; - } else { - process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = originalLogPath; - } - await fs.rm(tmpDir, { recursive: true, force: true }); - } + const enableIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "enable" && c[1] === serviceId, + ); + const bootstrapIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, + ); + expect(enableIndex).toBeGreaterThanOrEqual(0); + expect(bootstrapIndex).toBeGreaterThanOrEqual(0); + expect(enableIndex).toBeLessThan(bootstrapIndex); }); }); describe("resolveLaunchAgentPlistPath", () => { - it("uses default label when OPENCLAW_PROFILE is default", () => { - const env = { HOME: "/Users/test", OPENCLAW_PROFILE: "default" }; - expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist", - ); - }); - it("uses default label when OPENCLAW_PROFILE is unset", () => { const env = { HOME: "/Users/test" }; expect(resolveLaunchAgentPlistPath(env)).toBe( @@ -282,25 +199,4 @@ describe("resolveLaunchAgentPlistPath", () => { "/Users/test/Library/LaunchAgents/ai.openclaw.myprofile.plist", ); }); - - it("handles case-insensitive 'Default' profile", () => { - const env = { HOME: "/Users/test", OPENCLAW_PROFILE: "Default" }; - expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist", - ); - }); - - it("handles case-insensitive 'DEFAULT' profile", () => { - const env = { HOME: "/Users/test", OPENCLAW_PROFILE: "DEFAULT" }; - expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist", - ); - }); - - it("trims whitespace from OPENCLAW_PROFILE", () => { - const env = { HOME: "/Users/test", OPENCLAW_PROFILE: " myprofile " }; - expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/ai.openclaw.myprofile.plist", - ); - }); }); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 3d33af682dc..795fe828096 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import { - formatGatewayServiceDescription, GATEWAY_LAUNCH_AGENT_LABEL, + resolveGatewayServiceDescription, resolveGatewayLaunchAgentLabel, resolveLegacyGatewayLaunchAgentLabels, } from "./constants.js"; @@ -384,12 +384,7 @@ export async function installLaunchAgent({ const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); await fs.mkdir(path.dirname(plistPath), { recursive: true }); - const serviceDescription = - description ?? - formatGatewayServiceDescription({ - profile: env.OPENCLAW_PROFILE, - version: environment?.OPENCLAW_SERVICE_VERSION ?? env.OPENCLAW_SERVICE_VERSION, - }); + const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const plist = buildLaunchAgentPlist({ label, comment: serviceDescription, diff --git a/src/daemon/paths.test.ts b/src/daemon/paths.test.ts deleted file mode 100644 index 5bcdb22cf69..00000000000 --- a/src/daemon/paths.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { resolveGatewayStateDir } from "./paths.js"; - -describe("resolveGatewayStateDir", () => { - it("uses the default state dir when no overrides are set", () => { - const env = { HOME: "/Users/test" }; - expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".openclaw")); - }); - - it("appends the profile suffix when set", () => { - const env = { HOME: "/Users/test", OPENCLAW_PROFILE: "rescue" }; - expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".openclaw-rescue")); - }); - - it("treats default profiles as the base state dir", () => { - const env = { HOME: "/Users/test", OPENCLAW_PROFILE: "Default" }; - expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".openclaw")); - }); - - it("uses OPENCLAW_STATE_DIR when provided", () => { - const env = { HOME: "/Users/test", OPENCLAW_STATE_DIR: "/var/lib/openclaw" }; - expect(resolveGatewayStateDir(env)).toBe(path.resolve("/var/lib/openclaw")); - }); - - it("expands ~ in OPENCLAW_STATE_DIR", () => { - const env = { HOME: "/Users/test", OPENCLAW_STATE_DIR: "~/openclaw-state" }; - expect(resolveGatewayStateDir(env)).toBe(path.resolve("/Users/test/openclaw-state")); - }); - - it("preserves Windows absolute paths without HOME", () => { - const env = { OPENCLAW_STATE_DIR: "C:\\State\\openclaw" }; - expect(resolveGatewayStateDir(env)).toBe("C:\\State\\openclaw"); - }); -}); diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts index c2a2fab42f0..a0d24d89da0 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -35,13 +35,6 @@ describe("schtasks runtime parsing", () => { }); describe("resolveTaskScriptPath", () => { - it("uses default path when OPENCLAW_PROFILE is default", () => { - const env = { USERPROFILE: "C:\\Users\\test", OPENCLAW_PROFILE: "default" }; - expect(resolveTaskScriptPath(env)).toBe( - path.join("C:\\Users\\test", ".openclaw", "gateway.cmd"), - ); - }); - it("uses default path when OPENCLAW_PROFILE is unset", () => { const env = { USERPROFILE: "C:\\Users\\test" }; expect(resolveTaskScriptPath(env)).toBe( @@ -65,27 +58,6 @@ describe("resolveTaskScriptPath", () => { expect(resolveTaskScriptPath(env)).toBe(path.join("C:\\State\\openclaw", "gateway.cmd")); }); - it("handles case-insensitive 'Default' profile", () => { - const env = { USERPROFILE: "C:\\Users\\test", OPENCLAW_PROFILE: "Default" }; - expect(resolveTaskScriptPath(env)).toBe( - path.join("C:\\Users\\test", ".openclaw", "gateway.cmd"), - ); - }); - - it("handles case-insensitive 'DEFAULT' profile", () => { - const env = { USERPROFILE: "C:\\Users\\test", OPENCLAW_PROFILE: "DEFAULT" }; - expect(resolveTaskScriptPath(env)).toBe( - path.join("C:\\Users\\test", ".openclaw", "gateway.cmd"), - ); - }); - - it("trims whitespace from OPENCLAW_PROFILE", () => { - const env = { USERPROFILE: "C:\\Users\\test", OPENCLAW_PROFILE: " myprofile " }; - expect(resolveTaskScriptPath(env)).toBe( - path.join("C:\\Users\\test", ".openclaw-myprofile", "gateway.cmd"), - ); - }); - it("falls back to HOME when USERPROFILE is not set", () => { const env = { HOME: "/home/test", OPENCLAW_PROFILE: "default" }; expect(resolveTaskScriptPath(env)).toBe(path.join("/home/test", ".openclaw", "gateway.cmd")); @@ -93,74 +65,6 @@ describe("resolveTaskScriptPath", () => { }); describe("readScheduledTaskCommand", () => { - it("parses basic command script", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-schtasks-test-")); - try { - const scriptPath = path.join(tmpDir, ".openclaw", "gateway.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.writeFile( - scriptPath, - ["@echo off", "node gateway.js --port 18789"].join("\r\n"), - "utf8", - ); - - const env = { USERPROFILE: tmpDir, OPENCLAW_PROFILE: "default" }; - const result = await readScheduledTaskCommand(env); - expect(result).toEqual({ - programArguments: ["node", "gateway.js", "--port", "18789"], - }); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } - }); - - it("parses script with working directory", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-schtasks-test-")); - try { - const scriptPath = path.join(tmpDir, ".openclaw", "gateway.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.writeFile( - scriptPath, - ["@echo off", "cd /d C:\\Projects\\openclaw", "node gateway.js"].join("\r\n"), - "utf8", - ); - - const env = { USERPROFILE: tmpDir, OPENCLAW_PROFILE: "default" }; - const result = await readScheduledTaskCommand(env); - expect(result).toEqual({ - programArguments: ["node", "gateway.js"], - workingDirectory: "C:\\Projects\\openclaw", - }); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } - }); - - it("parses script with environment variables", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-schtasks-test-")); - try { - const scriptPath = path.join(tmpDir, ".openclaw", "gateway.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.writeFile( - scriptPath, - ["@echo off", "set NODE_ENV=production", "set PORT=18789", "node gateway.js"].join("\r\n"), - "utf8", - ); - - const env = { USERPROFILE: tmpDir, OPENCLAW_PROFILE: "default" }; - const result = await readScheduledTaskCommand(env); - expect(result).toEqual({ - programArguments: ["node", "gateway.js"], - environment: { - NODE_ENV: "production", - PORT: "18789", - }, - }); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } - }); - it("parses script with quoted arguments containing spaces", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-schtasks-test-")); try { diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index d29d470ffe8..a70f100793a 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import { splitArgsPreservingQuotes } from "./arg-split.js"; -import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; +import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; import { formatLine } from "./output.js"; import { resolveGatewayStateDir } from "./paths.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; @@ -190,12 +190,7 @@ export async function installScheduledTask({ await assertSchtasksAvailable(); const scriptPath = resolveTaskScriptPath(env); await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - const taskDescription = - description ?? - formatGatewayServiceDescription({ - profile: env.OPENCLAW_PROFILE, - version: environment?.OPENCLAW_SERVICE_VERSION ?? env.OPENCLAW_SERVICE_VERSION, - }); + const taskDescription = resolveGatewayServiceDescription({ env, environment, description }); const script = buildTaskScript({ description: taskDescription, programArguments, diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 7ead6b2ec66..6a3b6a9399f 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolveGatewayStateDir } from "./paths.js"; import { buildMinimalServicePath, buildNodeServiceEnvironment, @@ -254,3 +255,35 @@ describe("buildNodeServiceEnvironment", () => { expect(env.HOME).toBe("/home/user"); }); }); + +describe("resolveGatewayStateDir", () => { + it("uses the default state dir when no overrides are set", () => { + const env = { HOME: "/Users/test" }; + expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".openclaw")); + }); + + it("appends the profile suffix when set", () => { + const env = { HOME: "/Users/test", OPENCLAW_PROFILE: "rescue" }; + expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".openclaw-rescue")); + }); + + it("treats default profiles as the base state dir", () => { + const env = { HOME: "/Users/test", OPENCLAW_PROFILE: "Default" }; + expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".openclaw")); + }); + + it("uses OPENCLAW_STATE_DIR when provided", () => { + const env = { HOME: "/Users/test", OPENCLAW_STATE_DIR: "/var/lib/openclaw" }; + expect(resolveGatewayStateDir(env)).toBe(path.resolve("/var/lib/openclaw")); + }); + + it("expands ~ in OPENCLAW_STATE_DIR", () => { + const env = { HOME: "/Users/test", OPENCLAW_STATE_DIR: "~/openclaw-state" }; + expect(resolveGatewayStateDir(env)).toBe(path.resolve("/Users/test/openclaw-state")); + }); + + it("preserves Windows absolute paths without HOME", () => { + const env = { OPENCLAW_STATE_DIR: "C:\\State\\openclaw" }; + expect(resolveGatewayStateDir(env)).toBe("C:\\State\\openclaw"); + }); +}); diff --git a/src/daemon/systemd-availability.test.ts b/src/daemon/systemd-availability.test.ts deleted file mode 100644 index 4897084ce52..00000000000 --- a/src/daemon/systemd-availability.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const execFileMock = vi.hoisted(() => vi.fn()); - -vi.mock("node:child_process", () => ({ - execFile: execFileMock, -})); - -import { isSystemdUserServiceAvailable } from "./systemd.js"; - -describe("systemd availability", () => { - beforeEach(() => { - execFileMock.mockReset(); - }); - - it("returns true when systemctl --user succeeds", async () => { - execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { - cb(null, "", ""); - }); - await expect(isSystemdUserServiceAvailable()).resolves.toBe(true); - }); - - it("returns false when systemd user bus is unavailable", async () => { - execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { - const err = new Error("Failed to connect to bus") as Error & { - stderr?: string; - code?: number; - }; - err.stderr = "Failed to connect to bus"; - err.code = 1; - cb(err, "", ""); - }); - await expect(isSystemdUserServiceAvailable()).resolves.toBe(false); - }); -}); diff --git a/src/daemon/systemd-unit.test.ts b/src/daemon/systemd-unit.test.ts deleted file mode 100644 index c671763409f..00000000000 --- a/src/daemon/systemd-unit.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseSystemdExecStart } from "./systemd-unit.js"; - -describe("parseSystemdExecStart", () => { - it("splits on whitespace outside quotes", () => { - const execStart = "/usr/bin/openclaw gateway start --foo bar"; - expect(parseSystemdExecStart(execStart)).toEqual([ - "/usr/bin/openclaw", - "gateway", - "start", - "--foo", - "bar", - ]); - }); - - it("preserves quoted arguments", () => { - const execStart = '/usr/bin/openclaw gateway start --name "My Bot"'; - expect(parseSystemdExecStart(execStart)).toEqual([ - "/usr/bin/openclaw", - "gateway", - "start", - "--name", - "My Bot", - ]); - }); - - it("parses path arguments", () => { - const execStart = "/usr/bin/openclaw gateway start --path /tmp/openclaw"; - expect(parseSystemdExecStart(execStart)).toEqual([ - "/usr/bin/openclaw", - "gateway", - "start", - "--path", - "/tmp/openclaw", - ]); - }); -}); diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 692a445e5a9..5e9e09deed3 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -1,5 +1,44 @@ -import { describe, expect, it } from "vitest"; -import { parseSystemdShow, resolveSystemdUserUnitPath } from "./systemd.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const execFileMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + execFile: execFileMock, +})); + +import { splitArgsPreservingQuotes } from "./arg-split.js"; +import { parseSystemdExecStart } from "./systemd-unit.js"; +import { + isSystemdUserServiceAvailable, + parseSystemdShow, + resolveSystemdUserUnitPath, +} from "./systemd.js"; + +describe("systemd availability", () => { + beforeEach(() => { + execFileMock.mockReset(); + }); + + it("returns true when systemctl --user succeeds", async () => { + execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { + cb(null, "", ""); + }); + await expect(isSystemdUserServiceAvailable()).resolves.toBe(true); + }); + + it("returns false when systemd user bus is unavailable", async () => { + execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { + const err = new Error("Failed to connect to bus") as Error & { + stderr?: string; + code?: number; + }; + err.stderr = "Failed to connect to bus"; + err.code = 1; + cb(err, "", ""); + }); + await expect(isSystemdUserServiceAvailable()).resolves.toBe(false); + }); +}); describe("systemd runtime parsing", () => { it("parses active state details", () => { @@ -20,13 +59,6 @@ describe("systemd runtime parsing", () => { }); describe("resolveSystemdUserUnitPath", () => { - it("uses default service name when OPENCLAW_PROFILE is default", () => { - const env = { HOME: "/home/test", OPENCLAW_PROFILE: "default" }; - expect(resolveSystemdUserUnitPath(env)).toBe( - "/home/test/.config/systemd/user/openclaw-gateway.service", - ); - }); - it("uses default service name when OPENCLAW_PROFILE is unset", () => { const env = { HOME: "/home/test" }; expect(resolveSystemdUserUnitPath(env)).toBe( @@ -71,25 +103,51 @@ describe("resolveSystemdUserUnitPath", () => { "/home/test/.config/systemd/user/custom-unit.service", ); }); +}); - it("handles case-insensitive 'Default' profile", () => { - const env = { HOME: "/home/test", OPENCLAW_PROFILE: "Default" }; - expect(resolveSystemdUserUnitPath(env)).toBe( - "/home/test/.config/systemd/user/openclaw-gateway.service", - ); +describe("splitArgsPreservingQuotes", () => { + it("splits on whitespace outside quotes", () => { + expect(splitArgsPreservingQuotes('/usr/bin/openclaw gateway start --name "My Bot"')).toEqual([ + "/usr/bin/openclaw", + "gateway", + "start", + "--name", + "My Bot", + ]); }); - it("handles case-insensitive 'DEFAULT' profile", () => { - const env = { HOME: "/home/test", OPENCLAW_PROFILE: "DEFAULT" }; - expect(resolveSystemdUserUnitPath(env)).toBe( - "/home/test/.config/systemd/user/openclaw-gateway.service", - ); + it("supports systemd-style backslash escaping", () => { + expect( + splitArgsPreservingQuotes('openclaw --name "My \\"Bot\\"" --foo bar', { + escapeMode: "backslash", + }), + ).toEqual(["openclaw", "--name", 'My "Bot"', "--foo", "bar"]); }); - it("trims whitespace from OPENCLAW_PROFILE", () => { - const env = { HOME: "/home/test", OPENCLAW_PROFILE: " myprofile " }; - expect(resolveSystemdUserUnitPath(env)).toBe( - "/home/test/.config/systemd/user/openclaw-gateway-myprofile.service", - ); + it("supports schtasks-style escaped quotes while preserving other backslashes", () => { + expect( + splitArgsPreservingQuotes('openclaw --path "C:\\\\Program Files\\\\OpenClaw"', { + escapeMode: "backslash-quote-only", + }), + ).toEqual(["openclaw", "--path", "C:\\\\Program Files\\\\OpenClaw"]); + + expect( + splitArgsPreservingQuotes('openclaw --label "My \\"Quoted\\" Name"', { + escapeMode: "backslash-quote-only", + }), + ).toEqual(["openclaw", "--label", 'My "Quoted" Name']); + }); +}); + +describe("parseSystemdExecStart", () => { + it("preserves quoted arguments", () => { + const execStart = '/usr/bin/openclaw gateway start --name "My Bot"'; + expect(parseSystemdExecStart(execStart)).toEqual([ + "/usr/bin/openclaw", + "gateway", + "start", + "--name", + "My Bot", + ]); }); }); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 6ef8af5765f..41107d4a2ee 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import { - formatGatewayServiceDescription, LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, + resolveGatewayServiceDescription, resolveGatewaySystemdServiceName, } from "./constants.js"; import { execFileUtf8 } from "./exec-file.js"; @@ -200,12 +200,7 @@ export async function installSystemdService({ const unitPath = resolveSystemdUnitPath(env); await fs.mkdir(path.dirname(unitPath), { recursive: true }); - const serviceDescription = - description ?? - formatGatewayServiceDescription({ - profile: env.OPENCLAW_PROFILE, - version: environment?.OPENCLAW_SERVICE_VERSION ?? env.OPENCLAW_SERVICE_VERSION, - }); + const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const unit = buildSystemdUnit({ description: serviceDescription, programArguments, diff --git a/src/discord/components-registry.ts b/src/discord/components-registry.ts new file mode 100644 index 00000000000..ce7014aba75 --- /dev/null +++ b/src/discord/components-registry.ts @@ -0,0 +1,89 @@ +import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js"; + +const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000; + +const componentEntries = new Map(); +const modalEntries = new Map(); + +function isExpired(entry: { expiresAt?: number }, now: number) { + return typeof entry.expiresAt === "number" && entry.expiresAt <= now; +} + +function normalizeEntryTimestamps( + entry: T, + now: number, + ttlMs: number, +): T { + const createdAt = entry.createdAt ?? now; + const expiresAt = entry.expiresAt ?? createdAt + ttlMs; + return { ...entry, createdAt, expiresAt }; +} + +export function registerDiscordComponentEntries(params: { + entries: DiscordComponentEntry[]; + modals: DiscordModalEntry[]; + ttlMs?: number; + messageId?: string; +}): void { + const now = Date.now(); + const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS; + for (const entry of params.entries) { + const normalized = normalizeEntryTimestamps( + { ...entry, messageId: params.messageId ?? entry.messageId }, + now, + ttlMs, + ); + componentEntries.set(entry.id, normalized); + } + for (const modal of params.modals) { + const normalized = normalizeEntryTimestamps( + { ...modal, messageId: params.messageId ?? modal.messageId }, + now, + ttlMs, + ); + modalEntries.set(modal.id, normalized); + } +} + +export function resolveDiscordComponentEntry(params: { + id: string; + consume?: boolean; +}): DiscordComponentEntry | null { + const entry = componentEntries.get(params.id); + if (!entry) { + return null; + } + const now = Date.now(); + if (isExpired(entry, now)) { + componentEntries.delete(params.id); + return null; + } + if (params.consume !== false) { + componentEntries.delete(params.id); + } + return entry; +} + +export function resolveDiscordModalEntry(params: { + id: string; + consume?: boolean; +}): DiscordModalEntry | null { + const entry = modalEntries.get(params.id); + if (!entry) { + return null; + } + const now = Date.now(); + if (isExpired(entry, now)) { + modalEntries.delete(params.id); + return null; + } + if (params.consume !== false) { + modalEntries.delete(params.id); + } + return entry; +} + +export function clearDiscordComponentEntries(): void { + componentEntries.clear(); + modalEntries.clear(); +} diff --git a/src/discord/components.test.ts b/src/discord/components.test.ts new file mode 100644 index 00000000000..9a49af7b469 --- /dev/null +++ b/src/discord/components.test.ts @@ -0,0 +1,98 @@ +import { MessageFlags } from "discord-api-types/v10"; +import { describe, expect, it, beforeEach } from "vitest"; +import { + clearDiscordComponentEntries, + registerDiscordComponentEntries, + resolveDiscordComponentEntry, + resolveDiscordModalEntry, +} from "./components-registry.js"; +import { + buildDiscordComponentMessage, + buildDiscordComponentMessageFlags, + readDiscordComponentSpec, +} from "./components.js"; + +describe("discord components", () => { + it("builds v2 containers with modal trigger", () => { + const spec = readDiscordComponentSpec({ + text: "Choose a path", + blocks: [ + { + type: "actions", + buttons: [{ label: "Approve", style: "success" }], + }, + ], + modal: { + title: "Details", + fields: [{ type: "text", label: "Requester" }], + }, + }); + if (!spec) { + throw new Error("Expected component spec to be parsed"); + } + + const result = buildDiscordComponentMessage({ spec }); + expect(result.components).toHaveLength(1); + expect(result.components[0]?.isV2).toBe(true); + expect(buildDiscordComponentMessageFlags(result.components)).toBe(MessageFlags.IsComponentsV2); + expect(result.modals).toHaveLength(1); + + const trigger = result.entries.find((entry) => entry.kind === "modal-trigger"); + expect(trigger?.modalId).toBe(result.modals[0]?.id); + }); + + it("requires options for modal select fields", () => { + expect(() => + readDiscordComponentSpec({ + modal: { + title: "Details", + fields: [{ type: "select", label: "Priority" }], + }, + }), + ).toThrow("options"); + }); + + it("requires attachment references for file blocks", () => { + expect(() => + readDiscordComponentSpec({ + blocks: [{ type: "file", file: "https://example.com/report.pdf" }], + }), + ).toThrow("attachment://"); + expect(() => + readDiscordComponentSpec({ + blocks: [{ type: "file", file: "attachment://" }], + }), + ).toThrow("filename"); + }); +}); + +describe("discord component registry", () => { + beforeEach(() => { + clearDiscordComponentEntries(); + }); + + it("registers and consumes component entries", () => { + registerDiscordComponentEntries({ + entries: [{ id: "btn_1", kind: "button", label: "Confirm" }], + modals: [ + { + id: "mdl_1", + title: "Details", + fields: [{ id: "fld_1", name: "name", label: "Name", type: "text" }], + }, + ], + messageId: "msg_1", + ttlMs: 1000, + }); + + const entry = resolveDiscordComponentEntry({ id: "btn_1", consume: false }); + expect(entry?.messageId).toBe("msg_1"); + + const modal = resolveDiscordModalEntry({ id: "mdl_1", consume: false }); + expect(modal?.messageId).toBe("msg_1"); + + const consumed = resolveDiscordComponentEntry({ id: "btn_1" }); + expect(consumed?.id).toBe("btn_1"); + expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull(); + }); +}); diff --git a/src/discord/components.ts b/src/discord/components.ts new file mode 100644 index 00000000000..b45c28b2109 --- /dev/null +++ b/src/discord/components.ts @@ -0,0 +1,1120 @@ +import { + Button, + ChannelSelectMenu, + CheckboxGroup, + Container, + File, + Label, + LinkButton, + MediaGallery, + MentionableSelectMenu, + Modal, + RadioGroup, + RoleSelectMenu, + Row, + Section, + Separator, + StringSelectMenu, + TextDisplay, + TextInput, + Thumbnail, + UserSelectMenu, + parseCustomId, + type ComponentParserResult, + type TopLevelComponents, +} from "@buape/carbon"; +import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10"; +import crypto from "node:crypto"; + +export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp"; +export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal"; +export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://"; + +export type DiscordComponentButtonStyle = "primary" | "secondary" | "success" | "danger" | "link"; + +export type DiscordComponentSelectType = "string" | "user" | "role" | "mentionable" | "channel"; + +export type DiscordComponentModalFieldType = + | "text" + | "checkbox" + | "radio" + | "select" + | "role-select" + | "user-select"; + +export type DiscordComponentButtonSpec = { + label: string; + style?: DiscordComponentButtonStyle; + url?: string; + emoji?: { + name: string; + id?: string; + animated?: boolean; + }; + disabled?: boolean; +}; + +export type DiscordComponentSelectOption = { + label: string; + value: string; + description?: string; + emoji?: { + name: string; + id?: string; + animated?: boolean; + }; + default?: boolean; +}; + +export type DiscordComponentSelectSpec = { + type?: DiscordComponentSelectType; + placeholder?: string; + minValues?: number; + maxValues?: number; + options?: DiscordComponentSelectOption[]; +}; + +export type DiscordComponentSectionAccessory = + | { + type: "thumbnail"; + url: string; + } + | { + type: "button"; + button: DiscordComponentButtonSpec; + }; + +type DiscordComponentSeparatorSpacing = "small" | "large" | 1 | 2; + +export type DiscordComponentBlock = + | { + type: "text"; + text: string; + } + | { + type: "section"; + text?: string; + texts?: string[]; + accessory?: DiscordComponentSectionAccessory; + } + | { + type: "separator"; + spacing?: DiscordComponentSeparatorSpacing; + divider?: boolean; + } + | { + type: "actions"; + buttons?: DiscordComponentButtonSpec[]; + select?: DiscordComponentSelectSpec; + } + | { + type: "media-gallery"; + items: Array<{ url: string; description?: string; spoiler?: boolean }>; + } + | { + type: "file"; + file: `attachment://${string}`; + spoiler?: boolean; + }; + +export type DiscordModalFieldSpec = { + type: DiscordComponentModalFieldType; + name?: string; + label: string; + description?: string; + placeholder?: string; + required?: boolean; + options?: DiscordComponentSelectOption[]; + minValues?: number; + maxValues?: number; + minLength?: number; + maxLength?: number; + style?: "short" | "paragraph"; +}; + +export type DiscordModalSpec = { + title: string; + triggerLabel?: string; + triggerStyle?: DiscordComponentButtonStyle; + fields: DiscordModalFieldSpec[]; +}; + +export type DiscordComponentMessageSpec = { + text?: string; + container?: { + accentColor?: string | number; + spoiler?: boolean; + }; + blocks?: DiscordComponentBlock[]; + modal?: DiscordModalSpec; +}; + +export type DiscordComponentEntry = { + id: string; + kind: "button" | "select" | "modal-trigger"; + label: string; + selectType?: DiscordComponentSelectType; + options?: Array<{ value: string; label: string }>; + modalId?: string; + sessionKey?: string; + agentId?: string; + accountId?: string; + messageId?: string; + createdAt?: number; + expiresAt?: number; +}; + +export type DiscordModalFieldDefinition = { + id: string; + name: string; + label: string; + type: DiscordComponentModalFieldType; + description?: string; + placeholder?: string; + required?: boolean; + options?: DiscordComponentSelectOption[]; + minValues?: number; + maxValues?: number; + minLength?: number; + maxLength?: number; + style?: "short" | "paragraph"; +}; + +export type DiscordModalEntry = { + id: string; + title: string; + fields: DiscordModalFieldDefinition[]; + sessionKey?: string; + agentId?: string; + accountId?: string; + messageId?: string; + createdAt?: number; + expiresAt?: number; +}; + +export type DiscordComponentBuildResult = { + components: TopLevelComponents[]; + entries: DiscordComponentEntry[]; + modals: DiscordModalEntry[]; +}; + +const BLOCK_ALIASES = new Map([ + ["row", "actions"], + ["action-row", "actions"], +]); + +function createShortId(prefix: string) { + return `${prefix}${crypto.randomBytes(6).toString("base64url")}`; +} + +function requireObject(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string { + if (typeof value !== "string") { + throw new Error(`${label} must be a string`); + } + const trimmed = value.trim(); + if (!opts?.allowEmpty && !trimmed) { + throw new Error(`${label} cannot be empty`); + } + return opts?.allowEmpty ? value : trimmed; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function readOptionalNumber(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return value; +} + +function normalizeModalFieldName(value: string | undefined, index: number) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + return `field_${index + 1}`; +} + +function normalizeAttachmentRef(value: string, label: string): `attachment://${string}` { + const trimmed = value.trim(); + if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) { + throw new Error(`${label} must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`); + } + const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim(); + if (!attachmentName) { + throw new Error(`${label} must include an attachment filename`); + } + return `${DISCORD_COMPONENT_ATTACHMENT_PREFIX}${attachmentName}`; +} + +export function resolveDiscordComponentAttachmentName(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) { + throw new Error( + `Attachment reference must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`, + ); + } + const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim(); + if (!attachmentName) { + throw new Error("Attachment reference must include a filename"); + } + return attachmentName; +} + +function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle { + switch ((style ?? "primary").toLowerCase()) { + case "secondary": + return ButtonStyle.Secondary; + case "success": + return ButtonStyle.Success; + case "danger": + return ButtonStyle.Danger; + case "link": + return ButtonStyle.Link; + case "primary": + default: + return ButtonStyle.Primary; + } +} + +function mapTextInputStyle(style?: DiscordModalFieldSpec["style"]) { + return style === "paragraph" ? TextInputStyle.Paragraph : TextInputStyle.Short; +} + +function normalizeBlockType(raw: string) { + const lowered = raw.trim().toLowerCase(); + return BLOCK_ALIASES.get(lowered) ?? (lowered as DiscordComponentBlock["type"]); +} + +function parseSelectOptions( + raw: unknown, + label: string, +): DiscordComponentSelectOption[] | undefined { + if (raw === undefined) { + return undefined; + } + if (!Array.isArray(raw)) { + throw new Error(`${label} must be an array`); + } + return raw.map((entry, index) => { + const obj = requireObject(entry, `${label}[${index}]`); + return { + label: readString(obj.label, `${label}[${index}].label`), + value: readString(obj.value, `${label}[${index}].value`), + description: readOptionalString(obj.description), + emoji: + typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji) + ? { + name: readString( + (obj.emoji as { name?: unknown }).name, + `${label}[${index}].emoji.name`, + ), + id: readOptionalString((obj.emoji as { id?: unknown }).id), + animated: + typeof (obj.emoji as { animated?: unknown }).animated === "boolean" + ? (obj.emoji as { animated?: boolean }).animated + : undefined, + } + : undefined, + default: typeof obj.default === "boolean" ? obj.default : undefined, + }; + }); +} + +function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec { + const obj = requireObject(raw, label); + const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined; + const url = readOptionalString(obj.url); + if ((style === "link" || url) && !url) { + throw new Error(`${label}.url is required for link buttons`); + } + return { + label: readString(obj.label, `${label}.label`), + style, + url, + emoji: + typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji) + ? { + name: readString((obj.emoji as { name?: unknown }).name, `${label}.emoji.name`), + id: readOptionalString((obj.emoji as { id?: unknown }).id), + animated: + typeof (obj.emoji as { animated?: unknown }).animated === "boolean" + ? (obj.emoji as { animated?: boolean }).animated + : undefined, + } + : undefined, + disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined, + }; +} + +function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec { + const obj = requireObject(raw, label); + const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined; + const allowedTypes: DiscordComponentSelectType[] = [ + "string", + "user", + "role", + "mentionable", + "channel", + ]; + if (type && !allowedTypes.includes(type)) { + throw new Error(`${label}.type must be one of ${allowedTypes.join(", ")}`); + } + return { + type, + placeholder: readOptionalString(obj.placeholder), + minValues: readOptionalNumber(obj.minValues), + maxValues: readOptionalNumber(obj.maxValues), + options: parseSelectOptions(obj.options, `${label}.options`), + }; +} + +function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec { + const obj = requireObject(raw, label); + const type = readString( + obj.type, + `${label}.type`, + ).toLowerCase() as DiscordComponentModalFieldType; + const supported: DiscordComponentModalFieldType[] = [ + "text", + "checkbox", + "radio", + "select", + "role-select", + "user-select", + ]; + if (!supported.includes(type)) { + throw new Error(`${label}.type must be one of ${supported.join(", ")}`); + } + const options = parseSelectOptions(obj.options, `${label}.options`); + if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) { + throw new Error(`${label}.options is required for ${type} fields`); + } + return { + type, + name: normalizeModalFieldName(readOptionalString(obj.name), index), + label: readString(obj.label, `${label}.label`), + description: readOptionalString(obj.description), + placeholder: readOptionalString(obj.placeholder), + required: typeof obj.required === "boolean" ? obj.required : undefined, + options, + minValues: readOptionalNumber(obj.minValues), + maxValues: readOptionalNumber(obj.maxValues), + minLength: readOptionalNumber(obj.minLength), + maxLength: readOptionalNumber(obj.maxLength), + style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"], + }; +} + +function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock { + const obj = requireObject(raw, label); + const typeRaw = readString(obj.type, `${label}.type`).toLowerCase(); + const type = normalizeBlockType(typeRaw); + switch (type) { + case "text": + return { + type: "text", + text: readString(obj.text, `${label}.text`), + }; + case "section": { + const text = readOptionalString(obj.text); + const textsRaw = obj.texts; + const texts = Array.isArray(textsRaw) + ? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`)) + : undefined; + if (!text && (!texts || texts.length === 0)) { + throw new Error(`${label}.text or ${label}.texts is required for section blocks`); + } + let accessory: DiscordComponentSectionAccessory | undefined; + if (obj.accessory !== undefined) { + const accessoryObj = requireObject(obj.accessory, `${label}.accessory`); + const accessoryType = readString( + accessoryObj.type, + `${label}.accessory.type`, + ).toLowerCase(); + if (accessoryType === "thumbnail") { + accessory = { + type: "thumbnail", + url: readString(accessoryObj.url, `${label}.accessory.url`), + }; + } else if (accessoryType === "button") { + accessory = { + type: "button", + button: parseButtonSpec(accessoryObj.button, `${label}.accessory.button`), + }; + } else { + throw new Error(`${label}.accessory.type must be "thumbnail" or "button"`); + } + } + return { + type: "section", + text, + texts, + accessory, + }; + } + case "separator": { + const spacingRaw = obj.spacing; + let spacing: DiscordComponentSeparatorSpacing | undefined; + if (spacingRaw === "small" || spacingRaw === "large") { + spacing = spacingRaw; + } else if (spacingRaw === 1 || spacingRaw === 2) { + spacing = spacingRaw; + } else if (spacingRaw !== undefined) { + throw new Error(`${label}.spacing must be "small", "large", 1, or 2`); + } + const divider = typeof obj.divider === "boolean" ? obj.divider : undefined; + return { + type: "separator", + spacing, + divider, + }; + } + case "actions": { + const buttonsRaw = obj.buttons; + const buttons = Array.isArray(buttonsRaw) + ? buttonsRaw.map((entry, idx) => parseButtonSpec(entry, `${label}.buttons[${idx}]`)) + : undefined; + const select = obj.select ? parseSelectSpec(obj.select, `${label}.select`) : undefined; + if ((!buttons || buttons.length === 0) && !select) { + throw new Error(`${label} requires buttons or select`); + } + if (buttons && select) { + throw new Error(`${label} cannot include both buttons and select`); + } + return { + type: "actions", + buttons, + select, + }; + } + case "media-gallery": { + const itemsRaw = obj.items; + if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) { + throw new Error(`${label}.items must be a non-empty array`); + } + const items = itemsRaw.map((entry, idx) => { + const itemObj = requireObject(entry, `${label}.items[${idx}]`); + return { + url: readString(itemObj.url, `${label}.items[${idx}].url`), + description: readOptionalString(itemObj.description), + spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined, + }; + }); + return { + type: "media-gallery", + items, + }; + } + case "file": { + const file = readString(obj.file, `${label}.file`); + return { + type: "file", + file: normalizeAttachmentRef(file, `${label}.file`), + spoiler: typeof obj.spoiler === "boolean" ? obj.spoiler : undefined, + }; + } + default: + throw new Error(`${label}.type must be a supported component block`); + } +} + +export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageSpec | null { + if (raw === undefined || raw === null) { + return null; + } + const obj = requireObject(raw, "components"); + const blocksRaw = obj.blocks; + const blocks = Array.isArray(blocksRaw) + ? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`)) + : undefined; + const modalRaw = obj.modal; + let modal: DiscordModalSpec | undefined; + if (modalRaw !== undefined) { + const modalObj = requireObject(modalRaw, "components.modal"); + const fieldsRaw = modalObj.fields; + if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0) { + throw new Error("components.modal.fields must be a non-empty array"); + } + if (fieldsRaw.length > 5) { + throw new Error("components.modal.fields supports up to 5 inputs"); + } + const fields = fieldsRaw.map((entry, idx) => + parseModalField(entry, `components.modal.fields[${idx}]`, idx), + ); + modal = { + title: readString(modalObj.title, "components.modal.title"), + triggerLabel: readOptionalString(modalObj.triggerLabel), + triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle, + fields, + }; + } + return { + text: readOptionalString(obj.text), + container: + typeof obj.container === "object" && obj.container && !Array.isArray(obj.container) + ? { + accentColor: (obj.container as { accentColor?: unknown }).accentColor as + | string + | number + | undefined, + spoiler: + typeof (obj.container as { spoiler?: unknown }).spoiler === "boolean" + ? ((obj.container as { spoiler?: boolean }).spoiler as boolean) + : undefined, + } + : undefined, + blocks, + modal, + }; +} + +export function buildDiscordComponentCustomId(params: { + componentId: string; + modalId?: string; +}): string { + const base = `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${params.componentId}`; + return params.modalId ? `${base};mid=${params.modalId}` : base; +} + +export function buildDiscordModalCustomId(modalId: string): string { + return `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`; +} + +export function parseDiscordComponentCustomId( + id: string, +): { componentId: string; modalId?: string } | null { + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) { + return null; + } + const componentId = parsed.data.cid; + if (typeof componentId !== "string" || !componentId.trim()) { + return null; + } + const modalId = parsed.data.mid; + return { + componentId, + modalId: typeof modalId === "string" && modalId.trim() ? modalId : undefined, + }; +} + +export function parseDiscordModalCustomId(id: string): string | null { + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) { + return null; + } + const modalId = parsed.data.mid; + if (typeof modalId !== "string" || !modalId.trim()) { + return null; + } + return modalId; +} + +export function parseDiscordComponentCustomIdForCarbon(id: string): ComponentParserResult { + if (id === "*") { + return { key: "*", data: {} }; + } + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) { + return parsed; + } + return { key: "*", data: parsed.data }; +} + +export function parseDiscordModalCustomIdForCarbon(id: string): ComponentParserResult { + if (id === "*") { + return { key: "*", data: {} }; + } + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) { + return parsed; + } + return { key: "*", data: parsed.data }; +} + +function buildTextDisplays(text?: string, texts?: string[]): TextDisplay[] { + if (texts && texts.length > 0) { + return texts.map((entry) => new TextDisplay(entry)); + } + if (text) { + return [new TextDisplay(text)]; + } + return []; +} + +function createButtonComponent(params: { + spec: DiscordComponentButtonSpec; + componentId?: string; + modalId?: string; +}): { component: Button | LinkButton; entry?: DiscordComponentEntry } { + const style = mapButtonStyle(params.spec.style); + const isLink = style === ButtonStyle.Link || Boolean(params.spec.url); + if (isLink) { + if (!params.spec.url) { + throw new Error("Link buttons require a url"); + } + const linkUrl = params.spec.url; + class DynamicLinkButton extends LinkButton { + label = params.spec.label; + url = linkUrl; + } + return { component: new DynamicLinkButton() }; + } + const componentId = params.componentId ?? createShortId("btn_"); + const customId = buildDiscordComponentCustomId({ + componentId, + modalId: params.modalId, + }); + class DynamicButton extends Button { + label = params.spec.label; + customId = customId; + style = style; + emoji = params.spec.emoji; + disabled = params.spec.disabled ?? false; + } + return { + component: new DynamicButton(), + entry: { + id: componentId, + kind: params.modalId ? "modal-trigger" : "button", + label: params.spec.label, + modalId: params.modalId, + }, + }; +} + +function createSelectComponent(params: { + spec: DiscordComponentSelectSpec; + componentId?: string; +}): { + component: + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu; + entry: DiscordComponentEntry; +} { + const type = (params.spec.type ?? "string").toLowerCase() as DiscordComponentSelectType; + const componentId = params.componentId ?? createShortId("sel_"); + const customId = buildDiscordComponentCustomId({ componentId }); + if (type === "string") { + const options = params.spec.options ?? []; + if (options.length === 0) { + throw new Error("String select menus require options"); + } + class DynamicStringSelect extends StringSelectMenu { + customId = customId; + options = options; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicStringSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "select", + selectType: "string", + options: options.map((option) => ({ value: option.value, label: option.label })), + }, + }; + } + if (type === "user") { + class DynamicUserSelect extends UserSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicUserSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "user select", + selectType: "user", + }, + }; + } + if (type === "role") { + class DynamicRoleSelect extends RoleSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicRoleSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "role select", + selectType: "role", + }, + }; + } + if (type === "mentionable") { + class DynamicMentionableSelect extends MentionableSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicMentionableSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "mentionable select", + selectType: "mentionable", + }, + }; + } + class DynamicChannelSelect extends ChannelSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicChannelSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "channel select", + selectType: "channel", + }, + }; +} + +function isSelectComponent( + component: unknown, +): component is + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu { + return ( + component instanceof StringSelectMenu || + component instanceof UserSelectMenu || + component instanceof RoleSelectMenu || + component instanceof MentionableSelectMenu || + component instanceof ChannelSelectMenu + ); +} + +function createModalFieldComponent( + field: DiscordModalFieldDefinition, +): TextInput | StringSelectMenu | UserSelectMenu | RoleSelectMenu | CheckboxGroup | RadioGroup { + if (field.type === "text") { + class DynamicTextInput extends TextInput { + customId = field.id; + style = mapTextInputStyle(field.style); + placeholder = field.placeholder; + required = field.required; + minLength = field.minLength; + maxLength = field.maxLength; + } + return new DynamicTextInput(); + } + if (field.type === "select") { + const options = field.options ?? []; + class DynamicModalSelect extends StringSelectMenu { + customId = field.id; + options = options; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + placeholder = field.placeholder; + } + return new DynamicModalSelect(); + } + if (field.type === "role-select") { + class DynamicModalRoleSelect extends RoleSelectMenu { + customId = field.id; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + placeholder = field.placeholder; + } + return new DynamicModalRoleSelect(); + } + if (field.type === "user-select") { + class DynamicModalUserSelect extends UserSelectMenu { + customId = field.id; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + placeholder = field.placeholder; + } + return new DynamicModalUserSelect(); + } + if (field.type === "checkbox") { + const options = field.options ?? []; + class DynamicCheckboxGroup extends CheckboxGroup { + customId = field.id; + options = options; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + } + return new DynamicCheckboxGroup(); + } + const options = field.options ?? []; + class DynamicRadioGroup extends RadioGroup { + customId = field.id; + options = options; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + } + return new DynamicRadioGroup(); +} + +export function buildDiscordComponentMessage(params: { + spec: DiscordComponentMessageSpec; + fallbackText?: string; + sessionKey?: string; + agentId?: string; + accountId?: string; +}): DiscordComponentBuildResult { + const entries: DiscordComponentEntry[] = []; + const modals: DiscordModalEntry[] = []; + const components: TopLevelComponents[] = []; + const containerChildren: Array< + | Row< + | Button + | LinkButton + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu + > + | TextDisplay + | Section + | MediaGallery + | Separator + | File + > = []; + + const addEntry = (entry: DiscordComponentEntry) => { + entries.push({ + ...entry, + sessionKey: params.sessionKey, + agentId: params.agentId, + accountId: params.accountId, + }); + }; + + const text = params.spec.text ?? params.fallbackText; + if (text) { + containerChildren.push(new TextDisplay(text)); + } + + for (const block of params.spec.blocks ?? []) { + if (block.type === "text") { + containerChildren.push(new TextDisplay(block.text)); + continue; + } + if (block.type === "section") { + const displays = buildTextDisplays(block.text, block.texts); + if (displays.length > 3) { + throw new Error("Section blocks support up to 3 text displays"); + } + let accessory: Thumbnail | Button | LinkButton | undefined; + if (block.accessory?.type === "thumbnail") { + accessory = new Thumbnail(block.accessory.url); + } else if (block.accessory?.type === "button") { + const { component, entry } = createButtonComponent({ spec: block.accessory.button }); + accessory = component; + if (entry) { + addEntry(entry); + } + } + containerChildren.push(new Section(displays, accessory)); + continue; + } + if (block.type === "separator") { + containerChildren.push(new Separator({ spacing: block.spacing, divider: block.divider })); + continue; + } + if (block.type === "media-gallery") { + containerChildren.push(new MediaGallery(block.items)); + continue; + } + if (block.type === "file") { + containerChildren.push(new File(block.file, block.spoiler)); + continue; + } + if (block.type === "actions") { + const rowComponents: Array< + | Button + | LinkButton + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu + > = []; + if (block.buttons) { + if (block.buttons.length > 5) { + throw new Error("Action rows support up to 5 buttons"); + } + for (const button of block.buttons) { + const { component, entry } = createButtonComponent({ spec: button }); + rowComponents.push(component); + if (entry) { + addEntry(entry); + } + } + } else if (block.select) { + const { component, entry } = createSelectComponent({ spec: block.select }); + rowComponents.push(component); + addEntry(entry); + } + containerChildren.push(new Row(rowComponents)); + } + } + + if (params.spec.modal) { + const modalId = createShortId("mdl_"); + const fields = params.spec.modal.fields.map((field, index) => ({ + id: createShortId("fld_"), + name: normalizeModalFieldName(field.name, index), + label: field.label, + type: field.type, + description: field.description, + placeholder: field.placeholder, + required: field.required, + options: field.options, + minValues: field.minValues, + maxValues: field.maxValues, + minLength: field.minLength, + maxLength: field.maxLength, + style: field.style, + })); + modals.push({ + id: modalId, + title: params.spec.modal.title, + fields, + sessionKey: params.sessionKey, + agentId: params.agentId, + accountId: params.accountId, + }); + + const triggerSpec: DiscordComponentButtonSpec = { + label: params.spec.modal.triggerLabel ?? "Open form", + style: params.spec.modal.triggerStyle ?? "primary", + }; + + const { component, entry } = createButtonComponent({ + spec: triggerSpec, + modalId, + }); + + if (entry) { + addEntry(entry); + } + + const lastChild = containerChildren.at(-1); + if (lastChild instanceof Row) { + const row = lastChild; + const hasSelect = row.components.some((entry) => isSelectComponent(entry)); + if (row.components.length < 5 && !hasSelect) { + row.addComponent(component as Button); + } else { + containerChildren.push(new Row([component as Button])); + } + } else { + containerChildren.push(new Row([component as Button])); + } + } + + if (containerChildren.length === 0) { + throw new Error("components must include at least one block, text, or modal trigger"); + } + + const container = new Container(containerChildren, params.spec.container); + components.push(container); + return { components, entries, modals }; +} + +export function buildDiscordComponentMessageFlags( + components: TopLevelComponents[], +): number | undefined { + const hasV2 = components.some((component) => component.isV2); + return hasV2 ? MessageFlags.IsComponentsV2 : undefined; +} + +export class DiscordFormModal extends Modal { + title: string; + customId: string; + components: Array
+
+ `; + }; + const selectedSet = new Set(selectedSessions); const selectedEntries = sortedWithDir.filter((s) => selectedSet.has(s.key)); const selectedCount = selectedEntries.length; @@ -720,83 +752,22 @@ function renderSessionsCard(
No recent sessions
` : html` -
- ${recentEntries.map((s) => { - const value = getSessionValue(s); - const isSelected = selectedSet.has(s.key); - const displayLabel = formatSessionListLabel(s); - const meta = buildSessionMeta(s); - return html` -
onSelectSession(s.key, e.shiftKey)} - title="${s.key}" - > -
-
${displayLabel}
- ${meta.length > 0 ? html`
${meta.join(" Β· ")}
` : nothing} -
- -
- -
${isTokenMode ? formatTokens(value) : formatCost(value)}
-
-
- `; - })} -
- ` +
+ ${recentEntries.map((s) => renderSessionBarRow(s, selectedSet.has(s.key)))} +
+ ` : sessions.length === 0 ? html`
No sessions in range
` : html` -
- ${sortedWithDir.slice(0, 50).map((s) => { - const value = getSessionValue(s); - const isSelected = selectedSessions.includes(s.key); - const displayLabel = formatSessionListLabel(s); - const meta = buildSessionMeta(s); - - return html` -
onSelectSession(s.key, e.shiftKey)} - title="${s.key}" - > -
-
${displayLabel}
- ${meta.length > 0 ? html`
${meta.join(" Β· ")}
` : nothing} -
- -
- -
${isTokenMode ? formatTokens(value) : formatCost(value)}
-
-
- `; - })} - ${sessions.length > 50 ? html`
+${sessions.length - 50} more
` : nothing} -
- ` +
+ ${sortedWithDir + .slice(0, 50) + .map((s) => renderSessionBarRow(s, selectedSet.has(s.key)))} + ${sessions.length > 50 ? html`
+${sessions.length - 50} more
` : nothing} +
+ ` } ${ selectedCount > 1 @@ -804,37 +775,7 @@ function renderSessionsCard(
Selected (${selectedCount})
- ${selectedEntries.map((s) => { - const value = getSessionValue(s); - const displayLabel = formatSessionListLabel(s); - const meta = buildSessionMeta(s); - return html` -
onSelectSession(s.key, e.shiftKey)} - title="${s.key}" - > -
-
${displayLabel}
- ${meta.length > 0 ? html`
${meta.join(" Β· ")}
` : nothing} -
- -
- -
${isTokenMode ? formatTokens(value) : formatCost(value)}
-
-
- `; - })} + ${selectedEntries.map((s) => renderSessionBarRow(s, true))}
` diff --git a/vitest.config.ts b/vitest.config.ts index 71a92990525..c3a042499ed 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,7 +33,7 @@ export default defineConfig({ unstubGlobals: true, pool: "forks", maxWorkers: isCI ? ciWorkers : localWorkers, - include: ["src/**/*.test.ts", "extensions/**/*.test.ts", "test/format-error.test.ts"], + include: ["src/**/*.test.ts", "extensions/**/*.test.ts", "test/**/*.test.ts"], setupFiles: ["test/setup.ts"], exclude: [ "dist/**",