diff --git a/.github/codex/prompts/mantis-telegram-desktop-proof.md b/.github/codex/prompts/mantis-telegram-desktop-proof.md index a17073b1e43..ec1a89d49f1 100644 --- a/.github/codex/prompts/mantis-telegram-desktop-proof.md +++ b/.github/codex/prompts/mantis-telegram-desktop-proof.md @@ -24,6 +24,7 @@ Inputs are provided as environment variables: - `BASELINE_SHA` - `CANDIDATE_REF` - `CANDIDATE_SHA` +- `MANTIS_CANDIDATE_TRUST` - `MANTIS_OUTPUT_DIR` - `MANTIS_INSTRUCTIONS` - `CRABBOX_PROVIDER` @@ -34,9 +35,7 @@ Required workflow: 1. Read `.agents/skills/telegram-crabbox-e2e-proof/SKILL.md`. 2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and - `gh pr diff "$MANTIS_PR_NUMBER"` when `MANTIS_PR_NUMBER` is set. If the run - came from workflow dispatch without a PR number, inspect - `BASELINE_SHA..CANDIDATE_SHA`. + `gh pr diff "$MANTIS_PR_NUMBER"`. 3. Decide what Telegram message, mock model response, command, callback, button, media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra maintainer guidance, not as a replacement for reading the PR. @@ -44,6 +43,12 @@ Required workflow: `.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/baseline` and `.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/candidate`, then install and build each worktree with the repo's normal `pnpm` commands. + If `MANTIS_CANDIDATE_TRUST` is `fork-pr-head`, treat the + candidate worktree as untrusted fork code: do not pass GitHub, OpenAI, + Crabbox, Convex, or other workflow secrets into candidate install, build, or + runtime commands. The candidate SUT may receive only the proof runner's + short-lived Telegram bot token, generated local config/state paths, and mock + model key needed for this isolated proof. 5. In each worktree, run the real-user Telegram Crabbox proof flow from the skill with `$OPENCLAW_TELEGRAM_USER_PROOF_CMD`; do not run `pnpm qa:telegram-user:crabbox` directly. The proof command comes from the diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml index ecd24f02408..da6a0a01066 100644 --- a/.github/workflows/mantis-telegram-desktop-proof.yml +++ b/.github/workflows/mantis-telegram-desktop-proof.yml @@ -5,19 +5,9 @@ on: types: [created] workflow_dispatch: inputs: - baseline_ref: - description: Ref, tag, or SHA to capture as the before GIF - required: true - default: main - type: string - candidate_ref: - description: Ref, tag, or SHA to capture as the after GIF - required: true - default: main - type: string pr_number: - description: Optional PR number to receive the QA evidence comment - required: false + description: PR number to capture + required: true type: string instructions: description: Optional freeform proof instructions for the agent @@ -42,7 +32,7 @@ permissions: pull-requests: write concurrency: - group: mantis-telegram-desktop-proof-${{ github.event.issue.number || inputs.pr_number || inputs.candidate_ref || github.run_id }}-${{ github.run_attempt }} + group: mantis-telegram-desktop-proof-${{ github.event.issue.number || inputs.pr_number || github.run_id }}-${{ github.run_attempt }} cancel-in-progress: false env: @@ -63,6 +53,7 @@ jobs: ( github.event_name == 'issue_comment' && github.event.issue.pull_request && + contains(github.event.issue.labels.*.name, 'mantis: telegram-visible-proof') && ( contains(github.event.comment.body, '@openclaw-mantis') || contains(github.event.comment.body, '/openclaw-mantis') @@ -102,7 +93,6 @@ jobs: lease_id: ${{ steps.resolve.outputs.lease_id }} pr_number: ${{ steps.resolve.outputs.pr_number }} request_source: ${{ steps.resolve.outputs.request_source }} - should_run: ${{ steps.resolve.outputs.should_run }} steps: - name: Resolve refs and target PR id: resolve @@ -116,47 +106,11 @@ jobs: core.info(`${name}=${value ?? ""}`); } - if (eventName === "workflow_dispatch") { - const inputs = context.payload.inputs ?? {}; - setOutput("should_run", "true"); - setOutput("baseline_ref", inputs.baseline_ref || "main"); - setOutput("candidate_ref", inputs.candidate_ref || "main"); - setOutput("pr_number", inputs.pr_number || ""); - setOutput("instructions", inputs.instructions || ""); - setOutput("crabbox_provider", inputs.crabbox_provider || "aws"); - setOutput("lease_id", inputs.crabbox_lease_id || ""); - setOutput("request_source", "workflow_dispatch"); - return; - } - - if (eventName !== "issue_comment") { - core.setFailed(`Unsupported event: ${eventName}`); - return; - } - - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request) { - core.setFailed("Mantis issue_comment trigger requires a pull request comment."); - return; - } - - const normalized = body.toLowerCase(); - const requested = - (normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) && - normalized.includes("telegram") && - (normalized.includes("desktop") || normalized.includes("native")) && - normalized.includes("proof"); - if (!requested) { - core.notice("Comment mentioned Mantis but did not request Telegram desktop proof."); - setOutput("should_run", "false"); - setOutput("baseline_ref", ""); - setOutput("candidate_ref", ""); - setOutput("pr_number", ""); - setOutput("instructions", ""); - setOutput("crabbox_provider", ""); - setOutput("lease_id", ""); - setOutput("request_source", "unsupported_issue_comment"); + const inputs = context.payload.inputs ?? {}; + const prNumber = + eventName === "workflow_dispatch" ? inputs.pr_number : String(context.payload.issue?.number ?? ""); + if (!prNumber) { + core.setFailed("Mantis Telegram desktop proof requires a pull request."); return; } @@ -164,59 +118,40 @@ jobs: const { data: pr } = await github.rest.pulls.get({ owner, repo, - pull_number: issue.number, + pull_number: Number(prNumber), }); - let mergedBaseline = ""; - let mergedCandidate = ""; - if (pr.merged) { - const { data: commits } = await github.rest.pulls.listCommits({ - owner, - repo, - pull_number: issue.number, - per_page: 100, - }); - mergedCandidate = pr.merge_commit_sha || commits.at(-1)?.sha || ""; - mergedBaseline = mergedCandidate && commits.length > 0 ? `${mergedCandidate}~${commits.length}` : ""; - } - const baselineMatch = body.match(/(?:baseline|base)[\s:=]+([^\s`]+)/i); - const candidateMatch = body.match(/(?:candidate|head)[\s:=]+([^\s`]+)/i); - const providerMatch = body.match(/(?:provider|crabbox_provider)[\s:=]+([^\s`]+)/i); - const leaseMatch = body.match(/(?:lease|lease_id|crabbox_lease_id)[\s:=]+([^\s`]+)/i); - const provider = providerMatch?.[1] || "aws"; + const body = eventName === "workflow_dispatch" ? inputs.instructions || "" : context.payload.comment?.body || ""; + const provider = inputs.crabbox_provider || "aws"; if (!["aws", "hetzner"].includes(provider)) { core.setFailed(`Unsupported Crabbox provider for Mantis Telegram desktop proof: ${provider}`); return; } - const rawCandidate = candidateMatch?.[1]; - const candidate = - rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) - ? rawCandidate - : mergedCandidate || pr.head.sha; - setOutput("should_run", "true"); - setOutput("baseline_ref", baselineMatch?.[1] || mergedBaseline || "main"); - setOutput("candidate_ref", candidate); - setOutput("pr_number", String(issue.number)); + setOutput("baseline_ref", pr.base.sha); + setOutput("candidate_ref", pr.head.sha); + setOutput("pr_number", String(pr.number)); setOutput("instructions", body); setOutput("crabbox_provider", provider); - setOutput("lease_id", leaseMatch?.[1] || ""); - setOutput("request_source", "issue_comment"); + setOutput("lease_id", inputs.crabbox_lease_id || ""); + setOutput("request_source", eventName); - await github.rest.reactions.createForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - content: "eyes", - }).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); + if (eventName === "issue_comment") { + await github.rest.reactions.createForIssueComment({ + owner, + repo, + comment_id: context.payload.comment.id, + content: "eyes", + }).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); + } validate_refs: name: Validate selected refs needs: resolve_request - if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: ubuntu-24.04 outputs: baseline_revision: ${{ steps.validate.outputs.baseline_revision }} candidate_revision: ${{ steps.validate.outputs.candidate_revision }} + candidate_trust: ${{ steps.validate.outputs.candidate_trust }} steps: - name: Checkout harness ref uses: actions/checkout@v6 @@ -240,55 +175,56 @@ jobs: git fetch --no-tags origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" || true fi - validate_ref() { - local label="$1" + resolve_commit() { local input_ref="$2" local revision="" - local reason="" if ! revision="$(git rev-parse --verify "${input_ref}^{commit}" 2>/dev/null)"; then - echo "${label} ref '${input_ref}' is not available in the workflow checkout." >&2 - exit 1 - fi - if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then - reason="main-ancestor" - elif git tag --points-at "$revision" | grep -Eq '^v'; then - reason="release-tag" - else - local pr_head_count - pr_head_count="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ - --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' - )" - if [[ "$pr_head_count" != "0" ]]; then - reason="open-pr-head" - fi - fi - - if [[ -z "$reason" ]]; then - echo "${label} ref '${input_ref}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2 + echo "$1 ref '${input_ref}' is not available in the workflow checkout." >&2 exit 1 fi printf '%s\n' "$revision" } - baseline_revision="$(validate_ref baseline "$BASELINE_REF")" - candidate_revision="$(validate_ref candidate "$CANDIDATE_REF")" + baseline_revision="$(resolve_commit baseline "$BASELINE_REF")" + candidate_revision="$(resolve_commit candidate "$CANDIDATE_REF")" + if ! git merge-base --is-ancestor "$baseline_revision" refs/remotes/origin/main; then + echo "baseline ref '${BASELINE_REF}' resolved to ${baseline_revision}, which is not on main." >&2 + exit 1 + fi + pr_head="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq '{state, head_sha: .head.sha, head_repo: .head.repo.full_name}' + )" + pr_state="$(jq -r '.state' <<<"$pr_head")" + pr_head_sha="$(jq -r '.head_sha' <<<"$pr_head")" + pr_head_repo="$(jq -r '.head_repo' <<<"$pr_head")" + if [[ "$pr_state" != "open" || "$candidate_revision" != "$pr_head_sha" ]]; then + echo "candidate ref '${CANDIDATE_REF}' resolved to ${candidate_revision}, which is not the open PR head." >&2 + exit 1 + fi + candidate_trust="open-pr-head" + if [[ "$pr_head_repo" != "$GITHUB_REPOSITORY" ]]; then + candidate_trust="fork-pr-head" + fi + echo "baseline_revision=${baseline_revision}" >> "$GITHUB_OUTPUT" echo "candidate_revision=${candidate_revision}" >> "$GITHUB_OUTPUT" + echo "candidate_trust=${candidate_trust}" >> "$GITHUB_OUTPUT" { echo "baseline: \`${BASELINE_REF}\`" echo "baseline SHA: \`${baseline_revision}\`" + echo "baseline trust: \`main-ancestor\`" echo "candidate: \`${CANDIDATE_REF}\`" echo "candidate SHA: \`${candidate_revision}\`" + echo "candidate trust: \`${candidate_trust}\`" } >> "$GITHUB_STEP_SUMMARY" run_telegram_desktop_proof: name: Run agentic native Telegram proof needs: [resolve_request, validate_refs] - if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 360 environment: qa-live-shared @@ -375,7 +311,7 @@ jobs: printf '%s\n' 'Defaults env_keep += "CODEX_HOME CODEX_INTERNAL_ORIGINATOR_OVERRIDE"' printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"' printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"' - printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"' + printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"' printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"' printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"' } | sudo tee /etc/sudoers.d/mantis-codex-env >/dev/null @@ -406,6 +342,7 @@ jobs: CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }} CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }} GH_TOKEN: ${{ github.token }} + MANTIS_CANDIDATE_TRUST: ${{ needs.validate_refs.outputs.candidate_trust }} MANTIS_INSTRUCTIONS: ${{ needs.resolve_request.outputs.instructions }} MANTIS_OUTPUT_DIR: ${{ env.MANTIS_OUTPUT_DIR }} MANTIS_PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bbba5a40c00..9a5c2ed2b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - CI: add a non-blocking `plugin-inspector-advisory` artifact to Plugin Prerelease so release runs capture bundled plugin compatibility triage without changing the blocking gate. +- Runtime/Fly: detect Fly Machines as container environments from their runtime env vars, so gateway bind and Bonjour defaults match remote container launches. (#80209) Thanks @liorb-mountapps. - Providers/fal: route GPT Image 2 and Nano Banana 2 reference-image edit requests to `/edit` with `image_urls` array, enforce NB2 edit geometry using `aspect_ratio` and `resolution` params, lift Fal edit mode input-image caps to 10 for GPT Image 2 and 14 for Nano Banana 2, and allow aspect-ratio hints in edit mode. (#77295) Thanks @leoge007. - Control UI: show a plain HTML recovery panel when the app module never registers, giving blank dashboard pages a retry path and browser-extension troubleshooting link. Fixes #44107. Thanks @BunsDev. @@ -39,6 +40,7 @@ Docs: https://docs.openclaw.ai - Talk: add `talk.realtime.instructions` so operators can append realtime voice style instructions while preserving OpenClaw's built-in agent-consult guidance. (#79081) Thanks @VACInc. - Discord/voice: default test and source installs to the pure-JS `opusscript` decoder by ignoring optional native `@discordjs/opus` builds, avoiding slow native addon compiles outside dedicated voice-performance lanes. - Discord/voice: add an opt-in native `@discordjs/opus` install script and decoder preference for live voice-performance lanes without charging unrelated Docker/tests for native addon builds. +- Discord/voice: add `voice.allowedChannels` to restrict voice joins and bot voice-state moves to configured channels while preserving open voice behavior when unset. - Gateway/skills: add an opt-in private skill archive upload install path gated by `skills.install.allowUploadedArchives`, so trusted Gateway clients can stage and install zip-backed skills only when operators explicitly enable the code-install surface. (#74430) Thanks @samzong. - Codex app-server: enable Codex native code-mode-only for harness threads so deferred OpenClaw dynamic tools run through Codex's own searchable code execution surface instead of a PI-style wrapper. - Dependencies: refresh workspace pins and patch targets, including ACPX `@agentclientprotocol/claude-agent-acp` `0.33.1`, Codex ACP `0.14.0`, Baileys `7.0.0-rc10`, Google GenAI `2.0.1`, OpenAI `6.37.0`, AWS SDK `3.1045.0`, Kysely `0.29.0`, Tlon skill `0.3.6`, Aimock `1.19.5`, and tsdown `0.22.0`. @@ -55,7 +57,13 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu: make manual App ID/App Secret setup the default channel-binding path while keeping QR scan-to-create as an optional best-effort flow, and document the manual fallback for domestic Feishu mobile clients that do not react to the QR code. Fixes #80591. Thanks @wei-wei-zhao. +- Telegram: show resolved thinking defaults in native `/status` and `/think` menus while preserving explicit session overrides. (#80341) Thanks @VACInc. +- Channels: cache selected channel registry lookups against the active fallback snapshot so pinned-empty registries refresh native command and alias routing after active registry swaps. (#80333) Thanks @samzong. +- Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong. +- Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong. - Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc. +- Providers/self-hosted: read model-scoped llama.cpp runtime context from `/props.default_generation_settings.n_ctx` while keeping top-level `n_ctx` as a fallback, so session budgeting reflects the loaded context window. Fixes #73664. (#74057) Thanks @brokemac79. - Memory: reject symlinked directory components in configured extra memory paths before reading Markdown files. (#80331) Thanks @samzong. - Sessions/transcripts: replace whole-file `readFile` scans with shared streaming helpers (`streamSessionTranscriptLines` and `streamSessionTranscriptLinesReverse`) for idempotency lookup, latest/tail assistant text reads, delivery-mirror dedupe, and compaction fork loading, so long-running sessions no longer materialize the full transcript in memory. Forward scans use `readline` over a bounded `createReadStream`; reverse scans read bounded chunks from the file end and decode complete JSONL lines newest-first without a fixed tail cap. Synthetic 200 MiB transcript: peak RSS delta drops from +252 MiB to +27 MiB while preserving malformed-line tolerance and idempotency-key return semantics. Fixes #54296. Thanks @jack-stormentswe. - WhatsApp: apply hot-reloaded `dmPolicy` and `allowFrom` settings to the active Web listener before processing new inbound DMs. Fixes #80538. Thanks @Ampaskopi129. @@ -145,6 +153,7 @@ Docs: https://docs.openclaw.ai - Control UI: show compact one-line live/idle/terminal run status badges in the Sessions table and rename the active-minute filter to its updated-within meaning. Fixes #78307. Thanks @BunsDev. - Control UI: scope chat session-list refreshes by agent and skip disk-only agent store discovery for configured-only lists, preventing post-first-message session switching stalls on large Windows stores. Fixes #79675. Thanks @lovelefeng-glitch, @BunsDev. - Control UI: allow Appearance tweakcn theme imports through the served CSP so browser-local custom theme links no longer fail with a `connect-src` violation. Fixes #78504. Thanks @BunsDev. +- Control UI/config: remove plugin allowlist entries that the form auto-added when a plugin enable toggle is reverted before saving, so reverting the visible toggle clears dirty state without persisting unintended allowlist changes. (#78329) Thanks @samzong. - Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments. - Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so `openclaw doctor` stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys. - Doctor/OpenAI Codex: preserve Codex auth intent when auto-repairing legacy `openai-codex/*` model refs to canonical `openai/*` by adding provider/model-scoped Codex runtime policy, preventing repaired configs from falling through to direct OpenAI API-key auth. Fixes #78533 and #78570. Thanks @superck110 and @Azmodump. @@ -411,6 +420,7 @@ Docs: https://docs.openclaw.ai - Channels/iMessage: honor `channels.imessage.groups..systemPrompt` (and the `groups["*"]` wildcard) by forwarding it as `GroupSystemPrompt` on inbound group turns, mirroring the byte-identical resolver semantic from WhatsApp where defining the key as an empty string on a specific group suppresses the wildcard fallback. Brings iMessage to parity with the per-group `systemPrompt` pattern already supported by Discord, Telegram, IRC, Slack, GoogleChat, and the retired BlueBubbles channel. Fixes #78285. (#79383) Thanks @omarshahine. - iMessage: add opt-in inbound catchup that replays messages received while the gateway was offline (crash, restart, mac sleep) on next startup. Enable with `channels.imessage.catchup.enabled: true`; tunables for `maxAgeMinutes`, `perRunLimit`, `firstRunLookbackMinutes`, and `maxFailureRetries`. Persists a per-account cursor under the OpenClaw state dir (`/imessage/catchup/`), replays each row through the live dispatch path so allowlists/group policy/dedupe behave identically on replayed and live messages, and force-advances past wedged guids after `maxFailureRetries` to prevent stuck cursors. Extends the persisted echo-cache retention window so the agent's own outbound rows from before a gap are not re-fed as inbound on replay. Includes a regenerated `src/config/bundled-channel-config-metadata.generated.ts` so the runtime AJV schema accepts the new `channels.imessage.catchup` block. Fixes #78649. (#79387) Thanks @omarshahine. - Channels/Yuanbao: bump the bundled `openclaw-plugin-yuanbao` npm spec from `2.11.0` to `2.13.0` in the official external channel catalog and refresh the pinned integrity hash, so fresh installs and catalog-driven reinstalls pick up the newer Yuanbao channel plugin release. (#79620) Thanks @loongfay. +- Gateway/OpenAI-compatible Chat Completions: support function `tools`, `tool_choice`, `tool_calls`, and `role: "tool"` follow-up turns while keeping tool-call stream finalization aligned with the command result and reporting client-tool name conflicts as invalid requests. (#66278) Thanks @Lellansin. - Providers/Mistral: add `mistral-medium-3-5` to the bundled catalog with reasoning support. Thanks @sliekens. - Docs/Mistral: document Medium 3.5 setup, local infer smoke usage, adjustable reasoning, and the Mistral HTTP 400 caveat for `reasoning_effort="high"` with `temperature: 0`. @@ -420,6 +430,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI: surface browser-blocked WebSocket security failures with wss:// and loopback dashboard guidance instead of leaving the connection on a dead security error. Thanks @BunsDev. +- Gateway/diagnostics: keep active-only transient event-loop max-delay samples as info-level stability telemetry instead of warning-level liveness diagnostics. Thanks @BunsDev. - Google/Gemini: default new API-key onboarding to stable `google/gemini-2.5-flash` instead of the preview Pro route, reducing surprise daily quota exhaustion. Fixes #79670. Thanks @HugeBunny. - Amazon Bedrock: expose Claude thinking profiles through the lightweight provider policy surface so `/think:adaptive` validates before the Bedrock runtime plugin is loaded. Fixes #79754. Thanks @phoenixyy and @hclsys. - Codex/transcripts: mirror dynamic tool calls and outputs into Codex app-server transcripts so tool activity is visible alongside assistant text instead of being elided, with per-item output capped at 12,000 characters. (#79952) Thanks @scoootscooob. @@ -584,6 +596,7 @@ Docs: https://docs.openclaw.ai - Network/runtime: avoid importing Undici's package dispatcher during no-proxy timeout bootstrap so external channel plugin fetch requests with explicit Content-Length keep working. Fixes #78007. Thanks @shakkernerd. - Status/doctor: treat a single healthy OpenClaw Gateway listener on loopback, LAN, or wildcard bind as the expected configured gateway instead of warning that the port is already in use. Fixes #77939. Thanks @GitHoubi and @brokemac79. - Agents/TTS: send media-bearing block replies directly when block streaming is off, so agent `tts` tool audio attached to a final text reply is delivered instead of being consumed before final Telegram/media delivery. Thanks @Conan-Scott. +- Doctor: avoid crashing on partial Linux environments when the legacy crontab probe or terminal note wrapper receives missing or non-string output. Fixes #77773. Thanks @brokemac79 and @blackflame7983. - Gateway/performance: reuse the current compatible plugin metadata snapshot across hot read-only status, channel, auth, skills, and embedded agent settings paths, avoiding repeated synchronous plugin metadata scans during Gateway activity. Fixes #77983. Thanks @shakkernerd. - Tasks/maintenance: prune stale cron run session registry entries while preserving running cron jobs and non-cron sessions. Fixes #73867. Thanks @brokemac79. - Plugins: dispatch cached descriptor-backed tools by the resolved runtime tool name for unnamed factories, fixing multi-tool plugins whose shared manifest contracts exposed sibling tools but failed at execution. Fixes #78671. Thanks @zanni098. @@ -1290,6 +1303,7 @@ Docs: https://docs.openclaw.ai - Plugins/npm: build package-local runtime dist files for publishable plugins and stop listing root-package-excluded plugin sidecars in the core package metadata, so npm plugin installs such as `@openclaw/diffs` and `@openclaw/discord` no longer publish source-only runtime payloads. Fixes #76426. Thanks @PrinceOfEgypt. - Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. Fixes #76371. (#76449) Thanks @joshavant and @neeravmakwana. - Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc. +- Exec/security: treat configured `tools.exec.security` as authoritative for normal tool calls so model-supplied `security` arguments cannot downgrade or tighten the operator policy, while preserving explicitly granted elevated-full overrides. (#65933) Thanks @bryanpearson. - Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev. - Agents/compaction: add an optional bundled compaction notifier hook and retry once from the compacted transcript when automatic compaction leaves a turn without a final visible reply. (#76651) Thanks @simplyclever914. - Agents/incomplete-turn: detect and surface a warning when the agent's final text after a tool-call chain is silently dropped because the post-tool assistant response was never produced, instead of completing the turn with only the pre-tool analysis text. Fixes #76477. Thanks @amknight. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 15b5640518d..4fd1a4f7941 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -ebb8fa25af8be3a6c42a8bbf505f119819ee49b3c28a317ae04a244f740be381 config-baseline.json +c963f607273fcce55080dc6d8068d8e124a4aa111a8ecf04807ebef98dfa5fd5 config-baseline.json 647f7a12deed46b4a962848a17ed5666d24fc526b777feab62cf331d84ce957d config-baseline.core.json -f90c9d96ccc4c0c703d6c489f86d89fde208cd7f697b396aeee96ff3ee087956 config-baseline.channel.json +222d0338d6ed290870cac70cdf5e390bc1bb60c4462e46f847003bafe25c5a6e config-baseline.channel.json 18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json diff --git a/docs/channels/discord.md b/docs/channels/discord.md index df7add5bef1..0b78a2dce1f 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1179,6 +1179,12 @@ Auto-join example: channelId: "234567890123456789", }, ], + allowedChannels: [ + { + guildId: "123456789012345678", + channelId: "234567890123456789", + }, + ], daveEncryption: true, decryptionFailureTolerance: 24, connectTimeoutMs: 30000, @@ -1212,6 +1218,7 @@ Notes: - Discord voice is opt-in for text-only configs; set `channels.discord.voice.enabled=true` (or keep an existing `channels.discord.voice` block) to enable `/vc` commands, the voice runtime, and the `GuildVoiceStates` gateway intent. - `channels.discord.intents.voiceStates` can explicitly override voice-state intent subscription. Leave it unset for the intent to follow effective voice enablement. - If `voice.autoJoin` has multiple entries for the same guild, OpenClaw joins the last configured channel for that guild. +- `voice.allowedChannels` is an optional residency allowlist. Leave it unset to allow `/vc join` into any authorized Discord voice channel. When set, `/vc join`, startup auto-join, and bot voice-state moves are restricted to the listed `{ guildId, channelId }` entries. Set it to an empty array to deny all Discord voice joins. If Discord moves the bot outside the allowlist, OpenClaw leaves that channel and rejoins the configured auto-join target when one is available. - `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options. - `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset. - OpenClaw defaults to the pure-JS `opusscript` decoder for Discord voice receive. The optional native `@discordjs/opus` package is ignored by the repo pnpm install policy so normal installs, Docker lanes, and unrelated tests do not compile a native addon. Dedicated voice-performance hosts can opt in with `OPENCLAW_DISCORD_OPUS_DECODER=native` after installing the native addon. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 8620c8e673b..04fedd65820 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -23,7 +23,7 @@ Requires OpenClaw 2026.4.25 or above. Run `openclaw --version` to check. Upgrade ```bash openclaw channels login --channel feishu ``` - Scan the QR code with your Feishu/Lark mobile app to create a Feishu/Lark bot automatically. + Choose manual setup to paste an App ID and App Secret from Feishu Open Platform, or choose QR setup to create a bot automatically. If the domestic Feishu mobile app does not react to the QR code, rerun setup and choose manual setup. @@ -211,6 +211,13 @@ Feishu/Lark does not support native slash-command menus, so send these as plain 5. Ensure the gateway is running: `openclaw gateway status` 6. Check logs: `openclaw logs --follow` +### QR setup does not react in the Feishu mobile app + +1. Rerun setup: `openclaw channels login --channel feishu` +2. Choose manual setup +3. In Feishu Open Platform, create a self-built app and copy its App ID and App Secret +4. Paste those credentials into the setup wizard + ### App Secret leaked 1. Reset the App Secret in Feishu Open Platform / Lark Developer diff --git a/docs/gateway/openai-http-api.md b/docs/gateway/openai-http-api.md index 4b20c93c1c3..3e2f6cfe1d4 100644 --- a/docs/gateway/openai-http-api.md +++ b/docs/gateway/openai-http-api.md @@ -191,6 +191,63 @@ Set `stream: true` to receive Server-Sent Events (SSE): - Each event line is `data: ` - Stream ends with `data: [DONE]` +## Chat tool contract + +`/v1/chat/completions` supports a function-tool subset compatible with common OpenAI Chat clients. + +### Supported request fields + +- `tools`: array of `{ "type": "function", "function": { ... } }` +- `tool_choice`: `"auto"`, `"none"` +- `messages[*].role: "tool"` follow-up turns +- `messages[*].tool_call_id` for binding tool results back to a prior tool call + +### Unsupported variants + +The endpoint returns `400 invalid_request_error` for unsupported tool variants, including: + +- non-array `tools` +- non-function tool entries +- missing `tool.function.name` +- `tool_choice` variants such as `allowed_tools` and `custom` +- `tool_choice: "required"` (not yet enforced at runtime; will be supported once hard enforcement is implemented) +- `tool_choice: { "type": "function", "function": { "name": "..." } }` (same rationale as `required`) +- `tool_choice.function.name` values that do not match provided `tools` + +### Non-streaming tool response shape + +When the agent decides to call tools, the response uses: + +- `choices[0].finish_reason = "tool_calls"` +- `choices[0].message.tool_calls[]` entries with: + - `id` + - `type: "function"` + - `function.name` + - `function.arguments` (JSON string) + +Assistant commentary before the tool call is returned in `choices[0].message.content` (possibly empty). + +### Streaming tool response shape + +When `stream: true`, tool calls are emitted as incremental SSE chunks: + +- initial assistant role delta +- optional assistant commentary deltas +- one or more `delta.tool_calls` chunks carrying tool identity and argument fragments +- final chunk with `finish_reason: "tool_calls"` +- `data: [DONE]` + +If `stream_options.include_usage=true`, a trailing usage chunk is emitted before `[DONE]`. + +### Tool follow-up loop + +After receiving `tool_calls`, the client should execute the requested function(s) and send a follow-up request that includes: + +- prior assistant tool-call message +- one or more `role: "tool"` messages with matching `tool_call_id` + +This allows the gateway agent run to continue the same reasoning loop and produce the final assistant answer. + ## Open WebUI quick setup For a basic Open WebUI connection: diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 32aad7c5a12..a764b1aec81 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -81,15 +81,15 @@ or Docker-facing stages need it. The Docker release-path stage runs these chunks when `live_suite_filter` is empty: -| Chunk | Coverage | -| --------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `core` | Core Docker release-path smoke lanes. | -| `package-update-openai` | OpenAI package install/update behavior, including Codex on-demand install. | -| `package-update-anthropic` | Anthropic package install and update behavior. | -| `package-update-core` | Provider-neutral package and update behavior. | -| `plugins-runtime-plugins` | Plugin runtime lanes that exercise plugin behavior. | -| `plugins-runtime-services` | Service-backed and live plugin runtime lanes; includes OpenWebUI when requested. | -| `plugins-runtime-install-a` through `plugins-runtime-install-h` | Plugin install/runtime batches split for parallel release validation. | +| Chunk | Coverage | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `core` | Core Docker release-path smoke lanes. | +| `package-update-openai` | OpenAI package install/update behavior, Codex on-demand install, and Chat Completions tool calls. | +| `package-update-anthropic` | Anthropic package install and update behavior. | +| `package-update-core` | Provider-neutral package and update behavior. | +| `plugins-runtime-plugins` | Plugin runtime lanes that exercise plugin behavior. | +| `plugins-runtime-services` | Service-backed and live plugin runtime lanes; includes OpenWebUI when requested. | +| `plugins-runtime-install-a` through `plugins-runtime-install-h` | Plugin install/runtime batches split for parallel release validation. | Use targeted `docker_lanes=` on the reusable live/E2E workflow when only one Docker lane failed. The release artifacts include per-lane rerun diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 17baa8e5f90..c86bada78a4 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -46,7 +46,9 @@ Where to execute. `auto` resolves to `sandbox` when a sandbox runtime is active -Enforcement mode for `gateway` / `node` execution. +Ignored for normal tool calls. `gateway` / `node` security is controlled by +`tools.exec.security` and `~/.openclaw/exec-approvals.json`; elevated mode can +force `security=full` only when the operator explicitly grants elevated access. diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 32579a6feea..a8a86e803d3 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -250,6 +250,22 @@ describe("gateway bonjour advertiser", () => { await expect(started.stop()).resolves.toBeUndefined(); }); + it("auto-disables Bonjour on Fly Machines without Docker sentinel files", async () => { + enableAdvertiserUnitMode(); + process.env.FLY_MACHINE_ID = "3d8d5459a03038"; + process.env.FLY_APP_NAME = "openclaw-clawcks-test"; + vi.spyOn(fs, "existsSync").mockReturnValue(false); + vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n"); + + const started = await startAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + expect(createService).not.toHaveBeenCalled(); + await expect(started.stop()).resolves.toBeUndefined(); + }); + it("honors explicit Bonjour opt-in inside detected containers", async () => { enableAdvertiserUnitMode(); process.env.OPENCLAW_DISABLE_BONJOUR = "0"; diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index 6daa3898943..e7ca242bd79 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -135,6 +135,10 @@ function readBonjourDisableOverride(): boolean | null { } function isContainerEnvironment() { + if (process.env.FLY_MACHINE_ID?.trim() && process.env.FLY_APP_NAME?.trim()) { + return true; + } + for (const sentinelPath of ["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"]) { try { if (fs.existsSync(sentinelPath)) { diff --git a/extensions/discord/src/config-schema.test.ts b/extensions/discord/src/config-schema.test.ts index 98344dbde00..d559d4a95be 100644 --- a/extensions/discord/src/config-schema.test.ts +++ b/extensions/discord/src/config-schema.test.ts @@ -226,6 +226,25 @@ describe("discord config schema", () => { expect(cfg.voice?.captureSilenceGraceMs).toBe(3_500); }); + it("accepts Discord voice allowed channels", () => { + const cfg = expectValidDiscordConfig({ + voice: { + allowedChannels: [{ guildId: "123", channelId: "456" }], + }, + }); + + expect(cfg.voice?.allowedChannels).toEqual([{ guildId: "123", channelId: "456" }]); + }); + + it("rejects invalid Discord voice allowed channels", () => { + for (const voice of [ + { allowedChannels: [{ guildId: "", channelId: "456" }] }, + { allowedChannels: [{ guildId: "123", channelId: "" }] }, + ]) { + expectInvalidDiscordConfig({ voice }); + } + }); + it("rejects invalid Discord voice timing overrides", () => { for (const voice of [ { connectTimeoutMs: 0 }, diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index 4a0adf488e7..39703b6feeb 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -230,6 +230,10 @@ export const discordChannelConfigUiHints = { label: "Discord Voice Auto-Join", help: "Voice channels to auto-join on startup (list of guildId/channelId entries).", }, + "voice.allowedChannels": { + label: "Discord Voice Allowed Channels", + help: "Optional voice channel residency allowlist. When set, /vc join, auto-join, and bot voice-state moves are restricted to these guildId/channelId entries. Leave unset to allow any voice channel.", + }, "voice.daveEncryption": { label: "Discord Voice DAVE Encryption", help: "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).", diff --git a/extensions/discord/src/internal/listeners.ts b/extensions/discord/src/internal/listeners.ts index 4c4c576178b..d2297843d1a 100644 --- a/extensions/discord/src/internal/listeners.ts +++ b/extensions/discord/src/internal/listeners.ts @@ -2,6 +2,7 @@ import { GatewayDispatchEvents, type APIMessage, type APIReaction, + type APIVoiceState, type GatewayPresenceUpdateDispatchData, type GatewayThreadUpdateDispatchData, } from "discord-api-types/v10"; @@ -76,6 +77,11 @@ export abstract class PresenceUpdateListener extends BaseListener { ): Promise | void; } +export abstract class VoiceStateUpdateListener extends BaseListener { + readonly type = GatewayDispatchEvents.VoiceStateUpdate; + abstract override handle(data: APIVoiceState, client: Client): Promise | void; +} + export abstract class ThreadUpdateListener extends BaseListener { readonly type = GatewayDispatchEvents.ThreadUpdate; abstract override handle( diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 8af0e7c1d42..ca1955e54ff 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -131,6 +131,7 @@ vi.mock("../voice/manager.runtime.js", () => { DiscordVoiceManager: function DiscordVoiceManager() {}, DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {}, DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {}, + DiscordVoiceStateUpdateListener: function DiscordVoiceStateUpdateListener() {}, }; }); describe("monitorDiscordProvider", () => { @@ -263,6 +264,7 @@ describe("monitorDiscordProvider", () => { DiscordVoiceManager: function DiscordVoiceManager() {}, DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {}, DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {}, + DiscordVoiceStateUpdateListener: function DiscordVoiceStateUpdateListener() {}, } as never; }); providerTesting.setLoadDiscordProviderSessionRuntime( diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 2bfbf63f126..9a3edcc9ff0 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -497,8 +497,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { let voiceManager: DiscordVoiceManager | null = null; if (voiceEnabled) { - const { DiscordVoiceManager, DiscordVoiceReadyListener, DiscordVoiceResumedListener } = - await loadDiscordVoiceRuntime(); + const { + DiscordVoiceManager, + DiscordVoiceReadyListener, + DiscordVoiceResumedListener, + DiscordVoiceStateUpdateListener, + } = await loadDiscordVoiceRuntime(); voiceManager = new DiscordVoiceManager({ client, cfg, @@ -510,6 +514,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { voiceManagerRef.current = voiceManager; registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager)); registerDiscordListener(client.listeners, new DiscordVoiceResumedListener(voiceManager)); + registerDiscordListener(client.listeners, new DiscordVoiceStateUpdateListener(voiceManager)); } const messageHandler = discordProviderSessionRuntime.createDiscordMessageHandler({ diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 79bbbf17d44..a04665b5f50 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -560,6 +560,76 @@ describe("DiscordVoiceManager", () => { expectConnectedStatus(manager, "1002"); }); + it("rejects joins outside configured allowed voice channels", async () => { + const manager = createManager({ + voice: { + enabled: true, + mode: "stt-tts", + allowedChannels: [{ guildId: "g1", channelId: "1001" }], + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1002" }); + + expect(result.ok).toBe(false); + expect(result.message).toBe( + "<#1002> is not allowed by channels.discord.voice.allowedChannels.", + ); + expect(joinVoiceChannelMock).not.toHaveBeenCalled(); + }); + + it("allows joins inside configured allowed voice channels", async () => { + const manager = createManager({ + voice: { + enabled: true, + mode: "stt-tts", + allowedChannels: [{ guildId: "g1", channelId: "1001" }], + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(true); + expectConnectedStatus(manager, "1001"); + }); + + it("treats an empty allowed voice channel list as deny-all", async () => { + const manager = createManager({ + voice: { + enabled: true, + mode: "stt-tts", + allowedChannels: [], + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(false); + expect(joinVoiceChannelMock).not.toHaveBeenCalled(); + }); + + it("leaves and rejoins the configured target when Discord moves the bot outside allowed voice channels", async () => { + const manager = createManager({ + voice: { + enabled: true, + mode: "stt-tts", + autoJoin: [{ guildId: "g1", channelId: "1001" }], + allowedChannels: [{ guildId: "g1", channelId: "1001" }], + }, + }); + manager.setBotUserId("bot-user"); + await manager.join({ guildId: "g1", channelId: "1001" }); + + await manager.handleVoiceStateUpdate({ + guild_id: "g1", + user_id: "bot-user", + channel_id: "1002", + } as never); + + expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2); + expectConnectedStatus(manager, "1001"); + }); + it("skips destroying stale tracked voice connections that are already destroyed", async () => { const staleConnection = createConnectionMock(); staleConnection.state.status = "destroyed"; diff --git a/extensions/discord/src/voice/manager.ready-listener.test.ts b/extensions/discord/src/voice/manager.ready-listener.test.ts index 344ca85e802..3bcd6f64791 100644 --- a/extensions/discord/src/voice/manager.ready-listener.test.ts +++ b/extensions/discord/src/voice/manager.ready-listener.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { GatewayDispatchEvents } from "../internal/discord.js"; -import { DiscordVoiceReadyListener, DiscordVoiceResumedListener } from "./manager.js"; +import { + DiscordVoiceReadyListener, + DiscordVoiceResumedListener, + DiscordVoiceStateUpdateListener, +} from "./manager.js"; describe("DiscordVoiceReadyListener", () => { it("starts auto-join without blocking the ready listener", async () => { @@ -34,4 +38,17 @@ describe("DiscordVoiceReadyListener", () => { expect(listener.type).toBe(GatewayDispatchEvents.Resumed); expect(autoJoin).toHaveBeenCalledTimes(1); }); + + it("forwards bot voice state updates to the voice manager", async () => { + const handleVoiceStateUpdate = vi.fn(async () => {}); + const listener = new DiscordVoiceStateUpdateListener({ + handleVoiceStateUpdate, + } as unknown as ConstructorParameters[0]); + const payload = { guild_id: "g1", user_id: "bot", channel_id: "1001" }; + + await expect(listener.handle(payload as never, {} as never)).resolves.toBeUndefined(); + + expect(listener.type).toBe(GatewayDispatchEvents.VoiceStateUpdate); + expect(handleVoiceStateUpdate).toHaveBeenCalledWith(payload); + }); }); diff --git a/extensions/discord/src/voice/manager.runtime.ts b/extensions/discord/src/voice/manager.runtime.ts index 84d73726160..326027ec363 100644 --- a/extensions/discord/src/voice/manager.runtime.ts +++ b/extensions/discord/src/voice/manager.runtime.ts @@ -2,6 +2,7 @@ import { DiscordVoiceManager as DiscordVoiceManagerImpl, DiscordVoiceReadyListener as DiscordVoiceReadyListenerImpl, DiscordVoiceResumedListener as DiscordVoiceResumedListenerImpl, + DiscordVoiceStateUpdateListener as DiscordVoiceStateUpdateListenerImpl, } from "./manager.js"; export class DiscordVoiceManager extends DiscordVoiceManagerImpl {} @@ -9,3 +10,5 @@ export class DiscordVoiceManager extends DiscordVoiceManagerImpl {} export class DiscordVoiceReadyListener extends DiscordVoiceReadyListenerImpl {} export class DiscordVoiceResumedListener extends DiscordVoiceResumedListenerImpl {} + +export class DiscordVoiceStateUpdateListener extends DiscordVoiceStateUpdateListenerImpl {} diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 745cd3fad66..1c8ddfd9aa7 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -5,7 +5,13 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveDiscordAccountAllowFrom } from "../accounts.js"; -import { type Client, ReadyListener, ResumedListener } from "../internal/discord.js"; +import { + type APIVoiceState, + type Client, + ReadyListener, + ResumedListener, + VoiceStateUpdateListener, +} from "../internal/discord.js"; import type { VoicePlugin } from "../internal/voice.js"; import { formatMention } from "../mentions.js"; import { parseDiscordTarget } from "../target-parsing.js"; @@ -61,6 +67,10 @@ const VOICE_LOG_PREVIEW_CHARS = 500; type DiscordVoiceSdk = ReturnType; type DiscordVoiceConnection = ReturnType; +type VoiceChannelResidency = { + guildId: string; + channelId: string; +}; function formatVoiceLogPreview(text: string): string { const oneLine = text.replace(/\s+/g, " ").trim(); @@ -98,6 +108,33 @@ function destroyVoiceConnectionSafely(params: { } } +function normalizeVoiceChannelResidencies( + entries: Array<{ guildId?: string; channelId?: string }> | undefined, +): VoiceChannelResidency[] { + const normalized: VoiceChannelResidency[] = []; + for (const entry of entries ?? []) { + const guildId = entry.guildId?.trim(); + const channelId = entry.channelId?.trim(); + if (guildId && channelId) { + normalized.push({ guildId, channelId }); + } + } + return normalized; +} + +function isVoiceChannelAllowed(params: { + allowedChannels: VoiceChannelResidency[] | null; + guildId: string; + channelId: string; +}): boolean { + return ( + params.allowedChannels === null || + params.allowedChannels.some( + (entry) => entry.guildId === params.guildId && entry.channelId === params.channelId, + ) + ); +} + function startAutoJoin(manager: Pick) { void manager .autoJoin() @@ -160,6 +197,7 @@ export class DiscordVoiceManager { private autoJoinTask: Promise | null = null; private readonly ownerAllowFrom?: string[]; private readonly speakerContext: DiscordVoiceSpeakerContextResolver; + private readonly allowedChannels: VoiceChannelResidency[] | null; constructor( private params: { @@ -178,6 +216,10 @@ export class DiscordVoiceManager { params.discordConfig.allowFrom ?? params.discordConfig.dm?.allowFrom ?? []; + this.allowedChannels = + params.discordConfig.voice?.allowedChannels === undefined + ? null + : normalizeVoiceChannelResidencies(params.discordConfig.voice.allowedChannels); this.speakerContext = new DiscordVoiceSpeakerContextResolver({ client: params.client, ownerAllowFrom: this.ownerAllowFrom, @@ -229,10 +271,15 @@ export class DiscordVoiceManager { for (const entry of entriesByGuild.values()) { logVoiceVerbose(`autoJoin: joining guild ${entry.guildId} channel ${entry.channelId}`); - await this.join({ + const result = await this.join({ guildId: entry.guildId, channelId: entry.channelId, }); + if (!result.ok) { + logger.warn( + `discord voice: autoJoin skipped guild=${entry.guildId} channel=${entry.channelId}: ${result.message}`, + ); + } } })().finally(() => { this.autoJoinTask = null; @@ -249,6 +296,14 @@ export class DiscordVoiceManager { })); } + isAllowedVoiceChannel(params: { guildId: string; channelId: string }): boolean { + return isVoiceChannelAllowed({ + allowedChannels: this.allowedChannels, + guildId: params.guildId.trim(), + channelId: params.channelId.trim(), + }); + } + async join(params: { guildId: string; channelId: string }): Promise { if (!this.voiceEnabled) { return { @@ -261,6 +316,17 @@ export class DiscordVoiceManager { if (!guildId || !channelId) { return { ok: false, message: "Missing guildId or channelId." }; } + if (!this.isAllowedVoiceChannel({ guildId, channelId })) { + logger.warn( + `discord voice: join rejected for non-allowed channel guild=${guildId} channel=${channelId}`, + ); + return { + ok: false, + message: `${formatMention({ channelId })} is not allowed by channels.discord.voice.allowedChannels.`, + guildId, + channelId, + }; + } logVoiceVerbose(`join requested: guild ${guildId} channel ${channelId}`); const existing = this.sessions.get(guildId); @@ -590,6 +656,53 @@ export class DiscordVoiceManager { }; } + async handleVoiceStateUpdate(data: APIVoiceState): Promise { + if (!this.botUserId || data.user_id !== this.botUserId) { + return; + } + const guildId = data.guild_id?.trim(); + const channelId = data.channel_id?.trim(); + if (!guildId || !channelId) { + return; + } + + const existing = this.sessions.get(guildId); + if (this.isAllowedVoiceChannel({ guildId, channelId })) { + if (existing && existing.channelId !== channelId) { + logger.warn( + `discord voice: bot moved to allowed channel guild=${guildId} from=${existing.channelId} to=${channelId}; rebuilding voice session`, + ); + await this.join({ guildId, channelId }); + } + return; + } + + logger.warn( + `discord voice: bot moved to non-allowed channel guild=${guildId} channel=${channelId}; leaving`, + ); + if (existing) { + await this.leave({ guildId }); + } else { + const voiceSdk = loadDiscordVoiceSdk(); + const connection = voiceSdk.getVoiceConnection(guildId); + if (connection) { + destroyVoiceConnectionSafely({ + connection, + voiceSdk, + reason: `non-allowed voice state guild ${guildId} channel ${channelId}`, + }); + } + } + + const target = this.resolveVoiceResidencyTarget(guildId); + if (target) { + logger.warn( + `discord voice: rejoining allowed voice channel guild=${guildId} channel=${target.channelId}`, + ); + await this.join(target); + } + } + async destroy(): Promise { for (const entry of this.sessions.values()) { entry.stop(); @@ -597,6 +710,22 @@ export class DiscordVoiceManager { this.sessions.clear(); } + private resolveVoiceResidencyTarget(guildId: string): VoiceChannelResidency | null { + const autoJoinTarget = normalizeVoiceChannelResidencies( + this.params.discordConfig.voice?.autoJoin, + ) + .toReversed() + .find((entry) => entry.guildId === guildId); + if (autoJoinTarget && this.isAllowedVoiceChannel(autoJoinTarget)) { + return autoJoinTarget; + } + if (this.allowedChannels === null) { + return null; + } + const guildAllowed = this.allowedChannels.filter((entry) => entry.guildId === guildId); + return guildAllowed.length === 1 ? guildAllowed[0] : null; + } + private enqueueProcessing(entry: VoiceSessionEntry, task: () => Promise) { entry.processingQueue = entry.processingQueue .then(task) @@ -972,3 +1101,13 @@ export class DiscordVoiceResumedListener extends ResumedListener { startAutoJoin(this.manager); } } + +export class DiscordVoiceStateUpdateListener extends VoiceStateUpdateListener { + constructor(private manager: DiscordVoiceManager) { + super(); + } + + async handle(data: APIVoiceState, _client: Client): Promise { + await this.manager.handleVoiceStateUpdate(data); + } +} diff --git a/extensions/feishu/src/app-registration.ts b/extensions/feishu/src/app-registration.ts index af7463735cd..e39e5beb476 100644 --- a/extensions/feishu/src/app-registration.ts +++ b/extensions/feishu/src/app-registration.ts @@ -167,7 +167,7 @@ export async function pollAppRegistration(params: { expireIn: number; initialDomain?: FeishuDomain; abortSignal?: AbortSignal; - /** Registration type parameter: "ob_user" for user mode, "ob_app" for bot mode. */ + /** Registration type parameter. The CLI bot QR flow uses "ob_cli_app". */ tp?: string; }): Promise { const { deviceCode, expireIn, initialDomain = "feishu", abortSignal, tp } = params; diff --git a/extensions/feishu/src/setup-surface.test.ts b/extensions/feishu/src/setup-surface.test.ts index a18dfa2648a..74cd93a5355 100644 --- a/extensions/feishu/src/setup-surface.test.ts +++ b/extensions/feishu/src/setup-surface.test.ts @@ -8,7 +8,19 @@ import { import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuProbeResult } from "./types.js"; -const { probeFeishuMock } = vi.hoisted(() => ({ +const { + beginAppRegistrationMock, + getAppOwnerOpenIdMock, + initAppRegistrationMock, + pollAppRegistrationMock, + printQrCodeMock, + probeFeishuMock, +} = vi.hoisted(() => ({ + beginAppRegistrationMock: vi.fn(), + getAppOwnerOpenIdMock: vi.fn(), + initAppRegistrationMock: vi.fn(), + pollAppRegistrationMock: vi.fn(), + printQrCodeMock: vi.fn(), probeFeishuMock: vi.fn<() => Promise>(async () => ({ ok: false, error: "mocked", @@ -20,13 +32,11 @@ vi.mock("./probe.js", () => ({ })); vi.mock("./app-registration.js", () => ({ - initAppRegistration: vi.fn(async () => { - throw new Error("mocked: scan-to-create not available"); - }), - beginAppRegistration: vi.fn(), - pollAppRegistration: vi.fn(), - printQrCode: vi.fn(async () => {}), - getAppOwnerOpenId: vi.fn(async () => undefined), + initAppRegistration: initAppRegistrationMock, + beginAppRegistration: beginAppRegistrationMock, + pollAppRegistration: pollAppRegistrationMock, + printQrCode: printQrCodeMock, + getAppOwnerOpenId: getAppOwnerOpenIdMock, })); import { feishuPlugin } from "./channel.js"; @@ -86,6 +96,116 @@ describe("feishu setup wizard", () => { beforeEach(() => { probeFeishuMock.mockReset(); probeFeishuMock.mockResolvedValue({ ok: false, error: "mocked" }); + initAppRegistrationMock.mockReset(); + initAppRegistrationMock.mockRejectedValue(new Error("mocked: scan-to-create not available")); + beginAppRegistrationMock.mockReset(); + pollAppRegistrationMock.mockReset(); + printQrCodeMock.mockReset(); + printQrCodeMock.mockResolvedValue(undefined); + getAppOwnerOpenIdMock.mockReset(); + getAppOwnerOpenIdMock.mockResolvedValue(undefined); + }); + + it("uses manual credentials by default instead of starting scan-to-create", async () => { + const text = vi.fn().mockResolvedValueOnce("cli_manual").mockResolvedValueOnce("secret_manual"); + const prompter = createTestWizardPrompter({ text }); + + const result = await runSetupWizardConfigure({ + configure: feishuConfigure, + cfg: {} as never, + prompter, + runtime: createNonExitingRuntimeEnv(), + }); + + expect(initAppRegistrationMock).not.toHaveBeenCalled(); + expect(beginAppRegistrationMock).not.toHaveBeenCalled(); + expect(result.cfg.channels?.feishu).toMatchObject({ + appId: "cli_manual", + appSecret: "secret_manual", + connectionMode: "websocket", + domain: "feishu", + }); + }); + + it("passes selected domain through scan-to-create and poll", async () => { + initAppRegistrationMock.mockResolvedValueOnce(undefined); + beginAppRegistrationMock.mockResolvedValueOnce({ + deviceCode: "device-code", + qrUrl: "https://accounts.larksuite.com/qr", + userCode: "user-code", + interval: 1, + expireIn: 10, + }); + pollAppRegistrationMock.mockResolvedValueOnce({ + status: "success", + result: { + appId: "cli_lark", + appSecret: "secret_lark", + domain: "lark", + openId: "ou_owner", + }, + }); + const prompter = createTestWizardPrompter({ + select: vi + .fn() + .mockResolvedValueOnce("scan") + .mockResolvedValueOnce("lark") + .mockResolvedValueOnce("open") as never, + }); + + const result = await runSetupWizardConfigure({ + configure: feishuConfigure, + cfg: {} as never, + prompter, + runtime: createNonExitingRuntimeEnv(), + }); + + expect(initAppRegistrationMock).toHaveBeenCalledWith("lark"); + expect(beginAppRegistrationMock).toHaveBeenCalledWith("lark"); + expect(pollAppRegistrationMock).toHaveBeenCalledWith( + expect.objectContaining({ + deviceCode: "device-code", + initialDomain: "lark", + tp: "ob_cli_app", + }), + ); + expect(result.cfg.channels?.feishu).toMatchObject({ + appId: "cli_lark", + appSecret: "secret_lark", + domain: "lark", + groupPolicy: "open", + requireMention: true, + }); + }); + + it("falls back to manual credentials when selected scan-to-create is unavailable", async () => { + const text = vi + .fn() + .mockResolvedValueOnce("cli_from_fallback") + .mockResolvedValueOnce("secret_from_fallback"); + const prompter = createTestWizardPrompter({ + text, + select: vi + .fn() + .mockResolvedValueOnce("scan") + .mockResolvedValueOnce("feishu") + .mockResolvedValueOnce("allowlist") as never, + }); + + const result = await runSetupWizardConfigure({ + configure: feishuConfigure, + cfg: {} as never, + prompter, + runtime: createNonExitingRuntimeEnv(), + }); + + expect(initAppRegistrationMock).toHaveBeenCalledWith("feishu"); + expect(beginAppRegistrationMock).not.toHaveBeenCalled(); + expect(result.cfg.channels?.feishu).toMatchObject({ + appId: "cli_from_fallback", + appSecret: "secret_from_fallback", + domain: "feishu", + }); }); it("prompts over SecretRef appId/appSecret config objects", async () => { diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 5f4fc8008c3..8f02da89563 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -17,6 +17,7 @@ import type { AppRegistrationResult } from "./app-registration.js"; import type { FeishuConfig, FeishuDomain } from "./types.js"; const channel = "feishu" as const; +const SCAN_TO_CREATE_TP = "ob_cli_app"; // --------------------------------------------------------------------------- // Helpers @@ -213,6 +214,7 @@ const feishuDmPolicy: ChannelSetupDmPolicy = { }; type WizardPrompter = Parameters>[0]["prompter"]; +type FeishuSetupMethod = "manual" | "scan"; // --------------------------------------------------------------------------- // Security policy helpers @@ -245,11 +247,39 @@ function applyNewAppSecurityPolicy( // Scan-to-create flow // --------------------------------------------------------------------------- -async function runScanToCreate(prompter: WizardPrompter): Promise { +async function promptFeishuDomain(params: { + prompter: WizardPrompter; + initialValue?: FeishuDomain; +}): Promise { + return (await params.prompter.select({ + message: "Which Feishu domain?", + options: [ + { value: "feishu", label: "Feishu (feishu.cn) - China" }, + { value: "lark", label: "Lark (larksuite.com) - International" }, + ], + initialValue: params.initialValue ?? "feishu", + })) as FeishuDomain; +} + +async function promptFeishuSetupMethod(prompter: WizardPrompter): Promise { + return (await prompter.select({ + message: "How do you want to connect Feishu?", + options: [ + { value: "manual", label: "Enter App ID and App Secret manually" }, + { value: "scan", label: "Scan a QR code to create a bot automatically" }, + ], + initialValue: "manual", + })) as FeishuSetupMethod; +} + +async function runScanToCreate( + prompter: WizardPrompter, + domain: FeishuDomain, +): Promise { const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } = await import("./app-registration.js"); try { - await initAppRegistration("feishu"); + await initAppRegistration(domain); } catch { await prompter.note( "Scan-to-create is not available in this environment. Falling back to manual input.", @@ -258,9 +288,12 @@ async function runScanToCreate(prompter: WizardPrompter): Promise { }); expect(resolveApiKeyForProviderMock).toHaveBeenCalledOnce(); - expect(resolveApiKeyForProviderMock.mock.calls[0]?.[0]).toEqual({ + expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({ provider: "openrouter", cfg: { models: { diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 88190918c2e..9ff1144bc40 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -28,6 +28,7 @@ type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< >; type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; type DeliverRepliesParams = Parameters[0]; +type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog; type MatchPluginCommandFn = typeof import("./bot-native-commands.runtime.js").matchPluginCommand; const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { @@ -53,6 +54,16 @@ const sessionMocks = vi.hoisted(() => ({ const commandAuthMocks = vi.hoisted(() => ({ resolveCommandArgMenu: vi.fn(), })); +const agentRuntimeMocks = vi.hoisted(() => ({ + loadModelCatalog: vi.fn(async () => [ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]), +})); const pluginRuntimeMocks = vi.hoisted(() => ({ executePluginCommand: vi.fn(async () => ({ text: "ok" })), matchPluginCommand: vi.fn(() => null), @@ -169,6 +180,15 @@ vi.mock("openclaw/plugin-sdk/command-auth-native", async () => { resolveCommandArgMenu: commandAuthMocks.resolveCommandArgMenu, }; }); +vi.mock("openclaw/plugin-sdk/agent-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/agent-runtime", + ); + return { + ...actual, + loadModelCatalog: agentRuntimeMocks.loadModelCatalog, + }; +}); vi.mock("./bot-native-commands.runtime.js", async () => { const actual = await vi.importActual( "./bot-native-commands.runtime.js", @@ -524,6 +544,14 @@ describe("registerTelegramNativeCommands — session metadata", () => { persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); commandAuthMocks.resolveCommandArgMenu.mockClear(); + agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]); sessionMocks.loadSessionStore.mockClear().mockReturnValue({}); sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); sessionMocks.resolveAndPersistSessionFile.mockClear().mockImplementation(async (params) => { @@ -701,6 +729,36 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); + it("hydrates runtime catalog metadata for thinking menu defaults", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + }, + }, + } as OpenClawConfig; + sessionMocks.loadSessionStore.mockReturnValue({}); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith({ + config: cfg, + }); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: medium.\nChoose level for /think.", + requireReplyMarkup: true, + label: "runtime catalog thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + it("uses target model thinking defaults before global thinking defaults", async () => { const cfg = { agents: { diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 775e5edf8ed..9ccfa6090b2 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -2,10 +2,10 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; import type { Bot, Context } from "grammy"; import { - buildConfiguredModelCatalog, + loadModelCatalog, resolveAgentConfig, resolveDefaultModelForAgent, - resolveThinkingDefault, + resolveThinkingDefaultWithRuntimeCatalog, } from "openclaw/plugin-sdk/agent-runtime"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; @@ -256,13 +256,26 @@ function resolveTelegramCommandMenuModelContext(params: { } } -function resolveTelegramThinkMenuCurrentLevel(params: { +async function resolveTelegramDefaultThinkingLevel(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): Promise { + return resolveThinkingDefaultWithRuntimeCatalog({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + loadModelCatalog: () => loadModelCatalog({ config: params.cfg }), + }); +} + +async function resolveTelegramThinkMenuCurrentLevel(params: { cfg: OpenClawConfig; agentId: string; provider?: string; model?: string; thinkingLevel?: string; -}): string { +}): Promise { const explicit = normalizeOptionalString(params.thinkingLevel); if (explicit) { return explicit; @@ -277,11 +290,10 @@ function resolveTelegramThinkMenuCurrentLevel(params: { cfg: params.cfg, agentId: params.agentId, }); - return resolveThinkingDefault({ + return await resolveTelegramDefaultThinkingLevel({ cfg: params.cfg, provider: params.provider ?? defaultModel.provider, model: params.model ?? defaultModel.model, - catalog: buildConfiguredModelCatalog({ cfg: params.cfg }), }); } @@ -1050,7 +1062,7 @@ export const registerTelegramNativeCommands = ({ menu, currentThinkingLevel: commandDefinition.key === "think" - ? resolveTelegramThinkMenuCurrentLevel({ + ? await resolveTelegramThinkMenuCurrentLevel({ cfg: runtimeCfg, agentId: route.agentId, ...menuModelContext, diff --git a/package.json b/package.json index 10f6ff71aaa..aab739d9fa5 100644 --- a/package.json +++ b/package.json @@ -1601,6 +1601,7 @@ "test:docker:npm-onboard-slack-channel-agent": "OPENCLAW_NPM_ONBOARD_CHANNEL=slack bash scripts/e2e/npm-onboard-channel-agent-docker.sh", "test:docker:npm-telegram-live": "bash scripts/e2e/npm-telegram-live-docker.sh", "test:docker:onboard": "bash scripts/e2e/onboard-docker.sh", + "test:docker:openai-chat-tools": "bash scripts/e2e/openai-chat-tools-docker.sh", "test:docker:openai-image-auth": "bash scripts/e2e/openai-image-auth-docker.sh", "test:docker:openai-web-search-minimal": "bash scripts/e2e/openai-web-search-minimal-docker.sh", "test:docker:openwebui": "bash scripts/e2e/openwebui-docker.sh", diff --git a/scripts/check-docker-e2e-boundaries.mjs b/scripts/check-docker-e2e-boundaries.mjs index 5e966b4403a..788fe4de5e0 100644 --- a/scripts/check-docker-e2e-boundaries.mjs +++ b/scripts/check-docker-e2e-boundaries.mjs @@ -14,7 +14,12 @@ const packageJson = JSON.parse(readText("package.json")); const packageScripts = new Set(Object.keys(packageJson.scripts ?? {})); // These lanes prove package-installed surfaces against live auth, so they // intentionally need both live credentials and a package-backed image. -const livePackageBackedLanes = new Set(["live-codex-npm-plugin", "live-plugin-tool", "openwebui"]); +const livePackageBackedLanes = new Set([ + "live-codex-npm-plugin", + "live-plugin-tool", + "openai-chat-tools", + "openwebui", +]); function readText(relativePath) { return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8"); diff --git a/scripts/e2e/lib/openai-chat-tools/client.mjs b/scripts/e2e/lib/openai-chat-tools/client.mjs new file mode 100644 index 00000000000..047cd009871 --- /dev/null +++ b/scripts/e2e/lib/openai-chat-tools/client.mjs @@ -0,0 +1,100 @@ +const port = process.env.PORT; +const token = process.env.OPENCLAW_GATEWAY_TOKEN; +const backendModel = process.env.MODEL_REF || "openai/gpt-5.4-mini"; +const timeoutSeconds = Number.parseInt( + process.env.OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS ?? "180", + 10, +); + +if (!port || !token) { + throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN"); +} + +const controller = new AbortController(); +const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000); +const started = Date.now(); +const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-openclaw-model": backendModel, + }, + body: JSON.stringify({ + model: "openclaw", + stream: false, + messages: [ + { + role: "user", + content: + "Use the get_weather tool exactly once for Paris, France. Return the tool call only.", + }, + ], + tool_choice: "auto", + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Return weather for a city.", + strict: true, + parameters: { + type: "object", + additionalProperties: false, + properties: { + city: { type: "string", description: "City and country." }, + }, + required: ["city"], + }, + }, + }, + ], + }), + signal: controller.signal, +}); +clearTimeout(timeout); + +const text = await response.text(); +let body; +try { + body = text ? JSON.parse(text) : {}; +} catch { + throw new Error(`non-JSON response ${response.status}: ${text}`); +} + +if (!response.ok) { + throw new Error(`chat completions request failed ${response.status}: ${JSON.stringify(body)}`); +} + +const choice = body.choices?.[0]; +const toolCalls = choice?.message?.tool_calls; +if (choice?.finish_reason !== "tool_calls") { + throw new Error(`expected finish_reason tool_calls: ${JSON.stringify(body)}`); +} +if (!Array.isArray(toolCalls) || toolCalls.length !== 1) { + throw new Error(`expected exactly one tool call: ${JSON.stringify(body)}`); +} +const [toolCall] = toolCalls; +if (toolCall?.type !== "function" || toolCall?.function?.name !== "get_weather") { + throw new Error(`unexpected tool call: ${JSON.stringify(toolCall)}`); +} + +let args = {}; +try { + args = JSON.parse(toolCall.function.arguments || "{}"); +} catch { + throw new Error(`tool arguments were not valid JSON: ${toolCall.function.arguments}`); +} +if (typeof args.city !== "string" || !/paris/i.test(args.city)) { + throw new Error(`expected Paris city argument: ${JSON.stringify(args)}`); +} + +console.log( + JSON.stringify({ + ok: true, + elapsedMs: Date.now() - started, + finishReason: choice.finish_reason, + toolName: toolCall.function.name, + args, + }), +); diff --git a/scripts/e2e/lib/openai-chat-tools/scenario.sh b/scripts/e2e/lib/openai-chat-tools/scenario.sh new file mode 100644 index 00000000000..2625d85f297 --- /dev/null +++ b/scripts/e2e/lib/openai-chat-tools/scenario.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +source scripts/lib/openclaw-e2e-instance.sh +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" +export OPENCLAW_SKIP_CHANNELS=1 +export OPENCLAW_SKIP_GMAIL_WATCHER=1 +export OPENCLAW_SKIP_CRON=1 +export OPENCLAW_SKIP_CANVAS_HOST=1 +export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1 +export OPENCLAW_SKIP_ACPX_RUNTIME=1 +export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1 +export OPENCLAW_AGENT_HARNESS_FALLBACK=none + +for profile_path in "$HOME/.profile" /home/appuser/.profile; do + if [ -f "$profile_path" ] && [ -r "$profile_path" ]; then + set +e +u + # shellcheck disable=SC1090 + source "$profile_path" + set -euo pipefail + break + fi +done +if [ -z "${OPENAI_API_KEY:-}" ]; then + echo "ERROR: OPENAI_API_KEY was not available after sourcing ~/.profile." >&2 + exit 1 +fi +export OPENAI_API_KEY +if [ -n "${OPENAI_BASE_URL:-}" ]; then + export OPENAI_BASE_URL +fi + +PORT="${PORT:?missing PORT}" +TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}" +MODEL_REF="${OPENCLAW_OPENAI_CHAT_TOOLS_MODEL:?missing OPENCLAW_OPENAI_CHAT_TOOLS_MODEL}" +GATEWAY_LOG="/tmp/openclaw-openai-chat-tools-gateway.log" +CLIENT_LOG="/tmp/openclaw-openai-chat-tools-client.log" +gateway_pid="" + +cleanup() { + openclaw_e2e_stop_process "$gateway_pid" +} +trap cleanup EXIT + +dump_debug_logs() { + local status="$1" + echo "OpenAI Chat Completions tools Docker E2E failed with exit code $status" >&2 + openclaw_e2e_dump_logs "$GATEWAY_LOG" "$CLIENT_LOG" + if [ -f "$OPENCLAW_CONFIG_PATH" ]; then + echo "--- $OPENCLAW_CONFIG_PATH keys ---" >&2 + node -e "const fs=require('fs'); const cfg=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); console.error(JSON.stringify({model:cfg.agents?.defaults?.model, tools:cfg.tools, provider:cfg.models?.providers?.openai && {api:cfg.models.providers.openai.api, baseUrl:cfg.models.providers.openai.baseUrl, agentRuntime:cfg.models.providers.openai.agentRuntime}}, null, 2));" "$OPENCLAW_CONFIG_PATH" || true + fi +} +trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR + +entry="$(openclaw_e2e_resolve_entrypoint)" +mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR" + +node scripts/e2e/lib/openai-chat-tools/write-config.mjs + +gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")" +for _ in $(seq 1 360); do + if ! kill -0 "$gateway_pid" 2>/dev/null; then + echo "gateway exited before listening" >&2 + exit 1 + fi + if node "$entry" gateway health \ + --url "ws://127.0.0.1:$PORT" \ + --token "$TOKEN" \ + --timeout 120000 \ + --json >/dev/null 2>&1; then + break + fi + sleep 0.25 +done +node "$entry" gateway health \ + --url "ws://127.0.0.1:$PORT" \ + --token "$TOKEN" \ + --timeout 120000 \ + --json >/dev/null + +PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" MODEL_REF="$MODEL_REF" \ + node scripts/e2e/lib/openai-chat-tools/client.mjs >"$CLIENT_LOG" 2>&1 + +cat "$CLIENT_LOG" +echo "OpenAI Chat Completions tools Docker E2E passed" diff --git a/scripts/e2e/lib/openai-chat-tools/write-config.mjs b/scripts/e2e/lib/openai-chat-tools/write-config.mjs new file mode 100644 index 00000000000..0c3bd2ecd43 --- /dev/null +++ b/scripts/e2e/lib/openai-chat-tools/write-config.mjs @@ -0,0 +1,90 @@ +import fs from "node:fs"; +import path from "node:path"; + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`missing ${name}`); + } + return value; +} + +const configPath = requireEnv("OPENCLAW_CONFIG_PATH"); +const stateDir = requireEnv("OPENCLAW_STATE_DIR"); +const workspaceDir = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR"); +const modelRef = requireEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MODEL"); +const token = requireEnv("OPENCLAW_GATEWAY_TOKEN"); +const timeoutSeconds = Number.parseInt( + process.env.OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS ?? "180", + 10, +); +const [providerId, modelId] = modelRef.split("/"); +if (providerId !== "openai" || !modelId) { + throw new Error(`OPENCLAW_OPENAI_CHAT_TOOLS_MODEL must be openai/*, got ${modelRef}`); +} + +const config = { + gateway: { + port: Number.parseInt(process.env.PORT ?? "18789", 10), + bind: "loopback", + auth: { mode: "token", token }, + controlUi: { enabled: false }, + http: { + endpoints: { + chatCompletions: { enabled: true }, + }, + }, + }, + models: { + mode: "merge", + providers: { + openai: { + api: "openai-responses", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(), + agentRuntime: { id: "pi" }, + timeoutSeconds, + models: [ + { + id: modelId, + name: modelId, + api: "openai-responses", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + contextTokens: 64000, + maxTokens: 512, + }, + ], + }, + }, + }, + agents: { + defaults: { + model: { primary: modelRef, fallbacks: [] }, + models: { + [modelRef]: { + agentRuntime: { id: "pi" }, + params: { transport: "sse", openaiWsWarmup: false }, + }, + }, + workspace: workspaceDir, + skipBootstrap: true, + timeoutSeconds, + contextTokens: 64000, + }, + }, + plugins: { + enabled: true, + allow: ["openai"], + entries: { openai: { enabled: true } }, + }, + skills: { allowBundled: [] }, + tools: { allow: ["get_weather"] }, +}; + +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.mkdirSync(workspaceDir, { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); +fs.mkdirSync(path.join(stateDir, "logs"), { recursive: true }); diff --git a/scripts/e2e/openai-chat-tools-docker.sh b/scripts/e2e/openai-chat-tools-docker.sh new file mode 100644 index 00000000000..5541a4b17ca --- /dev/null +++ b/scripts/e2e/openai-chat-tools-docker.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-openai-chat-tools-e2e" OPENCLAW_OPENAI_CHAT_TOOLS_E2E_IMAGE)" +SKIP_BUILD="${OPENCLAW_OPENAI_CHAT_TOOLS_E2E_SKIP_BUILD:-0}" +PORT="${OPENCLAW_OPENAI_CHAT_TOOLS_PORT:-18789}" +TOKEN="openai-chat-tools-e2e-$$" +PROFILE_FILE="${OPENCLAW_OPENAI_CHAT_TOOLS_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFILE_FILE:-$HOME/.openclaw-testbox-live.profile}}" +if [ ! -f "$PROFILE_FILE" ] && [ -f "$HOME/.profile" ]; then + PROFILE_FILE="$HOME/.profile" +fi + +docker_e2e_build_or_reuse "$IMAGE_NAME" openai-chat-tools "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" +OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 openai-chat-tools empty)" + +PROFILE_MOUNT=() +PROFILE_STATUS="none" +if [ -f "$PROFILE_FILE" ] && [ -r "$PROFILE_FILE" ]; then + set -a + # shellcheck disable=SC1090 + source "$PROFILE_FILE" + set +a + PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/appuser/.profile:ro) + PROFILE_STATUS="$PROFILE_FILE" +fi + +echo "Running OpenAI Chat Completions tools Docker E2E..." +echo "Profile file: $PROFILE_STATUS" +docker_e2e_run_logged_with_harness openai-chat-tools \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENAI_API_KEY \ + -e OPENAI_BASE_URL \ + -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ + -e "OPENCLAW_OPENAI_CHAT_TOOLS_MODEL=${OPENCLAW_OPENAI_CHAT_TOOLS_MODEL:-openai/gpt-5.4-mini}" \ + -e "OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS=${OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS:-180}" \ + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + -e "PORT=$PORT" \ + "${PROFILE_MOUNT[@]}" \ + "$IMAGE_NAME" \ + bash scripts/e2e/lib/openai-chat-tools/scenario.sh diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index 0f6194bd605..22a4db5990a 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -417,9 +417,40 @@ function optionalString(source: JsonObject, key: string) { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function childProcessBaseEnv() { + const keys = [ + "CI", + "COREPACK_HOME", + "FORCE_COLOR", + "HOME", + "LANG", + "LC_ALL", + "NODE_OPTIONS", + "OPENCLAW_BUILD_PRIVATE_QA", + "OPENCLAW_ENABLE_PRIVATE_QA_CLI", + "PATH", + "PNPM_HOME", + "SHELL", + "TEMP", + "TMP", + "TMPDIR", + "USER", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + ]; + const env: NodeJS.ProcessEnv = {}; + for (const key of keys) { + const value = process.env[key]; + if (value) { + env[key] = value; + } + } + return env; +} + function mockServerEnv(params: { mockPort: number; mockResponseText: string; requestLog: string }) { return { - ...process.env, + ...childProcessBaseEnv(), MOCK_PORT: String(params.mockPort), MOCK_REQUEST_LOG: params.requestLog, SUCCESS_MARKER: params.mockResponseText, @@ -428,7 +459,7 @@ function mockServerEnv(params: { mockPort: number; mockResponseText: string; req function gatewayEnv(params: { configPath: string; stateDir: string; sutToken: string }) { return { - ...process.env, + ...childProcessBaseEnv(), OPENAI_API_KEY: "sk-openclaw-e2e-mock", OPENCLAW_CONFIG_PATH: params.configPath, OPENCLAW_STATE_DIR: params.stateDir, diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index b0ecbb8095a..82ceb440379 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -333,6 +333,7 @@ function laneCredentialRequirements(poolLane) { } if ( poolLane.name === "openwebui" || + poolLane.name === "openai-chat-tools" || poolLane.name === "openai-web-search-minimal" || poolLane.name === "live-codex-npm-plugin" || poolLane.name === "live-plugin-tool" diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index b1a1004e4ab..956d04ac459 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -141,6 +141,22 @@ function livePluginToolLane() { ); } +function liveOpenAiChatToolsLane() { + return liveLane( + "openai-chat-tools", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-chat-tools", + { + e2eImageKind: "functional", + needsLiveImage: false, + provider: "openai", + resources: ["service"], + stateScenario: "empty", + timeoutMs: 10 * 60 * 1000, + weight: 2, + }, + ); +} + export const mainLanes = [ liveLane("live-models", liveDockerScriptCommand("test-live-models-docker.sh"), { providers: ["claude-cli", "codex-cli", "google-gemini-cli"], @@ -539,6 +555,7 @@ const releasePathPackageInstallOpenAiLanes = [ weight: 3, }, ), + liveOpenAiChatToolsLane(), npmLane("codex-on-demand", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:codex-on-demand", { resources: ["service"], stateScenario: "empty", diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index 6b3d2f9d094..19e4a46e724 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -1244,7 +1244,7 @@ async function main() { if (buildEnabled) { const buildEntries = []; - if (scheduledLanes.some((poolLane) => poolLane.live)) { + if (scheduledLanes.some((poolLane) => poolLane.needsLiveImage)) { buildEntries.push({ command: liveDockerHarnessScriptCommand("test-live-build-docker.sh"), label: "shared live-test image once", diff --git a/src/agents/bash-tools.exec.security-floor.test.ts b/src/agents/bash-tools.exec.security-floor.test.ts new file mode 100644 index 00000000000..3e247f51d8d --- /dev/null +++ b/src/agents/bash-tools.exec.security-floor.test.ts @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { resetProcessRegistryForTests } from "./bash-process-registry.js"; +import { createExecTool } from "./bash-tools.exec.js"; + +describe("exec security floor", () => { + let envSnapshot: ReturnType; + let tempRoot: string | undefined; + + beforeEach(() => { + envSnapshot = captureEnv([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_HOME", + "OPENCLAW_STATE_DIR", + "SHELL", + ]); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-security-floor-")); + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + process.env.OPENCLAW_HOME = tempRoot; + process.env.OPENCLAW_STATE_DIR = path.join(tempRoot, "state"); + if (process.platform === "win32") { + const parsed = path.parse(tempRoot); + process.env.HOMEDRIVE = parsed.root.slice(0, 2); + process.env.HOMEPATH = tempRoot.slice(2) || "\\"; + } else { + delete process.env.HOMEDRIVE; + delete process.env.HOMEPATH; + } + resetProcessRegistryForTests(); + }); + + afterEach(() => { + const dir = tempRoot; + tempRoot = undefined; + envSnapshot.restore(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("ignores model-supplied allowlist security when configured security is full", async () => { + const tool = createExecTool({ + security: "full", + ask: "off", + }); + + const result = await tool.execute("call-1", { + command: "echo hello", + security: "allowlist", + ask: "off", + }); + + expect(result.content[0]).toMatchObject({ type: "text" }); + const text = (result.content[0] as { text?: string }).text ?? ""; + expect(text).not.toMatch(/exec denied/i); + expect(text).not.toMatch(/allowlist miss/i); + expect(text.trim()).toContain("hello"); + }); + + it("enforces configured allowlist security when model also passes allowlist", async () => { + const tool = createExecTool({ + security: "allowlist", + ask: "off", + safeBins: [], + }); + + await expect( + tool.execute("call-2", { + command: "echo hello", + security: "allowlist", + ask: "off", + }), + ).rejects.toThrow(/exec denied: allowlist miss/i); + }); + + it("ignores model-supplied deny security when configured security is allowlist", async () => { + const tool = createExecTool({ + security: "allowlist", + ask: "off", + safeBins: [], + }); + + await expect( + tool.execute("call-3", { + command: "echo hello", + security: "deny", + ask: "off", + }), + ).rejects.toThrow(/exec denied: allowlist miss/i); + }); + + it("ignores model-supplied full security when configured security is deny", async () => { + const tool = createExecTool({ + security: "deny", + ask: "off", + }); + + await expect( + tool.execute("call-4", { + command: "echo hello", + security: "full", + ask: "off", + }), + ).rejects.toThrow(/exec denied/i); + }); +}); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 5beb58f5573..c80a702b5a8 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -8,7 +8,6 @@ import { type ExecSecurity, loadExecApprovals, maxAsk, - minSecurity, requireValidExecTarget, } from "../infra/exec-approvals.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; @@ -40,7 +39,6 @@ import { applyPathPrepend, applyShellPath, normalizeExecAsk, - normalizeExecSecurity, normalizePathPrepend, resolveExecTarget, resolveApprovalRunningNoticeMs, @@ -1346,8 +1344,7 @@ export function createExecTool( const approvalDefaults = loadExecApprovals().defaults; const configuredSecurity = defaults?.security ?? approvalDefaults?.security ?? (host === "sandbox" ? "deny" : "full"); - const requestedSecurity = normalizeExecSecurity(params.security); - let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity); + let security = configuredSecurity; if (elevatedRequested && elevatedMode === "full") { security = "full"; } diff --git a/src/agents/bash-tools.schemas.ts b/src/agents/bash-tools.schemas.ts index 80fe33a9f01..a0e9e8089ad 100644 --- a/src/agents/bash-tools.schemas.ts +++ b/src/agents/bash-tools.schemas.ts @@ -34,7 +34,8 @@ export const execSchema = Type.Object({ }), security: Type.Optional( Type.String({ - description: "Exec security mode (deny|allowlist|full).", + description: + "Ignored for normal calls; exec security is set by tools.exec.security and host approvals.", }), ), ask: Type.Optional( diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index f304c81b036..3ea7e98b63e 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -17,7 +17,10 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { findModelInCatalog } from "./model-catalog-lookup.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; -export { resolveThinkingDefault } from "./model-thinking-default.js"; +export { + resolveThinkingDefault, + resolveThinkingDefaultWithRuntimeCatalog, +} from "./model-thinking-default.js"; import { type ModelRef, findNormalizedProviderKey, diff --git a/src/agents/model-thinking-default.ts b/src/agents/model-thinking-default.ts index 99422bd4c10..2b96da565d7 100644 --- a/src/agents/model-thinking-default.ts +++ b/src/agents/model-thinking-default.ts @@ -7,6 +7,7 @@ import { import type { ModelCatalogEntry } from "./model-catalog.types.js"; import { legacyModelKey, modelKey, normalizeProviderId } from "./model-selection-normalize.js"; import { normalizeModelSelection } from "./model-selection-resolve.js"; +import { buildConfiguredModelCatalog } from "./model-selection-shared.js"; type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max"; @@ -77,3 +78,33 @@ export function resolveThinkingDefault(params: { catalog: params.catalog, }); } + +export async function resolveThinkingDefaultWithRuntimeCatalog(params: { + cfg: OpenClawConfig; + provider: string; + model: string; + loadModelCatalog: () => Promise; +}): Promise { + const configuredCatalog = buildConfiguredModelCatalog({ cfg: params.cfg }); + const configuredSelectedEntry = configuredCatalog.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const needsRuntimeCatalog = + configuredCatalog.length === 0 || + !configuredSelectedEntry || + configuredSelectedEntry.reasoning === undefined; + const runtimeCatalog = needsRuntimeCatalog ? await params.loadModelCatalog() : undefined; + const runtimeSelectedEntry = runtimeCatalog?.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const catalog = + runtimeSelectedEntry || configuredCatalog.length === 0 + ? (runtimeCatalog ?? configuredCatalog) + : configuredCatalog; + return resolveThinkingDefault({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + catalog, + }); +} diff --git a/src/agents/pi-tool-definition-adapter.test.ts b/src/agents/pi-tool-definition-adapter.test.ts index fe1d571445f..036dc86aa36 100644 --- a/src/agents/pi-tool-definition-adapter.test.ts +++ b/src/agents/pi-tool-definition-adapter.test.ts @@ -124,9 +124,10 @@ function makeClientTool(name: string): ClientToolDefinition { }; } -async function executeClientTool( - params: unknown, -): Promise<{ calledWith: Record | undefined }> { +async function executeClientTool(params: unknown): Promise<{ + calledWith: Record | undefined; + result: Awaited>; +}> { let captured: Record | undefined; const [def] = toClientToolDefinitions([makeClientTool("search")], (_name, p) => { captured = p; @@ -134,14 +135,40 @@ async function executeClientTool( if (!def) { throw new Error("missing client tool definition"); } - await def.execute("call-c1", params, undefined, undefined, extensionContext); - return { calledWith: captured }; + const result = await def.execute("call-c1", params, undefined, undefined, extensionContext); + return { calledWith: captured, result }; } describe("toClientToolDefinitions – param coercion", () => { + it("returns terminal pending results for each client tool in a batch", async () => { + const completed: Array<{ id: string; name: string; params: Record }> = []; + const defs = toClientToolDefinitions([makeClientTool("search"), makeClientTool("lookup")], { + complete: (id, name, params) => { + completed.push({ id, name, params }); + }, + }); + const [search, lookup] = defs; + if (!search || !lookup) { + throw new Error("missing client tool definition"); + } + + const [searchResult, lookupResult] = await Promise.all([ + search.execute("call-search", { query: "first" }, undefined, undefined, extensionContext), + lookup.execute("call-lookup", { query: "second" }, undefined, undefined, extensionContext), + ]); + + expect(searchResult.terminate).toBe(true); + expect(lookupResult.terminate).toBe(true); + expect(completed).toEqual([ + { id: "call-search", name: "search", params: { query: "first" } }, + { id: "call-lookup", name: "lookup", params: { query: "second" } }, + ]); + }); + it("passes plain object params through unchanged", async () => { - const { calledWith } = await executeClientTool({ query: "hello" }); + const { calledWith, result } = await executeClientTool({ query: "hello" }); expect(calledWith).toEqual({ query: "hello" }); + expect(result.terminate).toBe(true); }); it("parses a JSON string into an object (streaming delta accumulation)", async () => { diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index 512b483b271..2a1a7859b6c 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -377,12 +377,15 @@ export function toClientToolDefinitions( } throw err; } - // Return a pending result - the client will execute this tool - return jsonResult({ - status: "pending", - tool: func.name, - message: "Tool execution delegated to client", - }); + // Return a terminal pending result; the client will execute the tool. + return { + ...jsonResult({ + status: "pending", + tool: func.name, + message: "Tool execution delegated to client", + }), + terminate: true, + }; }, } satisfies ToolDefinition; }); diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index f8dc07ed3f6..955bd772e21 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -67,6 +67,13 @@ function expectFields(value: unknown, expected: Record, label = } } +function expectSubagentSessionKey(value: unknown, label: string): string { + expect(value, label).toBeTypeOf("string"); + const sessionKey = value as string; + expect(sessionKey.startsWith("agent:main:subagent:")).toBe(true); + return sessionKey; +} + function setConfig(next: Record) { hoisted.configOverride = createSubagentSpawnTestConfig(undefined, next); } @@ -234,9 +241,16 @@ describe("sessions_spawn subagent lifecycle hooks", () => { expectFields(result, { status: "accepted", runId: "run-1" }, "spawn result"); expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1); - expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledWith( + const [spawningEvent, spawningContext] = (hookRunnerMocks.runSubagentSpawning.mock.calls[0] ?? + []) as unknown as [Record, Record]; + const spawningChildSessionKey = expectSubagentSessionKey( + spawningEvent?.childSessionKey, + "spawning event child session key", + ); + expectFields( + spawningEvent, { - childSessionKey: expect.stringMatching(/^agent:main:subagent:/), + childSessionKey: spawningChildSessionKey, agentId: "main", label: "research", mode: "session", @@ -248,10 +262,15 @@ describe("sessions_spawn subagent lifecycle hooks", () => { }, threadRequested: true, }, + "spawning event", + ); + expectFields( + spawningContext, { - childSessionKey: expect.stringMatching(/^agent:main:subagent:/), + childSessionKey: spawningChildSessionKey, requesterSessionKey: "main", }, + "spawning context", ); expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1); @@ -280,7 +299,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => { }, "spawned requester", ); - expect(event.childSessionKey).toEqual(expect.stringMatching(/^agent:main:subagent:/)); + expectSubagentSessionKey(event.childSessionKey, "spawned event child session key"); expectFields( ctx, { @@ -423,7 +442,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => { const [event] = (hookRunnerMocks.runSubagentEnded.mock.calls[0] ?? []) as unknown as [ Record, ]; - expect(event.targetSessionKey).toEqual(expect.stringMatching(/^agent:main:subagent:/)); + expectSubagentSessionKey(event.targetSessionKey, "ended event target session key"); expectFields( event, { diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 9bc8d33b2a0..8540ae21cb2 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -28,12 +28,11 @@ import { buildTaskStatusSnapshotForRelatedSessionKeyForOwner } from "../../tasks import { formatTaskStatusDetail, formatTaskStatusTitle } from "../../tasks/task-status.js"; import { loadModelCatalog } from "../model-catalog.js"; import { - buildConfiguredModelCatalog, buildModelAliasIndex, modelKey, resolveDefaultModelForAgent, resolveModelRefFromString, - resolveThinkingDefault, + resolveThinkingDefaultWithRuntimeCatalog, } from "../model-selection.js"; import { createModelVisibilityPolicy } from "../model-visibility-policy.js"; import { @@ -713,32 +712,13 @@ export function createSessionStatusTool(opts?: { resolvedVerboseLevel: (statusSessionEntry.verboseLevel ?? "off") as VerboseLevel, resolvedReasoningLevel: (statusSessionEntry.reasoningLevel ?? "off") as ReasoningLevel, resolvedElevatedLevel: statusSessionEntry.elevatedLevel as ElevatedLevel | undefined, - resolveDefaultThinkingLevel: async () => { - const configuredCatalog = buildConfiguredModelCatalog({ cfg }); - const configuredSelectedEntry = configuredCatalog.find( - (entry) => entry.provider === providerForCard && entry.id === defaultModelForCard, - ); - const shouldHydrateRuntimeCatalog = - configuredCatalog.length === 0 || - !configuredSelectedEntry || - configuredSelectedEntry.reasoning === undefined; - const runtimeCatalog = shouldHydrateRuntimeCatalog - ? await loadModelCatalog({ config: cfg }) - : undefined; - const runtimeSelectedEntry = runtimeCatalog?.find( - (entry) => entry.provider === providerForCard && entry.id === defaultModelForCard, - ); - const catalog = - runtimeSelectedEntry || configuredCatalog.length === 0 - ? (runtimeCatalog ?? configuredCatalog) - : configuredCatalog; - return resolveThinkingDefault({ + resolveDefaultThinkingLevel: () => + resolveThinkingDefaultWithRuntimeCatalog({ cfg, provider: providerForCard, model: defaultModelForCard, - catalog, - }); - }, + loadModelCatalog: () => loadModelCatalog({ config: cfg }), + }), isGroup, defaultGroupActivation: () => "mention", taskLineOverride: taskLine, diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index fc3386f079f..9262fd4a4ec 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { + pinActivePluginChannelRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { buildCommandText, @@ -82,12 +86,27 @@ function installOllamaThinkingProvider() { setActivePluginRegistry(registry); } +function createNativeCommandsRegistry(id: "discord" | "slack") { + return createTestRegistry([ + { + pluginId: id, + plugin: createChannelTestPluginBase({ + id, + capabilities: { nativeCommands: true, chatTypes: ["direct"] }, + }), + source: "test", + }, + ]); +} + beforeEach(() => { vi.doUnmock("../channels/plugins/index.js"); + resetPluginRuntimeStateForTest(); setActivePluginRegistry(createTestRegistry([])); }); afterEach(() => { + resetPluginRuntimeStateForTest(); setActivePluginRegistry(createTestRegistry([])); }); @@ -455,6 +474,65 @@ describe("commands registry", () => { ).toBe(true); }); + it("refreshes dock commands when pinned-empty fallback active registry changes", () => { + const pinnedEmptyRegistry = createTestRegistry([]); + setActivePluginRegistry(pinnedEmptyRegistry); + pinActivePluginChannelRegistry(pinnedEmptyRegistry); + + setActivePluginRegistry(createNativeCommandsRegistry("discord")); + expect([...commandKeySet(listChatCommands())]).toEqual( + expect.arrayContaining(["dock:discord"]), + ); + expect([...commandKeySet(listChatCommands())]).not.toEqual( + expect.arrayContaining(["dock:slack"]), + ); + + setActivePluginRegistry(createNativeCommandsRegistry("slack")); + expect([...commandKeySet(listChatCommands())]).not.toEqual( + expect.arrayContaining(["dock:discord"]), + ); + expect([...commandKeySet(listChatCommands())]).toEqual(expect.arrayContaining(["dock:slack"])); + }); + + it("refreshes text-command gating when pinned-empty fallback active registry changes", () => { + const cfg = { commands: { text: false } }; + const pinnedEmptyRegistry = createTestRegistry([]); + setActivePluginRegistry(pinnedEmptyRegistry); + pinActivePluginChannelRegistry(pinnedEmptyRegistry); + + setActivePluginRegistry(createNativeCommandsRegistry("discord")); + expect( + shouldHandleTextCommands({ + cfg, + surface: "discord", + commandSource: "text", + }), + ).toBe(false); + expect( + shouldHandleTextCommands({ + cfg, + surface: "slack", + commandSource: "text", + }), + ).toBe(true); + + setActivePluginRegistry(createNativeCommandsRegistry("slack")); + expect( + shouldHandleTextCommands({ + cfg, + surface: "discord", + commandSource: "text", + }), + ).toBe(true); + expect( + shouldHandleTextCommands({ + cfg, + surface: "slack", + commandSource: "text", + }), + ).toBe(false); + }); + it("normalizes telegram-style command mentions for the current bot", () => { expect(normalizeCommandBody("/help@openclaw", { botUsername: "openclaw" })).toBe("/help"); expect( diff --git a/src/auto-reply/reply/conversation-label-generator.test.ts b/src/auto-reply/reply/conversation-label-generator.test.ts index d7fe4210371..cc614791760 100644 --- a/src/auto-reply/reply/conversation-label-generator.test.ts +++ b/src/auto-reply/reply/conversation-label-generator.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const completeSimple = vi.hoisted(() => vi.fn()); const getRuntimeAuthForModel = vi.hoisted(() => vi.fn()); @@ -63,6 +63,10 @@ describe("generateConversationLabel", () => { }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("uses routed agentDir for model and auth resolution", async () => { await generateConversationLabel({ userMessage: "Need help with invoices", @@ -94,31 +98,35 @@ describe("generateConversationLabel", () => { }); it("passes the label prompt as systemPrompt and the user text as message content", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_710_000_000_000); + await generateConversationLabel({ userMessage: "Need help with invoices", prompt: "Generate a label", cfg: {}, }); - expect(completeSimple).toHaveBeenCalledWith( - { provider: "openai" }, - { - systemPrompt: "Generate a label", - messages: [ - { - role: "user", - content: "Need help with invoices", - timestamp: expect.any(Number), - }, - ], - }, - expect.objectContaining({ - apiKey: "resolved-key", - maxTokens: 100, - temperature: 0.3, - signal: expect.any(AbortSignal), - }), - ); + expect(completeSimple).toHaveBeenCalledOnce(); + const call = completeSimple.mock.calls[0]; + if (!call) { + throw new Error("expected simple completion call"); + } + expect(call[0]).toStrictEqual({ provider: "openai" }); + expect(call[1]).toStrictEqual({ + systemPrompt: "Generate a label", + messages: [ + { + role: "user", + content: "Need help with invoices", + timestamp: 1_710_000_000_000, + }, + ], + }); + expect(call[2].apiKey).toBe("resolved-key"); + expect(call[2].maxTokens).toBe(100); + expect(call[2].temperature).toBe(0.3); + expect(call[2].signal).toBeInstanceOf(AbortSignal); }); it("omits temperature for Codex Responses simple completions", async () => { @@ -135,9 +143,12 @@ describe("generateConversationLabel", () => { cfg: {}, }); - expect(completeSimple.mock.calls[0]?.[2]).toEqual( - expect.not.objectContaining({ temperature: expect.anything() }), - ); + expect(completeSimple).toHaveBeenCalledOnce(); + const options = completeSimple.mock.calls[0]?.[2]; + if (!options) { + throw new Error("expected simple completion options"); + } + expect(Object.hasOwn(options, "temperature")).toBe(false); }); it("logs completion errors instead of treating them as empty labels", async () => { diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index 747e6064a6c..b47c42fbccd 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -1,10 +1,15 @@ -import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { + resolveThinkingDefaultWithRuntimeCatalog, + type ModelAliasIndex, +} from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import type { GetReplyOptions } from "../get-reply-options.types.js"; import type { ReplyPayload } from "../reply-payload.js"; import type { MsgContext } from "../templating.js"; +import { normalizeThinkLevel, type ThinkLevel } from "../thinking.js"; import { buildCommandContext } from "./commands-context.js"; import { clearInlineDirectives } from "./get-reply-directives-utils.js"; import { resolveReplyDirectives } from "./get-reply-directives.js"; @@ -42,6 +47,19 @@ function shouldRunNativeSlashCommandFastPath(ctx: MsgContext): boolean { return Boolean(commandName && commandName !== "new" && commandName !== "reset"); } +async function resolveNativeSlashDefaultThinkingLevel(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): Promise { + return resolveThinkingDefaultWithRuntimeCatalog({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + loadModelCatalog: () => loadModelCatalog({ config: params.cfg }), + }); +} + export async function maybeResolveNativeSlashCommandFastReply(params: { ctx: MsgContext; cfg: OpenClawConfig; @@ -84,6 +102,16 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { if (command.commandBodyNormalized === "/status") { const targetSessionEntry = sessionState.sessionStore[sessionState.sessionKey] ?? sessionState.sessionEntry; + let resolvedDefaultThinkingLevel: ThinkLevel | undefined; + const resolveDefaultThinkingLevel = async () => { + resolvedDefaultThinkingLevel ??= await resolveNativeSlashDefaultThinkingLevel({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + }); + return resolvedDefaultThinkingLevel; + }; + const resolvedThinkLevel = normalizeThinkLevel(targetSessionEntry?.thinkingLevel); const { buildStatusReply } = await loadStatusCommandRuntime(); return { handled: true, @@ -98,11 +126,11 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { provider: params.provider, model: params.model, workspaceDir: params.workspaceDir, - resolvedThinkLevel: undefined, + resolvedThinkLevel, resolvedVerboseLevel: "off", resolvedReasoningLevel: "off", resolvedElevatedLevel: "off", - resolveDefaultThinkingLevel: async () => undefined, + resolveDefaultThinkingLevel, isGroup: sessionState.isGroup, defaultGroupActivation: () => "always", mediaDecisions: params.ctx.MediaUnderstandingDecisions, diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 22fba651c74..f31433a2382 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -18,12 +18,37 @@ import { import { loadGetReplyModuleForTest } from "./get-reply.test-loader.js"; import "./get-reply.test-runtime-mocks.js"; +type LoadModelCatalogFn = typeof import("../../agents/model-catalog.js").loadModelCatalog; +type ModelAliasIndex = import("../../agents/model-selection.js").ModelAliasIndex; + +function emptyAliasIndex(): ModelAliasIndex { + return { byAlias: new Map(), byKey: new Map() }; +} + const mocks = vi.hoisted(() => ({ ensureAgentWorkspace: vi.fn(), initSessionState: vi.fn(), + loadModelCatalog: vi.fn(async () => [ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]), resolveReplyDirectives: vi.fn(), })); +vi.mock("../../agents/model-catalog.js", async () => { + const actual = await vi.importActual( + "../../agents/model-catalog.js", + ); + return { + ...actual, + loadModelCatalog: mocks.loadModelCatalog, + }; +}); + vi.mock("../../agents/workspace.js", () => ({ DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace", ensureAgentWorkspace: (...args: unknown[]) => mocks.ensureAgentWorkspace(...args), @@ -31,11 +56,14 @@ vi.mock("../../agents/workspace.js", () => ({ registerGetReplyRuntimeOverrides(mocks); let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig; +let resolveDefaultModelMock: typeof import("./directive-handling.defaults.js").resolveDefaultModel; let loadConfigMock: typeof import("../../config/config.js").getRuntimeConfig; let runPreparedReplyMock: typeof import("./get-reply-run.js").runPreparedReply; async function loadGetReplyRuntimeForTest() { ({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url })); + ({ resolveDefaultModel: resolveDefaultModelMock } = + await import("./directive-handling.defaults.js")); ({ getRuntimeConfig: loadConfigMock } = await import("../../config/config.js")); ({ runPreparedReply: runPreparedReplyMock } = await import("./get-reply-run.js")); } @@ -49,7 +77,22 @@ describe("getReplyFromConfig fast test bootstrap", () => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); mocks.ensureAgentWorkspace.mockReset(); mocks.initSessionState.mockReset(); + mocks.loadModelCatalog.mockReset(); + mocks.loadModelCatalog.mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]); mocks.resolveReplyDirectives.mockReset(); + vi.mocked(resolveDefaultModelMock).mockReset(); + vi.mocked(resolveDefaultModelMock).mockReturnValue({ + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex: emptyAliasIndex(), + }); vi.mocked(loadConfigMock).mockReset(); vi.mocked(runPreparedReplyMock).mockReset(); vi.mocked(loadConfigMock).mockReturnValue({}); @@ -89,11 +132,12 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); - expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledWith( - expect.objectContaining({ - cfg, - }), - ); + expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledOnce(); + const preparedReplyParams = vi.mocked(runPreparedReplyMock).mock.calls[0]?.[0]; + if (!preparedReplyParams) { + throw new Error("expected prepared reply params"); + } + expect(preparedReplyParams.cfg).toBe(cfg); }); it("still merges partial config overrides against getRuntimeConfig()", async () => { @@ -219,6 +263,11 @@ describe("getReplyFromConfig fast test bootstrap", () => { }, session: { store: path.join(home, "sessions.json") }, } as OpenClawConfig); + vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({ + defaultProvider: "openai", + defaultModel: "gpt-5.5", + aliasIndex: emptyAliasIndex(), + }); const reply = await getReplyFromConfig( buildGetReplyCtx({ @@ -235,7 +284,117 @@ describe("getReplyFromConfig fast test bootstrap", () => { cfg, ); - expect(reply).toEqual(expect.objectContaining({ text: expect.stringContaining("OpenClaw") })); + if (!reply || Array.isArray(reply) || typeof reply.text !== "string") { + throw new Error("expected status reply text"); + } + expect(reply.text.includes("OpenClaw")).toBe(true); + expect(reply.text.includes("Think: medium")).toBe(true); + expect(mocks.loadModelCatalog).toHaveBeenCalledWith({ config: cfg }); + expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(mocks.initSessionState).not.toHaveBeenCalled(); + expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); + expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + }); + + it("uses configured agent thinking defaults for native /status", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-agent-think-")); + const targetSessionKey = "agent:main:telegram:123"; + const cfg = markCompleteReplyConfig({ + agents: { + defaults: { + model: "openai/gpt-5.5", + workspace: path.join(home, "workspace"), + thinkingDefault: "low", + }, + list: [ + { + id: "main", + thinkingDefault: "high", + }, + ], + }, + session: { store: path.join(home, "sessions.json") }, + } as OpenClawConfig); + vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({ + defaultProvider: "openai", + defaultModel: "gpt-5.5", + aliasIndex: emptyAliasIndex(), + }); + + const reply = await getReplyFromConfig( + buildGetReplyCtx({ + Body: "/status", + BodyForAgent: "/status", + RawBody: "/status", + CommandBody: "/status", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: targetSessionKey, + }), + undefined, + cfg, + ); + + expect(reply).toEqual( + expect.objectContaining({ text: expect.stringContaining("Think: high") }), + ); + expect(mocks.loadModelCatalog).not.toHaveBeenCalled(); + expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(mocks.initSessionState).not.toHaveBeenCalled(); + expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); + expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + }); + + it("uses the target session thinking override for native /status", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-think-")); + const storePath = path.join(home, "sessions.json"); + const targetSessionKey = "agent:main:telegram:123"; + await fs.writeFile( + storePath, + JSON.stringify({ + [targetSessionKey]: { + sessionId: "existing-telegram-session", + thinkingLevel: "xhigh", + updatedAt: 1, + }, + }), + "utf8", + ); + const cfg = markCompleteReplyConfig({ + agents: { + defaults: { + model: "openai/gpt-5.5", + workspace: path.join(home, "workspace"), + }, + }, + session: { store: storePath }, + } as OpenClawConfig); + vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({ + defaultProvider: "openai", + defaultModel: "gpt-5.5", + aliasIndex: emptyAliasIndex(), + }); + + const reply = await getReplyFromConfig( + buildGetReplyCtx({ + Body: "/status", + BodyForAgent: "/status", + RawBody: "/status", + CommandBody: "/status", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: targetSessionKey, + }), + undefined, + cfg, + ); + + expect(reply).toEqual( + expect.objectContaining({ text: expect.stringContaining("Think: xhigh") }), + ); + expect(mocks.loadModelCatalog).not.toHaveBeenCalled(); expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); @@ -279,12 +438,15 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); - expect(mocks.resolveReplyDirectives).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: targetSessionKey, - workspaceDir: expect.any(String), - }), - ); + expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce(); + const directiveParams = mocks.resolveReplyDirectives.mock.calls[0]?.[0] as + | { sessionKey?: string; workspaceDir?: string } + | undefined; + if (!directiveParams) { + throw new Error("expected directive params"); + } + expect(directiveParams.sessionKey).toBe(targetSessionKey); + expect(directiveParams.workspaceDir).toBe("/tmp/workspace"); }); it("uses native command target session keys during fast bootstrap", () => { diff --git a/src/channels/registry-lookup.ts b/src/channels/registry-lookup.ts new file mode 100644 index 00000000000..82130194ceb --- /dev/null +++ b/src/channels/registry-lookup.ts @@ -0,0 +1,95 @@ +import type { + ActivePluginChannelRegistration, + ActivePluginChannelRegistry, +} from "../plugins/channel-registry-state.types.js"; +import { getActivePluginChannelRegistrySnapshotFromState } from "../plugins/runtime-channel-state.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; + +export type RegisteredChannelPluginEntry = ActivePluginChannelRegistration & { + plugin: ActivePluginChannelRegistration["plugin"] & { + id?: string | null; + meta?: { + aliases?: readonly string[]; + markdownCapable?: boolean; + } | null; + }; +}; + +type RegisteredChannelPluginLookup = { + registry: ActivePluginChannelRegistry | null; + channels: ActivePluginChannelRegistration[] | undefined; + channelCount: number; + version: number; + entries: RegisteredChannelPluginEntry[]; + byKey: Map; + byId: Map; +}; + +let registeredChannelPluginLookup: RegisteredChannelPluginLookup | undefined; + +function setLookupEntry( + map: Map, + key: string | undefined, + entry: RegisteredChannelPluginEntry, +): void { + if (key && !map.has(key)) { + map.set(key, entry); + } +} + +function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup { + const { registry, version } = getActivePluginChannelRegistrySnapshotFromState(); + const channels = Array.isArray(registry?.channels) ? registry.channels : undefined; + const channelCount = channels?.length ?? 0; + const cached = registeredChannelPluginLookup; + if ( + cached && + cached.registry === registry && + cached.channels === channels && + cached.channelCount === channelCount && + cached.version === version + ) { + return cached; + } + const entries = channelCount > 0 ? (channels as RegisteredChannelPluginEntry[]) : []; + const byKey = new Map(); + const byId = new Map(); + for (const entry of entries) { + const id = normalizeOptionalLowercaseString(entry.plugin.id ?? ""); + setLookupEntry(byKey, id, entry); + setLookupEntry(byId, id, entry); + for (const alias of entry.plugin.meta?.aliases ?? []) { + setLookupEntry(byKey, normalizeOptionalLowercaseString(alias), entry); + } + } + registeredChannelPluginLookup = { + registry, + channels, + channelCount, + version, + entries, + byKey, + byId, + }; + return registeredChannelPluginLookup; +} + +export function listRegisteredChannelPluginEntries(): RegisteredChannelPluginEntry[] { + return buildRegisteredChannelPluginLookup().entries; +} + +export function findRegisteredChannelPluginEntry( + normalizedKey: string, +): RegisteredChannelPluginEntry | undefined { + return buildRegisteredChannelPluginLookup().byKey.get(normalizedKey); +} + +export function findRegisteredChannelPluginEntryById( + id: string, +): RegisteredChannelPluginEntry | undefined { + const normalizedId = normalizeOptionalLowercaseString(id); + if (!normalizedId) { + return undefined; + } + return buildRegisteredChannelPluginLookup().byId.get(normalizedId); +} diff --git a/src/channels/registry-normalize.ts b/src/channels/registry-normalize.ts index c1a9a6cd3de..ed293cb6bf5 100644 --- a/src/channels/registry-normalize.ts +++ b/src/channels/registry-normalize.ts @@ -1,30 +1,11 @@ -import type { ActivePluginChannelRegistration } from "../plugins/channel-registry-state.types.js"; -import { getActivePluginChannelRegistryFromState } from "../plugins/runtime-channel-state.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { ChannelId } from "./plugins/channel-id.types.js"; - -function listRegisteredChannelPluginEntries(): ActivePluginChannelRegistration[] { - const channelRegistry = getActivePluginChannelRegistryFromState(); - if (channelRegistry?.channels && channelRegistry.channels.length > 0) { - return channelRegistry.channels; - } - return []; -} +import { findRegisteredChannelPluginEntry } from "./registry-lookup.js"; export function normalizeAnyChannelId(raw?: string | null): ChannelId | null { const key = normalizeOptionalLowercaseString(raw); if (!key) { return null; } - return ( - listRegisteredChannelPluginEntries().find((entry) => { - const id = normalizeOptionalLowercaseString(entry.plugin.id ?? "") ?? ""; - if (id && id === key) { - return true; - } - return (entry.plugin.meta?.aliases ?? []).some( - (alias) => normalizeOptionalLowercaseString(alias) === key, - ); - })?.plugin.id ?? null - ); + return findRegisteredChannelPluginEntry(key)?.plugin.id ?? null; } diff --git a/src/channels/registry.helpers.test.ts b/src/channels/registry.helpers.test.ts index 79ac6d08802..b1152d7cfda 100644 --- a/src/channels/registry.helpers.test.ts +++ b/src/channels/registry.helpers.test.ts @@ -2,12 +2,16 @@ import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { pinActivePluginChannelRegistry, + getActivePluginChannelRegistryVersion, resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { listChatChannels } from "./chat-meta.js"; +import { normalizeAnyChannelId as normalizeAnyChannelIdLight } from "./registry-normalize.js"; import { formatChannelSelectionLine, + getRegisteredChannelPluginMeta, listRegisteredChannelPluginIds, normalizeAnyChannelId, } from "./registry.js"; @@ -32,6 +36,16 @@ describe("channel registry helpers", () => { return label ?? path ?? ""; } + function createRegistryWithRegisteredChannel(id: string, aliases: string[] = []) { + return createTestRegistry([ + { + pluginId: id, + plugin: { id, meta: { aliases } }, + source: "test", + }, + ]); + } + it("keeps Feishu first in the current default order", () => { const channels = listChatChannels(); expect(channels[0]?.id).toBe("feishu"); @@ -53,29 +67,16 @@ describe("channel registry helpers", () => { }); it("prefers the pinned channel registry when resolving registered plugin channels", () => { - const startupRegistry = createEmptyPluginRegistry(); - startupRegistry.channels = [ - { - pluginId: "openclaw-weixin", - plugin: { id: "openclaw-weixin", meta: { aliases: ["weixin"] } }, - source: "test", - }, - ] as never; + const startupRegistry = createRegistryWithRegisteredChannel("openclaw-weixin", ["weixin"]); setActivePluginRegistry(startupRegistry); pinActivePluginChannelRegistry(startupRegistry); - const replacementRegistry = createEmptyPluginRegistry(); - replacementRegistry.channels = [ - { - pluginId: "qqbot", - plugin: { id: "qqbot", meta: { aliases: ["qq"] } }, - source: "test", - }, - ] as never; + const replacementRegistry = createRegistryWithRegisteredChannel("qqbot", ["qq"]); setActivePluginRegistry(replacementRegistry); expect(listRegisteredChannelPluginIds()).toEqual(["openclaw-weixin"]); expect(normalizeAnyChannelId("weixin")).toBe("openclaw-weixin"); + expect(getRegisteredChannelPluginMeta("OPENCLAW-WEIXIN")?.aliases).toEqual(["weixin"]); }); it("falls back to the active registry when the pinned channel registry has no channels", () => { @@ -83,17 +84,45 @@ describe("channel registry helpers", () => { setActivePluginRegistry(startupRegistry); pinActivePluginChannelRegistry(startupRegistry); - const replacementRegistry = createEmptyPluginRegistry(); - replacementRegistry.channels = [ - { - pluginId: "qqbot", - plugin: { id: "qqbot", meta: { aliases: ["qq"] } }, - source: "test", - }, - ] as never; + const replacementRegistry = createRegistryWithRegisteredChannel("qqbot", ["qq"]); setActivePluginRegistry(replacementRegistry); expect(listRegisteredChannelPluginIds()).toEqual(["qqbot"]); expect(normalizeAnyChannelId("qq")).toBe("qqbot"); }); + + it("rebuilds registered channel lookups when pinned-empty fallback active registry changes", () => { + const startupRegistry = createEmptyPluginRegistry(); + setActivePluginRegistry(startupRegistry); + pinActivePluginChannelRegistry(startupRegistry); + + const alphaRegistry = createRegistryWithRegisteredChannel("alpha", ["a"]); + setActivePluginRegistry(alphaRegistry); + + const channelVersion = getActivePluginChannelRegistryVersion(); + expect(normalizeAnyChannelId("a")).toBe("alpha"); + expect(normalizeAnyChannelIdLight("a")).toBe("alpha"); + + const betaRegistry = createRegistryWithRegisteredChannel("beta", ["b"]); + setActivePluginRegistry(betaRegistry); + + expect(getActivePluginChannelRegistryVersion()).not.toBe(channelVersion); + expect(normalizeAnyChannelId("a")).toBeNull(); + expect(normalizeAnyChannelId("b")).toBe("beta"); + expect(normalizeAnyChannelIdLight("a")).toBeNull(); + expect(normalizeAnyChannelIdLight("b")).toBe("beta"); + }); + + it("refreshes registered channel lookups when selected registry channels grow in place", () => { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry); + + expect(normalizeAnyChannelId("a")).toBeNull(); + expect(normalizeAnyChannelIdLight("a")).toBeNull(); + + registry.channels.push(createRegistryWithRegisteredChannel("alpha", ["a"]).channels[0]); + + expect(normalizeAnyChannelId("a")).toBe("alpha"); + expect(normalizeAnyChannelIdLight("a")).toBe("alpha"); + }); }); diff --git a/src/channels/registry.ts b/src/channels/registry.ts index c82b0911492..bb020b52600 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,4 +1,3 @@ -import { getActivePluginChannelRegistryFromState } from "../plugins/runtime-channel-state.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -6,50 +5,14 @@ import { import { normalizeChatChannelId, type ChatChannelId } from "./ids.js"; import type { ChannelId } from "./plugins/channel-id.types.js"; import type { ChannelMeta } from "./plugins/types.core.js"; +import { + findRegisteredChannelPluginEntry, + findRegisteredChannelPluginEntryById, + listRegisteredChannelPluginEntries, +} from "./registry-lookup.js"; export { getChatChannelMeta } from "./chat-meta.js"; export { CHAT_CHANNEL_ORDER } from "./ids.js"; export type { ChatChannelId } from "./ids.js"; - -type RegisteredChannelPluginEntry = { - plugin: { - id?: string | null; - meta?: Pick | null; - }; -}; - -function listRegisteredChannelPluginEntries(): RegisteredChannelPluginEntry[] { - const channelRegistry = getActivePluginChannelRegistryFromState(); - if (channelRegistry && channelRegistry.channels && channelRegistry.channels.length > 0) { - return channelRegistry.channels; - } - return []; -} - -function findRegisteredChannelPluginEntry( - normalizedKey: string, -): RegisteredChannelPluginEntry | undefined { - return listRegisteredChannelPluginEntries().find((entry) => { - const id = normalizeOptionalLowercaseString(entry.plugin.id ?? "") ?? ""; - if (id && id === normalizedKey) { - return true; - } - return (entry.plugin.meta?.aliases ?? []).some( - (alias) => normalizeOptionalLowercaseString(alias) === normalizedKey, - ); - }); -} - -function findRegisteredChannelPluginEntryById( - id: string, -): RegisteredChannelPluginEntry | undefined { - const normalizedId = normalizeOptionalLowercaseString(id); - if (!normalizedId) { - return undefined; - } - return listRegisteredChannelPluginEntries().find( - (entry) => normalizeOptionalLowercaseString(entry.plugin.id) === normalizedId, - ); -} export { normalizeChatChannelId }; // Channel docking: prefer this helper in shared code. Importing from diff --git a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts index f83c7e5714b..bcb8c3d9919 100644 --- a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts @@ -1,4 +1,5 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { VERSION } from "../../version.js"; import { defaultRuntime, resetLifecycleRuntimeLogs, @@ -9,6 +10,15 @@ import { const readConfigFileSnapshotMock = vi.fn(); const loadConfig = vi.fn(() => ({})); +const newerConfigHints = [ + "Run the newer openclaw binary on PATH, or reinstall the intended gateway service from the newer install.", + "Set OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 only for an intentional downgrade or recovery action.", +]; +const newerConfigHintItems = newerConfigHints.map((text) => ({ kind: "generic", text })); + +function expectLatestRuntimeJson(payload: unknown) { + expect(defaultRuntime.writeJson.mock.calls.at(-1)?.[0]).toEqual(payload); +} vi.mock("../../config/config.js", () => ({ getRuntimeConfig: () => loadConfig(), @@ -90,11 +100,14 @@ describe("runServiceRestart config pre-flight (#35862)", () => { await expect(runServiceRestart(createServiceRunArgs())).rejects.toThrow("__exit__:1"); expect(service.restart).not.toHaveBeenCalled(); - expect(defaultRuntime.writeJson).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.stringContaining("Refusing to restart the gateway service"), - }), - ); + expectLatestRuntimeJson({ + action: "restart", + ok: false, + error: `Gateway restart blocked: Refusing to restart the gateway service because this OpenClaw binary (${VERSION}) is older than the config last written by OpenClaw 9999.1.1.`, + hints: newerConfigHints, + hintItems: newerConfigHintItems, + warnings: undefined, + }); }); it("proceeds with restart when config is valid", async () => { @@ -208,10 +221,13 @@ describe("runServiceStop future-config guard", () => { ).rejects.toThrow("__exit__:1"); expect(service.stop).not.toHaveBeenCalled(); - expect(defaultRuntime.writeJson).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.stringContaining("Refusing to stop the gateway service"), - }), - ); + expectLatestRuntimeJson({ + action: "stop", + ok: false, + error: `Gateway stop blocked: Refusing to stop the gateway service because this OpenClaw binary (${VERSION}) is older than the config last written by OpenClaw 9999.1.1.`, + hints: newerConfigHints, + hintItems: newerConfigHintItems, + warnings: undefined, + }); }); }); diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 76d11b3e8d9..22dfba59b56 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -283,6 +283,30 @@ async function createSignaledLoopHarness(exitCallOrder?: string[]) { return { close, start, runtime, exited, loopPromise }; } +function expectRestartHandoffCall(expected: { + restartKind: "full-process" | "update-process"; + reason: string | undefined; + supervisorMode: "external" | "launchd"; +}) { + expect(writeGatewayRestartHandoffSync).toHaveBeenCalledTimes(1); + const [handoff] = writeGatewayRestartHandoffSync.mock.calls[0] ?? []; + if (!handoff || typeof handoff !== "object" || Array.isArray(handoff)) { + throw new Error("expected restart handoff options object"); + } + const processInstanceId = (handoff as { processInstanceId?: unknown }).processInstanceId; + expect(typeof processInstanceId).toBe("string"); + if (typeof processInstanceId !== "string") { + throw new Error("expected restart handoff processInstanceId string"); + } + expect(processInstanceId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(handoff).toEqual({ + ...expected, + processInstanceId, + }); +} + describe("runGatewayLoop", () => { it("exits 0 on SIGTERM after graceful close", async () => { vi.clearAllMocks(); @@ -405,9 +429,7 @@ describe("runGatewayLoop", () => { expect(waitForActiveEmbeddedRuns).not.toHaveBeenCalled(); expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" }); expect(gatewayLog.warn).toHaveBeenCalledWith( - expect.stringContaining( - "restart blocked by active background task run(s): taskId=task-force", - ), + "restart blocked by active background task run(s): taskId=task-force runId=run-force status=running runtime=cron label=forced", ); expect(gatewayLog.warn).toHaveBeenCalledWith( "forced restart requested; skipping active work drain", @@ -662,10 +684,9 @@ describe("runGatewayLoop", () => { await expect(exited).resolves.toBe(0); expect(runtime.exit).toHaveBeenCalledWith(0); - expect(writeGatewayRestartHandoffSync).toHaveBeenCalledWith({ + expectRestartHandoffCall({ restartKind: "full-process", reason: undefined, - processInstanceId: expect.any(String), supervisorMode: "launchd", }); }); @@ -741,7 +762,7 @@ describe("runGatewayLoop", () => { expect(acquireGatewayLock).toHaveBeenCalledTimes(2); expect(start).toHaveBeenCalledTimes(1); expect(gatewayLog.error).toHaveBeenCalledWith( - expect.stringContaining("failed to reacquire gateway lock for in-process restart"), + "failed to reacquire gateway lock for in-process restart: Error: lock timeout", ); }); }); @@ -791,10 +812,9 @@ describe("runGatewayLoop", () => { await expect(exited).resolves.toBe(0); expect(runtime.exit).toHaveBeenCalledWith(0); - expect(writeGatewayRestartHandoffSync).toHaveBeenCalledWith({ + expectRestartHandoffCall({ restartKind: "update-process", reason: "update.run", - processInstanceId: expect.any(String), supervisorMode: "external", }); }); diff --git a/src/commands/agents.add.test.ts b/src/commands/agents.add.test.ts index 0365a78ea21..76d9b1513a8 100644 --- a/src/commands/agents.add.test.ts +++ b/src/commands/agents.add.test.ts @@ -1,9 +1,11 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; +import { formatCliCommand } from "../cli/command-format.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); @@ -33,6 +35,10 @@ import { agentsAddCommand } from "./agents.js"; const runtime = createTestRuntime(); +function oauthProfileSecretId(authStorePath: string, profileId: string): string { + return createHash("sha256").update(`${authStorePath}\0${profileId}`).digest("hex").slice(0, 32); +} + describe("agents add command", () => { beforeEach(() => { readConfigFileSnapshotMock.mockClear(); @@ -49,7 +55,10 @@ describe("agents add command", () => { await agentsAddCommand({ name: "Work" }, runtime, { hasFlags: true }); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--workspace")); + expect(runtime.error).toHaveBeenCalledOnce(); + expect(runtime.error).toHaveBeenCalledWith( + `Non-interactive agent creation requires --workspace. Re-run ${formatCliCommand("openclaw agents add --workspace ")} or omit flags to use the wizard.`, + ); expect(runtime.exit).toHaveBeenCalledWith(1); expect(writeConfigFileMock).not.toHaveBeenCalled(); }); @@ -61,7 +70,10 @@ describe("agents add command", () => { hasFlags: false, }); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--workspace")); + expect(runtime.error).toHaveBeenCalledOnce(); + expect(runtime.error).toHaveBeenCalledWith( + `Non-interactive agent creation requires --workspace. Re-run ${formatCliCommand("openclaw agents add --workspace ")} or omit flags to use the wizard.`, + ); expect(runtime.exit).toHaveBeenCalledWith(1); expect(writeConfigFileMock).not.toHaveBeenCalled(); }); @@ -146,6 +158,7 @@ describe("agents add command", () => { const sourceAgentDir = path.join(root, "main", "agent"); const destAgentDir = path.join(root, "work", "agent"); const destAuthPath = path.join(destAgentDir, "auth-profiles.json"); + const expires = Date.now() + 60_000; await fs.mkdir(sourceAgentDir, { recursive: true }); saveAuthProfileStore( { @@ -156,7 +169,7 @@ describe("agents add command", () => { provider: "openai-codex", access: "codex-copy-access-token", refresh: "codex-copy-refresh-token", - expires: Date.now() + 60_000, + expires, copyToAgents: true, }, }, @@ -177,19 +190,17 @@ describe("agents add command", () => { profiles: Record>; }; const credential = copied.profiles["openai-codex:default"]; - expect(credential).toMatchObject({ + expect(credential).toStrictEqual({ type: "oauth", provider: "openai-codex", + expires, copyToAgents: true, oauthRef: { source: "openclaw-credentials", provider: "openai-codex", - id: expect.any(String), + id: oauthProfileSecretId(destAuthPath, "openai-codex:default"), }, }); - expect(credential).not.toHaveProperty("access"); - expect(credential).not.toHaveProperty("refresh"); - expect(credential).not.toHaveProperty("idToken"); } finally { if (previousStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index 171372e4495..a96c9e8722a 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -422,4 +422,33 @@ describe("noteLegacyWhatsAppCrontabHealthCheck", () => { expect(noteMock).not.toHaveBeenCalled(); }); + + it("ignores malformed crontab output instead of crashing", async () => { + await expect( + noteLegacyWhatsAppCrontabHealthCheck({ + platform: "linux", + readCrontab: async () => ({ + stdout: undefined, + }), + }), + ).resolves.toBeUndefined(); + await expect( + noteLegacyWhatsAppCrontabHealthCheck({ + platform: "linux", + readCrontab: async () => ({ + stdout: 12345, + }), + }), + ).resolves.toBeUndefined(); + await expect( + noteLegacyWhatsAppCrontabHealthCheck({ + platform: "linux", + readCrontab: async () => ({ + stdout: { lines: ["*/5 * * * * ~/.openclaw/bin/ensure-whatsapp.sh"] }, + }), + }), + ).resolves.toBeUndefined(); + + expect(noteMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts index 0cda4155abf..a8258168b1e 100644 --- a/src/commands/doctor-cron.ts +++ b/src/commands/doctor-cron.ts @@ -22,7 +22,7 @@ type CronDoctorOutcome = { warnings: string[]; }; -type CrontabReader = () => Promise<{ stdout: string; stderr?: string }>; +type CrontabReader = () => Promise<{ stdout?: unknown; stderr?: unknown }>; const execFileAsync = promisify(execFile); const LEGACY_WHATSAPP_HEALTH_SCRIPT_RE = @@ -153,8 +153,21 @@ async function readUserCrontab(): Promise<{ stdout: string; stderr?: string }> { }; } -function findLegacyWhatsAppHealthCrontabLines(crontab: string): string[] { - return crontab +function coerceCrontabText(crontab: unknown): string { + if (typeof crontab === "string") { + return crontab; + } + if (crontab == null) { + return ""; + } + if (typeof crontab === "number" || typeof crontab === "boolean" || typeof crontab === "bigint") { + return String(crontab); + } + return ""; +} + +function findLegacyWhatsAppHealthCrontabLines(crontab: unknown): string[] { + return coerceCrontabText(crontab) .split(/\r?\n/u) .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("#")) @@ -171,7 +184,7 @@ export async function noteLegacyWhatsAppCrontabHealthCheck( return; } - let crontab: string; + let crontab: unknown; try { crontab = (await (params.readCrontab ?? readUserCrontab)()).stdout; } catch { diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 0f1f45121e0..7136da2275c 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -1,10 +1,14 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatCliCommand } from "../cli/command-format.js"; import type { ExtraGatewayService } from "../daemon/inspect.js"; import * as launchd from "../daemon/launchd.js"; import type { GatewayRestartHandoff } from "../infra/restart-handoff.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createDoctorPrompter } from "./doctor-prompter.js"; -import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js"; +import { + EXTERNAL_SERVICE_REPAIR_NOTE, + SERVICE_REPAIR_POLICY_ENV, +} from "./doctor-service-repair-policy.js"; const service = vi.hoisted(() => ({ isLoaded: vi.fn(), @@ -164,6 +168,7 @@ describe("maybeRepairGatewayDaemon", () => { }); afterEach(() => { + vi.useRealTimers(); if (originalPlatformDescriptor) { Object.defineProperty(process, "platform", originalPlatformDescriptor); } @@ -266,6 +271,8 @@ describe("maybeRepairGatewayDaemon", () => { }); it("reports recent restart handoffs during deep doctor", async () => { + vi.useFakeTimers(); + vi.setSystemTime(40_000); setPlatform("linux"); service.readCommand.mockResolvedValueOnce({ programArguments: ["/bin/node", "cli", "gateway"], @@ -306,11 +313,7 @@ describe("maybeRepairGatewayDaemon", () => { expect(handoffEnv?.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-service"); expect(handoffEnv?.OPENCLAW_CONFIG_PATH).toBe("/tmp/openclaw-service/openclaw.json"); expect(note).toHaveBeenCalledWith( - expect.stringContaining("Recent restart handoff: full-process via systemd"), - "Gateway", - ); - expect(note).toHaveBeenCalledWith( - expect.stringContaining("reason=plugin source changed"), + "Recent restart handoff: full-process via systemd; source=plugin-change; reason=plugin source changed; pid=12345; age=30s; expiresIn=30s", "Gateway", ); }); @@ -382,7 +385,7 @@ describe("maybeRepairGatewayDaemon", () => { expect(service.install).not.toHaveBeenCalled(); expect(service.restart).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( - expect.stringContaining("openclaw gateway install"), + `Run ${formatCliCommand("openclaw gateway install")} when you want to install the gateway service.`, "Gateway", ); }); @@ -428,7 +431,13 @@ describe("maybeRepairGatewayDaemon", () => { expect(service.install).not.toHaveBeenCalled(); expect(service.restart).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( - expect.stringContaining("System-level OpenClaw gateway service detected"), + [ + "System-level OpenClaw gateway service detected while the user gateway service is not installed.", + "- openclaw-gateway.service (unit: /etc/systemd/system/openclaw-gateway.service)", + "OpenClaw will not install a second user-level gateway service automatically.", + "Run `openclaw gateway status --deep` or `openclaw doctor --deep` to inspect duplicate services.", + `Set ${SERVICE_REPAIR_POLICY_ENV}=external if a system supervisor owns the gateway lifecycle.`, + ].join("\n"), "Gateway", ); }); diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index dde2bd464c8..f866d5e9c0b 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { formatCliCommand } from "../cli/command-format.js"; import type { RuntimeEnv } from "../runtime.js"; import { onboardCommand, setupWizardCommand } from "./onboard.js"; @@ -66,10 +67,10 @@ describe("setupWizardCommand", () => { runtime, ); + expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining('Invalid --secret-input-mode. Use "plaintext" or "ref", or run '), + `Invalid --secret-input-mode. Use "plaintext" or "ref", or run ${formatCliCommand("openclaw onboard")} for the interactive setup.`, ); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("onboard")); expect(runtime.exit).toHaveBeenCalledWith(1); expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled(); @@ -161,12 +162,10 @@ describe("setupWizardCommand", () => { runtime, ); + expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid --reset-scope. Use "config", "config+creds+sessions", or "full".', - ), + `Invalid --reset-scope. Use "config", "config+creds+sessions", or "full". Run ${formatCliCommand("openclaw onboard --reset --reset-scope config")} for a config-only reset.`, ); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("config-only reset")); expect(runtime.exit).toHaveBeenCalledWith(1); expect(mocks.handleReset).not.toHaveBeenCalled(); expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index ca124bd2a83..a33529c398b 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -129,6 +129,13 @@ export type DiscordVoiceAutoJoinConfig = { channelId: string; }; +export type DiscordVoiceAllowedChannelConfig = { + /** Guild ID that owns the voice channel. */ + guildId: string; + /** Voice channel ID allowed for realtime voice sessions. */ + channelId: string; +}; + export type DiscordVoiceMode = "stt-tts" | "agent-proxy" | "bidi"; export type DiscordVoiceRealtimeConsultPolicy = "auto" | "always"; @@ -178,6 +185,8 @@ export type DiscordVoiceConfig = { realtime?: DiscordVoiceRealtimeConfig; /** Voice channels to auto-join on startup. */ autoJoin?: DiscordVoiceAutoJoinConfig[]; + /** Voice channels the bot is allowed to join or remain in. Unset means any voice channel is allowed. */ + allowedChannels?: DiscordVoiceAllowedChannelConfig[]; /** Enable/disable DAVE end-to-end encryption (default: true; Discord may require this). */ daveEncryption?: boolean; /** Consecutive decrypt failures before DAVE session reinitialization (default: 24). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 5e3f0c85390..ca4bfe0ecbc 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -540,6 +540,13 @@ const DiscordVoiceAutoJoinSchema = z }) .strict(); +const DiscordVoiceAllowedChannelSchema = z + .object({ + guildId: z.string().min(1), + channelId: z.string().min(1), + }) + .strict(); + const DiscordVoiceRealtimeToolPolicySchema = z.enum(["safe-read-only", "owner", "none"]); const DiscordVoiceRealtimeConsultPolicySchema = z.enum(["auto", "always"]); const DiscordVoiceRealtimeSchema = z @@ -581,6 +588,7 @@ const DiscordVoiceSchema = z model: z.string().min(1).optional(), realtime: DiscordVoiceRealtimeSchema.optional(), autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(), + allowedChannels: z.array(DiscordVoiceAllowedChannelSchema).optional(), daveEncryption: z.boolean().optional(), decryptionFailureTolerance: z.number().int().min(0).optional(), connectTimeoutMs: z.number().int().positive().max(120_000).optional(), diff --git a/src/cron/run-diagnostics.test.ts b/src/cron/run-diagnostics.test.ts index 5e3546a380d..86d6a89ca45 100644 --- a/src/cron/run-diagnostics.test.ts +++ b/src/cron/run-diagnostics.test.ts @@ -21,7 +21,7 @@ describe("cron run diagnostics", () => { expect(diagnostics?.entries).toHaveLength(10); expect(diagnostics?.entries[0]?.message).toBe("entry 2"); - expect(diagnostics?.entries.at(-1)?.message).toMatch(/…$/); + expect(diagnostics?.entries.at(-1)?.message.endsWith("…")).toBe(true); expect(diagnostics?.entries.at(-1)?.message).not.toContain("sk-1234567890abcdef"); expect(diagnostics?.entries.at(-1)?.truncated).toBe(true); expect(diagnostics?.summary).toHaveLength(2_000); @@ -47,7 +47,8 @@ describe("cron run diagnostics", () => { expect(diagnostics?.entries).toHaveLength(10); expect(diagnostics?.entries.map((entry) => entry.message)).not.toContain("tool warning 0"); - expect(diagnostics?.entries.at(-1)).toMatchObject({ + expect(diagnostics?.entries.at(-1)).toEqual({ + ts: 11, source: "delivery", severity: "error", message: "delivery failed", @@ -118,8 +119,11 @@ describe("cron run diagnostics", () => { "retry limit exceeded", "SYSTEM_RUN_DENIED", ]); - expect(diagnostics?.entries[1]).toMatchObject({ + expect(diagnostics?.entries[1]).toEqual({ + ts: 123, source: "exec", + severity: "warn", + message: "stdout\nstderr failure", toolName: "exec", exitCode: 2, }); @@ -146,26 +150,30 @@ describe("cron run diagnostics", () => { }); it("captures silent failed exec details with a fallback message", () => { - const diagnostics = createCronRunDiagnosticsFromAgentResult({ - payloads: [ - { - toolName: "exec", - details: { - status: "completed", - exitCode: 2, + const diagnostics = createCronRunDiagnosticsFromAgentResult( + { + payloads: [ + { + toolName: "exec", + details: { + status: "completed", + exitCode: 2, + }, }, - }, - ], - }); + ], + }, + { nowMs: () => 500 }, + ); expect(diagnostics?.entries).toEqual([ - expect.objectContaining({ + { + ts: 500, source: "exec", severity: "warn", message: "exec failed with exit code 2", toolName: "exec", exitCode: 2, - }), + }, ]); }); }); diff --git a/src/cron/service.armtimer-tight-loop.test.ts b/src/cron/service.armtimer-tight-loop.test.ts index a63af5cdd87..afd0a2ac771 100644 --- a/src/cron/service.armtimer-tight-loop.test.ts +++ b/src/cron/service.armtimer-tight-loop.test.ts @@ -46,6 +46,14 @@ describe("CronService - armTimer tight loop prevention", () => { .filter((d: unknown): d is number => typeof d === "number"); } + function latestTimeoutHandle(timeoutSpy: ReturnType) { + const result = timeoutSpy.mock.results.at(-1); + if (!result || result.type !== "return") { + throw new Error("Expected setTimeout to return a timer handle"); + } + return result.value; + } + function createTimerState(params: { storePath: string; now: number; @@ -90,7 +98,7 @@ describe("CronService - armTimer tight loop prevention", () => { armTimer(state); - expect(state.timer).toEqual(expect.anything()); + expect(state.timer).toBe(latestTimeoutHandle(timeoutSpy)); const delays = extractTimeoutDelays(timeoutSpy); // Before the fix, delay would be 0 (tight loop). @@ -171,7 +179,7 @@ describe("CronService - armTimer tight loop prevention", () => { armTimer(state); - expect(state.timer).toEqual(expect.anything()); + expect(state.timer).toBe(latestTimeoutHandle(timeoutSpy)); const delays = extractTimeoutDelays(timeoutSpy); expect(delays).toContain(60_000); @@ -208,7 +216,7 @@ describe("CronService - armTimer tight loop prevention", () => { await onTimer(state); expect(state.running).toBe(false); - expect(state.timer).toEqual(expect.anything()); + expect(state.timer).toBe(latestTimeoutHandle(timeoutSpy)); // The re-armed timer must NOT use delay=0. It should use at least // MIN_REFIRE_GAP_MS to prevent the hot-loop. diff --git a/src/cron/service/jobs.schedule-error-isolation.test.ts b/src/cron/service/jobs.schedule-error-isolation.test.ts index c0933b9249f..268ea920292 100644 --- a/src/cron/service/jobs.schedule-error-isolation.test.ts +++ b/src/cron/service/jobs.schedule-error-isolation.test.ts @@ -111,12 +111,13 @@ describe("cron schedule error isolation", () => { recomputeNextRuns(state); expect(state.deps.log.warn).toHaveBeenCalledWith( - expect.objectContaining({ + { jobId: "bad-job", name: "Bad Job", errorCount: 1, - }), - expect.stringContaining("failed to compute next run"), + err: "TypeError: CronPattern: invalid configuration format ('not valid'), exactly five, six, or seven space separated parts are required.", + }, + "cron: failed to compute next run for job (skipping)", ); }); @@ -135,12 +136,13 @@ describe("cron schedule error isolation", () => { expect(badJob.enabled).toBe(false); expect(badJob.state.scheduleErrorCount).toBe(3); expect(state.deps.log.error).toHaveBeenCalledWith( - expect.objectContaining({ + { jobId: "bad-job", name: "Bad Job", errorCount: 3, - }), - expect.stringContaining("auto-disabled job"), + err: "TypeError: CronPattern: invalid configuration format ('garbage'), exactly five, six, or seven space separated parts are required.", + }, + "cron: auto-disabled job after repeated schedule errors", ); }); diff --git a/src/cron/session-reaper.test.ts b/src/cron/session-reaper.test.ts index d093f1e5104..f98eaa92256 100644 --- a/src/cron/session-reaper.test.ts +++ b/src/cron/session-reaper.test.ts @@ -116,17 +116,23 @@ describe("sweepCronRunSessions", () => { expect(result.pruned).toBe(2); const updated = JSON.parse(fs.readFileSync(storePath, "utf-8")); - expect(updated["agent:main:cron:job1"]).toMatchObject({ sessionId: "base-session" }); - expect(updated["agent:main:cron:job1:run:old-run"]).toBeUndefined(); - expect(updated["agent:main:cron:job1:run:old-run:subagent:worker"]).toBeUndefined(); - expect(updated["agent:main:cron:job1:run:recent-run"]).toMatchObject({ - sessionId: "recent-run", - }); - expect(updated["agent:main:cron:job1:run:recent-run:thread:reply"]).toMatchObject({ - sessionId: "recent-run-thread", - }); - expect(updated["agent:main:telegram:dm:123"]).toMatchObject({ - sessionId: "regular-session", + expect(updated).toEqual({ + "agent:main:cron:job1": { + sessionId: "base-session", + updatedAt: now, + }, + "agent:main:cron:job1:run:recent-run": { + sessionId: "recent-run", + updatedAt: now - 1 * 3_600_000, + }, + "agent:main:cron:job1:run:recent-run:thread:reply": { + sessionId: "recent-run-thread", + updatedAt: now - 1 * 3_600_000, + }, + "agent:main:telegram:dm:123": { + sessionId: "regular-session", + updatedAt: now - 100 * 3_600_000, + }, }); }); diff --git a/src/daemon/schtasks.install.test.ts b/src/daemon/schtasks.install.test.ts index 7cef5c81095..4b116bf6a49 100644 --- a/src/daemon/schtasks.install.test.ts +++ b/src/daemon/schtasks.install.test.ts @@ -103,7 +103,7 @@ describe("installScheduledTask", () => { expect(script).not.toContain("set OC_INJECT="); const parsed = await readScheduledTaskCommand(env); - expect(parsed).toMatchObject({ + expect(parsed).toStrictEqual({ programArguments: [ "node", "gateway.js", @@ -115,15 +115,22 @@ describe("installScheduledTask", () => { "!token!", ], workingDirectory: "C:\\temp\\poc&calc", + environment: { + OC_INJECT: "safe & whoami | calc", + OC_CARET: "a^b", + OC_PERCENT: "%TEMP%", + OC_BANG: "!token!", + OC_QUOTE: 'he said "hi"', + }, + environmentValueSources: { + OC_INJECT: "inline", + OC_CARET: "inline", + OC_PERCENT: "inline", + OC_BANG: "inline", + OC_QUOTE: "inline", + }, + sourcePath: scriptPath, }); - expect(parsed?.environment).toMatchObject({ - OC_INJECT: "safe & whoami | calc", - OC_CARET: "a^b", - OC_PERCENT: "%TEMP%", - OC_BANG: "!token!", - OC_QUOTE: 'he said "hi"', - }); - expect(parsed?.environment).not.toHaveProperty("OC_EMPTY"); expect(schtasksCalls[0]).toEqual(["/Query"]); expect(schtasksCalls[1]).toEqual(["/Query", "/TN", "OpenClaw Gateway"]); @@ -258,11 +265,18 @@ describe("installScheduledTask", () => { }); const command = await readScheduledTaskCommand(env); - expect(command?.environmentValueSources).toMatchObject({ - OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline", - TAVILY_API_KEY: "inline", + expect(command).toStrictEqual({ + programArguments: ["node", "gateway.js"], + environment: { + OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "TAVILY_API_KEY", + TAVILY_API_KEY: "old-inline-value", + }, + environmentValueSources: { + OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline", + TAVILY_API_KEY: "inline", + }, + sourcePath: scriptPath, }); - expect(command?.sourcePath).toBe(scriptPath); const audit = await auditGatewayServiceConfig({ env, diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index a1586bdf360..636dc3ffe21 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -283,7 +283,8 @@ describe("auditGatewayServiceConfig", () => { const issue = audit.issues.find( (entry) => entry.code === SERVICE_AUDIT_CODES.gatewayPortMismatch, ); - expect(issue).toMatchObject({ + expect(issue).toStrictEqual({ + code: SERVICE_AUDIT_CODES.gatewayPortMismatch, message: "Gateway service port does not match current gateway config.", detail: "18789 -> 18888", level: "recommended", @@ -497,9 +498,12 @@ describe("checkTokenDrift", () => { it("detects drift when config has token but service has different token", () => { const result = checkTokenDrift({ serviceToken: "old-token", configToken: "new-token" }); - expect(result).toMatchObject({ + expect(result).toStrictEqual({ code: SERVICE_AUDIT_CODES.gatewayTokenDrift, - message: expect.stringContaining("differs from service token"), + message: + "Config token differs from service token. The daemon will use the old token after restart.", + detail: "Run `openclaw gateway install --force` to sync the token.", + level: "recommended", }); }); diff --git a/src/gateway/assistant-identity.test.ts b/src/gateway/assistant-identity.test.ts index 443ff48e2a6..98614db287b 100644 --- a/src/gateway/assistant-identity.test.ts +++ b/src/gateway/assistant-identity.test.ts @@ -16,11 +16,10 @@ describe("resolveAssistantIdentity avatar normalization", () => { }, }; - expect(resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" })).toMatchObject({ - agentId: "main", - name: "Main assistant", - avatar: "M", - }); + const identity = resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" }); + expect(identity.agentId).toBe("main"); + expect(identity.name).toBe("Main assistant"); + expect(identity.avatar).toBe("M"); }); it("prefers non-default agent identity over global ui.assistant identity", () => { @@ -36,13 +35,10 @@ describe("resolveAssistantIdentity avatar normalization", () => { }, }; - expect(resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" })).toMatchObject( - { - agentId: "fs-daying", - name: "大颖", - avatar: "D", - }, - ); + const identity = resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" }); + expect(identity.agentId).toBe("fs-daying"); + expect(identity.name).toBe("大颖"); + expect(identity.avatar).toBe("D"); }); it("falls back to ui.assistant identity for non-default agents without their own identity", () => { @@ -58,11 +54,10 @@ describe("resolveAssistantIdentity avatar normalization", () => { }, }; - expect(resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" })).toMatchObject({ - agentId: "worker", - name: "Main assistant", - avatar: "M", - }); + const identity = resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" }); + expect(identity.agentId).toBe("worker"); + expect(identity.name).toBe("Main assistant"); + expect(identity.avatar).toBe("M"); }); it("drops sentence-like avatar placeholders", () => { diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 99271e4242b..9c56f9fec27 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -136,12 +136,8 @@ describe("runBootOnce", () => { expect(agentCommand).toHaveBeenCalledTimes(1); const call = agentCommand.mock.calls[0]?.[0]; - expect(call).toEqual( - expect.objectContaining({ - deliver: false, - sessionKey: resolveMainSessionKey({}), - }), - ); + expect(call?.deliver).toBe(false); + expect(call?.sessionKey).toBe(resolveMainSessionKey({})); expect(call?.message).toContain("BOOT.md:"); expect(call?.message).toContain(content); expect(call?.message).toContain("NO_REPLY"); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 931cbe8dda3..23b3eb08864 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -894,12 +894,16 @@ describe("callGateway error details", () => { expect(err?.message).toContain("Source: local loopback"); expect(err?.message).toContain("Bind: loopback"); expect(isGatewayTransportError(err)).toBe(true); - expect(err).toMatchObject({ - name: "GatewayTransportError", - kind: "closed", - code: 1006, - reason: "no close reason", - }); + const transportError = err as { + name?: string; + kind?: string; + code?: number; + reason?: string; + }; + expect(transportError.name).toBe("GatewayTransportError"); + expect(transportError.kind).toBe("closed"); + expect(transportError.code).toBe(1006); + expect(transportError.reason).toBe("no close reason"); }); it("keeps the request alive through internally retried startup-unavailable handshakes", async () => { @@ -944,11 +948,10 @@ describe("callGateway error details", () => { await promise; expect(isGatewayTransportError(err)).toBe(true); - expect(err).toMatchObject({ - name: "GatewayTransportError", - kind: "timeout", - timeoutMs: 5, - }); + const transportError = err as { name?: string; kind?: string; timeoutMs?: number }; + expect(transportError.name).toBe("GatewayTransportError"); + expect(transportError.kind).toBe("timeout"); + expect(transportError.timeoutMs).toBe(5); }); it("charges event-loop readiness against the wrapper timeout", async () => { @@ -985,11 +988,15 @@ describe("callGateway error details", () => { aborted: false, }; - await expect(callGateway({ method: "health", timeoutMs: 5 })).rejects.toMatchObject({ - name: "GatewayTransportError", - kind: "timeout", - timeoutMs: 5, + let err: unknown; + await callGateway({ method: "health", timeoutMs: 5 }).catch((caught) => { + err = caught; }); + expect(isGatewayTransportError(err)).toBe(true); + const transportError = err as { name?: string; kind?: string; timeoutMs?: number }; + expect(transportError.name).toBe("GatewayTransportError"); + expect(transportError.kind).toBe("timeout"); + expect(transportError.timeoutMs).toBe(5); expect(eventLoopReadyState.calls).toHaveLength(1); expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(5); expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 3e376f9c366..9a7eebf5c5f 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { abortChatRunById, isChatStopCommandText, @@ -6,6 +6,23 @@ import { type ChatAbortControllerEntry, } from "./chat-abort.js"; +type ChatAbortPayload = { + runId: string; + sessionKey: string; + seq: number; + state: "aborted"; + stopReason?: string; + message?: { + role: "assistant"; + content: Array<{ type: "text"; text: string }>; + timestamp: number; + }; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + function createActiveEntry(sessionKey: string): ChatAbortControllerEntry { const now = Date.now(); return { @@ -64,6 +81,9 @@ describe("isChatStopCommandText", () => { describe("abortChatRunById", () => { it("broadcasts aborted payload with partial message when buffered text exists", () => { + const now = new Date("2026-01-02T03:04:05.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); const runId = "run-1"; const sessionKey = "main"; const entry = createActiveEntry(sessionKey); @@ -85,23 +105,19 @@ describe("abortChatRunById", () => { expect(ops.agentRunSeq.has("client-run-1")).toBe(false); expect(ops.broadcast).toHaveBeenCalledTimes(1); - const payload = ops.broadcast.mock.calls[0]?.[1] as Record; - expect(payload).toEqual( - expect.objectContaining({ - runId, - sessionKey, - seq: 3, - state: "aborted", - stopReason: "user", - }), - ); - expect(payload.message).toEqual( - expect.objectContaining({ + const payload = ops.broadcast.mock.calls[0]?.[1] as ChatAbortPayload; + expect(payload).toEqual({ + runId, + sessionKey, + seq: 3, + state: "aborted", + stopReason: "user", + message: { role: "assistant", content: [{ type: "text", text: " Partial reply " }], - }), - ); - expect((payload.message as { timestamp?: unknown }).timestamp).toBeGreaterThan(0); + timestamp: now.getTime(), + }, + }); expect(ops.nodeSendToSession).toHaveBeenCalledWith(sessionKey, "chat", payload); }); @@ -119,6 +135,9 @@ describe("abortChatRunById", () => { }); it("preserves partial message even when abort listeners clear buffers synchronously", () => { + const now = new Date("2026-01-02T03:04:05.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); const runId = "run-1"; const sessionKey = "main"; const entry = createActiveEntry(sessionKey); @@ -132,12 +151,18 @@ describe("abortChatRunById", () => { const result = abortChatRunById(ops, { runId, sessionKey }); expect(result).toEqual({ aborted: true }); - const payload = ops.broadcast.mock.calls[0]?.[1] as Record; - expect(payload.message).toEqual( - expect.objectContaining({ + const payload = ops.broadcast.mock.calls[0]?.[1] as ChatAbortPayload; + expect(payload).toEqual({ + runId, + sessionKey, + seq: 1, + state: "aborted", + stopReason: undefined, + message: { role: "assistant", content: [{ type: "text", text: "streamed text" }], - }), - ); + timestamp: now.getTime(), + }, + }); }); }); diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index 59ae0c6eef2..b8f164f1744 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -295,16 +295,19 @@ describe("parseMessageWithAttachments", () => { describe("parseMessageWithAttachments validation errors", () => { it("throws UnsupportedAttachmentError on empty payload", async () => { - await expect( - parseMessageWithAttachments( + let caught: unknown; + try { + await parseMessageWithAttachments( "x", [{ type: "file", mimeType: "application/pdf", fileName: "empty.pdf", content: "" }], { log: { warn: () => {} } }, - ), - ).rejects.toMatchObject({ - name: "UnsupportedAttachmentError", - reason: "empty-payload", - }); + ); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(UnsupportedAttachmentError); + expect((caught as UnsupportedAttachmentError).name).toBe("UnsupportedAttachmentError"); + expect((caught as UnsupportedAttachmentError).reason).toBe("empty-payload"); expect(saveMediaBufferMock).not.toHaveBeenCalled(); }); @@ -396,10 +399,8 @@ describe("parseMessageWithAttachments validation errors", () => { expect(parsed.images).toHaveLength(0); expect(parsed.imageOrder).toStrictEqual([]); expect(parsed.offloadedRefs).toHaveLength(1); - expect(parsed.offloadedRefs[0]).toMatchObject({ - mimeType: "application/pdf", - label: "brief.pdf", - }); + expect(parsed.offloadedRefs[0]?.mimeType).toBe("application/pdf"); + expect(parsed.offloadedRefs[0]?.label).toBe("brief.pdf"); expect(parsed.message).toBe("read this"); } finally { await cleanupOffloadedRefs(parsed.offloadedRefs); @@ -477,7 +478,9 @@ describe("parseMessageWithAttachments validation errors", () => { expect(parsed.message).toContain( "[image attachment omitted: text-only attachment limit reached]", ); - expect(logs).toContainEqual(expect.stringMatching(/offload limit 10/i)); + expect(logs).toEqual([ + "attachment dot-10.png: dropping image because text-only offload limit 10 was reached", + ]); } finally { await cleanupOffloadedRefs(parsed.offloadedRefs); } diff --git a/src/gateway/client-start-readiness.test.ts b/src/gateway/client-start-readiness.test.ts index c90527b8b00..444b1c6f250 100644 --- a/src/gateway/client-start-readiness.test.ts +++ b/src/gateway/client-start-readiness.test.ts @@ -16,7 +16,9 @@ describe("startGatewayClientWhenEventLoopReady", () => { await vi.advanceTimersByTimeAsync(1); expect(client.start).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - await expect(promise).resolves.toMatchObject({ ready: true }); + const readiness = await promise; + expect(readiness.ready).toBe(true); + expect(readiness.aborted).toBe(false); expect(client.start).toHaveBeenCalledTimes(1); }); @@ -32,7 +34,9 @@ describe("startGatewayClientWhenEventLoopReady", () => { }); controller.abort(); - await expect(promise).resolves.toMatchObject({ ready: false, aborted: true }); + const readiness = await promise; + expect(readiness.ready).toBe(false); + expect(readiness.aborted).toBe(true); expect(client.start).not.toHaveBeenCalled(); }); }); diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 8240f9193a8..d54fe00fd03 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -513,10 +513,10 @@ describe("GatewayClient request errors", () => { expect(onConnectError).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); expect(ws.lastClose).toEqual({ code: 1013, reason: "gateway starting" }); - expect(logDebugMock).toHaveBeenCalledWith(expect.stringContaining("gateway connect failed:")); - expect(logErrorMock).not.toHaveBeenCalledWith( - expect.stringContaining("gateway connect failed:"), - ); + expect(logDebugMock.mock.calls).toEqual([ + ["gateway connect failed: GatewayClientRequestError: gateway starting; retry shortly"], + ]); + expect(logErrorMock.mock.calls).toEqual([]); expect(wsInstances).toHaveLength(1); await vi.advanceTimersByTimeAsync(249); @@ -568,7 +568,7 @@ describe("GatewayClient close handling", () => { expect(getLatestWs().emitClose(1008, "unauthorized: device token mismatch")).toBeUndefined(); expect(logDebugMock).toHaveBeenCalledWith( - expect.stringContaining("failed clearing stale device-auth token"), + "failed clearing stale device-auth token for device dev-2: Error: disk unavailable", ); expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch"); client.stop(); diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index f5a3314fcd9..690959e3f1b 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -214,7 +214,7 @@ describe("handleControlUiHttpRequest", () => { }) { expect(params.handled).toBe(true); expect(params.res.statusCode).toBe(403); - expect(JSON.parse(String(params.end.mock.calls[0]?.[0] ?? ""))).toMatchObject({ + expect(JSON.parse(String(params.end.mock.calls[0]?.[0] ?? ""))).toEqual({ ok: false, error: { type: "forbidden", @@ -377,7 +377,7 @@ describe("handleControlUiHttpRequest", () => { mediaTicket?: string; mediaTicketExpiresAt?: string; }; - expect(payload).toMatchObject({ available: true }); + expect(payload.available).toBe(true); expect(payload.mediaTicket).toMatch(/^v1\./); expect(Date.parse(payload.mediaTicketExpiresAt ?? "")).not.toBeNaN(); } finally { @@ -419,7 +419,7 @@ describe("handleControlUiHttpRequest", () => { mediaTicket?: string; mediaTicketExpiresAt?: string; }; - expect(payload).toMatchObject({ available: true }); + expect(payload.available).toBe(true); expect(payload.mediaTicket).toMatch(/^v1\./); expect(Date.parse(payload.mediaTicketExpiresAt ?? "")).not.toBeNaN(); }, diff --git a/src/gateway/gateway-cli-backend.connect.test.ts b/src/gateway/gateway-cli-backend.connect.test.ts index 290edd28cda..adb7578569e 100644 --- a/src/gateway/gateway-cli-backend.connect.test.ts +++ b/src/gateway/gateway-cli-backend.connect.test.ts @@ -136,9 +136,7 @@ describe("gateway cli backend connect", () => { const health = await client.request("health", undefined, { timeoutMs: 1_000, }); - expect(health).toMatchObject({ - ok: true, - }); + expect(health.ok).toBe(true); expect(server.requests).toEqual(["connect", "health"]); } finally { await client?.stopAndWait({ timeoutMs: 1_000 }).catch(() => {}); diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 261a920668a..a5d3c2c46bf 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -90,15 +90,13 @@ describe("gateway cli backend live helpers", () => { expect(client.start).toBeTypeOf("function"); expect(client.stopAndWait).toBeTypeOf("function"); - expect(gatewayClientState.lastOptions).toMatchObject({ - url: "ws://127.0.0.1:18789", - token: "gateway-token", - clientName: GATEWAY_CLIENT_NAMES.TEST, - clientDisplayName: "vitest-live", - clientVersion: "dev", - mode: GATEWAY_CLIENT_MODES.TEST, - connectChallengeTimeoutMs: 45_000, - }); + expect(gatewayClientState.lastOptions?.url).toBe("ws://127.0.0.1:18789"); + expect(gatewayClientState.lastOptions?.token).toBe("gateway-token"); + expect(gatewayClientState.lastOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.TEST); + expect(gatewayClientState.lastOptions?.clientDisplayName).toBe("vitest-live"); + expect(gatewayClientState.lastOptions?.clientVersion).toBe("dev"); + expect(gatewayClientState.lastOptions?.mode).toBe(GATEWAY_CLIENT_MODES.TEST); + expect(gatewayClientState.lastOptions?.connectChallengeTimeoutMs).toBe(45_000); expect(gatewayClientState.lastOptions).not.toHaveProperty("requestTimeoutMs"); }); diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index af7b91a8d14..59accc8fdcc 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -461,11 +461,13 @@ describeLive("gateway live (cli backend)", () => { } else { expect(text).toContain(`CLI-BACKEND-${nonce}`); } - expect( + const injectedFileNames = resultWithMeta.meta?.systemPromptReport?.injectedWorkspaceFiles?.map( (entry) => entry.name, - ) ?? [], - ).toEqual(expect.arrayContaining(bootstrapWorkspace?.expectedInjectedFiles ?? [])); + ) ?? []; + for (const expectedFile of bootstrapWorkspace?.expectedInjectedFiles ?? []) { + expect(injectedFileNames).toContain(expectedFile); + } } if (modelSwitchTarget) { diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 7e60fefaa49..951426a5370 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -19,6 +19,7 @@ import { DEFAULT_DANGEROUS_NODE_COMMANDS, resolveNodeCommandAllowlist, } from "./node-command-policy.js"; +import type { SerializedEventPayload } from "./node-registry.js"; import type { RequestFrame } from "./protocol/index.js"; import { createGatewayBroadcaster } from "./server-broadcast.js"; import { createChatRunRegistry } from "./server-chat.js"; @@ -26,6 +27,7 @@ import { MAX_BUFFERED_BYTES } from "./server-constants.js"; import { handleNodeInvokeResult } from "./server-methods/nodes.handlers.invoke-result.js"; import type { GatewayClient as GatewayMethodClient } from "./server-methods/types.js"; import type { GatewayRequestContext, RespondFn } from "./server-methods/types.js"; +import { createGatewayNodeSessionRuntime } from "./server-node-session-runtime.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; import type { GatewayWsClient } from "./server/ws-types.js"; @@ -110,9 +112,10 @@ describe("GatewayClient", () => { const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); client.start(); const last = wsMockState.last as { url: unknown; opts: unknown } | null; + const opts = last?.opts as { maxPayload?: number } | undefined; expect(last?.url).toBe("ws://127.0.0.1:1"); - expect(last?.opts).toEqual(expect.objectContaining({ maxPayload: 25 * 1024 * 1024 })); + expect(opts?.maxPayload).toBe(25 * 1024 * 1024); }); test("does not pass an explicit direct agent for loopback control-plane WebSocket connections", () => { @@ -571,6 +574,47 @@ describe("gateway broadcaster", () => { ]); }); + it("reuses the same payload shape while assigning per-client seq values", () => { + const firstSocket = makeRecordingSocket(); + const secondSocket = makeRecordingSocket(); + const thirdSocket = makeRecordingSocket(); + const clients = new Set([ + makeGatewayWsClient("c-1", firstSocket, { + role: "operator", + scopes: ["operator.read"], + } as GatewayWsClient["connect"]), + makeGatewayWsClient("c-2", secondSocket, { + role: "operator", + scopes: ["operator.write"], + } as GatewayWsClient["connect"]), + makeGatewayWsClient("c-3", thirdSocket, { + role: "operator", + scopes: ["operator.admin"], + } as GatewayWsClient["connect"]), + ]); + const payloadKeys: string[] = []; + const payload = { + toJSON(key: string) { + payloadKeys.push(key); + return { foo: key }; + }, + }; + + const { broadcast } = createGatewayBroadcaster({ clients }); + broadcast("talk.mode", { enabled: true }); + broadcast("chat", payload); + + expect(payloadKeys).toEqual(["payload"]); + expect(firstSocket.sent.at(-1)?.payload).toEqual({ foo: "payload" }); + expect(secondSocket.sent.at(-1)?.payload).toEqual({ foo: "payload" }); + expect(thirdSocket.sent.at(-1)?.payload).toEqual({ foo: "payload" }); + expect([ + firstSocket.sent.at(-1)?.seq, + secondSocket.sent.at(-1)?.seq, + thirdSocket.sent.at(-1)?.seq, + ]).toEqual([1, 2, 2]); + }); + it("preserves seq gaps when dropIfSlow skips an eligible broadcast", () => { const slowReadSocket = makeRecordingSocket(); slowReadSocket.bufferedAmount = Number.MAX_SAFE_INTEGER; @@ -621,16 +665,13 @@ describe("gateway broadcaster", () => { broadcast("chat", { sessionKey: "agent:main:main", message: "secret" }, { dropIfSlow: true }); broadcast("heartbeat", { ts: 1 }); - expect(events).toContainEqual( - expect.objectContaining({ - type: "payload.large", - surface: "gateway.ws.outbound_buffer", - action: "rejected", - bytes: MAX_BUFFERED_BYTES + 1, - limitBytes: MAX_BUFFERED_BYTES, - reason: "ws_send_buffer_drop", - }), - ); + const payloadEvent = events.find((event) => event.type === "payload.large"); + expect(payloadEvent?.type).toBe("payload.large"); + expect(payloadEvent?.surface).toBe("gateway.ws.outbound_buffer"); + expect(payloadEvent?.action).toBe("rejected"); + expect(payloadEvent?.bytes).toBe(MAX_BUFFERED_BYTES + 1); + expect(payloadEvent?.limitBytes).toBe(MAX_BUFFERED_BYTES); + expect(payloadEvent?.reason).toBe("ws_send_buffer_drop"); expect( events.reduce((count, event) => count + (event.type === "payload.large" ? 1 : 0), 0), ).toBe(1); @@ -710,10 +751,13 @@ describe("node subscription manager", () => { const sent: Array<{ nodeId: string; event: string; - payloadJSON?: string | null; + payloadJSON?: SerializedEventPayload | null; }> = []; - const sendEvent = (evt: { nodeId: string; event: string; payloadJSON?: string | null }) => - sent.push(evt); + const sendEvent = (evt: { + nodeId: string; + event: string; + payloadJSON?: SerializedEventPayload | null; + }) => sent.push(evt); manager.subscribe("node-a", "main"); manager.subscribe("node-b", "main"); @@ -724,6 +768,45 @@ describe("node subscription manager", () => { expect(sent[0].event).toBe("chat"); }); + test("runtime forwards subscribed node payload json without parsing it again", () => { + const frames: string[] = []; + const socket: TestSocket = { + bufferedAmount: 0, + send: vi.fn((payload: string) => frames.push(payload)), + close: vi.fn(), + }; + const parseSpy = vi.spyOn(JSON, "parse"); + try { + const runtime = createGatewayNodeSessionRuntime({ broadcast: vi.fn() }); + runtime.nodeRegistry.register( + makeGatewayWsClient("conn-node-a", socket, { + role: "node", + scopes: [], + client: { + id: "node-client", + version: "1.0.0", + platform: "darwin", + mode: "node", + }, + device: { id: "node-a" }, + } as unknown as GatewayWsClient["connect"]), + {}, + ); + runtime.nodeSubscribe("node-a", "main"); + + runtime.nodeSendToSession("main", "chat", { ok: true }); + + expect(parseSpy).not.toHaveBeenCalled(); + } finally { + parseSpy.mockRestore(); + } + expect(JSON.parse(frames[0] ?? "{}")).toEqual({ + type: "event", + event: "chat", + payload: { ok: true }, + }); + }); + test("unsubscribeAll clears session mappings", () => { const manager = createNodeSubscriptionManager(); const sent: string[] = []; diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 6a1d79ad425..fb6e74e5779 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -1358,10 +1358,8 @@ describe("sanitizeAuthProfileStoreForLiveGateway", () => { try { const sanitized = sanitizeAuthProfileStoreForLiveGateway(store); expect(sanitized.profiles.openaiProfile).toBeUndefined(); - expect(sanitized.profiles.codexProfile).toMatchObject({ - type: "oauth", - provider: "openai-codex", - }); + expect(sanitized.profiles.codexProfile?.type).toBe("oauth"); + expect(sanitized.profiles.codexProfile?.provider).toBe("openai-codex"); expect(sanitized.order).toEqual({ "openai-codex": ["codexProfile"] }); expect(sanitized.lastGood).toEqual({ "openai-codex": "codexProfile" }); expect(sanitized.usageStats).toEqual({ codexProfile: { lastUsed: 2 } }); diff --git a/src/gateway/gateway-trajectory-export.live.test.ts b/src/gateway/gateway-trajectory-export.live.test.ts index 062bc8ab706..21f8e51fb90 100644 --- a/src/gateway/gateway-trajectory-export.live.test.ts +++ b/src/gateway/gateway-trajectory-export.live.test.ts @@ -152,10 +152,8 @@ async function approveTrajectoryExport(client: GatewayClient): Promise { const approval = approvals.find((entry) => entry.request?.command?.includes("sessions export-trajectory"), ); - expect(approval).toMatchObject({ - id: expect.any(String), - request: { command: expect.stringContaining("sessions export-trajectory") }, - }); + expect(typeof approval?.id).toBe("string"); + expect(approval?.request?.command).toContain("sessions export-trajectory"); if (!approval?.id) { throw new Error("expected trajectory export approval id"); } @@ -285,17 +283,18 @@ describeLive("gateway live trajectory export", () => { if (finalText) { expect(finalText).toContain("Approve once"); } - expect(await listDirectoryNames(bundleDir)).toEqual( - expect.arrayContaining([ - "artifacts.json", - "events.jsonl", - "manifest.json", - "metadata.json", - "prompts.json", - "session.jsonl", - "tools.json", - ]), - ); + const bundleNames = await listDirectoryNames(bundleDir); + for (const expectedName of [ + "artifacts.json", + "events.jsonl", + "manifest.json", + "metadata.json", + "prompts.json", + "session.jsonl", + "tools.json", + ]) { + expect(bundleNames).toContain(expectedName); + } expect(beforeExport.has("bundle")).toBe(false); const manifest = JSON.parse( diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 3031b068ed8..e3e7f7e199e 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -454,13 +454,10 @@ describe("gateway hooks helpers", () => { }, } as OpenClawConfig); - expect(resolved.mappings).toMatchObject([ - { - action: "wake", - matchPath: "wake", - sessionKey: "hook:wake:{{payload.id}}", - }, - ]); + expect(resolved.mappings).toHaveLength(1); + expect(resolved.mappings[0]?.action).toBe("wake"); + expect(resolved.mappings[0]?.matchPath).toBe("wake"); + expect(resolved.mappings[0]?.sessionKey).toBe("hook:wake:{{payload.id}}"); expect(resolved.sessionPolicy.allowedSessionKeyPrefixes).toBeUndefined(); }); diff --git a/src/gateway/http-endpoint-helpers.test.ts b/src/gateway/http-endpoint-helpers.test.ts index 788a48ef355..951e4c12cab 100644 --- a/src/gateway/http-endpoint-helpers.test.ts +++ b/src/gateway/http-endpoint-helpers.test.ts @@ -159,13 +159,11 @@ describe("handleGatewayPostJsonEndpoint", () => { }, ); - expect(resolveOperatorScopes).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - authMethod: "token", - trustDeclaredOperatorScopes: false, - }), - ); + const [, requestAuth] = (resolveOperatorScopes.mock.calls[0] as unknown as + | [IncomingMessage, { authMethod?: string; trustDeclaredOperatorScopes: boolean }] + | undefined) ?? [undefined, undefined]; + expect(requestAuth?.authMethod).toBe("token"); + expect(requestAuth?.trustDeclaredOperatorScopes).toBe(false); expect(result).toEqual({ body: { ok: true }, requestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false }, diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index 2b308a539d9..0471bee5f6a 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -65,15 +65,12 @@ describe("live-agent-probes", () => { exactReply: spec.name, }), ).toContain("previous OpenClaw cron MCP tool call was cancelled"); - expect(JSON.parse(spec.argsJson)).toEqual( - expect.objectContaining({ - job: expect.objectContaining({ - sessionTarget: "session:agent:codex:acp:test", - agentId: "codex", - sessionKey: "agent:codex:acp:test", - }), - }), - ); + const args = JSON.parse(spec.argsJson) as { + job?: { sessionTarget?: string; agentId?: string; sessionKey?: string }; + }; + expect(args.job?.sessionTarget).toBe("session:agent:codex:acp:test"); + expect(args.job?.agentId).toBe("codex"); + expect(args.job?.sessionKey).toBe("agent:codex:acp:test"); }); it("validates cron cli job shape for the shared live probe", () => { diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 71b6106ad50..3b8a4a0f3b4 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -1,5 +1,5 @@ import os from "node:os"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js"; import { __resetContainerCacheForTest, @@ -18,6 +18,40 @@ import { resolveHostName, } from "./net.js"; +const flyMachineEnvKeys = ["FLY_MACHINE_ID", "FLY_APP_NAME"] as const; + +function clearFlyMachineEnvForTest(): () => void { + const previousEnv = new Map<(typeof flyMachineEnvKeys)[number], string | undefined>(); + for (const key of flyMachineEnvKeys) { + previousEnv.set(key, process.env[key]); + delete process.env[key]; + } + + return () => { + for (const key of flyMachineEnvKeys) { + const value = previousEnv.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; +} + +function useClearedFlyMachineEnv() { + let restoreFlyMachineEnv: (() => void) | undefined; + + beforeEach(() => { + restoreFlyMachineEnv = clearFlyMachineEnvForTest(); + }); + + afterEach(() => { + restoreFlyMachineEnv?.(); + restoreFlyMachineEnv = undefined; + }); +} + describe("resolveHostName", () => { it.each([ { input: "localhost:18789", expected: "localhost" }, @@ -482,6 +516,8 @@ describe("isPrivateOrLoopbackHost", () => { }); describe("isContainerEnvironment", () => { + useClearedFlyMachineEnv(); + afterEach(() => { __resetContainerCacheForTest(); vi.restoreAllMocks(); @@ -515,6 +551,18 @@ describe("isContainerEnvironment", () => { expect(isContainerEnvironment()).toBe(true); }); + it("returns true on Fly Machines without Docker sentinel files", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n"); + + process.env.FLY_MACHINE_ID = "3d8d5459a03038"; + process.env.FLY_APP_NAME = "openclaw-test"; + expect(isContainerEnvironment()).toBe(true); + }); + it("returns true when /proc/1/cgroup contains docker marker", () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => { @@ -586,6 +634,8 @@ describe("isContainerEnvironment", () => { }); describe("resolveGatewayBindHost", () => { + useClearedFlyMachineEnv(); + afterEach(() => { __resetContainerCacheForTest(); vi.restoreAllMocks(); @@ -625,6 +675,8 @@ describe("resolveGatewayBindHost", () => { }); describe("defaultGatewayBindMode", () => { + useClearedFlyMachineEnv(); + afterEach(() => { __resetContainerCacheForTest(); vi.restoreAllMocks(); diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index 087d2c327cc..2a20453cebf 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -319,15 +319,20 @@ describe("sanitizeSystemRunParamsForForwarding", () => { const forwarded = result.params as Record; expect(forwarded.command).toEqual(["/usr/bin/echo", "SAFE"]); expect(forwarded.rawCommand).toBe("/usr/bin/echo SAFE"); - expect(forwarded.systemRunPlan).toEqual( - expect.objectContaining({ - argv: ["/usr/bin/echo", "SAFE"], - cwd: "/real/cwd", - commandText: "/usr/bin/echo SAFE", - agentId: "main", - sessionKey: "agent:main:main", - }), - ); + const systemRunPlan = forwarded.systemRunPlan as + | { + argv?: string[]; + cwd?: string; + commandText?: string; + agentId?: string; + sessionKey?: string; + } + | undefined; + expect(systemRunPlan?.argv).toEqual(["/usr/bin/echo", "SAFE"]); + expect(systemRunPlan?.cwd).toBe("/real/cwd"); + expect(systemRunPlan?.commandText).toBe("/usr/bin/echo SAFE"); + expect(systemRunPlan?.agentId).toBe("main"); + expect(systemRunPlan?.sessionKey).toBe("agent:main:main"); expect(forwarded.cwd).toBe("/real/cwd"); expect(forwarded.agentId).toBe("main"); expect(forwarded.sessionKey).toBe("agent:main:main"); diff --git a/src/gateway/node-registry.test.ts b/src/gateway/node-registry.test.ts index 8b334138bed..06e3cf56d0d 100644 --- a/src/gateway/node-registry.test.ts +++ b/src/gateway/node-registry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { NodeRegistry } from "./node-registry.js"; +import { NodeRegistry, serializeEventPayload } from "./node-registry.js"; import type { GatewayWsClient } from "./server/ws-types.js"; function makeClient(connId: string, nodeId: string, sent: string[] = []): GatewayWsClient { @@ -54,4 +54,34 @@ describe("gateway/node-registry", () => { expect(registry.get("node-1")).toBe(newSession); await expect(oldDisconnected).resolves.toBeInstanceOf(Error); }); + + it("sends raw event payload JSON without changing the envelope shape", () => { + const registry = new NodeRegistry(); + const frames: string[] = []; + registry.register(makeClient("conn-1", "node-1", frames), {}); + const payload = serializeEventPayload({ foo: "bar" }); + + expect(registry.sendEventRaw("node-1", "chat", payload)).toBe(true); + expect(registry.sendEventRaw("missing-node", "chat", payload)).toBe(false); + expect(registry.sendEventRaw("node-1", "heartbeat", null)).toBe(true); + expect( + registry.sendEventRaw( + "node-1", + "chat", + "not-json" as unknown as Parameters[2], + ), + ).toBe(false); + expect( + registry.sendEventRaw( + "node-1", + "chat", + '{"x":1},"seq":999' as unknown as Parameters[2], + ), + ).toBe(false); + + expect(frames).toEqual([ + '{"type":"event","event":"chat","payload":{"foo":"bar"}}', + '{"type":"event","event":"heartbeat"}', + ]); + }); }); diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 493bfb0654f..6a5e5278bfa 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -38,6 +38,30 @@ type NodeInvokeResult = { error?: { code?: string; message?: string } | null; }; +const SERIALIZED_EVENT_PAYLOAD = Symbol("openclaw.serializedEventPayload"); + +export type SerializedEventPayload = { + readonly json: string; + readonly [SERIALIZED_EVENT_PAYLOAD]: true; +}; + +export function serializeEventPayload(payload: unknown): SerializedEventPayload | null { + if (!payload) { + return null; + } + const json = JSON.stringify(payload); + return typeof json === "string" ? { json, [SERIALIZED_EVENT_PAYLOAD]: true } : null; +} + +function isSerializedEventPayload(value: unknown): value is SerializedEventPayload { + return ( + typeof value === "object" && + value !== null && + (value as { [SERIALIZED_EVENT_PAYLOAD]?: unknown })[SERIALIZED_EVENT_PAYLOAD] === true && + typeof (value as { json?: unknown }).json === "string" + ); +} + export class NodeRegistry { private nodesById = new Map(); private nodesByConn = new Map(); @@ -198,6 +222,18 @@ export class NodeRegistry { return this.sendEventToSession(node, event, payload); } + sendEventRaw( + nodeId: string, + event: string, + payloadJSON?: SerializedEventPayload | null, + ): boolean { + const node = this.nodesById.get(nodeId); + if (!node) { + return false; + } + return this.sendEventRawInternal(node, event, payloadJSON); + } + private sendEventInternal(node: NodeSession, event: string, payload: unknown): boolean { try { node.client.socket.send( @@ -213,6 +249,29 @@ export class NodeRegistry { } } + private sendEventRawInternal( + node: NodeSession, + event: string, + payloadJSON?: SerializedEventPayload | null, + ): boolean { + if ( + payloadJSON !== null && + payloadJSON !== undefined && + !isSerializedEventPayload(payloadJSON) + ) { + return false; + } + try { + const payloadFragment = payloadJSON ? `,"payload":${payloadJSON.json}` : ""; + node.client.socket.send( + `{"type":"event","event":${JSON.stringify(event)}${payloadFragment}}`, + ); + return true; + } catch { + return false; + } + } + private sendEventToSession(node: NodeSession, event: string, payload: unknown): boolean { return this.sendEventInternal(node, event, payload); } diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 8d3b2f8bd68..06c173d0657 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -7,6 +7,7 @@ import { emitAssistantTextDelta, } from "../agents/pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "../agents/pi-embedded-subscribe.js"; +import { createClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; @@ -136,6 +137,15 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { message?: string; extraSystemPrompt?: string; images?: Array<{ type: string; data: string; mimeType: string }>; + clientTools?: Array<{ + type?: string; + function?: { + name?: string; + description?: string; + parameters?: Record; + strict?: boolean; + }; + }>; senderIsOwner?: boolean; } | undefined; @@ -649,6 +659,397 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { await res.text(); } + { + mockAgentOnce([{ text: "tool choice none" }]); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: "none", + tools: [ + { + type: "function", + function: { + name: "get_time", + description: "Get current time", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "time?" }], + }); + expect(res.status).toBe(200); + const firstCall = getFirstAgentCall(); + expect(firstCall?.clientTools).toBeUndefined(); + await res.text(); + } + + { + mockAgentOnce([{ text: "tool choice auto" }]); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: "auto", + tools: [ + { + type: "function", + function: { + name: "get_time", + description: "Get current time", + parameters: { type: "object", properties: {} }, + strict: true, + }, + }, + ], + messages: [{ role: "user", content: "time?" }], + }); + expect(res.status).toBe(200); + const firstCall = getFirstAgentCall(); + const clientTools = firstCall?.clientTools ?? []; + expect(clientTools).toHaveLength(1); + expect(clientTools[0]?.type).toBe("function"); + expect(clientTools[0]?.function?.name).toBe("get_time"); + expect(clientTools[0]?.function?.strict).toBe(true); + expect(firstCall).not.toHaveProperty("toolsAllow"); + await res.text(); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: { type: "function", function: { name: "get_weather" } }, + tools: [ + { + type: "function", + function: { + name: "get_time", + description: "Get current time", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_weather", + description: "Get current weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + ], + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("not supported"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: "required", + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("tool_choice=required"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: { type: "function", function: { name: "missing_tool" } }, + tools: [ + { + type: "function", + function: { + name: "get_time", + description: "Get current time", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("not supported"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: { + type: "allowed_tools", + tools: [{ type: "function", function: { name: "x" } }], + }, + tools: [ + { + type: "function", + function: { + name: "x", + description: "x", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "x?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("allowed_tools"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tools: [ + { + type: "function", + name: "invalid_flat_shape", + parameters: { type: "object", properties: {} }, + }, + ], + messages: [{ role: "user", content: "x?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("tool.function is required"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + mockAgentOnce([{ text: "ok" }]); + const res = await postChatCompletions(port, { + model: "openclaw", + messages: [ + { role: "user", content: "What's the weather?" }, + { role: "assistant", content: "Checking the weather." }, + { + role: "tool", + tool_call_id: "call_1", + content: [{ type: "text", text: "Sunny, 70F." }], + }, + ], + }); + expect(res.status).toBe(200); + const message = getFirstAgentMessage(); + expectMessageContext(message, { + history: ["User: What's the weather?", "Assistant: Checking the weather."], + current: ["Tool:call_1: Sunny, 70F."], + }); + await res.text(); + } + + { + mockAgentOnce([{ text: "ok" }]); + const res = await postChatCompletions(port, { + model: "openclaw", + messages: [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_1", + content: [{ type: "text", text: "Sunny, 70F." }], + }, + ], + }); + expect(res.status).toBe(200); + const message = getFirstAgentMessage(); + expectMessageContext(message, { + history: [ + "User: What's the weather?", + 'Assistant: tool_call id=call_1 name=get_weather arguments={"city":"Taipei"}', + ], + current: ["Tool:call_1: Sunny, 70F."], + }); + await res.text(); + } + + { + agentCommand.mockClear(); + agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"])); + const res = await postChatCompletions(port, { + stream: false, + model: "openclaw", + tools: [ + { + type: "function", + function: { + name: "exec", + description: "conflicts with a built-in tool", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "run command" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message).toBe("invalid tool configuration"); + } + + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "Let me check that." }], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + { + id: "call_2", + name: "get_time", + arguments: "{}", + }, + ], + agentMeta: { + usage: { + input: 10, + output: 5, + total: 15, + }, + }, + }, + } as never); + const res = await postChatCompletions(port, { + stream: false, + model: "openclaw", + tool_choice: "auto", + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + { + type: "function", + function: { + name: "get_time", + description: "Get time", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { + choices?: Array<{ + finish_reason?: string | null; + message?: { + role?: string; + content?: string; + tool_calls?: Array<{ + index?: number; + id?: string; + type?: string; + function?: { name?: string; arguments?: string }; + }>; + }; + }>; + }; + const choice = json.choices?.[0]; + expect(choice?.finish_reason).toBe("tool_calls"); + expect(choice?.message?.role).toBe("assistant"); + expect(choice?.message?.content).toBe("Let me check that."); + expect(choice?.message?.tool_calls).toEqual([ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: '{"city":"Taipei"}' }, + }, + { + id: "call_2", + type: "function", + function: { name: "get_time", arguments: "{}" }, + }, + ]); + expect(choice?.message?.tool_calls?.some((call) => Object.hasOwn(call, "index"))).toBe( + false, + ); + } + + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + ], + }, + } as never); + const res = await postChatCompletions(port, { + stream: false, + model: "openclaw", + tool_choice: "auto", + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { + choices?: Array<{ + finish_reason?: string | null; + message?: { content?: string; tool_calls?: unknown[] }; + }>; + }; + const choice = json.choices?.[0]; + expect(choice?.finish_reason).toBe("tool_calls"); + expect(choice?.message?.content).toBe(""); + expect(choice?.message?.tool_calls).toHaveLength(1); + } + { mockAgentOnce([{ text: "hello" }]); const json = await postSyncUserMessage("hi"); @@ -999,6 +1400,221 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(fallbackText).toContain("hello"); } + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "Let me check that." }], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + ], + }, + } as never); + + const toolCallRes = await postChatCompletions(port, { + stream: true, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(toolCallRes.status).toBe(200); + const toolCallText = await toolCallRes.text(); + const toolCallData = parseSseDataLines(toolCallText); + const toolCallChunks = toolCallData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const toolDeltaChunks = toolCallChunks.filter((chunk) => { + const choice = ((chunk.choices as Array> | undefined) ?? [])[0]; + const delta = (choice?.delta as Record | undefined) ?? {}; + return Array.isArray(delta.tool_calls); + }); + expect(toolDeltaChunks.length).toBeGreaterThan(0); + const toolCallDeltaRecords = toolDeltaChunks.flatMap((chunk) => { + const choice = ((chunk.choices as Array> | undefined) ?? [])[0]; + const delta = (choice?.delta as Record | undefined) ?? {}; + return (delta.tool_calls as Array> | undefined) ?? []; + }); + const withIdentity = toolCallDeltaRecords.find( + (record) => + record.id === "call_1" && + record.type === "function" && + ((record.function as Record | undefined)?.name as + | string + | undefined) === "get_weather", + ); + expect(withIdentity).toBeTruthy(); + const argsJoined = toolCallDeltaRecords + .filter((record) => record.index === 0) + .map( + (record) => + ((record.function as Record | undefined)?.arguments as + | string + | undefined) ?? "", + ) + .join(""); + expect(argsJoined).toBe('{"city":"Taipei"}'); + const finishChunk = toolCallChunks + .flatMap((chunk) => (chunk.choices as Array> | undefined) ?? []) + .find((choice) => choice.finish_reason === "tool_calls"); + expect(finishChunk).toBeTruthy(); + } + + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "Let me check that." }], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + ], + agentMeta: { + usage: { + input: 12, + output: 3, + total: 15, + }, + }, + }, + } as never); + + const toolCallUsageRes = await postChatCompletions(port, { + stream: true, + stream_options: { include_usage: true }, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(toolCallUsageRes.status).toBe(200); + const toolCallUsageText = await toolCallUsageRes.text(); + const toolCallUsageData = parseSseDataLines(toolCallUsageText); + const jsonChunks = toolCallUsageData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const usageChunk = jsonChunks.find((chunk) => "usage" in chunk); + expect(usageChunk).toBeTruthy(); + expect(usageChunk?.choices).toEqual([]); + expect(usageChunk?.usage).toEqual({ + prompt_tokens: 12, + completion_tokens: 3, + total_tokens: 15, + }); + expect(toolCallUsageData[toolCallUsageData.length - 1]).toBe("[DONE]"); + } + + { + agentCommand.mockClear(); + let resolveLateToolCall: + | ((result: { + payloads: Array<{ text: string }>; + meta: { + stopReason: string; + pendingToolCalls: Array<{ id: string; name: string; arguments: string }>; + }; + }) => void) + | undefined; + agentCommand.mockImplementationOnce( + ((opts: unknown) => + new Promise((resolve) => { + resolveLateToolCall = resolve; + const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; + emitAgentEvent({ runId, stream: "assistant", data: { delta: "Let me check that." } }); + emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }); + })) as never, + ); + + const lateToolCallRes = await postChatCompletions(port, { + stream: true, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(lateToolCallRes.status).toBe(200); + const lateToolCallTextPromise = lateToolCallRes.text(); + const earlyCompletion = await Promise.race([ + lateToolCallTextPromise.then(() => "completed" as const), + new Promise<"pending">((resolve) => { + setTimeout(() => resolve("pending"), 1200); + }), + ]); + expect(earlyCompletion).toBe("pending"); + + resolveLateToolCall?.({ + payloads: [{ text: "Let me check that." }], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + ], + }, + }); + const lateToolCallText = await lateToolCallTextPromise; + const lateToolCallData = parseSseDataLines(lateToolCallText); + const lateToolCallChunks = lateToolCallData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const finishChunk = lateToolCallChunks + .flatMap((chunk) => (chunk.choices as Array> | undefined) ?? []) + .find((choice) => choice.finish_reason === "tool_calls"); + expect(finishChunk).toBeTruthy(); + const anyToolCalls = lateToolCallChunks.some((chunk) => { + const choice = ((chunk.choices as Array> | undefined) ?? [])[0]; + const delta = (choice?.delta as Record | undefined) ?? {}; + return Array.isArray(delta.tool_calls); + }); + expect(anyToolCalls).toBe(true); + } + + { + agentCommand.mockClear(); + agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"])); + + const toolConflictRes = await postChatCompletions(port, { + stream: true, + model: "openclaw", + tools: [ + { + type: "function", + function: { + name: "exec", + description: "conflicts with a built-in tool", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "run command" }], + }); + expect(toolConflictRes.status).toBe(200); + const toolConflictText = await toolConflictRes.text(); + const toolConflictData = parseSseDataLines(toolConflictText); + expect(toolConflictData[toolConflictData.length - 1]).toBe("[DONE]"); + + const toolConflictChunks = toolConflictData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const protocolError = toolConflictChunks.find( + (chunk) => + typeof chunk.error === "object" && + ((chunk.error as { type?: unknown }).type ?? "") === "invalid_request_error" && + ((chunk.error as { message?: unknown }).message ?? "") === "invalid tool configuration", + ); + expect(protocolError).toBeTruthy(); + const stopChoice = toolConflictChunks + .flatMap((c) => (c.choices as Array> | undefined) ?? []) + .find((choice) => choice.finish_reason === "stop"); + expect(stopChoice).toBeUndefined(); + } + { agentCommand.mockClear(); agentCommand.mockRejectedValueOnce(new Error("boom")); @@ -1297,15 +1913,18 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { }, ); - it("does not block stream finalization on usage when include_usage is not requested", async () => { + it("does not require usage to finalize when include_usage is not requested", async () => { const port = enabledPort; agentCommand.mockClear(); agentCommand.mockImplementationOnce( ((opts: unknown) => - new Promise(() => { + new Promise((resolve) => { const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; emitAgentEvent({ runId, stream: "assistant", data: { delta: "hello" } }); emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }); + setTimeout(() => { + resolve({ payloads: [{ text: "hello" }] }); + }, 100); })) as never, ); diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index ab3153a4a23..d186270bdfd 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ClientToolDefinition } from "../agents/command/shared-types.js"; import type { ImageContent } from "../agents/command/types.js"; +import { isClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { hasNonzeroUsage, normalizeUsage, @@ -58,6 +60,8 @@ type OpenAiChatMessage = { role?: unknown; content?: unknown; name?: unknown; + tool_call_id?: unknown; + tool_calls?: unknown; }; type OpenAiChatCompletionRequest = { @@ -65,6 +69,8 @@ type OpenAiChatCompletionRequest = { stream?: unknown; // Naming/style reference: src/agents/openai-transport-stream.ts:1262-1273 stream_options?: unknown; + tools?: unknown; + tool_choice?: unknown; messages?: unknown; user?: unknown; }; @@ -119,6 +125,7 @@ function writeSse(res: ServerResponse, data: unknown) { function buildAgentCommandInput(params: { prompt: { message: string; extraSystemPrompt?: string; images?: ImageContent[] }; + clientTools?: ClientToolDefinition[]; modelOverride?: string; sessionKey: string; runId: string; @@ -130,6 +137,7 @@ function buildAgentCommandInput(params: { message: params.prompt.message, extraSystemPrompt: params.prompt.extraSystemPrompt, images: params.prompt.images, + clientTools: params.clientTools, model: params.modelOverride, sessionKey: params.sessionKey, runId: params.runId, @@ -142,6 +150,72 @@ function buildAgentCommandInput(params: { }; } +function extractClientToolsFromChatRequest(tools: unknown): ClientToolDefinition[] { + if (tools == null) { + return []; + } + if (!Array.isArray(tools)) { + throw new Error("tools must be an array"); + } + const clientTools: ClientToolDefinition[] = []; + for (const tool of tools) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) { + throw new Error("each tool must be an object"); + } + if ((tool as { type?: unknown }).type !== "function") { + throw new Error("only function tools are supported"); + } + const functionValue = (tool as { function?: unknown }).function; + if (!functionValue || typeof functionValue !== "object" || Array.isArray(functionValue)) { + throw new Error("tool.function is required"); + } + const rawName = (functionValue as { name?: unknown }).name; + const name = typeof rawName === "string" ? rawName.trim() : ""; + if (!name) { + throw new Error("tool.function.name is required"); + } + const description = (functionValue as { description?: unknown }).description; + const parameters = (functionValue as { parameters?: unknown }).parameters; + const strict = (functionValue as { strict?: unknown }).strict; + clientTools.push({ + type: "function", + function: { + name, + ...(typeof description === "string" ? { description } : {}), + ...(parameters && typeof parameters === "object" && !Array.isArray(parameters) + ? { parameters: parameters as Record } + : {}), + ...(typeof strict === "boolean" ? { strict } : {}), + }, + }); + } + return clientTools; +} + +function applyChatToolChoice(params: { tools: ClientToolDefinition[]; toolChoice: unknown }): { + tools: ClientToolDefinition[]; + extraSystemPrompt?: string; +} { + const { tools, toolChoice } = params; + if (toolChoice == null || toolChoice === "auto") { + return { tools }; + } + if (toolChoice === "none") { + return { tools: [] }; + } + if (toolChoice === "required") { + throw new Error("tool_choice=required is not supported"); + } + if (typeof toolChoice !== "object" || Array.isArray(toolChoice)) { + throw new Error("tool_choice must be a string or object"); + } + const choiceType = (toolChoice as { type?: unknown }).type; + if (typeof choiceType !== "string") { + throw new Error("unsupported tool_choice type"); + } + throw new Error(`tool_choice ${choiceType} is not supported`); +} + function writeAssistantRoleChunk(res: ServerResponse, params: { runId: string; model: string }) { writeSse(res, { id: params.runId, @@ -171,7 +245,10 @@ function writeAssistantContentChunk( }); } -function writeAssistantStopChunk(res: ServerResponse, params: { runId: string; model: string }) { +function writeAssistantFinishChunk( + res: ServerResponse, + params: { runId: string; model: string; finishReason: "stop" | "tool_calls" }, +) { writeSse(res, { id: params.runId, object: "chat.completion.chunk", @@ -181,12 +258,81 @@ function writeAssistantStopChunk(res: ServerResponse, params: { runId: string; m { index: 0, delta: {}, - finish_reason: "stop", + finish_reason: params.finishReason, }, ], }); } +function splitArgumentsForStreaming(argumentsValue: string): string[] { + if (!argumentsValue) { + return [""]; + } + const chunkSize = 256; + const chunks: string[] = []; + for (let i = 0; i < argumentsValue.length; i += chunkSize) { + chunks.push(argumentsValue.slice(i, i + chunkSize)); + } + return chunks.length > 0 ? chunks : [""]; +} + +function writeAssistantToolCallsIncrementalChunks( + res: ServerResponse, + params: { + runId: string; + model: string; + toolCalls: Array<{ id: string; name: string; arguments: string }>; + }, +) { + for (const [index, call] of params.toolCalls.entries()) { + writeSse(res, { + id: params.runId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: params.model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index, + id: call.id, + type: "function", + function: { name: call.name, arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + + for (const argsDelta of splitArgumentsForStreaming(call.arguments)) { + writeSse(res, { + id: params.runId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: params.model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index, + function: { arguments: argsDelta }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + } + } +} + function writeUsageChunk( res: ServerResponse, params: { @@ -239,6 +385,59 @@ function extractTextContent(content: unknown): string { return ""; } +type AssistantToolCall = { + id: string; + name: string; + arguments: string; +}; + +function stringifyToolCallArguments(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value == null) { + return ""; + } + try { + const serialized = JSON.stringify(value); + return typeof serialized === "string" ? serialized : ""; + } catch { + return ""; + } +} + +function extractAssistantToolCalls(value: unknown): AssistantToolCall[] { + if (!Array.isArray(value)) { + return []; + } + const calls: AssistantToolCall[] = []; + for (const rawCall of value) { + if (!rawCall || typeof rawCall !== "object" || Array.isArray(rawCall)) { + continue; + } + const id = normalizeOptionalString((rawCall as { id?: unknown }).id) ?? ""; + const functionValue = (rawCall as { function?: unknown }).function; + if (!functionValue || typeof functionValue !== "object" || Array.isArray(functionValue)) { + continue; + } + const name = normalizeOptionalString((functionValue as { name?: unknown }).name) ?? ""; + if (!id || !name) { + continue; + } + const argumentsValue = stringifyToolCallArguments( + (functionValue as { arguments?: unknown }).arguments, + ); + calls.push({ id, name, arguments: argumentsValue }); + } + return calls; +} + +function renderAssistantToolCalls(calls: AssistantToolCall[]): string { + return calls + .map((call) => `tool_call id=${call.id} name=${call.name} arguments=${call.arguments}`) + .join("\n"); +} + function resolveImageUrlPart(part: unknown): string | undefined { if (!part || typeof part !== "object") { return undefined; @@ -410,26 +609,36 @@ function buildAgentPrompt( if (normalizedRole !== "user" && normalizedRole !== "assistant" && normalizedRole !== "tool") { continue; } + const assistantToolCalls = + normalizedRole === "assistant" ? extractAssistantToolCalls(msg.tool_calls) : []; + const assistantToolCallsSummary = + assistantToolCalls.length > 0 ? renderAssistantToolCalls(assistantToolCalls) : ""; // Keep the image-only placeholder scoped to the active user turn so we don't // mention historical image-only turns whose bytes are intentionally not replayed. - const messageContent = + const baseMessageContent = normalizedRole === "user" && !content && hasImage && i === activeUserMessageIndex ? IMAGE_ONLY_USER_MESSAGE : content; + const messageContent = [baseMessageContent, assistantToolCallsSummary] + .filter((part): part is string => Boolean(part)) + .join("\n"); if (!messageContent) { continue; } const name = normalizeOptionalString(msg.name) ?? ""; + const toolCallId = normalizeOptionalString(msg.tool_call_id) ?? ""; const sender = normalizedRole === "assistant" ? "Assistant" : normalizedRole === "user" ? "User" - : name - ? `Tool:${name}` - : "Tool"; + : toolCallId + ? `Tool:${toolCallId}` + : name + ? `Tool:${name}` + : "Tool"; conversationEntries.push({ role: normalizedRole, @@ -464,6 +673,17 @@ function resolveAgentResponseText(result: unknown): string { return content || "No response from OpenClaw."; } +function resolveAgentResponseCommentary(result: unknown): string { + const payloads = (result as { payloads?: Array<{ text?: string }> } | null)?.payloads; + if (!Array.isArray(payloads) || payloads.length === 0) { + return ""; + } + return payloads + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n"); +} + type AgentUsageMeta = { input?: number; output?: number; @@ -472,6 +692,12 @@ type AgentUsageMeta = { total?: number; }; +type PendingToolCall = { + id?: unknown; + name?: unknown; + arguments?: unknown; +}; + function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { const agentMeta = ( result as { @@ -494,6 +720,38 @@ function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { return primary ?? fallback; } +function resolveStopReasonAndPendingToolCalls(meta: unknown): { + stopReason: string | undefined; + pendingToolCalls: Array<{ id: string; name: string; arguments: string }> | undefined; +} { + if (!meta || typeof meta !== "object" || Array.isArray(meta)) { + return { stopReason: undefined, pendingToolCalls: undefined }; + } + const stopReasonRaw = (meta as { stopReason?: unknown }).stopReason; + const stopReason = typeof stopReasonRaw === "string" ? stopReasonRaw : undefined; + const pendingRaw = (meta as { pendingToolCalls?: unknown }).pendingToolCalls; + if (!Array.isArray(pendingRaw)) { + return { stopReason, pendingToolCalls: undefined }; + } + const pendingToolCalls: Array<{ id: string; name: string; arguments: string }> = []; + for (const call of pendingRaw as PendingToolCall[]) { + const id = typeof call?.id === "string" ? call.id.trim() : ""; + const name = typeof call?.name === "string" ? call.name.trim() : ""; + const argsValue = call?.arguments; + const argumentsValue = + typeof argsValue === "string" + ? argsValue + : argsValue == null + ? "" + : JSON.stringify(argsValue); + if (!id || !name) { + continue; + } + pendingToolCalls.push({ id, name, arguments: argumentsValue }); + } + return { stopReason, pendingToolCalls }; +} + function resolveChatCompletionUsage(result: unknown): { prompt_tokens: number; completion_tokens: number; @@ -512,6 +770,16 @@ function resolveIncludeUsageForStreaming(payload: OpenAiChatCompletionRequest): return (streamOptions as { include_usage?: unknown }).include_usage === true; } +function resolveErrorMessage(err: unknown): string { + if (err instanceof Error) { + const message = err.message.trim(); + if (message) { + return message; + } + } + return String(err); +} + export async function handleOpenAiHttpRequest( req: IncomingMessage, res: ServerResponse, @@ -567,6 +835,25 @@ export async function handleOpenAiHttpRequest( } const activeTurnContext = resolveActiveTurnContext(payload.messages); const prompt = buildAgentPrompt(payload.messages, activeTurnContext.activeUserMessageIndex); + let resolvedClientTools: ClientToolDefinition[] = []; + let toolChoicePrompt: string | undefined; + try { + const parsedClientTools = extractClientToolsFromChatRequest(payload.tools); + const toolChoiceResult = applyChatToolChoice({ + tools: parsedClientTools, + toolChoice: payload.tool_choice, + }); + resolvedClientTools = toolChoiceResult.tools; + toolChoicePrompt = toolChoiceResult.extraSystemPrompt; + } catch (err) { + sendJson(res, 400, { + error: { + message: `Invalid tools/tool_choice: ${resolveErrorMessage(err)}`, + type: "invalid_request_error", + }, + }); + return true; + } let images: ImageContent[] = []; try { images = await resolveImagesForRequest(activeTurnContext, limits); @@ -594,12 +881,16 @@ export async function handleOpenAiHttpRequest( const runId = `chatcmpl_${randomUUID()}`; const deps = createDefaultDeps(); const abortController = new AbortController(); + const mergedExtraSystemPrompt = [prompt.extraSystemPrompt, toolChoicePrompt] + .filter((part): part is string => Boolean(part)) + .join("\n\n"); const commandInput = buildAgentCommandInput({ prompt: { message: prompt.message, - extraSystemPrompt: prompt.extraSystemPrompt, + extraSystemPrompt: mergedExtraSystemPrompt || undefined, images: images.length > 0 ? images : undefined, }, + clientTools: resolvedClientTools.length > 0 ? resolvedClientTools : undefined, modelOverride, sessionKey, runId, @@ -617,8 +908,37 @@ export async function handleOpenAiHttpRequest( return true; } - const content = resolveAgentResponseText(result); const usage = resolveChatCompletionUsage(result); + const meta = (result as { meta?: unknown } | null)?.meta; + const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta); + + if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) { + const commentary = resolveAgentResponseCommentary(result); + sendJson(res, 200, { + id: runId, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: commentary, + tool_calls: pendingToolCalls.map((call) => ({ + id: call.id, + type: "function", + function: { name: call.name, arguments: call.arguments }, + })), + }, + finish_reason: "tool_calls", + }, + ], + usage, + }); + return true; + } + const content = resolveAgentResponseText(result); sendJson(res, 200, { id: runId, @@ -639,6 +959,12 @@ export async function handleOpenAiHttpRequest( return true; } logWarn(`openai-compat: chat completion failed: ${String(err)}`); + if (isClientToolNameConflictError(err)) { + sendJson(res, 400, { + error: { message: "invalid tool configuration", type: "invalid_request_error" }, + }); + return true; + } sendJson(res, 500, { error: { message: "internal error", type: "api_error" }, }); @@ -661,6 +987,8 @@ export async function handleOpenAiHttpRequest( } | undefined; let finalizeRequested = false; + let finalizeFinishReason: "stop" | "tool_calls" = "stop"; + let resultResolved = false; let closed = false; let stopWatchingDisconnect = () => {}; @@ -668,6 +996,9 @@ export async function handleOpenAiHttpRequest( if (closed || !finalizeRequested) { return; } + if (!resultResolved) { + return; + } if (streamIncludeUsage && !finalUsage) { return; } @@ -675,7 +1006,7 @@ export async function handleOpenAiHttpRequest( stopWatchingDisconnect(); unsubscribe(); if (!wroteStopChunk) { - writeAssistantStopChunk(res, { runId, model }); + writeAssistantFinishChunk(res, { runId, model, finishReason: finalizeFinishReason }); wroteStopChunk = true; } if (streamIncludeUsage && finalUsage) { @@ -685,7 +1016,8 @@ export async function handleOpenAiHttpRequest( res.end(); }; - const requestFinalize = () => { + const requestFinalize = (finishReason: "stop" | "tool_calls" = "stop") => { + finalizeFinishReason = finishReason; finalizeRequested = true; maybeFinalize(); }; @@ -738,12 +1070,41 @@ export async function handleOpenAiHttpRequest( void (async () => { try { const result = await agentCommandFromIngress(commandInput, defaultRuntime, deps); + resultResolved = true; if (closed) { return; } finalUsage = resolveChatCompletionUsage(result); + const meta = (result as { meta?: unknown } | null)?.meta; + const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta); + + if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) { + if (!wroteRole) { + wroteRole = true; + writeAssistantRoleChunk(res, { runId, model }); + } + if (!sawAssistantDelta) { + const commentary = resolveAgentResponseCommentary(result); + if (commentary) { + sawAssistantDelta = true; + writeAssistantContentChunk(res, { + runId, + model, + content: commentary, + finishReason: null, + }); + } + } + writeAssistantToolCallsIncrementalChunks(res, { + runId, + model, + toolCalls: pendingToolCalls, + }); + requestFinalize("tool_calls"); + return; + } if (!sawAssistantDelta) { if (!wroteRole) { @@ -763,14 +1124,27 @@ export async function handleOpenAiHttpRequest( } requestFinalize(); } catch (err) { + resultResolved = true; if (closed || abortController.signal.aborted) { return; } logWarn(`openai-compat: streaming chat completion failed: ${String(err)}`); + if (isClientToolNameConflictError(err)) { + closed = true; + stopWatchingDisconnect(); + unsubscribe(); + writeSse(res, { + error: { message: "invalid tool configuration", type: "invalid_request_error" }, + }); + writeDone(res); + res.end(); + return; + } + const content = "Error: internal error"; writeAssistantContentChunk(res, { runId, model, - content: "Error: internal error", + content, finishReason: "stop", }); wroteStopChunk = true; diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 096713c9505..bee0dd9cfad 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -51,6 +51,13 @@ const EVENT_SCOPE_GUARDS: Record = { // (e.g. reconfiguring wake-word triggers). const NODE_ALLOWED_EVENTS = new Set(["voicewake.changed", "voicewake.routing.changed"]); +function serializeFrameField(name: "payload" | "stateVersion", value: unknown): string { + const fieldJSON = JSON.stringify({ [name]: value }); + const keyJSON = JSON.stringify(name); + const prefix = `{${keyJSON}:`; + return fieldJSON.startsWith(prefix) ? `,${keyJSON}:${fieldJSON.slice(prefix.length, -1)}` : ""; +} + function hasEventScope(client: GatewayWsClient, event: string): boolean { const required = EVENT_SCOPE_GUARDS[event]; // Plugin-defined gateway broadcast events (plugin.* namespace) are allowed @@ -113,6 +120,26 @@ export function createGatewayBroadcaster(params: { clients: Set } logWs("out", "event", logMeta); } + let frameBase: + | { + eventJSON: string; + payloadFragment: string; + stateVersionFragment: string; + } + | undefined; + const getFrameBase = () => { + if (!frameBase) { + frameBase = { + eventJSON: JSON.stringify(event), + payloadFragment: serializeFrameField("payload", payload), + stateVersionFragment: + opts?.stateVersion === undefined + ? "" + : serializeFrameField("stateVersion", opts.stateVersion), + }; + } + return frameBase; + }; for (const c of params.clients) { if (targetConnIds && !targetConnIds.has(c.connId)) { continue; @@ -152,13 +179,9 @@ export function createGatewayBroadcaster(params: { clients: Set if (!isTargeted) { clientSeq.set(c, nextSeq); } - const frame = JSON.stringify({ - type: "event", - event, - payload, - seq: eventSeq, - stateVersion: opts?.stateVersion, - }); + const base = getFrameBase(); + const seqFragment = eventSeq === undefined ? "" : `,"seq":${eventSeq}`; + const frame = `{"type":"event","event":${base.eventJSON}${base.payloadFragment}${seqFragment}${base.stateVersionFragment}}`; c.socket.send(frame); } catch { /* ignore */ diff --git a/src/gateway/server-discovery-runtime.test.ts b/src/gateway/server-discovery-runtime.test.ts index 159a7e570be..937825875c0 100644 --- a/src/gateway/server-discovery-runtime.test.ts +++ b/src/gateway/server-discovery-runtime.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginGatewayDiscoveryServiceRegistration } from "../plugins/registry-types.js"; +type WriteWideAreaGatewayZone = typeof import("../infra/widearea-dns.js").writeWideAreaGatewayZone; + const mocks = vi.hoisted(() => ({ pickPrimaryTailnetIPv4: vi.fn(() => "100.64.0.10"), pickPrimaryTailnetIPv6: vi.fn(() => undefined as string | undefined), resolveWideAreaDiscoveryDomain: vi.fn(() => "openclaw.internal."), - writeWideAreaGatewayZone: vi.fn(async () => ({ + writeWideAreaGatewayZone: vi.fn(async () => ({ changed: true, zonePath: "/tmp/openclaw.internal.db", })), @@ -218,15 +220,15 @@ describe("startGatewayDiscovery", () => { expect(service.service.advertise).not.toHaveBeenCalled(); expect(mocks.resolveTailnetDnsHint).toHaveBeenCalledWith({ enabled: true }); - expect(mocks.writeWideAreaGatewayZone).toHaveBeenCalledWith( - expect.objectContaining({ - domain: "openclaw.internal.", - gatewayPort: 18789, - displayName: "Lab Mac (OpenClaw)", - tailnetIPv4: "100.64.0.10", - tailnetDns: "gateway.tailnet.example.ts.net", - }), - ); + const [zoneParams] = mocks.writeWideAreaGatewayZone.mock.calls.at(-1) ?? []; + if (zoneParams === undefined) { + throw new Error("Expected wide-area gateway zone to be written"); + } + expect(zoneParams.domain).toBe("openclaw.internal."); + expect(zoneParams.gatewayPort).toBe(18789); + expect(zoneParams.displayName).toBe("Lab Mac (OpenClaw)"); + expect(zoneParams.tailnetIPv4).toBe("100.64.0.10"); + expect(zoneParams.tailnetDns).toBe("gateway.tailnet.example.ts.net"); expect(logs.info).toHaveBeenCalledWith(expect.stringContaining("wide-area DNS-SD updated")); expect(result.bonjourStop).toBeNull(); }); diff --git a/src/gateway/server-methods.control-plane-rate-limit.test.ts b/src/gateway/server-methods.control-plane-rate-limit.test.ts index c5608041e29..01832bc92d9 100644 --- a/src/gateway/server-methods.control-plane-rate-limit.test.ts +++ b/src/gateway/server-methods.control-plane-rate-limit.test.ts @@ -95,14 +95,11 @@ describe("gateway control-plane write rate limit", () => { const blocked = await runRequest({ method: "config.patch", context, client, handler }); expect(handlerCalls).toHaveBeenCalledTimes(3); - expect(blocked).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: "UNAVAILABLE", - retryable: true, - }), - ); + const error = blocked.mock.calls[0]?.[2] as { code?: string; retryable?: boolean } | undefined; + expect(blocked.mock.calls[0]?.[0]).toBe(false); + expect(blocked.mock.calls[0]?.[1]).toBeUndefined(); + expect(error?.code).toBe("UNAVAILABLE"); + expect(error?.retryable).toBe(true); expect(logWarn).toHaveBeenCalledTimes(1); }); @@ -120,11 +117,9 @@ describe("gateway control-plane write rate limit", () => { await runRequest({ method: "update.run", context, client, handler }); const blocked = await runRequest({ method: "update.run", context, client, handler }); - expect(blocked).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ code: "UNAVAILABLE" }), - ); + expect(blocked.mock.calls[0]?.[0]).toBe(false); + expect(blocked.mock.calls[0]?.[1]).toBeUndefined(); + expect(blocked.mock.calls[0]?.[2]?.code).toBe("UNAVAILABLE"); vi.advanceTimersByTime(60_001); @@ -150,17 +145,13 @@ describe("gateway control-plane write rate limit", () => { const blocked = await runRequest({ method, context, client, handler }); expect(handlerCalls).not.toHaveBeenCalled(); - expect(blocked).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: "UNAVAILABLE", - retryable: true, - retryAfterMs: 500, - details: { reason: "startup-sidecars", method }, - }), - ); const error = blocked.mock.calls[0]?.[2]; + expect(blocked.mock.calls[0]?.[0]).toBe(false); + expect(blocked.mock.calls[0]?.[1]).toBeUndefined(); + expect(error?.code).toBe("UNAVAILABLE"); + expect(error?.retryable).toBe(true); + expect(error?.retryAfterMs).toBe(500); + expect(error?.details).toEqual({ reason: "startup-sidecars", method }); expect(isRetryableGatewayStartupUnavailableError(error)).toBe(true); }, ); diff --git a/src/gateway/server-methods/agent-wait-dedupe.test.ts b/src/gateway/server-methods/agent-wait-dedupe.test.ts index 0564bc96c80..143a17030d5 100644 --- a/src/gateway/server-methods/agent-wait-dedupe.test.ts +++ b/src/gateway/server-methods/agent-wait-dedupe.test.ts @@ -386,16 +386,15 @@ describe("agent wait dedupe helper", () => { payload: { runId, status: "ok" }, }); - await expect(first).resolves.toEqual( - expect.objectContaining({ - status: "ok", - }), - ); - await expect(second).resolves.toEqual( - expect.objectContaining({ - status: "ok", - }), - ); + const firstResult = await first; + const secondResult = await second; + if (!firstResult || !secondResult) { + throw new Error("expected waiters to resolve"); + } + expect(firstResult.status).toBe("ok"); + expect(firstResult.error).toBeUndefined(); + expect(secondResult.status).toBe("ok"); + expect(secondResult.error).toBeUndefined(); expect(__testing.getWaiterCount(runId)).toBe(0); }); diff --git a/src/gateway/server-methods/agent.create-event.test.ts b/src/gateway/server-methods/agent.create-event.test.ts index 38f4c2fff60..d8e2208b9b1 100644 --- a/src/gateway/server-methods/agent.create-event.test.ts +++ b/src/gateway/server-methods/agent.create-event.test.ts @@ -89,26 +89,29 @@ describe("agent handler session create events", () => { req: { id: "req-agent-create-event" } as never, }); - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ - status: "accepted", - runId: "idem-agent-create-event", - }), - undefined, - { runId: "idem-agent-create-event" }, - ); + const responseCall = respond.mock.calls[0] as + | [boolean, { status?: string; runId?: string }, unknown, { runId?: string }] + | undefined; + expect(responseCall?.[0]).toBe(true); + expect(responseCall?.[1]?.status).toBe("accepted"); + expect(responseCall?.[1]?.runId).toBe("idem-agent-create-event"); + expect(responseCall?.[2]).toBeUndefined(); + expect(responseCall?.[3]?.runId).toBe("idem-agent-create-event"); await vi.waitFor( () => { - expect(broadcastToConnIds).toHaveBeenCalledWith( - "sessions.changed", - expect.objectContaining({ - sessionKey: "agent:main:subagent:create-test", - reason: "create", - }), - new Set(["conn-1"]), - { dropIfSlow: true }, - ); + const call = broadcastToConnIds.mock.calls[0] as + | [ + string, + { sessionKey?: string; reason?: string }, + Set, + { dropIfSlow?: boolean }, + ] + | undefined; + expect(call?.[0]).toBe("sessions.changed"); + expect(call?.[1]?.sessionKey).toBe("agent:main:subagent:create-test"); + expect(call?.[1]?.reason).toBe("create"); + expect(call?.[2]).toEqual(new Set(["conn-1"])); + expect(call?.[3]).toEqual({ dropIfSlow: true }); }, { timeout: 2_000, interval: 5 }, ); diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 40030414452..9c24ced22fb 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -600,8 +600,15 @@ describe("agents.create", () => { rootDir: "/resolved/tmp/ws", relativePath: "IDENTITY.md", }); - expect(write.data).toEqual( - expect.stringMatching(/- Name: Fancy Agent[\s\S]*- Emoji: 🤖[\s\S]*- Avatar:/), + expect(write.data).toBe( + [ + "# IDENTITY.md - Agent Identity", + "", + "- Name: Fancy Agent", + "- Emoji: 🤖", + "- Avatar: https://example.com/avatar.png", + "", + ].join("\n"), ); }); @@ -772,10 +779,16 @@ describe("agents.update", () => { rootDir: "/workspace/test-agent", relativePath: "IDENTITY.md", }); - expect(write.data).toEqual( - expect.stringMatching( - /- Name: Current Agent[\s\S]*- Theme: steady[\s\S]*- Emoji: 🐢[\s\S]*- Avatar: https:\/\/example\.com\/avatar\.png/, - ), + expect(write.data).toBe( + [ + "# IDENTITY.md - Agent Identity", + "", + "- Name: Current Agent", + "- Theme: steady", + "- Emoji: 🐢", + "- Avatar: https://example.com/avatar.png", + "", + ].join("\n"), ); }); @@ -793,8 +806,15 @@ describe("agents.update", () => { rootDir: "/workspace/test-agent", relativePath: "IDENTITY.md", }); - expect(write.data).toEqual( - expect.stringMatching(/- Name: Current Agent[\s\S]*- Theme: steady[\s\S]*- Emoji: 🦀/), + expect(write.data).toBe( + [ + "# IDENTITY.md - Agent Identity", + "", + "- Name: Current Agent", + "- Theme: steady", + "- Emoji: 🦀", + "", + ].join("\n"), ); }); @@ -820,10 +840,16 @@ describe("agents.update", () => { rootDir: "/workspace/test-agent", relativePath: "IDENTITY.md", }); - expect(write.data).toEqual( - expect.stringMatching( - /- Name: New Name[\s\S]*- Theme: steady[\s\S]*- Emoji: 🤖[\s\S]*- Avatar: https:\/\/example\.com\/new\.png/, - ), + expect(write.data).toBe( + [ + "# IDENTITY.md - Agent Identity", + "", + "- Name: New Name", + "- Theme: steady", + "- Emoji: 🤖", + "- Avatar: https://example.com/new.png", + "", + ].join("\n"), ); }); diff --git a/src/gateway/server-methods/chat-reply-media.test.ts b/src/gateway/server-methods/chat-reply-media.test.ts index fad5390a02a..a9d78f3f3b0 100644 --- a/src/gateway/server-methods/chat-reply-media.test.ts +++ b/src/gateway/server-methods/chat-reply-media.test.ts @@ -60,7 +60,12 @@ describe("normalizeWebchatReplyMediaPathsForDisplay", () => { } async function expectPathMissing(targetPath: string): Promise { - await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); + try { + await fs.stat(targetPath); + throw new Error(`expected ${targetPath} to be missing`); + } catch (error) { + expect((error as { code?: string }).code).toBe("ENOENT"); + } } it("stages Codex-home image paths before Gateway managed-image display", async () => { diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index 78f5e6437a5..c1c308619ef 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -332,12 +332,8 @@ describe("config shared auth disconnects", () => { await configHandlers["config.patch"](options); - expect(restartSentinelMocks.writeRestartSentinel).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: "agent:main:main", - }), - ); const payload = restartSentinelMocks.writeRestartSentinel.mock.calls.at(-1)?.[0]; + expect(payload?.sessionKey).toBe("agent:main:main"); expect(payload?.continuation).toBeUndefined(); }); }); diff --git a/src/gateway/server-methods/diagnostics.test.ts b/src/gateway/server-methods/diagnostics.test.ts index 474fe7e4f4f..0c512320155 100644 --- a/src/gateway/server-methods/diagnostics.test.ts +++ b/src/gateway/server-methods/diagnostics.test.ts @@ -21,9 +21,13 @@ describe("diagnostics gateway methods", () => { stopDiagnosticStabilityRecorder(); resetDiagnosticStabilityRecorderForTest(); resetDiagnosticEventsForTest(); + vi.useRealTimers(); }); it("returns a filtered stability snapshot", async () => { + const now = new Date("2026-01-02T03:04:05.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); emitDiagnosticEvent({ type: "webhook.received", channel: "telegram" }); emitDiagnosticEvent({ type: "payload.large", @@ -43,20 +47,53 @@ describe("diagnostics gateway methods", () => { respond, }); - expect(respond).toHaveBeenCalledWith( + expect(respond).toHaveBeenCalledTimes(1); + expect(respond.mock.calls[0]).toEqual([ true, - expect.objectContaining({ + { + generatedAt: now.toISOString(), + capacity: 1000, count: 1, + dropped: 0, + firstSeq: 2, + lastSeq: 2, events: [ - expect.objectContaining({ + { + seq: 2, + ts: now.getTime(), type: "payload.large", surface: "gateway.http.json", action: "rejected", - }), + bytes: 1024, + limitBytes: 512, + count: undefined, + channel: undefined, + pluginId: undefined, + }, ], - }), + summary: { + byType: { "payload.large": 1 }, + payloadLarge: { + count: 1, + rejected: 1, + truncated: 0, + chunked: 0, + bySurface: { "gateway.http.json": 1 }, + }, + }, + }, undefined, - ); + ]); + expect(Object.keys(respond.mock.calls[0]?.[1] as Record).toSorted()).toEqual([ + "capacity", + "count", + "dropped", + "events", + "firstSeq", + "generatedAt", + "lastSeq", + "summary", + ]); }); it("rejects invalid stability params", async () => { @@ -70,13 +107,15 @@ describe("diagnostics gateway methods", () => { respond, }); - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: "INVALID_REQUEST", - message: "limit must be between 1 and 1000", - }), - ); + expect(respond.mock.calls).toEqual([ + [ + false, + undefined, + { + code: "INVALID_REQUEST", + message: "limit must be between 1 and 1000", + }, + ], + ]); }); }); diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index dcca649abb8..7e0adfedf96 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -236,13 +236,12 @@ describe("models.list", () => { } as never, }); - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: ErrorCodes.UNAVAILABLE, - message: "Error: catalog failed", - }), - ); + const call = respond.mock.calls[0] as + | [boolean, unknown, { code?: number; message?: string }] + | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[1]).toBeUndefined(); + expect(call?.[2]?.code).toBe(ErrorCodes.UNAVAILABLE); + expect(call?.[2]?.message).toBe("Error: catalog failed"); }); }); diff --git a/src/gateway/server-methods/native-hook-relay.test.ts b/src/gateway/server-methods/native-hook-relay.test.ts index 176ec4afba0..a4cc03917de 100644 --- a/src/gateway/server-methods/native-hook-relay.test.ts +++ b/src/gateway/server-methods/native-hook-relay.test.ts @@ -55,14 +55,13 @@ describe("native hook relay gateway method", () => { context: {} as never, }); - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: "INVALID_REQUEST", - message: expect.stringContaining("not found"), - }), - ); + const call = respond.mock.calls[0] as + | [boolean, unknown, { code?: string; message?: string }] + | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[1]).toBeUndefined(); + expect(call?.[2]?.code).toBe("INVALID_REQUEST"); + expect(call?.[2]?.message).toContain("not found"); }); }); diff --git a/src/gateway/server-methods/nodes-pending.test.ts b/src/gateway/server-methods/nodes-pending.test.ts index 436e54981b3..47cfb0cabde 100644 --- a/src/gateway/server-methods/nodes-pending.test.ts +++ b/src/gateway/server-methods/nodes-pending.test.ts @@ -166,14 +166,13 @@ describe("node.pending handlers", () => { timeoutMs: 3_000, }); expect(mocks.maybeSendNodeWakeNudge).not.toHaveBeenCalled(); - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ - nodeId: "ios-node-2", - revision: 4, - wakeTriggered: true, - }), - undefined, - ); + const call = respond.mock.calls[0] as + | [boolean, { nodeId?: string; revision?: number; wakeTriggered?: boolean }, unknown?] + | undefined; + expect(call?.[0]).toBe(true); + expect(call?.[1]?.nodeId).toBe("ios-node-2"); + expect(call?.[1]?.revision).toBe(4); + expect(call?.[1]?.wakeTriggered).toBe(true); + expect(call?.[2]).toBeUndefined(); }); }); diff --git a/src/gateway/server-methods/plugin-approval.test.ts b/src/gateway/server-methods/plugin-approval.test.ts index 9638640d7bd..31241ae279c 100644 --- a/src/gateway/server-methods/plugin-approval.test.ts +++ b/src/gateway/server-methods/plugin-approval.test.ts @@ -97,6 +97,22 @@ function acceptedApprovalId(source: MockCallSource) { return id as string; } +function expectPluginApprovalId(value: unknown, label: string): string { + expect(value, label).toBeTypeOf("string"); + if (typeof value !== "string") { + throw new Error(`${label} must be a string`); + } + expect(value.startsWith("plugin:"), label).toBe(true); + const uuid = value.slice("plugin:".length); + expect(uuid).toHaveLength(36); + expect(uuid.split("-").map((part) => part.length)).toEqual([8, 4, 4, 4, 12]); + expect( + uuid.split("-").every((part) => /^[0-9a-f]+$/.test(part)), + label, + ).toBe(true); + return value; +} + function broadcastCall(opts: GatewayRequestHandlerOptions, index = 0) { const call = mockCall( opts.context.broadcast as unknown as MockCallSource, @@ -336,7 +352,7 @@ describe("createPluginApprovalHandlers", () => { ); await handlers["plugin.approval.request"](opts); const result = respond.mock.calls[0]?.[1] as Record | undefined; - expect(result?.id).toEqual(expect.stringMatching(/^plugin:/)); + expectPluginApprovalId(result?.id, "generated plugin approval id"); }); it("passes plugin-prefixed IDs directly to manager.create", async () => { @@ -353,7 +369,7 @@ describe("createPluginApprovalHandlers", () => { await handlers["plugin.approval.request"](opts); expect(createSpy).toHaveBeenCalledTimes(1); - expect(createSpy.mock.calls[0]?.[2]).toEqual(expect.stringMatching(/^plugin:/)); + expectPluginApprovalId(createSpy.mock.calls[0]?.[2], "manager.create approval id"); }); it("rejects plugin-provided id field", async () => { @@ -433,13 +449,14 @@ describe("createPluginApprovalHandlers", () => { ); expect(approvals).toHaveLength(1); const approval = requireRecord(approvals[0], "approval"); - expect(approval.id).toEqual(expect.stringMatching(/^plugin:/)); + const listedApprovalId = expectPluginApprovalId(approval.id, "listed approval id"); const request = requireRecord(approval.request, "approval request"); expect(request.title).toBe("Sensitive action"); expect(request.description).toBe("Desc"); expect(responseCall(listRespond as unknown as MockCallSource).error).toBeUndefined(); const approvalId = acceptedApprovalId(respond as unknown as MockCallSource); + expect(listedApprovalId).toBe(approvalId); manager.resolve(approvalId, "allow-once"); await handlerPromise; }); diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index 776130d6587..8308c36dbf4 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -159,7 +159,9 @@ describe("push.test handler", () => { expect(sendApnsAlert).toHaveBeenCalledTimes(1); const call = respond.mock.calls[0] as RespondCall | undefined; expect(call?.[0]).toBe(true); - expect(call?.[1]).toMatchObject({ ok: true, status: 200 }); + const result = call?.[1] as ApnsPushResult | undefined; + expect(result?.ok).toBe(true); + expect(result?.status).toBe(200); }); it("sends push test through relay registrations", async () => { @@ -216,7 +218,10 @@ describe("push.test handler", () => { expect(sendApnsAlert).toHaveBeenCalledTimes(1); const call = respond.mock.calls[0] as RespondCall | undefined; expect(call?.[0]).toBe(true); - expect(call?.[1]).toMatchObject({ ok: true, status: 200, transport: "relay" }); + const result = call?.[1] as ApnsPushResult | undefined; + expect(result?.ok).toBe(true); + expect(result?.status).toBe(200); + expect(result?.transport).toBe("relay"); }); it("clears stale registrations after invalid token push-test failures", async () => { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index b01dfba0697..8103491d8d2 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -787,13 +787,10 @@ describe("gateway send mirroring", () => { idempotencyKey: "idem-send-options", }); - expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( - expect.objectContaining({ - forceDocument: true, - silent: true, - formatting: { parseMode: "HTML" }, - }), - ); + const options = mocks.deliverOutboundPayloads.mock.calls[0]?.[0]; + expect(options?.forceDocument).toBe(true); + expect(options?.silent).toBe(true); + expect(options?.formatting).toEqual({ parseMode: "HTML" }); }); it("updates mirror session keys and delivery thread ids when Slack routing derives a thread", async () => { @@ -1098,7 +1095,7 @@ describe("gateway send mirroring", () => { }); expect(firstRespondCall(respond)?.[0]).toBe(true); - expect(capturedMediaLocalRoots).toEqual(expect.arrayContaining([TEST_AGENT_WORKSPACE])); + expect(capturedMediaLocalRoots).toContain(TEST_AGENT_WORKSPACE); }); it("forces senderIsOwner=false for narrowly-scoped callers but honors it for full operators", async () => { diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 69455832348..62b1bb99851 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -2303,13 +2303,23 @@ describe("gateway healthHandlers.health cache freshness", () => { isWebchatConnect: () => false, }); - expect(mockCallArg(respond, 0, 1)).toMatchObject({ - modelPricing: { - state: "degraded", - detail: "OpenRouter pricing fetch failed: TypeError: fetch failed", - sources: [{ source: "openrouter", state: "degraded", lastFailureAt: 123 }], - }, - }); + const payload = mockCallArg(respond, 0, 1) as + | { + modelPricing?: { + state?: string; + detail?: string; + sources?: Array<{ source?: string; state?: string; lastFailureAt?: number }>; + }; + } + | undefined; + expect(payload?.modelPricing?.state).toBe("degraded"); + expect(payload?.modelPricing?.detail).toBe( + "OpenRouter pricing fetch failed: TypeError: fetch failed", + ); + expect(payload?.modelPricing?.sources).toHaveLength(1); + expect(payload?.modelPricing?.sources?.[0]?.source).toBe("openrouter"); + expect(payload?.modelPricing?.sources?.[0]?.state).toBe("degraded"); + expect(payload?.modelPricing?.sources?.[0]?.lastFailureAt).toBe(123); expect(mockCallArg(respond, 0, 3)).toEqual({ cached: true }); expect(refreshHealthSnapshot).toHaveBeenCalledWith({ probe: false, diff --git a/src/gateway/server-methods/sessions.send-followup-status.test.ts b/src/gateway/server-methods/sessions.send-followup-status.test.ts index 5e6294fb4b1..e098118b8d1 100644 --- a/src/gateway/server-methods/sessions.send-followup-status.test.ts +++ b/src/gateway/server-methods/sessions.send-followup-status.test.ts @@ -88,7 +88,8 @@ describe("sessions.send completed subagent follow-up status", () => { }); const broadcastToConnIds = vi.fn(); - const respond = vi.fn() as unknown as RespondFn; + const respondMock = vi.fn(); + const respond = respondMock as unknown as RespondFn; const context = { chatAbortControllers: new Map(), broadcastToConnIds, @@ -109,16 +110,15 @@ describe("sessions.send completed subagent follow-up status", () => { isWebchatConnect: () => false, }); - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ - runId: "run-new", - status: "started", - messageSeq: 1, - }), - undefined, - undefined, - ); + const call = respondMock.mock.calls[0] as + | [boolean, { runId?: string; status?: string; messageSeq?: number }, unknown?, unknown?] + | undefined; + expect(call?.[0]).toBe(true); + expect(call?.[1]?.runId).toBe("run-new"); + expect(call?.[1]?.status).toBe("started"); + expect(call?.[1]?.messageSeq).toBe(1); + expect(call?.[2]).toBeUndefined(); + expect(call?.[3]).toBeUndefined(); expectSubagentFollowupReactivation({ replaceSubagentRunAfterSteerMock, broadcastToConnIds, diff --git a/src/gateway/server-methods/skills.clawhub.test.ts b/src/gateway/server-methods/skills.clawhub.test.ts index dda8a584d88..5c3ce6fc99d 100644 --- a/src/gateway/server-methods/skills.clawhub.test.ts +++ b/src/gateway/server-methods/skills.clawhub.test.ts @@ -81,12 +81,13 @@ describe("skills gateway handlers (clawhub)", () => { }); expect(ok).toBe(true); expect(error).toBeUndefined(); - expect(response).toMatchObject({ - ok: true, - message: "Installed calendar@1.2.3", - slug: "calendar", - version: "1.2.3", - }); + const result = response as + | { ok?: boolean; message?: string; slug?: string; version?: string } + | undefined; + expect(result?.ok).toBe(true); + expect(result?.message).toBe("Installed calendar@1.2.3"); + expect(result?.slug).toBe("calendar"); + expect(result?.version).toBe("1.2.3"); }); it("forwards dangerous override for local skill installs", async () => { @@ -129,10 +130,9 @@ describe("skills gateway handlers (clawhub)", () => { }); expect(ok).toBe(true); expect(error).toBeUndefined(); - expect(response).toMatchObject({ - ok: true, - message: "Installed", - }); + const result = response as { ok?: boolean; message?: string } | undefined; + expect(result?.ok).toBe(true); + expect(result?.message).toBe("Installed"); }); it("updates ClawHub skills through skills.update", async () => { @@ -172,20 +172,23 @@ describe("skills gateway handlers (clawhub)", () => { }); expect(ok).toBe(true); expect(error).toBeUndefined(); - expect(response).toMatchObject({ - ok: true, - skillKey: "calendar", - config: { - source: "clawhub", - results: [ - { - ok: true, - slug: "calendar", - version: "1.2.3", - }, - ], - }, - }); + const result = response as + | { + ok?: boolean; + skillKey?: string; + config?: { + source?: string; + results?: Array<{ ok?: boolean; slug?: string; version?: string }>; + }; + } + | undefined; + expect(result?.ok).toBe(true); + expect(result?.skillKey).toBe("calendar"); + expect(result?.config?.source).toBe("clawhub"); + expect(result?.config?.results).toHaveLength(1); + expect(result?.config?.results?.[0]?.ok).toBe(true); + expect(result?.config?.results?.[0]?.slug).toBe("calendar"); + expect(result?.config?.results?.[0]?.version).toBe("1.2.3"); }); it("rejects ClawHub skills.update requests without slug or all", async () => { diff --git a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts index e7364fcbe67..2ce1c1f1213 100644 --- a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts +++ b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts @@ -23,6 +23,18 @@ vi.mock("../../config/config.js", () => { const { skillsHandlers } = await import("./skills.js"); +function expectWrittenSkillEntry(skillKey: string, entry: unknown) { + expect(writtenConfig).toBeDefined(); + const config = writtenConfig as { + skills?: { + entries?: Record; + }; + }; + expect(Object.keys(config).toSorted()).toEqual(["skills"]); + expect(Object.keys(config.skills ?? {}).toSorted()).toEqual(["entries"]); + expect(config.skills?.entries?.[skillKey]).toEqual(entry); +} + describe("skills.update", () => { it("strips embedded CR/LF from apiKey", async () => { writtenConfig = null; @@ -51,14 +63,8 @@ describe("skills.update", () => { expect(ok).toBe(true); expect(error).toBeUndefined(); - expect(writtenConfig).toMatchObject({ - skills: { - entries: { - "brave-search": { - apiKey: "abcdef", - }, - }, - }, + expectWrittenSkillEntry("brave-search", { + apiKey: "abcdef", }); }); @@ -90,17 +96,11 @@ describe("skills.update", () => { }); // Full values must be persisted to config - expect(writtenConfig).toMatchObject({ - skills: { - entries: { - "demo-skill": { - apiKey: "secret-api-key-123", - env: { - GEMINI_API_KEY: "secret-env-key-456", - BRAVE_REGION: "us", - }, - }, - }, + expectWrittenSkillEntry("demo-skill", { + apiKey: "secret-api-key-123", + env: { + GEMINI_API_KEY: "secret-env-key-456", + BRAVE_REGION: "us", }, }); @@ -145,17 +145,11 @@ describe("skills.update", () => { respond: () => {}, }); - expect(writtenConfig).toMatchObject({ - skills: { - entries: { - "demo-skill": { - apiKey: "secret-api-key-123", - env: { - GEMINI_API_KEY: "secret-env-key-456", - BRAVE_REGION: "eu", - }, - }, - }, + expectWrittenSkillEntry("demo-skill", { + apiKey: "secret-api-key-123", + env: { + GEMINI_API_KEY: "secret-env-key-456", + BRAVE_REGION: "eu", }, }); }); diff --git a/src/gateway/server-methods/subagent-followup.test-helpers.ts b/src/gateway/server-methods/subagent-followup.test-helpers.ts index f502cd61417..a599f049c43 100644 --- a/src/gateway/server-methods/subagent-followup.test-helpers.ts +++ b/src/gateway/server-methods/subagent-followup.test-helpers.ts @@ -12,16 +12,32 @@ export function expectSubagentFollowupReactivation(params: { fallback: params.completedRun, runTimeoutSeconds: 0, }); - expect(params.broadcastToConnIds).toHaveBeenCalledWith( - "sessions.changed", - expect.objectContaining({ - sessionKey: params.childSessionKey, - reason: "send", - status: "running", - startedAt: 123, - endedAt: undefined, - }), - new Set(["conn-1"]), - { dropIfSlow: true }, - ); + const call = ( + params.broadcastToConnIds as { + mock?: { + calls?: Array< + [ + string, + { + sessionKey?: string; + reason?: string; + status?: string; + startedAt?: number; + endedAt?: number; + }, + Set, + { dropIfSlow?: boolean }, + ] + >; + }; + } + ).mock?.calls?.[0]; + expect(call?.[0]).toBe("sessions.changed"); + expect(call?.[1]?.sessionKey).toBe(params.childSessionKey); + expect(call?.[1]?.reason).toBe("send"); + expect(call?.[1]?.status).toBe("running"); + expect(call?.[1]?.startedAt).toBe(123); + expect(call?.[1]?.endedAt).toBeUndefined(); + expect(call?.[2]).toEqual(new Set(["conn-1"])); + expect(call?.[3]).toEqual({ dropIfSlow: true }); } diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index 2b34ae265ee..9d6a3bc7944 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -53,6 +53,12 @@ function createInvokeParams(params: Record) { }; } +function firstMockArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown { + const arg = mock.mock.calls[0]?.[0]; + expect(arg, label).toBeDefined(); + return arg; +} + describe("tools.catalog handler", () => { beforeEach(() => { pluginToolMetaState.clear(); @@ -124,10 +130,16 @@ describe("tools.catalog handler", () => { const voiceCall = pluginGroups .flatMap((group) => group.tools) .find((tool) => tool.id === "voice_call"); - expect(voiceCall).toMatchObject({ + expect(voiceCall).toEqual({ + id: "voice_call", + label: "voice_call", + description: "Plugin calling tool", source: "plugin", pluginId: "voice-call", optional: true, + risk: undefined, + tags: undefined, + defaultProfiles: [], }); }); @@ -159,15 +171,45 @@ describe("tools.catalog handler", () => { await invoke(); - expect(vi.mocked(resolvePluginTools)).toHaveBeenCalledWith( - expect.objectContaining({ - allowGatewaySubagentBinding: true, - }), - ); - expect(vi.mocked(ensureStandalonePluginToolRegistryLoaded)).toHaveBeenCalledWith( - expect.objectContaining({ - allowGatewaySubagentBinding: true, - }), - ); + const resolveArgs = firstMockArg(vi.mocked(resolvePluginTools), "resolvePluginTools args") as { + allowGatewaySubagentBinding?: boolean; + suppressNameConflicts?: boolean; + toolAllowlist?: string[]; + context?: { + agentId?: string; + workspaceDir?: string; + agentDir?: string; + }; + existingToolNames?: Set; + }; + expect(resolveArgs.allowGatewaySubagentBinding).toBe(true); + expect(resolveArgs.suppressNameConflicts).toBe(true); + expect(resolveArgs.toolAllowlist).toEqual(["group:plugins"]); + expect(resolveArgs.context?.agentId).toBe("main"); + expect(resolveArgs.context?.workspaceDir).toBe("/tmp/workspace-main"); + expect(resolveArgs.context?.agentDir).toBe("/tmp/agents/main/agent"); + expect(resolveArgs.existingToolNames).toBeInstanceOf(Set); + expect(resolveArgs.existingToolNames?.has("tts")).toBe(true); + + const registryArgs = firstMockArg( + vi.mocked(ensureStandalonePluginToolRegistryLoaded), + "registry load args", + ) as { + allowGatewaySubagentBinding?: boolean; + toolAllowlist?: string[]; + context?: { + agentId?: string; + workspaceDir?: string; + agentDir?: string; + }; + }; + expect(registryArgs.allowGatewaySubagentBinding).toBe(true); + expect(registryArgs.toolAllowlist).toEqual(["group:plugins"]); + expect(registryArgs.context).toEqual({ + config: {}, + workspaceDir: "/tmp/workspace-main", + agentDir: "/tmp/agents/main/agent", + agentId: "main", + }); }); }); diff --git a/src/gateway/server-methods/tts.test.ts b/src/gateway/server-methods/tts.test.ts index c24547b7313..5186f278a50 100644 --- a/src/gateway/server-methods/tts.test.ts +++ b/src/gateway/server-methods/tts.test.ts @@ -76,14 +76,13 @@ describe("ttsHandlers", () => { context: { getRuntimeConfig: mocks.getRuntimeConfig }, } as never); - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: ErrorCodes.INVALID_REQUEST, - message: 'Error: Unknown TTS provider "bad".', - }), - ); + const call = respond.mock.calls[0] as + | [boolean, unknown, { code?: number; message?: string }] + | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[1]).toBeUndefined(); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toBe('Error: Unknown TTS provider "bad".'); expect(mocks.textToSpeech).not.toHaveBeenCalled(); }); }); diff --git a/src/gateway/server-methods/usage.test.ts b/src/gateway/server-methods/usage.test.ts index eb870d63c1b..8a9e9e6996c 100644 --- a/src/gateway/server-methods/usage.test.ts +++ b/src/gateway/server-methods/usage.test.ts @@ -157,8 +157,8 @@ describe("gateway usage helpers", () => { expect(a.totals.totalTokens).toBe(1); expect(b.totals.totalTokens).toBe(1); expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(1); - expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[0]?.[0]).toMatchObject({ - refreshMode: "sync-when-empty", - }); + expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[0]?.[0]?.refreshMode).toBe( + "sync-when-empty", + ); }); }); diff --git a/src/gateway/server-node-session-runtime.ts b/src/gateway/server-node-session-runtime.ts index ffdee8f9782..0f702eb3e68 100644 --- a/src/gateway/server-node-session-runtime.ts +++ b/src/gateway/server-node-session-runtime.ts @@ -1,9 +1,8 @@ -import { NodeRegistry } from "./node-registry.js"; +import { NodeRegistry, type SerializedEventPayload } from "./node-registry.js"; import { createSessionEventSubscriberRegistry, createSessionMessageSubscriberRegistry, } from "./server-chat-state.js"; -import { safeParseJson } from "./server-json.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { hasConnectedTalkNode } from "./server-talk-nodes.js"; @@ -15,9 +14,12 @@ export function createGatewayNodeSessionRuntime(params: { const nodeSubscriptions = createNodeSubscriptionManager(); const sessionEventSubscribers = createSessionEventSubscriberRegistry(); const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); - const nodeSendEvent = (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => { - const payload = safeParseJson(opts.payloadJSON ?? null); - nodeRegistry.sendEvent(opts.nodeId, opts.event, payload); + const nodeSendEvent = (opts: { + nodeId: string; + event: string; + payloadJSON?: SerializedEventPayload | null; + }) => { + nodeRegistry.sendEventRaw(opts.nodeId, opts.event, opts.payloadJSON ?? null); }; const nodeSendToSession = (sessionKey: string, event: string, payload: unknown) => nodeSubscriptions.sendToSession(sessionKey, event, payload, nodeSendEvent); diff --git a/src/gateway/server-node-subscriptions.ts b/src/gateway/server-node-subscriptions.ts index 0c04c3a86f1..858b4028f05 100644 --- a/src/gateway/server-node-subscriptions.ts +++ b/src/gateway/server-node-subscriptions.ts @@ -1,7 +1,9 @@ +import { serializeEventPayload, type SerializedEventPayload } from "./node-registry.js"; + type NodeSendEventFn = (opts: { nodeId: string; event: string; - payloadJSON?: string | null; + payloadJSON?: SerializedEventPayload | null; }) => void; type NodeListConnectedFn = () => Array<{ nodeId: string }>; @@ -34,7 +36,7 @@ export function createNodeSubscriptionManager(): NodeSubscriptionManager { const nodeSubscriptions = new Map>(); const sessionSubscribers = new Map>(); - const toPayloadJSON = (payload: unknown) => (payload ? JSON.stringify(payload) : null); + const toPayloadJSON = (payload: unknown) => serializeEventPayload(payload); const subscribe = (nodeId: string, sessionKey: string) => { const normalizedNodeId = nodeId.trim(); diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index f5ea25d6ce3..16cc2c769ca 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -125,18 +125,30 @@ describe("gateway restart deferral preflight", () => { }, ); - expect(logReload.warn).toHaveBeenCalledWith( - expect.stringContaining( - "restart blocked by active background task run(s): taskId=task-nightly", - ), - ); - expect(logReload.warn).toHaveBeenCalledWith(expect.stringContaining("runId=run-nightly")); + expect(logReload.warn.mock.calls).toEqual([ + [ + "config change requires gateway restart (gateway.port) — deferring until 1 background task run(s) complete", + ], + [ + "restart blocked by active background task run(s): taskId=task-nightly runId=run-nightly status=running runtime=cron label=nightly sync title=refresh all accounts", + ], + ]); await vi.advanceTimersByTimeAsync(1_000); await Promise.resolve(); expect(signalSpy).toHaveBeenCalledTimes(1); - expect(logReload.warn).toHaveBeenCalledWith(expect.stringContaining("; forcing restart")); + expect(logReload.warn.mock.calls).toEqual([ + [ + "config change requires gateway restart (gateway.port) — deferring until 1 background task run(s) complete", + ], + [ + "restart blocked by active background task run(s): taskId=task-nightly runId=run-nightly status=running runtime=cron label=nightly sync title=refresh all accounts", + ], + [ + "restart timeout after 1000ms with 1 background task run(s) still active (taskId=task-nightly runId=run-nightly status=running runtime=cron label=nightly sync title=refresh all accounts); forcing restart", + ], + ]); } finally { hoisted.activeTaskCount.value = 0; vi.useRealTimers(); @@ -233,16 +245,16 @@ describe("gateway plugin hot reload handlers", () => { } } - expect(reloadPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - nextConfig: { - plugins: { - enabled: false, - }, - }, - changedPaths: ["plugins.enabled"], - }), - ); + const [reloadParams] = reloadPlugins.mock.calls.at(-1) ?? []; + const reloadParamsRecord = reloadParams as + | { nextConfig?: unknown; changedPaths?: unknown } + | undefined; + expect(reloadParamsRecord?.nextConfig).toEqual({ + plugins: { + enabled: false, + }, + }); + expect(reloadParamsRecord?.changedPaths).toEqual(["plugins.enabled"]); expect(stopChannel).toHaveBeenCalledWith("discord"); expect(startChannel).not.toHaveBeenCalled(); expect(events).toEqual(["reload:start", "stop", "registry:replace"]); diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 48844d8f370..3c997f49111 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -185,15 +185,21 @@ describe("server-runtime-services", () => { expect(services.heartbeatRunner).toBe(hoisted.heartbeatRunner); await vi.advanceTimersByTimeAsync(1_250); await vi.dynamicImportSettled(); + expect(log.child).toHaveBeenNthCalledWith(1, "delivery-recovery"); + expect(log.child).toHaveBeenNthCalledWith(2, "session-delivery-recovery"); + const deliveryLog = log.child.mock.results[0]?.value; + const sessionDeliveryLog = log.child.mock.results[1]?.value; + expect(deliveryLog).toBeDefined(); + expect(sessionDeliveryLog).toBeDefined(); expect(hoisted.recoverPendingDeliveries).toHaveBeenCalledWith({ deliver: hoisted.deliverOutboundPayloads, cfg: {}, - log: expect.any(Object), + log: deliveryLog, }); expect(hoisted.recoverPendingRestartContinuationDeliveries).toHaveBeenCalledWith({ deps: {}, maxEnqueuedAt: 123, - log: expect.any(Object), + log: sessionDeliveryLog, }); }); diff --git a/src/gateway/server.channels.test.ts b/src/gateway/server.channels.test.ts index 7e39082fb5d..38e82095aa9 100644 --- a/src/gateway/server.channels.test.ts +++ b/src/gateway/server.channels.test.ts @@ -121,9 +121,7 @@ describe("gateway server channels", () => { expect(res.ok).toBe(true); const telegram = res.payload?.channels?.telegram; const signal = res.payload?.channels?.signal; - expect(res.payload?.channels?.whatsapp).toMatchObject({ - configured: expect.any(Boolean), - }); + expect(res.payload?.channels?.whatsapp?.configured).toBeTypeOf("boolean"); expect(telegram?.configured).toBe(false); expect(telegram?.tokenSource).toBe("none"); expect(telegram?.probe).toBeUndefined(); diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 03b55ffead1..b78a9016abb 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -288,11 +288,9 @@ describe("gateway config methods", () => { expect(res.payload?.path).toBe("gateway.auth"); expect(res.payload?.hintPath).toBe("gateway.auth"); const tokenChild = res.payload?.children?.find((child) => child.key === "token"); - expect(tokenChild).toMatchObject({ - key: "token", - path: "gateway.auth.token", - hintPath: "gateway.auth.token", - }); + expect(tokenChild?.key).toBe("token"); + expect(tokenChild?.path).toBe("gateway.auth.token"); + expect(tokenChild?.hintPath).toBe("gateway.auth.token"); expect(res.payload?.schema?.properties).toBeUndefined(); }); diff --git a/src/gateway/server.device-pair-approve-supersede.test.ts b/src/gateway/server.device-pair-approve-supersede.test.ts index b7937b3cdc2..fef29daa6ff 100644 --- a/src/gateway/server.device-pair-approve-supersede.test.ts +++ b/src/gateway/server.device-pair-approve-supersede.test.ts @@ -36,7 +36,8 @@ describe("gateway device.pair.approve superseded request ids", () => { expect(latestApprove?.status).toBe("approved"); const paired = await getPairedDevice("supersede-device-1"); - expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"])); - expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.admin"])); + expect(paired?.roles).toContain("node"); + expect(paired?.roles).toContain("operator"); + expect(paired?.scopes).toContain("operator.admin"); }); }); diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index 3c29ff0b17b..7bbf1afd792 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -163,16 +163,10 @@ async function expectRePairingRequest(params: { JSON.stringify(lastNodes), ).toEqual(params.expectedVisibleCommands); - await expect(listNodePairing()).resolves.toEqual( - expect.objectContaining({ - pending: [ - expect.objectContaining({ - nodeId: pairedNode.identity.deviceId, - commands: params.reconnectCommands, - }), - ], - }), - ); + const pairing = await listNodePairing(); + const pending = pairing.pending?.find((entry) => entry.nodeId === pairedNode.identity.deviceId); + expect(pending?.nodeId).toBe(pairedNode.identity.deviceId); + expect(pending?.commands).toEqual(params.reconnectCommands); } finally { controlWs?.close(); await firstClient?.stopAndWait(); @@ -239,11 +233,8 @@ describe("gateway node pairing authorization", () => { expect(approve.payload?.requestId).toBe(request.request.requestId); expect(approve.payload?.node?.nodeId).toBe("node-approve-target"); - await expect(getPairedNode("node-approve-target")).resolves.toEqual( - expect.objectContaining({ - nodeId: "node-approve-target", - }), - ); + const pairedNode = await getPairedNode("node-approve-target"); + expect(pairedNode?.nodeId).toBe("node-approve-target"); } finally { pairingWs?.close(); started.ws.close(); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 80db8056f19..a50783bf2d7 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -420,9 +420,9 @@ describe("gateway plugin HTTP auth boundary", () => { }); expect(observedRuntimeScopes).toHaveLength(1); - expect(observedRuntimeScopes[0]).toEqual( - expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]), - ); + expect(observedRuntimeScopes[0]).toContain("operator.admin"); + expect(observedRuntimeScopes[0]).toContain("operator.read"); + expect(observedRuntimeScopes[0]).toContain("operator.write"); expect(adminAllowedResults).toEqual([true]); }); diff --git a/src/gateway/server.preauth-hardening.test.ts b/src/gateway/server.preauth-hardening.test.ts index 4a6b4ee7ad9..2b238d225aa 100644 --- a/src/gateway/server.preauth-hardening.test.ts +++ b/src/gateway/server.preauth-hardening.test.ts @@ -225,15 +225,12 @@ describe("gateway pre-auth hardening", () => { const result = await closed; expect(result.code).toBe(1009); - expect(events).toContainEqual( - expect.objectContaining({ - type: "payload.large", - surface: "gateway.ws.preauth", - action: "rejected", - limitBytes: MAX_PREAUTH_PAYLOAD_BYTES, - reason: "preauth_frame_limit", - }), - ); + const event = events.find((candidate) => candidate.type === "payload.large"); + expect(event?.type).toBe("payload.large"); + expect(event?.surface).toBe("gateway.ws.preauth"); + expect(event?.action).toBe("rejected"); + expect(event?.limitBytes).toBe(MAX_PREAUTH_PAYLOAD_BYTES); + expect(event?.reason).toBe("preauth_frame_limit"); } finally { stopDiagnostics(); resetDiagnosticEventsForTest(); diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index 44828551fd4..b541b8d76c1 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -210,14 +210,9 @@ async function expectPendingPairingCommands(nodeId: string, commands: string[]) pending?: Array<{ nodeId?: string; commands?: string[] }>; }>(ws, "node.pair.list", {}); expect(pairingList.ok).toBe(true); - expect(pairingList.payload?.pending ?? []).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - nodeId, - commands, - }), - ]), - ); + const pending = (pairingList.payload?.pending ?? []).find((entry) => entry.nodeId === nodeId); + expect(pending?.nodeId).toBe(nodeId); + expect(pending?.commands).toEqual(commands); } describe("gateway role enforcement", () => { @@ -250,7 +245,7 @@ describe("gateway role enforcement", () => { await expect(nodeClient.request("status", {})).rejects.toThrow("unauthorized role"); const healthPayload = await nodeClient.request("health", {}); - expect(healthPayload).toMatchObject({ ok: true }); + expect(healthPayload.ok).toBe(true); } finally { nodeClient?.stop(); } diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 3c1a218e310..16b56e70609 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -151,10 +151,8 @@ describe("sessions_send gateway loopback", () => { | { lane?: string; inputProvenance?: { kind?: string; sourceTool?: string } } | undefined; expect(firstCall?.lane).toMatch(/^nested(?::|$)/); - expect(firstCall?.inputProvenance).toMatchObject({ - kind: "inter_session", - sourceTool: "sessions_send", - }); + expect(firstCall?.inputProvenance?.kind).toBe("inter_session"); + expect(firstCall?.inputProvenance?.sourceTool).toBe("sessions_send"); }); }); diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index 1157d339af3..94bc4db01a9 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -49,6 +49,21 @@ function expectMainHookContext(context: HookEventRecord, sessionId: string) { expect(context.sessionId).toBe(sessionId); } +function expectStringValue(value: unknown, label: string): string { + expect(typeof value, label).toBe("string"); + if (typeof value !== "string") { + throw new Error(`${label} must be a string`); + } + return value; +} + +function expectStringWithPrefix(value: unknown, prefix: string, label: string): string { + const text = expectStringValue(value, label); + expect(text.startsWith(prefix), label).toBe(true); + expect(text.length, label).toBeGreaterThan(prefix.length); + return text; +} + test("sessions.reset emits internal command hook with reason", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); @@ -175,7 +190,12 @@ test("sessions.reset emits enriched session_end and session_start hooks", async expect(endEvent.sessionKey).toBe("agent:main:main"); expect(endEvent.reason).toBe("new"); expect(endEvent.transcriptArchived).toBe(true); - expect(endEvent.sessionFile).toEqual(expect.stringContaining(".jsonl.reset.")); + const archivedSessionFile = expectStringWithPrefix( + endEvent.sessionFile, + path.join(dir, "sess-main.jsonl.reset."), + "archived session file", + ); + expect(path.dirname(archivedSessionFile)).toBe(dir); expect(endEvent.nextSessionId).toBe(startEvent.sessionId); expectMainHookContext(endContext, "sess-main"); expect(startEvent.sessionKey).toBe("agent:main:main"); @@ -213,9 +233,9 @@ test("sessions.reset returns unavailable when active run does not stop", async ( >; expect(store["agent:main:main"]?.sessionId).toBe("sess-main"); const filesAfterResetAttempt = await fs.readdir(dir); - expect(filesAfterResetAttempt).not.toContainEqual( - expect.stringMatching(/^sess-main\.jsonl\.reset\./), - ); + expect( + filesAfterResetAttempt.filter((file) => file.startsWith("sess-main.jsonl.reset.")), + ).toEqual([]); }); test("sessions.reset emits before_reset for the entry actually reset in the writer slot", async () => { @@ -371,7 +391,7 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga expect(startEvent.resumedFrom).toBe("sess-parent-hooks"); expect(startEvent.sessionId).toBeTypeOf("string"); expect(startEvent.sessionId).not.toBe(""); - expect(startEvent.sessionKey).toEqual(expect.stringMatching(/^agent:main:dashboard:/)); + expectStringWithPrefix(startEvent.sessionKey, "agent:main:dashboard:", "created session key"); }); test("sessions.create with emitCommandHooks=true resets parent in place when session.dmScope is 'main' (#77434)", async () => { diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 233ddcc0ec4..f23309787a7 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -22,6 +22,17 @@ function collectNonEmptyLines(text: string): string[] { return lines; } +function expectSinglePrefixedFilename(files: string[], prefix: string): string { + const matches = files.filter((file) => file.startsWith(prefix)); + expect(matches).toHaveLength(1); + const [match] = matches; + if (!match) { + throw new Error(`Expected one filename with prefix ${prefix}`); + } + expect(match.length).toBeGreaterThan(prefix.length); + return match; +} + test("lists and patches session store via sessions.* RPC", async () => { const { dir, storePath } = await createSessionStoreDir(); const now = Date.now(); @@ -75,17 +86,14 @@ test("lists and patches session store via sessions.* RPC", async () => { }); const { ws, hello } = await openClient(); - expect((hello as { features?: { methods?: string[] } }).features?.methods).toEqual( - expect.arrayContaining([ - "sessions.list", - "sessions.preview", - "sessions.cleanup", - "sessions.patch", - "sessions.reset", - "sessions.delete", - "sessions.compact", - ]), - ); + const methods = (hello as { features?: { methods?: string[] } }).features?.methods ?? []; + expect(methods).toContain("sessions.list"); + expect(methods).toContain("sessions.preview"); + expect(methods).toContain("sessions.cleanup"); + expect(methods).toContain("sessions.patch"); + expect(methods).toContain("sessions.reset"); + expect(methods).toContain("sessions.delete"); + expect(methods).toContain("sessions.compact"); const sessionsHandlers = await getSessionsHandlers(); const { getRuntimeConfig } = await getGatewayConfigModule(); const directContext = { @@ -394,7 +402,7 @@ test("lists and patches session store via sessions.* RPC", async () => { ); expect(compactedLines).toHaveLength(3); const filesAfterCompact = await fs.readdir(dir); - expect(filesAfterCompact).toContainEqual(expect.stringMatching(/^sess-main\.jsonl\.bak\./)); + expectSinglePrefixedFilename(filesAfterCompact, "sess-main.jsonl.bak."); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "agent:main:discord:group:dev", @@ -409,7 +417,7 @@ test("lists and patches session store via sessions.* RPC", async () => { "agent:main:discord:group:dev", ); const filesAfterDelete = await fs.readdir(dir); - expect(filesAfterDelete).toContainEqual(expect.stringMatching(/^sess-group\.jsonl\.deleted\./)); + expectSinglePrefixedFilename(filesAfterDelete, "sess-group.jsonl.deleted."); const reset = await directSessionReq<{ ok: true; @@ -436,7 +444,7 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(storeAfterReset["agent:main:main"]?.lastAccountId).toBe("work"); expect(storeAfterReset["agent:main:main"]?.lastThreadId).toBe("1737500000.123456"); const filesAfterReset = await fs.readdir(dir); - expect(filesAfterReset).toContainEqual(expect.stringMatching(/^sess-main\.jsonl\.reset\./)); + expectSinglePrefixedFilename(filesAfterReset, "sess-main.jsonl.reset."); const badThinking = await directSessionReq("sessions.patch", { key: "agent:main:main", diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index 1aa92d20424..a84a963b0e7 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -250,15 +250,14 @@ describe("gateway silent scope-upgrade reconnect", () => { }); try { - await expect( - callGateway({ - url: `ws://127.0.0.1:${started.port}`, - token: "secret", - method: "health", - scopes: ["operator.admin"], - timeoutMs: 2_000, - }), - ).resolves.toMatchObject({ ok: true }); + const health = await callGateway({ + url: `ws://127.0.0.1:${started.port}`, + token: "secret", + method: "health", + scopes: ["operator.admin"], + timeoutMs: 2_000, + }); + expect(health.ok).toBe(true); const paired = await getPairedDevice(identity.deviceId); expect(paired?.approvedScopes).toEqual(["operator.read"]); diff --git a/src/gateway/server/ws-connection.startup.test.ts b/src/gateway/server/ws-connection.startup.test.ts index 5d0d061a144..9bd161fc55d 100644 --- a/src/gateway/server/ws-connection.startup.test.ts +++ b/src/gateway/server/ws-connection.startup.test.ts @@ -107,19 +107,32 @@ describe("attachGatewayWsConnectionHandler startup readiness", () => { ).toBe(true); }); - expect(sent).toContainEqual( - expect.objectContaining({ - type: "res", - id: "connect-1", - ok: false, - error: expect.objectContaining({ - code: "UNAVAILABLE", - retryable: true, - retryAfterMs: 500, - details: { reason: GATEWAY_STARTUP_UNAVAILABLE_REASON }, - }), - }), - ); + const response = sent.find( + (frame) => + typeof frame === "object" && + frame !== null && + (frame as { type?: unknown; id?: unknown }).type === "res" && + (frame as { id?: unknown }).id === "connect-1", + ) as + | { + type?: unknown; + id?: unknown; + ok?: unknown; + error?: { + code?: unknown; + retryable?: unknown; + retryAfterMs?: unknown; + details?: unknown; + }; + } + | undefined; + expect(response?.type).toBe("res"); + expect(response?.id).toBe("connect-1"); + expect(response?.ok).toBe(false); + expect(response?.error?.code).toBe("UNAVAILABLE"); + expect(response?.error?.retryable).toBe(true); + expect(response?.error?.retryAfterMs).toBe(500); + expect(response?.error?.details).toEqual({ reason: GATEWAY_STARTUP_UNAVAILABLE_REASON }); await vi.waitFor(() => { expect(socket.close).toHaveBeenCalledWith(1013, "gateway starting"); }); diff --git a/src/gateway/server/ws-connection.test.ts b/src/gateway/server/ws-connection.test.ts index fc3f7af3781..f511680648b 100644 --- a/src/gateway/server/ws-connection.test.ts +++ b/src/gateway/server/ws-connection.test.ts @@ -158,7 +158,7 @@ describe("attachGatewayWsConnectionHandler", () => { currentAuth = createResolvedAuth("token-after"); - expect(handlerParams.getResolvedAuth()).toMatchObject({ token: "token-after" }); + expect(handlerParams.getResolvedAuth().token).toBe("token-after"); expect(handlerParams.getRequiredSharedGatewaySessionGeneration?.()).toBe( resolveSharedGatewaySessionGeneration(currentAuth), ); diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index 5f379b8d381..dc37ce5ad7f 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -34,12 +34,12 @@ describe("handshake auth helpers", () => { browserRateLimiter, }); - expect(resolved).toMatchObject({ - hasBrowserOriginHeader: true, - enforceOriginCheckForAnyClient: true, - rateLimitClientIp: `${BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX}https://app.example`, - authRateLimiter: browserRateLimiter, - }); + expect(resolved.hasBrowserOriginHeader).toBe(true); + expect(resolved.enforceOriginCheckForAnyClient).toBe(true); + expect(resolved.rateLimitClientIp).toBe( + `${BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX}https://app.example`, + ); + expect(resolved.authRateLimiter).toBe(browserRateLimiter); }); it("falls back to the legacy synthetic ip when the browser origin is invalid", () => { diff --git a/src/gateway/session-utils.telegram-recreate.test.ts b/src/gateway/session-utils.telegram-recreate.test.ts index 423376aa929..7aee8b7343c 100644 --- a/src/gateway/session-utils.telegram-recreate.test.ts +++ b/src/gateway/session-utils.telegram-recreate.test.ts @@ -111,16 +111,10 @@ describe("Telegram direct session recreation after delete", () => { opts: {}, }); - expect(store[TELEGRAM_DIRECT_KEY]).toEqual( - expect.objectContaining({ - lastChannel: "telegram", - lastTo: "telegram:7463849194", - origin: expect.objectContaining({ - chatType: "direct", - provider: "telegram", - }), - }), - ); + expect(store[TELEGRAM_DIRECT_KEY]?.lastChannel).toBe("telegram"); + expect(store[TELEGRAM_DIRECT_KEY]?.lastTo).toBe("telegram:7463849194"); + expect(store[TELEGRAM_DIRECT_KEY]?.origin?.chatType).toBe("direct"); + expect(store[TELEGRAM_DIRECT_KEY]?.origin?.provider).toBe("telegram"); expect(listed.sessions.map((session) => session.key)).toContain(TELEGRAM_DIRECT_KEY); }); }); diff --git a/src/gateway/sessions-resolve-store.test.ts b/src/gateway/sessions-resolve-store.test.ts index 98b0de93a37..758e377cbb8 100644 --- a/src/gateway/sessions-resolve-store.test.ts +++ b/src/gateway/sessions-resolve-store.test.ts @@ -40,6 +40,101 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => { }); }); + it("does not resolve another agent store when agentId is scoped", async () => { + await withStateDirEnv("openclaw-sessions-resolve-agent-scope-", async () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + }; + const workStorePath = resolveStorePath(cfg.session?.store, { agentId: "work" }); + await saveSessionStore(workStorePath, { + "agent:work:target": { + sessionId: "sess-shared", + label: "shared-label", + updatedAt: freshUpdatedAt(), + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { sessionId: "sess-shared", agentId: "main" }, + }), + ).resolves.toEqual({ + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "No session found: sess-shared", + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { label: "shared-label", agentId: "main" }, + }), + ).resolves.toEqual({ + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "No session found with label: shared-label", + }, + }); + }); + }); + + it("preserves cross-agent ambiguity when agentId is absent", async () => { + await withStateDirEnv("openclaw-sessions-resolve-cross-agent-", async () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + }; + const updatedAt = freshUpdatedAt(); + await saveSessionStore(resolveStorePath(cfg.session?.store, { agentId: "main" }), { + "main-target": { + sessionId: "sess-shared", + label: "shared-label", + updatedAt, + }, + }); + await saveSessionStore(resolveStorePath(cfg.session?.store, { agentId: "work" }), { + "work-target": { + sessionId: "sess-shared", + label: "shared-label", + updatedAt, + }, + }); + + const sessionIdResult = await resolveSessionKeyFromResolveParams({ + cfg, + p: { sessionId: "sess-shared" }, + }); + expect(sessionIdResult.ok).toBe(false); + if (sessionIdResult.ok) { + throw new Error("expected ambiguous sessionId result"); + } + expect(sessionIdResult.error.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(sessionIdResult.error.message).toContain( + "Multiple sessions found for sessionId: sess-shared", + ); + expect(sessionIdResult.error.message).toContain("agent:main:main-target"); + expect(sessionIdResult.error.message).toContain("agent:work:work-target"); + + const labelResult = await resolveSessionKeyFromResolveParams({ + cfg, + p: { label: "shared-label" }, + }); + expect(labelResult.ok).toBe(false); + if (labelResult.ok) { + throw new Error("expected ambiguous label result"); + } + expect(labelResult.error.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(labelResult.error.message).toContain( + "Multiple sessions found with label: shared-label", + ); + expect(labelResult.error.message).toContain("agent:main:main-target"); + expect(labelResult.error.message).toContain("agent:work:work-target"); + }); + }); + it("still rejects non-alias agent:main matches when main is no longer configured", async () => { await withStateDirEnv("openclaw-sessions-resolve-stale-main-", async ({ stateDir }) => { const storePath = path.join(stateDir, "sessions.json"); diff --git a/src/gateway/sessions-resolve.test.ts b/src/gateway/sessions-resolve.test.ts index 481af06a226..7f21200874e 100644 --- a/src/gateway/sessions-resolve.test.ts +++ b/src/gateway/sessions-resolve.test.ts @@ -218,12 +218,16 @@ describe("resolveSessionKeyFromResolveParams", () => { throw new Error("session rows should not be materialized for exact sessionId lookup"); }); + const cfg = {}; const result = await resolveSessionKeyFromResolveParams({ - cfg: {}, - p: { sessionId: "sess-target" }, + cfg, + p: { sessionId: "sess-target", agentId: "main" }, }); expect(result).toEqual({ ok: true, key: "agent:main:target" }); + expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, { + agentId: "main", + }); expect(hoisted.listSessionsFromStoreMock).not.toHaveBeenCalled(); }); @@ -238,11 +242,15 @@ describe("resolveSessionKeyFromResolveParams", () => { }); hoisted.listAgentIdsMock.mockReturnValue(["main"]); + const cfg = {}; const result = await resolveSessionKeyFromResolveParams({ - cfg: {}, - p: { label: "my-label" }, + cfg, + p: { label: "my-label", agentId: "main" }, }); + expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, { + agentId: "main", + }); expect(result).toEqual({ ok: false, error: { diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index 7cb47832ede..40f85315153 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -166,7 +166,7 @@ export async function resolveSessionKeyFromResolveParams(params: { } if (hasSessionId) { - const { store } = loadCombinedSessionStoreForGateway(cfg); + const { store } = loadCombinedSessionStoreForGateway(cfg, { agentId: p.agentId }); const matches = findVisibleSessionIdMatches({ store, p, sessionId }); const selection = resolveSessionIdMatchSelection(matches, sessionId); if (selection.kind === "none") { @@ -200,7 +200,7 @@ export async function resolveSessionKeyFromResolveParams(params: { }; } - const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg, { agentId: p.agentId }); const list = listSessionsFromStore({ cfg, storePath, diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index 0109b6b7048..93669a9d9d6 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -415,7 +415,9 @@ export function expectActiveRunCleanup( const clearedKeys = ( sessionCleanupMocks.clearSessionQueues.mock.calls as unknown as Array<[string[]]> )[0]?.[0]; - expect(clearedKeys).toEqual(expect.arrayContaining(expectedQueueKeys)); + for (const key of expectedQueueKeys) { + expect(clearedKeys).toContain(key); + } expect(embeddedRunMock.abortCalls).toEqual([sessionId]); expect(embeddedRunMock.waitCalls).toEqual([sessionId]); } diff --git a/src/gateway/ws-log.test.ts b/src/gateway/ws-log.test.ts index bde66e5eccc..8fe7d7203de 100644 --- a/src/gateway/ws-log.test.ts +++ b/src/gateway/ws-log.test.ts @@ -89,31 +89,26 @@ describe("gateway ws log helpers", () => { }, }); - expect(summary).toMatchObject({ - agent: "main", - run: "12345678…9abc", - session: "main", - stream: "assistant", - aseq: 2, - media: 2, - }); + expect(summary.agent).toBe("main"); + expect(summary.run).toBe("12345678…9abc"); + expect(summary.session).toBe("main"); + expect(summary.stream).toBe("assistant"); + expect(summary.aseq).toBe(2); + expect(summary.media).toBe(2); expect(summary.text).toBeTypeOf("string"); expect(summary.text).not.toContain("\n"); }); test("summarizeAgentEventForWsLog includes tool metadata", () => { - expect( - summarizeAgentEventForWsLog({ - runId: "run-1", - stream: "tool", - data: { phase: "start", name: "fetch", toolCallId: "12345678-1234-1234-1234-123456789abc" }, - }), - ).toMatchObject({ - run: "run-1", + const summary = summarizeAgentEventForWsLog({ + runId: "run-1", stream: "tool", - tool: "start:fetch", - call: "12345678…9abc", + data: { phase: "start", name: "fetch", toolCallId: "12345678-1234-1234-1234-123456789abc" }, }); + expect(summary.run).toBe("run-1"); + expect(summary.stream).toBe("tool"); + expect(summary.tool).toBe("start:fetch"); + expect(summary.call).toBe("12345678…9abc"); }); test("summarizeAgentEventForWsLog includes lifecycle errors with compact previews", () => { @@ -128,13 +123,11 @@ describe("gateway ws log helpers", () => { }, }); - expect(summary).toMatchObject({ - agent: "main", - session: "thread-1", - stream: "lifecycle", - phase: "abort", - aborted: true, - }); + expect(summary.agent).toBe("main"); + expect(summary.session).toBe("thread-1"); + expect(summary.stream).toBe("lifecycle"); + expect(summary.phase).toBe("abort"); + expect(summary.aborted).toBe(true); expect(summary.error).toBeTypeOf("string"); expect((summary.error as string).length).toBeLessThanOrEqual(120); }); diff --git a/src/infra/approval-handler-bootstrap.test.ts b/src/infra/approval-handler-bootstrap.test.ts index 307b4a7bea0..0e250c6081c 100644 --- a/src/infra/approval-handler-bootstrap.test.ts +++ b/src/infra/approval-handler-bootstrap.test.ts @@ -259,17 +259,10 @@ describe("startChannelApprovalHandlerBootstrap", () => { expect(start).toHaveBeenCalledTimes(1); await flushTransitions(); - expect(logger.error).not.toHaveBeenCalledWith( - expect.stringContaining("failed to start native approval handler"), - ); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledOnce(); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("native approval handler deferred until gateway readiness recovers"), - ); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("gateway readiness unavailable before approval handler start"), - ); - expect(logger.warn).not.toHaveBeenCalledWith( - expect.stringContaining("gateway event loop readiness timeout"), + "native approval handler deferred until gateway readiness recovers: gateway readiness unavailable before approval handler start", ); await vi.advanceTimersByTimeAsync(1_000); diff --git a/src/infra/container-environment.ts b/src/infra/container-environment.ts index f209226993f..69cd8f9cafb 100644 --- a/src/infra/container-environment.ts +++ b/src/infra/container-environment.ts @@ -22,6 +22,10 @@ export function isContainerEnvironment(): boolean { } function detectContainerEnvironment(): boolean { + if (process.env.FLY_MACHINE_ID?.trim() && process.env.FLY_APP_NAME?.trim()) { + return true; + } + for (const sentinelPath of ["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"]) { try { fs.accessSync(sentinelPath, fs.constants.F_OK); diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index c2c03afcd69..b2976db8a90 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -86,8 +86,17 @@ describe("runtime-guard", () => { pathEnv: "/usr/bin", }; expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit"); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("requires Node")); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Detected: node 20.0.0")); + expect(runtime.error).toHaveBeenCalledOnce(); + expect(runtime.error).toHaveBeenCalledWith( + [ + "openclaw requires Node >=22.16.0.", + "Detected: node 20.0.0 (exec: /usr/bin/node).", + "PATH searched: /usr/bin", + "Install Node: https://nodejs.org/en/download", + "Upgrade Node and re-run openclaw.", + ].join("\n"), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); }); it("returns silently when runtime meets requirements", () => { @@ -122,8 +131,16 @@ describe("runtime-guard", () => { }; expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit"); + expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining("Detected: unknown runtime (exec: unknown)."), + [ + "openclaw requires Node >=22.16.0.", + "Detected: unknown runtime (exec: unknown).", + "PATH searched: (not set)", + "Install Node: https://nodejs.org/en/download", + "Upgrade Node and re-run openclaw.", + ].join("\n"), ); + expect(runtime.exit).toHaveBeenCalledWith(1); }); }); diff --git a/src/infra/windows-task-restart.test.ts b/src/infra/windows-task-restart.test.ts index d14b4581c33..fe584660310 100644 --- a/src/infra/windows-task-restart.test.ts +++ b/src/infra/windows-task-restart.test.ts @@ -98,10 +98,12 @@ describe("relaunchGatewayScheduledTask", () => { expect(result.tried).toContain(`cmd.exe /d /s /c ${seenCommandArg}`); const spawnCall = spawnMock.mock.calls[0]; expect(spawnCall?.[0]).toBe("cmd.exe"); - expect(spawnCall?.[1]).toEqual(["/d", "/s", "/c", expect.any(String)]); - expect(spawnCall?.[2]?.detached).toBe(true); - expect(spawnCall?.[2]?.stdio).toBe("ignore"); - expect(spawnCall?.[2]?.windowsHide).toBe(true); + expect(spawnCall?.[1]).toStrictEqual(["/d", "/s", "/c", seenCommandArg]); + expect(spawnCall?.[2]).toStrictEqual({ + detached: true, + stdio: "ignore", + windowsHide: true, + }); expect(unref).toHaveBeenCalledOnce(); const scriptPath = [...createdScriptPaths][0]; @@ -179,11 +181,29 @@ describe("relaunchGatewayScheduledTask", () => { relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" }); - expect(spawnMock).toHaveBeenCalledWith( - "cmd.exe", - ["/d", "/s", "/c", expect.stringMatching(/^".*&.*"$/)], - expect.any(Object), - ); + expect(spawnMock).toHaveBeenCalledOnce(); + const spawnCall = spawnMock.mock.calls[0]; + if (!spawnCall) { + throw new Error("expected restart helper spawn call"); + } + const commandArgs = spawnCall[1]; + if (!Array.isArray(commandArgs)) { + throw new Error("expected cmd.exe argument array"); + } + const commandArg = commandArgs[3]; + if (typeof commandArg !== "string") { + throw new Error("expected quoted restart helper path"); + } + expect(spawnCall[0]).toBe("cmd.exe"); + expect(commandArgs).toStrictEqual(["/d", "/s", "/c", commandArg]); + expect(commandArg.startsWith('"')).toBe(true); + expect(commandArg.endsWith('"')).toBe(true); + expect(commandArg).toContain("&"); + expect(spawnCall[2]).toStrictEqual({ + detached: true, + stdio: "ignore", + windowsHide: true, + }); }); it("includes startup fallback", () => { diff --git a/src/logging/diagnostic-stability.ts b/src/logging/diagnostic-stability.ts index 87df183d516..13778114946 100644 --- a/src/logging/diagnostic-stability.ts +++ b/src/logging/diagnostic-stability.ts @@ -7,6 +7,7 @@ import { const DEFAULT_DIAGNOSTIC_STABILITY_CAPACITY = 1000; const DEFAULT_DIAGNOSTIC_STABILITY_LIMIT = 50; export const MAX_DIAGNOSTIC_STABILITY_LIMIT = DEFAULT_DIAGNOSTIC_STABILITY_CAPACITY; +const LIVENESS_EVENT_LOOP_DELAY_WARN_MS = 1_000; const SAFE_REASON_CODE = /^[A-Za-z0-9_.:-]{1,120}$/u; @@ -170,6 +171,15 @@ function assignReasonCode( } } +function resolveDiagnosticLivenessRecordLevel( + event: Extract, +): "warning" | "info" { + const hasBlockingWork = event.waiting > 0 || event.queued > 0; + const hasSustainedEventLoopDelay = + (event.eventLoopDelayP99Ms ?? 0) >= LIVENESS_EVENT_LOOP_DELAY_WARN_MS; + return hasBlockingWork || (event.active > 0 && hasSustainedEventLoopDelay) ? "warning" : "info"; +} + function isRecord( record: DiagnosticStabilityEventRecord | undefined, ): record is DiagnosticStabilityEventRecord { @@ -317,7 +327,7 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi record.queued = event.queued; break; case "diagnostic.liveness.warning": - record.level = event.active > 0 || event.waiting > 0 || event.queued > 0 ? "warning" : "info"; + record.level = resolveDiagnosticLivenessRecordLevel(event); record.durationMs = event.intervalMs; record.count = event.reasons.length; assignReasonCode(record, event.reasons[0]); diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 9e87a63443e..fec37c82e89 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -1116,7 +1116,7 @@ describe("stuck session diagnostics threshold", () => { getDiagnosticStabilitySnapshot({ limit: 10 }).events, { type: "diagnostic.liveness.warning", - level: "warning", + level: "info", active: 1, waiting: 0, queued: 0, diff --git a/src/plugin-sdk/channel-policy.test.ts b/src/plugin-sdk/channel-policy.test.ts index 64311e28bd0..682326d9786 100644 --- a/src/plugin-sdk/channel-policy.test.ts +++ b/src/plugin-sdk/channel-policy.test.ts @@ -85,12 +85,12 @@ describe("createDangerousNameMatchingMutableAllowlistWarningCollector", () => { }, } as never, }), - ).toEqual( - expect.arrayContaining([ - expect.stringContaining("mutable allowlist entry"), - expect.stringContaining("channels.irc.allowFrom: charlie"), - ]), - ); + ).toEqual([ + "- Found 1 mutable allowlist entry across irc while name matching is disabled by default.", + "- channels.irc.allowFrom: charlie", + "- Option A (break-glass): enable channels.irc.dangerouslyAllowNameMatching=true to keep name/email/nick matching.", + "- Option B (recommended): resolve names/emails/nicks to stable sender IDs and rewrite the allowlist entries.", + ]); }); it("skips scopes that explicitly allow dangerous name matching", () => { diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index 94323d7cd40..5c73177a9c9 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -88,6 +88,7 @@ async function configureSelfHostedTestProvider(params: { describe("discoverOpenAICompatibleLocalModels", () => { it("uses guarded fetch pinned to the configured self-hosted provider", async () => { const release = vi.fn(async () => undefined); + const propsRelease = vi.fn(async () => undefined); fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: new Response(JSON.stringify({ data: [{ id: "Qwen/Qwen3-32B" }] }), { status: 200, @@ -95,6 +96,11 @@ describe("discoverOpenAICompatibleLocalModels", () => { finalUrl: "http://127.0.0.1:8000/v1/models", release, }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response("{}", { status: 404 }), + finalUrl: "http://127.0.0.1:8000/props", + release: propsRelease, + }); const models = await discoverOpenAICompatibleLocalModels({ baseUrl: "http://127.0.0.1:8000/v1/", @@ -114,15 +120,223 @@ describe("discoverOpenAICompatibleLocalModels", () => { maxTokens: 8192, }, ]); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ - url: "http://127.0.0.1:8000/v1/models", - init: { headers: { Authorization: "Bearer self-hosted-test-key" } }, - policy: { - hostnameAllowlist: ["127.0.0.1"], - allowPrivateNetwork: true, - }, - timeoutMs: 5000, + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "http://127.0.0.1:8000/v1/models", + init: { headers: { Authorization: "Bearer self-hosted-test-key" } }, + policy: { + hostnameAllowlist: ["127.0.0.1"], + allowPrivateNetwork: true, + }, + timeoutMs: 5000, + }), + ); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "http://127.0.0.1:8000/props", + init: { headers: { Authorization: "Bearer self-hosted-test-key" } }, + policy: { + hostnameAllowlist: ["127.0.0.1"], + allowPrivateNetwork: true, + }, + timeoutMs: 2500, + }), + ); + expect(release).toHaveBeenCalledOnce(); + expect(propsRelease).toHaveBeenCalledOnce(); + }); + + it("uses llama.cpp nested /props n_ctx as the runtime context cap", async () => { + const modelsRelease = vi.fn(async () => undefined); + const propsRelease = vi.fn(async () => undefined); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [ + { + id: "qwen3.6-mxfp4-moe", + meta: { n_ctx_train: 262_144 }, + }, + ], + }), + { status: 200 }, + ), + finalUrl: "http://127.0.0.1:8080/v1/models", + release: modelsRelease, }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65_536 } }), { + status: 200, + }), + finalUrl: "http://127.0.0.1:8080/props", + release: propsRelease, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + env: {}, + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "qwen3.6-mxfp4-moe", + contextWindow: 262_144, + contextTokens: 65_536, + }), + ]); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: "http://127.0.0.1:8080/props", + }), + ); + expect(modelsRelease).toHaveBeenCalledOnce(); + expect(propsRelease).toHaveBeenCalledOnce(); + }); + + it("scopes llama.cpp /props runtime caps to each discovered model without autoloading", async () => { + const modelsRelease = vi.fn(async () => undefined); + const firstPropsRelease = vi.fn(async () => undefined); + const secondPropsRelease = vi.fn(async () => undefined); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [ + { + id: "qwen/router-a", + meta: { n_ctx_train: 262_144 }, + }, + { + id: "qwen/router-b", + meta: { n_ctx_train: 131_072 }, + }, + ], + }), + { status: 200 }, + ), + finalUrl: "http://127.0.0.1:8080/v1/models", + release: modelsRelease, + }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65_536 } }), { + status: 200, + }), + finalUrl: "http://127.0.0.1:8080/props?model=qwen%2Frouter-a&autoload=false", + release: firstPropsRelease, + }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ default_generation_settings: { n_ctx: 32_768 } }), { + status: 200, + }), + finalUrl: "http://127.0.0.1:8080/props?model=qwen%2Frouter-b&autoload=false", + release: secondPropsRelease, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + env: {}, + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "qwen/router-a", + contextWindow: 262_144, + contextTokens: 65_536, + }), + expect.objectContaining({ + id: "qwen/router-b", + contextWindow: 131_072, + contextTokens: 32_768, + }), + ]); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: "http://127.0.0.1:8080/props?model=qwen%2Frouter-a&autoload=false", + }), + ); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + url: "http://127.0.0.1:8080/props?model=qwen%2Frouter-b&autoload=false", + }), + ); + expect(modelsRelease).toHaveBeenCalledOnce(); + expect(firstPropsRelease).toHaveBeenCalledOnce(); + expect(secondPropsRelease).toHaveBeenCalledOnce(); + }); + + it("keeps top-level llama.cpp /props n_ctx as a compatibility fallback", async () => { + const modelsRelease = vi.fn(async () => undefined); + const propsRelease = vi.fn(async () => undefined); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [ + { + id: "qwen3.6-mxfp4-moe", + meta: { n_ctx_train: 262_144 }, + }, + ], + }), + { status: 200 }, + ), + finalUrl: "http://127.0.0.1:8080/v1/models", + release: modelsRelease, + }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ n_ctx: 65_536 }), { status: 200 }), + finalUrl: "http://127.0.0.1:8080/props", + release: propsRelease, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + env: {}, + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "qwen3.6-mxfp4-moe", + contextWindow: 262_144, + contextTokens: 65_536, + }), + ]); + expect(modelsRelease).toHaveBeenCalledOnce(); + expect(propsRelease).toHaveBeenCalledOnce(); + }); + + it("preserves explicit configured context windows ahead of llama.cpp /props", async () => { + const release = vi.fn(async () => undefined); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [{ id: "qwen3.6-mxfp4-moe", meta: { n_ctx_train: 262_144 } }], + }), + { status: 200 }, + ), + finalUrl: "http://127.0.0.1:8080/v1/models", + release, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + contextWindow: 65_536, + env: {}, + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "qwen3.6-mxfp4-moe", + contextWindow: 65_536, + }), + ]); + expect(models[0]).not.toHaveProperty("contextTokens"); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); expect(release).toHaveBeenCalledOnce(); }); diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index b79bbdf4722..6c625f17ebd 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -35,9 +35,19 @@ const log = createSubsystemLogger("plugins/self-hosted-provider-setup"); type OpenAICompatModelsResponse = { data?: Array<{ id?: string; + meta?: { + n_ctx_train?: unknown; + }; }>; }; +type LlamaCppPropsResponse = { + default_generation_settings?: { + n_ctx?: unknown; + }; + n_ctx?: unknown; +}; + function isReasoningModelHeuristic(modelId: string): boolean { return /r1|reasoning|think|reason/i.test(modelId); } @@ -62,6 +72,66 @@ function buildSelfHostedBaseUrlSsrFPolicy(baseUrl: string): SsrFPolicy | undefin } } +function readPositiveInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return Math.trunc(value); +} + +function resolveLlamaCppPropsUrl(baseUrl: string, modelId?: string): string { + const parsed = new URL(baseUrl); + const pathname = parsed.pathname.replace(/\/+$/, ""); + const rootPathname = pathname.endsWith("/v1") ? pathname.slice(0, -3) || "/" : pathname; + parsed.pathname = `${rootPathname.replace(/\/+$/, "")}/props`; + parsed.search = ""; + parsed.hash = ""; + const normalizedModelId = normalizeOptionalString(modelId); + if (normalizedModelId) { + parsed.searchParams.set("model", normalizedModelId); + parsed.searchParams.set("autoload", "false"); + } + return parsed.toString(); +} + +async function discoverLlamaCppRuntimeContextTokens(params: { + baseUrl: string; + apiKey?: string; + modelId?: string; +}): Promise { + let url: string; + try { + url = resolveLlamaCppPropsUrl(params.baseUrl, params.modelId); + } catch { + return undefined; + } + try { + const trimmedApiKey = normalizeOptionalString(params.apiKey); + const { response, release } = await fetchWithSsrFGuard({ + url, + init: { + headers: trimmedApiKey ? { Authorization: `Bearer ${trimmedApiKey}` } : undefined, + }, + policy: buildSelfHostedBaseUrlSsrFPolicy(params.baseUrl), + timeoutMs: 2500, + }); + try { + if (!response.ok) { + return undefined; + } + const data = (await response.json()) as LlamaCppPropsResponse; + return ( + readPositiveInteger(data.default_generation_settings?.n_ctx) ?? + readPositiveInteger(data.n_ctx) + ); + } finally { + await release(); + } + } catch { + return undefined; + } +} + export async function discoverOpenAICompatibleLocalModels(params: { baseUrl: string; apiKey?: string; @@ -100,21 +170,55 @@ export async function discoverOpenAICompatibleLocalModels(params: { return []; } - return models - .map((model) => ({ id: normalizeOptionalString(model.id) ?? "" })) - .filter((model) => Boolean(model.id)) - .map((model) => { - const modelId = model.id; - return { - id: modelId, - name: modelId, - reasoning: isReasoningModelHeuristic(modelId), - input: ["text"], - cost: SELF_HOSTED_DEFAULT_COST, - contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, - maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS, - } satisfies ModelDefinitionConfig; - }); + const discoveredModels = models.flatMap((model) => { + const modelId = normalizeOptionalString(model.id); + if (!modelId) { + return []; + } + return [{ id: modelId, meta: model.meta }]; + }); + const runtimeContextTokensByModelId = new Map(); + if (params.contextWindow === undefined) { + const uniqueModelIds = [...new Set(discoveredModels.map((model) => model.id))]; + const runtimeContextTokenResults = await Promise.all( + uniqueModelIds.map( + async (modelId) => + [ + modelId, + await discoverLlamaCppRuntimeContextTokens({ + baseUrl: trimmedBaseUrl, + apiKey: params.apiKey, + modelId: uniqueModelIds.length > 1 ? modelId : undefined, + }), + ] as const, + ), + ); + for (const [modelId, runtimeContextTokens] of runtimeContextTokenResults) { + if (runtimeContextTokens) { + runtimeContextTokensByModelId.set(modelId, runtimeContextTokens); + } + } + } + + return discoveredModels.map((model) => { + const modelConfig: ModelDefinitionConfig = { + id: model.id, + name: model.id, + reasoning: isReasoningModelHeuristic(model.id), + input: ["text"], + cost: SELF_HOSTED_DEFAULT_COST, + contextWindow: + params.contextWindow ?? + readPositiveInteger(model.meta?.n_ctx_train) ?? + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS, + }; + const runtimeContextTokens = runtimeContextTokensByModelId.get(model.id); + if (runtimeContextTokens) { + modelConfig.contextTokens = runtimeContextTokens; + } + return modelConfig; + }); } finally { await release(); } diff --git a/src/plugins/runtime-channel-state.ts b/src/plugins/runtime-channel-state.ts index cb0b5711898..5e50dad8eab 100644 --- a/src/plugins/runtime-channel-state.ts +++ b/src/plugins/runtime-channel-state.ts @@ -13,24 +13,68 @@ type GlobalChannelRegistryState = typeof globalThis & { }; }; +type GlobalChannelRegistryRuntimeState = GlobalChannelRegistryState[typeof PLUGIN_REGISTRY_STATE]; + +export type ActivePluginChannelRegistrySnapshot = { + registry: ActivePluginChannelRegistry | null; + version: number; +}; + +let activePluginChannelRegistrySnapshot: + | { + state: GlobalChannelRegistryRuntimeState; + pinnedRegistry: ActivePluginChannelRegistry | null; + activeRegistry: ActivePluginChannelRegistry | null; + pinnedChannelCount: number; + activeChannelCount: number; + snapshot: ActivePluginChannelRegistrySnapshot; + } + | undefined; + function countChannels(registry: ActivePluginChannelRegistry | null | undefined): number { return registry?.channels?.length ?? 0; } -export function getActivePluginChannelRegistryFromState(): ActivePluginChannelRegistry | null { +export function getActivePluginChannelRegistrySnapshotFromState(): ActivePluginChannelRegistrySnapshot { const state = (globalThis as GlobalChannelRegistryState)[PLUGIN_REGISTRY_STATE]; const pinnedRegistry = state?.channel?.registry ?? null; - if (countChannels(pinnedRegistry) > 0) { - return pinnedRegistry; - } const activeRegistry = state?.activeRegistry ?? null; - if (countChannels(activeRegistry) > 0) { - return activeRegistry; + const pinnedChannelCount = countChannels(pinnedRegistry); + const activeChannelCount = countChannels(activeRegistry); + const selectedPinnedRegistry = + pinnedChannelCount > 0 || (pinnedRegistry !== null && activeChannelCount === 0); + const version = selectedPinnedRegistry + ? (state?.channel?.version ?? 0) + : (state?.activeVersion ?? 0); + const cached = activePluginChannelRegistrySnapshot; + if ( + cached && + cached.state === state && + cached.pinnedRegistry === pinnedRegistry && + cached.activeRegistry === activeRegistry && + cached.pinnedChannelCount === pinnedChannelCount && + cached.activeChannelCount === activeChannelCount && + cached.snapshot.version === version + ) { + return cached.snapshot; } - return pinnedRegistry ?? activeRegistry; + const registry = selectedPinnedRegistry ? pinnedRegistry : activeRegistry; + const snapshot = { registry, version }; + activePluginChannelRegistrySnapshot = { + state, + pinnedRegistry, + activeRegistry, + pinnedChannelCount, + activeChannelCount, + snapshot, + }; + return snapshot; +} + +export function getActivePluginChannelRegistryFromState(): ActivePluginChannelRegistry | null { + return getActivePluginChannelRegistrySnapshotFromState().registry; } export function getActivePluginChannelRegistryVersionFromState(): number { - const state = (globalThis as GlobalChannelRegistryState)[PLUGIN_REGISTRY_STATE]; - return state?.channel?.registry ? (state.channel.version ?? 0) : (state?.activeVersion ?? 0); + return getActivePluginChannelRegistrySnapshotFromState().version; } diff --git a/src/plugins/runtime.channel-pin.test.ts b/src/plugins/runtime.channel-pin.test.ts index f7a2db988c6..6a7c436009f 100644 --- a/src/plugins/runtime.channel-pin.test.ts +++ b/src/plugins/runtime.channel-pin.test.ts @@ -124,6 +124,19 @@ describe("channel registry pinning", () => { expect(isPluginRegistryRetired(startup)).toBe(true); }); + it("falls back to the active channel registry when the pinned registry is empty", () => { + const startup = createEmptyPluginRegistry(); + const { registry: replacement } = createRegistryWithChannel("replacement-channel"); + setActivePluginRegistry(startup); + pinActivePluginChannelRegistry(startup); + + const channelVersionBeforeSwap = getActivePluginChannelRegistryVersion(); + setActivePluginRegistry(replacement); + + expectActiveChannelRegistry(replacement); + expect(getActivePluginChannelRegistryVersion()).not.toBe(channelVersionBeforeSwap); + }); + it("re-pin invalidates cached channel lookups", () => { const { first, second } = createChannelRegistryPair(); const { registry: setup, plugin: setupPlugin } = first; diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 9147f951fec..14e9a6bf688 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -7,6 +7,7 @@ import { import { createEmptyPluginRegistry } from "./registry-empty.js"; import { markPluginRegistryActive, markPluginRegistryRetired } from "./registry-lifecycle.js"; import type { PluginRegistry } from "./registry-types.js"; +import { getActivePluginChannelRegistrySnapshotFromState } from "./runtime-channel-state.js"; import { PLUGIN_REGISTRY_STATE, type RegistryState, @@ -277,10 +278,6 @@ export function resolveActivePluginHttpRouteRegistry(fallback: PluginRegistry): return routeRegistry; } -/** Pin the channel registry so that subsequent `setActivePluginRegistry` calls - * do not replace the channel snapshot used by `getChannelPlugin`. Call at - * gateway startup after the initial plugin load so that config-schema reads - * and other non-primary registry loads cannot evict channel plugins. */ export function pinActivePluginChannelRegistry(registry: PluginRegistry) { const previousRegistry = asPluginRegistry(state.channel.registry); installSurfaceRegistry(state.channel, registry, true); @@ -303,15 +300,12 @@ export function releasePinnedPluginChannelRegistry(registry?: PluginRegistry) { } } -/** Return the registry that should be used for channel plugin resolution. - * When pinned, this returns the startup registry regardless of subsequent - * `setActivePluginRegistry` calls. */ export function getActivePluginChannelRegistry(): PluginRegistry | null { - return asPluginRegistry(state.channel.registry ?? state.activeRegistry); + return getActivePluginChannelRegistrySnapshotFromState().registry as PluginRegistry | null; } export function getActivePluginChannelRegistryVersion(): number { - return state.channel.registry ? state.channel.version : state.activeVersion; + return getActivePluginChannelRegistrySnapshotFromState().version; } export function requireActivePluginChannelRegistry(): PluginRegistry { diff --git a/src/terminal/note.ts b/src/terminal/note.ts index 81d38fde18c..bb64ad3efb5 100644 --- a/src/terminal/note.ts +++ b/src/terminal/note.ts @@ -147,13 +147,30 @@ function wrapLine(line: string, maxWidth: number): string[] { return lines; } +function coerceNoteMessage(message: unknown): string { + if (typeof message === "string") { + return message; + } + if (message == null) { + return ""; + } + if (typeof message === "number" || typeof message === "boolean" || typeof message === "bigint") { + return String(message); + } + if (message instanceof Error) { + return message.message ? `${message.name}: ${message.message}` : message.name; + } + return ""; +} + export function wrapNoteMessage( - message: string, + message: unknown, options: { maxWidth?: number; columns?: number } = {}, ): string { + const text = coerceNoteMessage(message); const columns = options.columns ?? resolveNoteColumns(process.stdout.columns); const maxWidth = options.maxWidth ?? Math.max(40, Math.min(88, columns - 10)); - return message + return text .split("\n") .flatMap((line) => wrapLine(line, maxWidth)) .join("\n"); @@ -179,7 +196,7 @@ function createNoteOutput(columns: number): NodeJS.WriteStream { return output; } -export function note(message: string, title?: string) { +export function note(message: unknown, title?: string) { if (isSuppressedByEnv(process.env.OPENCLAW_SUPPRESS_NOTES)) { return; } diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index c61082a661c..dd28b82043b 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -24,7 +24,7 @@ describe("renderTable", () => { }); expect(out).toContain("Dashboard"); - expect(out).toMatch(/│ Dashboard\s+│/); + expect(out).toMatch(/[│|] Dashboard\s+[│|]/); }); it("expands flex columns to fill available width", () => { @@ -86,7 +86,7 @@ describe("renderTable", () => { const lines = out.split("\n").filter((line) => line.includes("a")); for (const line of lines) { const resetIndex = line.lastIndexOf(reset); - const lastSep = line.lastIndexOf("│"); + const lastSep = Math.max(line.lastIndexOf("│"), line.lastIndexOf("|")); expect(resetIndex).toBeGreaterThan(-1); expect(lastSep).toBeGreaterThan(resetIndex); } @@ -279,4 +279,12 @@ describe("wrapNoteMessage", () => { expect(resolveNoteColumns(79)).toBe(80); expect(resolveNoteColumns(120)).toBe(120); }); + + it("coerces nullish and non-string note messages before wrapping", () => { + expect(wrapNoteMessage(undefined, { maxWidth: 20, columns: 80 })).toBe(""); + expect(wrapNoteMessage(null, { maxWidth: 20, columns: 80 })).toBe(""); + expect(wrapNoteMessage(12345, { maxWidth: 20, columns: 80 })).toBe("12345"); + expect(wrapNoteMessage(new Error("boom"), { maxWidth: 20, columns: 80 })).toBe("Error: boom"); + expect(wrapNoteMessage({ message: "boom" }, { maxWidth: 20, columns: 80 })).toBe(""); + }); }); diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 3edfd0cf2ea..3c3f17fcf4a 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -112,6 +112,7 @@ describe("scripts/lib/docker-e2e-plan", () => { }); expect(plan.credentials).toEqual(["anthropic", "openai"]); expect(plan.lanes.map((lane) => lane.name)).toContain("install-e2e-openai"); + expect(plan.lanes.map((lane) => lane.name)).toContain("openai-chat-tools"); expect(plan.lanes.map((lane) => lane.name)).toContain("codex-on-demand"); expect(plan.lanes.map((lane) => lane.name)).toContain("install-e2e-anthropic"); expect(plan.lanes.map((lane) => lane.name)).toContain("mcp-channels"); @@ -155,6 +156,7 @@ describe("scripts/lib/docker-e2e-plan", () => { const laneNames = plan.lanes.map((lane) => lane.name); expect(plan.releaseProfile).toBe("beta"); expect(laneNames).toContain("install-e2e-openai"); + expect(laneNames).toContain("openai-chat-tools"); expect(laneNames).toContain("install-e2e-anthropic"); expect(laneNames).toContain("update-channel-switch"); expect(laneNames).not.toContain("plugins"); @@ -243,6 +245,7 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(packageInstallOpenAi.lanes.map((lane) => lane.name)).toEqual([ "install-e2e-openai", + "openai-chat-tools", "codex-on-demand", ]); expect(packageInstallAnthropic.lanes.map((lane) => lane.name)).toEqual([ @@ -468,6 +471,7 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(packageUpdate.lanes.map((lane) => lane.name)).toEqual([ "install-e2e-openai", + "openai-chat-tools", "codex-on-demand", "install-e2e-anthropic", "npm-onboard-channel-agent", diff --git a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts index b2741619288..0814d7d328c 100644 --- a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts +++ b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts @@ -62,6 +62,7 @@ describe("Mantis Telegram Desktop proof workflow", () => { const workflow = readFileSync(WORKFLOW, "utf8"); expect(workflow).toContain("@openclaw-mantis"); expect(workflow).toContain("/openclaw-mantis"); + expect(workflow).toContain("mantis: telegram-visible-proof"); expect(workflow).not.toContain("@Mantis"); expect(workflow).not.toContain("@mantis"); expect(workflow).not.toContain('"/mantis"'); @@ -107,12 +108,42 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(prepare.run).toContain( "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD", ); + expect(prepare.run).toContain("MANTIS_CANDIDATE_TRUST"); const prompt = readFileSync(PROMPT, "utf8"); expect(prompt).toContain("$OPENCLAW_TELEGRAM_USER_PROOF_CMD"); expect(prompt).toContain("do not run\n `pnpm qa:telegram-user:crabbox` directly"); }); + it("derives refs from the PR instead of parsing comment prose", () => { + const workflowText = readFileSync(WORKFLOW, "utf8"); + expect(workflowText).toContain('setOutput("baseline_ref", pr.base.sha)'); + expect(workflowText).toContain('setOutput("candidate_ref", pr.head.sha)'); + expect(workflowText).not.toContain("body.match"); + expect(workflowText).not.toContain("baselineMatch"); + expect(workflowText).not.toContain("candidateMatch"); + expect(workflowText).not.toContain("leaseMatch"); + expect(workflowText).not.toContain("fork-ok"); + expect(workflowText).not.toContain("allow_fork_candidate"); + }); + + it("trusts the open PR head and marks fork heads for sandboxed handling", () => { + const workflowText = readFileSync(WORKFLOW, "utf8"); + expect(workflowText).toContain("repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"); + expect(workflowText).toContain('candidate_trust="fork-pr-head"'); + expect(workflowText).toContain('pr_head_repo" != "$GITHUB_REPOSITORY"'); + + const agent = workflowStep("Run Codex Mantis Telegram agent"); + expect(agent.env?.MANTIS_CANDIDATE_TRUST).toBe( + "${{ needs.validate_refs.outputs.candidate_trust }}", + ); + + const prompt = readFileSync(PROMPT, "utf8"); + expect(prompt).toContain("MANTIS_CANDIDATE_TRUST"); + expect(prompt).toContain("fork-pr-head"); + expect(prompt).toContain("untrusted fork code"); + }); + it("checks the Telegram user driver before leasing credentials", () => { const proofScript = readFileSync(PROOF_SCRIPT, "utf8"); const startSession = proofScript.slice( @@ -132,4 +163,12 @@ describe("Mantis Telegram Desktop proof workflow", () => { defaultProof.indexOf("leaseCredential({ localRoot, opts, root })"), ); }); + + it("does not pass the full workflow environment into the local Telegram SUT", () => { + const proofScript = readFileSync(PROOF_SCRIPT, "utf8"); + expect(proofScript).toContain("function childProcessBaseEnv()"); + expect(proofScript).toContain("...childProcessBaseEnv()"); + expect(proofScript).not.toContain("...process.env,\n OPENAI_API_KEY"); + expect(proofScript).not.toContain("...process.env,\n MOCK_PORT"); + }); }); diff --git a/ui/src/ui/app.talk.test.ts b/ui/src/ui/app.talk.test.ts index e1811b4c878..4fb77342a61 100644 --- a/ui/src/ui/app.talk.test.ts +++ b/ui/src/ui/app.talk.test.ts @@ -59,9 +59,8 @@ describe("OpenClawApp Talk controls", () => { expect(startMock).toHaveBeenCalledOnce(); expect(stopMock).not.toHaveBeenCalled(); expect(app.realtimeTalkStatus).toBe("connecting"); - expect(app.realtimeTalkSession).toMatchObject({ - start: startMock, - stop: stopMock, - }); + const session = app.realtimeTalkSession as { start?: unknown; stop?: unknown } | undefined; + expect(session?.start).toBe(startMock); + expect(session?.stop).toBe(stopMock); }); }); diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index d3c4ac2a90b..b44bdc6487d 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -268,6 +268,104 @@ describe("updateConfigFormValue", () => { expect(state.configFormDirty).toBe(false); }); + + it("removes only automatically added plugin allow entries", () => { + const state = createState(); + applyConfigSnapshot(state, { + hash: "hash-plugins", + config: { + plugins: { + allow: ["openai"], + entries: { + deepseek: { enabled: false }, + }, + }, + }, + valid: true, + issues: [], + raw: "{}", + }); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], true); + + expect(state.configForm).toEqual({ + plugins: { + allow: ["openai", "deepseek"], + entries: { + deepseek: { enabled: true }, + }, + }, + }); + expect(state.configFormDirty).toBe(true); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], false); + + expect(state.configForm).toEqual({ + plugins: { + allow: ["openai"], + entries: { + deepseek: { enabled: false }, + }, + }, + }); + expect(state.configFormDirty).toBe(false); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], true); + updateConfigFormValue(state, ["plugins", "allow"], ["openai", "deepseek", "firecrawl"]); + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], false); + + expect(state.configForm).toEqual({ + plugins: { + allow: ["openai", "deepseek", "firecrawl"], + entries: { + deepseek: { enabled: false }, + }, + }, + }); + expect(state.configFormDirty).toBe(true); + }); + + it("preserves empty plugin allowlists when enabling a plugin", () => { + const state = createState(); + applyConfigSnapshot(state, { + hash: "hash-plugins", + config: { + plugins: { + allow: [], + entries: { + deepseek: { enabled: false }, + }, + }, + }, + valid: true, + issues: [], + raw: "{}", + }); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], true); + + expect(state.configForm).toEqual({ + plugins: { + allow: [], + entries: { + deepseek: { enabled: true }, + }, + }, + }); + expect(state.configFormDirty).toBe(true); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], false); + + expect(state.configForm).toEqual({ + plugins: { + allow: [], + entries: { + deepseek: { enabled: false }, + }, + }, + }); + expect(state.configFormDirty).toBe(false); + }); }); describe("stageConfigPreset", () => { diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 1d3a5299111..2be2702ad5c 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -40,6 +40,8 @@ export type ConfigState = { lastError: string | null; }; +const autoAllowlistedPluginIdsByState = new WeakMap>(); + export type LoadConfigOptions = { discardPendingChanges?: boolean; }; @@ -118,6 +120,7 @@ export function applyConfigSnapshot( state.configRawOriginal = rawFromSnapshot; state.configFormDirty = false; state.configDraftBaseHash = snapshot.hash ?? null; + autoAllowlistedPluginIdsByState.delete(state); } else { state.configDraftBaseHash = draftBaseHash; } @@ -210,6 +213,7 @@ async function submitConfigChange( await state.client.request(method, { raw, baseHash, ...extraParams }); state.configFormDirty = false; state.configDraftBaseHash = null; + autoAllowlistedPluginIdsByState.delete(state); await loadConfig(state); return true; } catch (err) { @@ -279,12 +283,88 @@ function mutateConfigForm(state: ConfigState, mutate: (draft: Record, + path: Array, + value: unknown, +) { + if ( + path.length !== 4 || + path[0] !== "plugins" || + path[1] !== "entries" || + typeof path[2] !== "string" || + path[3] !== "enabled" + ) { + return; + } + const pluginId = path[2]; + const plugins = + draft.plugins && typeof draft.plugins === "object" && !Array.isArray(draft.plugins) + ? (draft.plugins as Record) + : null; + const allow = Array.isArray(plugins?.allow) ? plugins.allow : null; + if (!allow) { + untrackAutoAllowlistedPluginId(state, pluginId); + return; + } + if (value === true) { + if (allow.includes(pluginId)) { + return; + } + if (allow.length === 0) { + untrackAutoAllowlistedPluginId(state, pluginId); + return; + } + setPathValue(draft, ["plugins", "allow"], [...allow, pluginId]); + trackAutoAllowlistedPluginId(state, pluginId); + return; + } + const autoAllowlistedPluginIds = autoAllowlistedPluginIdsByState.get(state); + if (!autoAllowlistedPluginIds?.has(pluginId)) { + return; + } + setPathValue( + draft, + ["plugins", "allow"], + allow.filter((entry) => entry !== pluginId), + ); + untrackAutoAllowlistedPluginId(state, pluginId); +} + export function updateConfigFormValue( state: ConfigState, path: Array, value: unknown, ) { - mutateConfigForm(state, (draft) => setPathValue(draft, path, value)); + mutateConfigForm(state, (draft) => { + setPathValue(draft, path, value); + if (path[0] === "plugins" && path[1] === "allow") { + autoAllowlistedPluginIdsByState.delete(state); + return; + } + syncEnabledPluginAllowlist(state, draft, path, value); + }); } export function stageConfigPreset(state: ConfigState, patch: Record) { @@ -315,6 +395,7 @@ export function resetConfigPendingChanges(state: ConfigState) { serializeConfigForm(state.configFormOriginal ?? state.configSnapshot?.config ?? {}); state.configFormDirty = false; state.configDraftBaseHash = state.configSnapshot?.hash ?? null; + autoAllowlistedPluginIdsByState.delete(state); } export function removeConfigFormValue(state: ConfigState, path: Array) { diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 053bcab9455..ca84d63b068 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -121,7 +121,9 @@ function expectLatestRequestTiming( expected: Partial, ) { const timing = onRequestTiming.mock.calls.at(-1)?.[0] as RequestTimingPayload | undefined; - expect(timing).toMatchObject(expected); + for (const [key, value] of Object.entries(expected)) { + expect(timing?.[key as keyof RequestTimingPayload]).toBe(value); + } expect(timing?.startedAtMs).toBeTypeOf("number"); expect(timing?.endedAtMs).toBeTypeOf("number"); expect(timing?.durationMs).toBeTypeOf("number"); @@ -283,6 +285,97 @@ describe("GatewayBrowserClient", () => { expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]); }); + it("reports browser security errors from WebSocket construction without retrying", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + class ThrowingWebSocket { + static OPEN = 1; + + constructor(_url: string) { + const err = new Error("Cannot connect due to a security error."); + err.name = "SecurityError"; + throw err; + } + } + vi.stubGlobal("WebSocket", ThrowingWebSocket); + + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + token: "shared-auth-token", + onClose, + }); + + expect(() => client.start()).not.toThrow(); + const close = onClose.mock.calls[0]?.[0] as + | { + code?: number; + reason?: string; + error?: { + code?: string; + message?: string; + details?: { code?: string; browserErrorName?: string }; + }; + } + | undefined; + expect(close?.code).toBe(1006); + expect(close?.reason).toBe("security error"); + expect(close?.error?.code).toBe("BROWSER_WEBSOCKET_SECURITY_ERROR"); + expect(close?.error?.message).toContain("Use wss://"); + expect(close?.error?.details?.code).toBe("BROWSER_WEBSOCKET_SECURITY_ERROR"); + expect(close?.error?.details?.browserErrorName).toBe("SecurityError"); + expect(wsInstances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(onClose).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it("reports generic WebSocket construction failures without retrying", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + class ThrowingWebSocket { + static OPEN = 1; + + constructor(_url: string) { + throw new TypeError("constructor failed"); + } + } + vi.stubGlobal("WebSocket", ThrowingWebSocket); + + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + token: "shared-auth-token", + onClose, + }); + + expect(() => client.start()).not.toThrow(); + const close = onClose.mock.calls[0]?.[0] as + | { + code?: number; + reason?: string; + error?: { + code?: string; + message?: string; + details?: { code?: string; browserErrorName?: string; browserMessage?: string }; + }; + } + | undefined; + expect(close?.code).toBe(1006); + expect(close?.reason).toBe("websocket error"); + expect(close?.error?.code).toBe("BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR"); + expect(close?.error?.message).toContain("Could not create the Gateway WebSocket"); + expect(close?.error?.details?.code).toBe("BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR"); + expect(close?.error?.details?.browserErrorName).toBe("TypeError"); + expect(close?.error?.details?.browserMessage).toBe("constructor failed"); + expect(wsInstances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(onClose).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + it("reports request timing for attributed RPC latency", async () => { const onRequestTiming = vi.fn(); const client = new GatewayBrowserClient({ @@ -355,12 +448,14 @@ describe("GatewayBrowserClient", () => { error: { code: "CONFIG_ERROR", message: "config failed" }, }); - await expect(request).rejects.toMatchObject({ gatewayCode: "CONFIG_ERROR" }); - expect(onRequestTiming).toHaveBeenCalledWith( - expect.not.objectContaining({ - params: expect.anything(), - }), - ); + try { + await request; + throw new Error("expected config.get request to reject"); + } catch (error) { + expect((error as { gatewayCode?: string }).gatewayCode).toBe("CONFIG_ERROR"); + } + expect(onRequestTiming).toHaveBeenCalledTimes(1); + expect(onRequestTiming.mock.calls[0]?.[0]).not.toHaveProperty("params"); expectLatestRequestTiming(onRequestTiming, { id: frame.id, method: "config.get", diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 60f86f23764..50861faf25b 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -246,6 +246,9 @@ export type GatewayRequestTiming = { // 4008 = application-defined code (browser rejects 1008 "Policy Violation") const CONNECT_FAILED_CLOSE_CODE = 4008; const STARTUP_RETRY_CLOSE_CODE = 4013; +const BROWSER_WEBSOCKET_CLOSE_CODE = 1006; +const BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE = "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR"; +const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR"; function buildGatewayConnectAuth( selectedAuth: SelectedConnectAuth, @@ -261,6 +264,62 @@ function buildGatewayConnectAuth( }; } +function getErrorMessage(err: unknown): string { + return err instanceof Error && err.message ? err.message : String(err); +} + +function getErrorName(err: unknown): string | undefined { + if (err instanceof Error && err.name) { + return err.name; + } + if (err && typeof err === "object" && "name" in err) { + const name = (err as { name?: unknown }).name; + return typeof name === "string" && name.trim() ? name : undefined; + } + return undefined; +} + +function isBrowserWebSocketSecurityError(err: unknown): boolean { + const name = getErrorName(err)?.toLowerCase(); + const message = getErrorMessage(err).toLowerCase(); + return ( + name === "securityerror" || + message.includes("security error") || + message.includes("mixed content") || + message.includes("insecure websocket") + ); +} + +function formatBrowserWebSocketConstructorError(err: unknown, url: string): GatewayErrorInfo { + const securityError = isBrowserWebSocketSecurityError(err); + const browserMessage = getErrorMessage(err); + const isPlaintextWs = url.trim().toLowerCase().startsWith("ws://"); + if (securityError) { + return { + code: BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, + message: + "Browser refused the Gateway WebSocket for security reasons." + + (isPlaintextWs + ? " Use wss:// when the Control UI is served over HTTPS/Tailscale Serve, or open the loopback dashboard at http://127.0.0.1:18789." + : " Check the Gateway WebSocket URL and browser security policy."), + details: { + code: BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, + browserErrorName: getErrorName(err), + browserMessage, + }, + }; + } + return { + code: BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE, + message: `Could not create the Gateway WebSocket: ${browserMessage}`, + details: { + code: BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE, + browserErrorName: getErrorName(err), + browserMessage, + }, + }; +} + async function buildGatewayConnectDevice(params: { deviceIdentity: Awaited> | null; client: GatewayConnectClientInfo; @@ -350,7 +409,26 @@ export class GatewayBrowserClient { if (this.closed) { return; } - const ws = new WebSocket(this.opts.url); + let ws: WebSocket; + try { + ws = new WebSocket(this.opts.url); + } catch (err) { + const error = formatBrowserWebSocketConstructorError(err, this.opts.url); + this.ws = null; + this.pendingConnectError = undefined; + this.pendingDeviceTokenRetry = false; + this.pendingStartupReconnectDelayMs = null; + this.flushPending(new Error(error.message)); + this.opts.onClose?.({ + code: BROWSER_WEBSOCKET_CLOSE_CODE, + reason: + error.code === BROWSER_WEBSOCKET_SECURITY_ERROR_CODE + ? "security error" + : "websocket error", + error, + }); + return; + } const generation = ++this.connectGeneration; this.ws = ws; ws.addEventListener("open", () => this.queueConnect(ws, generation)); diff --git a/ui/src/ui/realtime-talk-consult.test.ts b/ui/src/ui/realtime-talk-consult.test.ts index 66ebafd00b9..5a2591a1b24 100644 --- a/ui/src/ui/realtime-talk-consult.test.ts +++ b/ui/src/ui/realtime-talk-consult.test.ts @@ -41,14 +41,13 @@ describe("RealtimeTalkSession consult handoff", () => { submit, }); - expect(request).toHaveBeenCalledWith( - "talk.client.toolCall", - expect.objectContaining({ - sessionKey: "agent:main:main", - name: "openclaw_agent_consult", - args: { question: "Are the basement lights off?" }, - }), - ); + const toolCall = request.mock.calls[0] as + | [string, { sessionKey?: string; name?: string; args?: { question?: string } }] + | undefined; + expect(toolCall?.[0]).toBe("talk.client.toolCall"); + expect(toolCall?.[1]?.sessionKey).toBe("agent:main:main"); + expect(toolCall?.[1]?.name).toBe("openclaw_agent_consult"); + expect(toolCall?.[1]?.args).toEqual({ question: "Are the basement lights off?" }); expect(submit).toHaveBeenCalledWith("call-1", { result: "Basement lights are off." }); }); }); diff --git a/ui/src/ui/realtime-talk-gateway-relay.test.ts b/ui/src/ui/realtime-talk-gateway-relay.test.ts index 1c346115cc8..ccdc92bb43c 100644 --- a/ui/src/ui/realtime-talk-gateway-relay.test.ts +++ b/ui/src/ui/realtime-talk-gateway-relay.test.ts @@ -210,10 +210,10 @@ describe("GatewayRelayRealtimeTalkTransport", () => { pumpMicrophone(new Float32Array(4096)); expect(client.request).not.toHaveBeenCalledWith("talk.session.cancelOutput", expect.anything()); - expect(client.request).toHaveBeenCalledWith( - "talk.session.appendAudio", - expect.objectContaining({ sessionId: "relay-1" }), - ); + const appendCall = vi + .mocked(client.request) + .mock.calls.find((call) => call[0] === "talk.session.appendAudio"); + expect((appendCall?.[1] as { sessionId?: string } | undefined)?.sessionId).toBe("relay-1"); transport.stop(); }); @@ -380,15 +380,14 @@ describe("GatewayRelayRealtimeTalkTransport", () => { args: { question: "status?" }, }, }); - await vi.waitFor(() => - expect(client.request).toHaveBeenCalledWith( - "talk.client.toolCall", - expect.objectContaining({ - callId: "call-1", - relaySessionId: "relay-1", - }), - ), - ); + await vi.waitFor(() => { + const toolCall = vi + .mocked(client.request) + .mock.calls.find((call) => call[0] === "talk.client.toolCall"); + const params = toolCall?.[1] as { callId?: string; relaySessionId?: string } | undefined; + expect(params?.callId).toBe("call-1"); + expect(params?.relaySessionId).toBe("relay-1"); + }); emitGatewayFrame({ event: "chat", diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 96816cbac15..7ab23df6074 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -740,13 +740,13 @@ describe("chat attachment picker", () => { input!.dispatchEvent(new Event("change", { bubbles: true })); await vi.waitFor(() => { - expect(onAttachmentsChange).toHaveBeenCalledWith([ - expect.objectContaining({ - fileName: "brief.pdf", - mimeType: "application/pdf", - sizeBytes: file.size, - }), - ]); + const attachments = onAttachmentsChange.mock.calls[0]?.[0] as + | Array<{ fileName?: string; mimeType?: string; sizeBytes?: number }> + | undefined; + expect(attachments).toHaveLength(1); + expect(attachments?.[0]?.fileName).toBe("brief.pdf"); + expect(attachments?.[0]?.mimeType).toBe("application/pdf"); + expect(attachments?.[0]?.sizeBytes).toBe(file.size); }); const nextAttachments = onAttachmentsChange.mock.calls[0]?.[0] ?? []; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index b17e84966d8..181efeb515e 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -746,20 +746,19 @@ describe("cron view", () => { "cron-delivery-to-suggestions", "cron-delivery-account-suggestions", ]); - expect( - Array.from(container.querySelectorAll("input[list]")).map((node) => - node.getAttribute("list"), - ), - ).toEqual( - expect.arrayContaining([ - "cron-agent-suggestions", - "cron-model-suggestions", - "cron-thinking-suggestions", - "cron-tz-suggestions", - "cron-delivery-to-suggestions", - "cron-delivery-account-suggestions", - ]), + const inputLists = Array.from(container.querySelectorAll("input[list]")).map((node) => + node.getAttribute("list"), ); + for (const expectedList of [ + "cron-agent-suggestions", + "cron-model-suggestions", + "cron-thinking-suggestions", + "cron-tz-suggestions", + "cron-delivery-to-suggestions", + "cron-delivery-account-suggestions", + ]) { + expect(inputLists).toContain(expectedList); + } expect(container.querySelectorAll("input[list]")).toHaveLength(6); }); }); diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index 7df4e601f37..d7773696b38 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -508,9 +508,11 @@ describe("dreaming view", () => { const labels = [...container.querySelectorAll(".dreams-diary__day-chip")].map((node) => node.textContent?.replace(/\s+/g, "").trim(), ); - expect(labels.filter((label): label is string => Boolean(label))).toEqual( - expect.arrayContaining([expect.stringMatching(/^\d+\/\d+$/)]), - ); + expect( + labels + .filter((label): label is string => Boolean(label)) + .some((label) => /^\d+\/\d+$/.test(label)), + ).toBe(true); setDreamSubTab("scene"); }); diff --git a/ui/src/ui/views/login-gate.test.ts b/ui/src/ui/views/login-gate.test.ts index 219f2dcf5ee..13212c78f6c 100644 --- a/ui/src/ui/views/login-gate.test.ts +++ b/ui/src/ui/views/login-gate.test.ts @@ -115,6 +115,36 @@ describe("resolveLoginFailureFeedback", () => { expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowInsecureAuth"); }); + it("explains browser WebSocket security failures as insecure context", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: + "Browser refused the Gateway WebSocket for security reasons. Use wss:// when the Control UI is served over HTTPS/Tailscale Serve, or open the loopback dashboard at http://127.0.0.1:18789.", + lastErrorCode: "BROWSER_WEBSOCKET_SECURITY_ERROR", + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("insecure-context"); + expect(feedback?.rawError).toContain("Use wss://"); + expect(feedback?.rawError).toContain("http://127.0.0.1:18789"); + expect(feedback?.steps.join(" ")).toContain("Tailscale Serve"); + expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowInsecureAuth"); + }); + + it("keeps generic browser WebSocket constructor failures on the network path", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "Could not create the Gateway WebSocket: constructor failed", + lastErrorCode: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + hasToken: false, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("network"); + expect(feedback?.steps.join(" ")).toContain("WebSocket URL"); + }); + it("explains browser origin rejections", () => { const feedback = resolveLoginFailureFeedback({ connected: false, diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index ecc184ef7f7..d3f759c30da 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -25,7 +25,10 @@ const AUTH_FAILURE_CODES = new Set([ ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH, ]); +const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR"; + const INSECURE_CONTEXT_CODES = new Set([ + BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, ]); diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index 33500d1dae6..a806ec56727 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -4,6 +4,7 @@ import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connec import { resolveAuthHintKind, resolvePairingHint, + shouldShowInsecureContextHint, shouldShowPairingHint, } from "./overview-hints.ts"; @@ -107,3 +108,25 @@ describe("resolveAuthHintKind", () => { ).toBe("failed"); }); }); + +describe("shouldShowInsecureContextHint", () => { + it("returns true for browser WebSocket security errors", () => { + expect( + shouldShowInsecureContextHint( + false, + "Browser refused the Gateway WebSocket for security reasons.", + "BROWSER_WEBSOCKET_SECURITY_ERROR", + ), + ).toBe(true); + }); + + it("does not treat generic WebSocket constructor errors as insecure context", () => { + expect( + shouldShowInsecureContextHint( + false, + "Could not create the Gateway WebSocket: constructor failed", + "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + ), + ).toBe(false); + }); +});