chore(openai): merge current main into lifecycle fix

* origin/main: (38 commits)
  fix(agents): compact session status change text (#116742)
  fix(llama-cpp): recover plaintext tool calls (#116736)
  fix(telegram): validate native queue arguments before fallback (#116726)
  chore(skills): add autonomous issue sweep workflow
  docs(auto-qa): require clean root-cause refactors
  fix(ollama): models advertise tools when /api/show fails (#109971)
  fix(ollama): use CJK-aware char estimate for usage fallback (#110073)
  fix(ollama): release failed setup response bodies before returning (#111802)
  feat(plugins): externalize Synthetic provider (#116720)
  fix(agents): unblock channel turns after restart recovery (#116728)
  fix(telegram): keep typing alive during active steered tasks (#116721)
  fix(gateway): admit standalone MCP app work (#116727)
  fix(googlechat): record canonical receipt thread (#116717)
  test(ui): cover the Talk relay start-stop race
  fix(ui): close superseded Talk allocations
  fix(agents): resume interrupted turns past progress commentary (#116725)
  fix(gateway): sign device proofs with challenge time (#116679)
  test(google): cover expired CLI OAuth compatibility
  fix(testing): load relay smoke through UI Vite config
  test(testing): guard realtime relay smoke module path
  ...
This commit is contained in:
Vincent Koc
2026-07-31 17:50:34 +08:00
147 changed files with 4174 additions and 1114 deletions
+11 -3
View File
@@ -5,7 +5,15 @@ description: "Continuously audit, live-test, and stress-test the current OpenCla
# OpenClaw Auto QA
Run a continuous, current-`main` OpenClaw product campaign. Treat a reviewer finding as a hypothesis, a passing test as evidence only for its actual head, and a merge as complete only when the canonical repository confirms it. Repair the actual root cause in its canonical owner; a smaller patch is not better if it leaves sibling paths, lifecycle invariants, or the defective abstraction intact.
Run a continuous, current-`main` OpenClaw product campaign. Treat a reviewer finding as a hypothesis, a passing test as evidence only for its actual head, and a merge as complete only when the canonical repository confirms it. Always prefer a clean, appropriately scoped root-cause refactor over a quick fix or smaller diff. Repair the actual root cause in its canonical owner; a patch is not acceptable when it leaves sibling paths, lifecycle invariants, or the defective abstraction intact.
## Prefer clean refactors over quick fixes
- Identify the broken ownership boundary, abstraction, state transition, or dependency contract before choosing an implementation. Compare the canonical owner, callers, callees, and sibling paths; prefer the design that makes their shared invariant obvious and reliable.
- Consolidate decisions and authoritative state in their actual owner. Propagate prepared facts through existing lifecycles, repair all affected siblings, and delete obsolete branches, duplicate policy, dead helpers, and stale abstractions when they are no longer needed.
- Reject symptom-masking guards, one-off exceptions, observed-example literals, parallel code paths, extra caches, fallback stacks, compatibility shims, and tests that merely make a narrow reproduction pass. A smaller change is not safer when it preserves the cause or makes the architecture harder to understand.
- Preserve shipped public contracts and ownership boundaries. If the clean refactor would affect security, persistent state, public configuration, plugin SDK compatibility, a protocol, or a product decision, mark it for maintainer review instead of substituting a tactical patch.
- During independent review, explicitly ask whether the change is the cleanest appropriately bounded root-cause solution. Green tests, a minimal diff, and a plausible local fix are insufficient without that architectural judgment.
## Start with the moving source
@@ -48,10 +56,10 @@ Read [references/live-proof-routing.md](references/live-proof-routing.md) before
1. Deduplicate against the current ledger, `origin/main`, current open and merged GitHub work, and sibling root causes. Count one broken invariant once, even when it produces multiple model, platform, route, lifecycle, or UI symptoms.
2. Independently reproduce the actual current-main user path. Map the entry point, canonical owner, callers, callees, sibling implementations, state lifecycle, existing regressions, shipped contracts, and relevant direct upstream source. Identify why the current design fails before proposing a repair.
3. Refactor the canonical owner in an isolated worktree. Repair all affected sibling paths in the same coherent change, simplify or remove the defective abstraction, and carry authoritative facts through the existing lifecycle. Prefer the appropriately sized root-cause solution over a minimal guard, special case, extra cache, fallback, compatibility shim, or narrowly passing test.
3. Refactor the canonical owner in an isolated worktree. Repair all affected sibling paths in the same coherent change, simplify or remove the defective abstraction, and carry authoritative facts through the existing lifecycle. Prefer the cleanest appropriately sized root-cause solution over a minimal diff; reject a guard, special case, extra cache, fallback, compatibility shim, or narrowly passing test that leaves the architectural defect behind.
4. Preserve public configuration, plugin ownership, gateway protocol, migrations, provider contracts, persistent state, and external dependencies. When a correct root-cause repair would change a sensitive contract or requires a product decision, prepare it for operator review; do not disguise that risk as a small autonomous fix.
5. Add authentic regression coverage for the original reproduction, affected siblings, lifecycle cleanup, and unchanged legitimate behavior. Run appropriately scoped proof on the exact candidate head. Route Docker, real providers, packaging, full checks, typechecking, broad suites, and browser work through the existing remote workflow; inspect actual exit status, nonzero scenario counts, and artifacts.
6. Run a fresh `$autoreview` on the complete final refactor. Resolve actionable findings; rerun review after any production, test, or head change. Personally read the latest ClawSweeper review, satisfy each applicable rank-up move with real evidence, and update the existing PR body before landing.
6. Run a fresh `$autoreview` on the complete final refactor. Require the reviewer to compare owner boundaries and sibling implementations, confirm this is the best clean root-cause solution, and reject quick-fix residue even when tests pass. Resolve actionable findings; rerun review after any production, test, or head change. Personally read the latest ClawSweeper review, satisfy each applicable rank-up move with real evidence, and update the existing PR body before landing.
7. Check existing open PRs, current author counts, and the actual repository automation before publishing. Read both the current labeler and response policy; verify the authenticated author association, repository permission, account type, automation branch prefix, and actual override label. Apply only exemptions proved by that current policy, including eligible owners, maintainers, collaborators, bots or apps, approved automation branches, and explicit overrides. Never infer capacity from a truncated list or assume that one privileged role represents every exemption. Reuse and repair an existing candidate PR for the same cause. When a real cap applies, hold reviewed worktrees and finish or land existing verified work first.
8. Create a focused PR with the repository's actual template, canonical cause, user impact, frozen head, completed proof, and risk. Use only the current repo-native `scripts/pr` review, artifact, prepare, and merge workflow for authorized main landing.
9. Autonomously merge only when the user authorized it **and** the canonical root-cause refactor is individually reproduced, low-risk, independently reviewed, current-main-compatible, and has green required exact-head proof. Evaluate risk by ownership and behavioral impact, not by whether the diff is the smallest possible. Verify the resulting canonical merge SHA before incrementing the ledger.
@@ -0,0 +1,253 @@
---
name: openclaw-autonomous-issue-sweep
description: "Orchestrate 64 autonomous OpenClaw issue workers newest-to-oldest; find existing PRs, deeply investigate bugs, simplify or refactor, live-test, independently review, land verified fixes, close already-fixed issues, and add only meaningful new evidence."
---
# OpenClaw Autonomous Issue Sweep
Run an end-to-end maintainer campaign, not a candidate shortlist. The parent
conversation is the orchestrator: delegate discovery, investigation, coding,
testing, review, GitHub mutations, PR preparation, landing, and cleanup to
subagents. Keep parent-thread updates to concise progress and clickable URLs.
## Authority and campaign shape
- Spawn exactly **64 first-class subagents** unless the user requests another
count or available capacity makes that impossible; disclose the actual count.
- Use full-history forks so every subagent inherits the orchestrator's model
and **xhigh reasoning effort**. Never print, record, or disclose model
identifiers; redact subprocess banners and diagnostics before reporting.
- Treat a request to run this workflow as authority to review, fix, refactor,
commit, push, create/update PRs, land eligible changes, comment, and close
issues individually. Do not ask for routine confirmation again.
- Never treat sweep authority as permission to publish releases, bump protocol
or SQLite schema versions, weaken security, break shipped compatibility,
change another owner's protected product surface, or execute untrusted code
with local credentials.
- Have subagents read the complete root `AGENTS.md`, relevant scoped guides,
`VISION.md`, and companion skills before acting. Use `$gitcrawl`, Octopool,
`$openclaw-pr-maintainer`, `$openclaw-testing`, `$crabbox`, and `$autoreview`
where each owns the workflow.
- Keep the parent out of operational work. It may spawn, assign, receive
results, serialize shared resources, monitor host/pool health, prewarm and
allocate needed remote leases, issue follow-up tasks, and report; it must
not inspect issues, edit code, run tests, mutate GitHub, or land PRs.
## Coordinate 64 workers safely
1. Assign one subagent to maintain the live open-issue queue in descending
`createdAt` order, one to coordinate landing/proof capacity, and the rest to
issue investigations. Coordinator agents also investigate when idle.
2. Claim issues from the newest unclaimed end only; replenish workers as they
finish. Parallel completions may arrive out of order, but never knowingly
start an older unclaimed issue ahead of a newer available issue.
3. Deduplicate by canonical root cause, not merely by issue number. Let one
owner fix a shared defect and link related issues/PRs to that outcome.
4. Freeze the reviewed source SHA for each wave. Designate a single fetch owner;
pause shared-ref refreshes while repo-native PR prepare/merge runs.
5. Never switch a shared checkout branch or edit it while sibling agents use it.
Use an existing agent-owned checkout, a repo-native isolated PR worktree, or
an explicitly user-authorized new worktree. Otherwise serialize write
access; parallel read-only investigations may continue.
6. Sample checkout/temp-volume free disk, CPU/load, memory pressure, process
count, operator-gateway health, actual worker count, and Octopool capacity
before each wave and periodically thereafter. Throttle expensive work for
sustained pressure or low disk; never kill unrelated operator processes.
7. Serialize merge operations and each Testbox lease. A lease has one owner and
one active command; never reclaim, sync, or change its head during a run.
8. Respect GitHub rate limits, active assignees, repository ownership, and
existing contributor work. Do not auto-assign broad-discovery candidates.
9. Replace finished workers while the queue remains. Record actual active,
completed, failed, fixed, landed, closed, commented, and skipped counts;
never report launched or finished workers as still running.
## Conserve GitHub capacity and host resources
- Prefer local `$gitcrawl` archives and source history for queue discovery,
issue/PR search, duplicate clusters, comments, and previously merged work.
Check archive freshness; do not broadly sync, enrich, or re-embed merely to
start a sweep.
- Prefer `octopool gh ...` or narrowly bounded `octopool request` for
necessary live GitHub reads and mutations. Check `octopool health` and
`octopool stats` periodically; let repo-native PR wrappers retain their
required GitHub transport and authenticated identity.
- Use plain `gh` only when Octopool cannot support the operation or the
canonical maintainer wrapper requires it. Request minimal fields, reuse
results across workers, batch compatible reads, avoid unbounded pagination,
and never use `gh run watch` or frequent unchanged CI polls.
- Require a fresh live state check only before consequential mutations, final
merge decisions, or a stale/contradictory cached result. Rate-limit and
deduplicate worker requests instead of having 64 agents independently fetch
the same issue, PR, author profile, or CI rollup.
- Keep disk, load, memory pressure, active lease IDs, provider trust class,
checkout ownership, and pool capacity in the orchestration ledger. Slow new
assignments, serialize builds/tests, clean only campaign-owned artifacts,
and offload heavy proof before resource pressure threatens the host.
- The parent may prewarm a trusted Crabbox/Testbox lease when a concrete heavy
proof is imminent, then hand its verified lease ID and checkout ownership to
one subagent at a time. Avoid speculative fleets, respect path-scoped lease
ownership, and stop campaign-owned leases before handoff or closeout.
- Keep untrusted contributor proof on a separate sanitized direct-AWS lease;
never transfer a credential-hydrated trusted lease to untrusted work.
## Search for existing work on every credible issue
Always investigate existing PRs before implementing a fix:
1. Read the live issue body, all material comments, labels, assignments,
timeline/cross-references, repro details, affected versions, and ClawSweeper
findings.
2. Search `$gitcrawl` for the issue number, title, error text, affected
subsystem, relevant symbols, duplicate symptoms, open PRs, merged PRs, and
recently closed work.
3. Verify candidates against Octopool-backed live GitHub search, directly
linked PRs, current PR heads, `origin/main`, and commit history. Search
exact issue references and symptom/root-cause terms; do not stop at the
first plausible PR.
4. Read competing implementations deeply enough to decide whether an existing
PR already fixes the real defect, merely masks one symptom, has gone stale,
or reveals a cleaner owner-boundary refactor.
5. Preserve contributor commits, attribution, issue reporter credit, and useful
ideas whenever repairing or replacing existing work.
Choose outcomes in this order:
1. **Fixed on main:** prove the original failure is resolved; close with the
exact merged PR, commit, current source/test, or release proof.
2. **Existing PR is the best fix:** improve it as needed, verify the exact
final head, and land it through the repo-native maintainer workflow.
3. **Existing PR is useful but incomplete:** finish it or create a cleaner
replacement that preserves human attribution and links the original.
4. **No suitable PR:** implement the best high-confidence root-cause repair or
a justified simplifying refactor; create, verify, and land a focused PR.
5. **Bug cannot be fixed, but simplification is real:** independently land a
proven behavior-neutral refactor when it meaningfully removes complexity
without pretending the original issue was fixed.
6. **Cannot fix or close:** comment only if investigation uncovered concrete,
material evidence missing from the issue and ClawSweeper's existing review.
## Prove the bug and choose the best design
- Trace the actual user path from entry point through caller, canonical owner,
callee, sibling implementations, transport/lifecycle boundaries, tests,
current `main`, shipped contracts, and direct dependency source or docs.
- Personally inspect sibling `../codex` source before any Codex integration
verdict or change, as required by the root guide; another agent's report is
not sufficient for the agent making that decision.
- Require a failing regression, reproducible command, real logs, live product
behavior, dependency contract, or exact source-level proof. Never repair an
issue on title, speculation, ClawSweeper output, or a plausible diff alone.
- Prefer the correct owner-boundary refactor over a narrow guard, workaround,
new fallback, duplicate policy, extra configuration, or compatibility shim.
A larger refactor is appropriate when it fixes the whole bug class more
clearly and its behavior/ownership risk remains understood and bounded.
- While reading, look for dead branches, unused helpers, duplicate paths,
stale abstractions, obsolete tests, and complexity that can be deleted as
part of the same coherent change.
- Measure `git diff --numstat`; aim to reduce **production LOC**, excluding
tests. Production growth is acceptable only when clearly justified by fewer
concepts, better ownership, essential product behavior, or stronger safety.
- Allow small missing product affordances, such as an obviously expected CLI
command, when adjacent behavior and docs establish the contract. Reject
substantial new features, speculative redesign, new paid services,
unsupported integrations, or unrelated drive-by changes.
- Do not edit `CHANGELOG.md`; capture user impact, issue/PR references, and
human credit in the PR body or commit message.
## Verify behavior and obtain two independent reviews
For every non-trivial production change:
1. Add focused regression coverage for the original bug and affected sibling
paths. Delete tests protecting removed obsolete implementation details.
2. Choose proof with `$openclaw-testing`. Live-test the real user/provider/
channel/CLI/package/UI path whenever feasible. Route heavy, packaging,
Docker, E2E, or broad checks through `$crabbox`; report an unavailable live
prerequisite accurately instead of calling a mock live proof.
3. Classify source trust before executing anything. Never run contributor/fork
scripts, hooks, config, tests, installs, or wrappers locally or on a
credential-hydrated host; follow the sanitized untrusted-source workflow.
4. Run `$autoreview` on the complete final change until no accepted actionable
findings remain. Re-run it after any production, test, or reviewed-head
change. Treat review findings as hypotheses and verify each against source.
Prose-only skill files and other non-production internal notes do not need
autoreview; validate their structure and formatting instead.
5. Separately self-invoke an independent Codex reviewer. First verify the
installed interface with `codex exec --help`, then run a bounded read-only,
ephemeral review from a trusted checkout, for example:
```bash
codex exec --json --sandbox read-only --ephemeral \
-C "$trusted_checkout" --output-last-message "$review_result" \
"Independently inspect the frozen candidate diff and its owner, callers,
siblings, tests, current main, user behavior, and dependency contracts.
Report only concrete correctness, architecture, simplification, or
verification gaps. Do not modify files or expose secrets." \
>/dev/null 2>/dev/null
```
Point the reviewer at the exact immutable diff/head. Do not substitute the
`$autoreview` Codex engine for this separate pass. Never run that reviewer
from an untrusted project-controlled checkout. Read only the final review
result; do not emit raw model banners. Verify actionable findings, make
justified fixes, rerun proof, and refresh both independent reviews.
6. Read the latest ClawSweeper comment and address each applicable `Rank-up
moves:` item with real evidence or an explicit reason for skipping it.
## Publish, land, and clean up
- Prefer an existing writable contributor PR. If its head is unsuitable or
cannot be updated safely, open a focused replacement, explain the
relationship, and preserve attribution.
- Before opening replacement PRs, verify author association, active-PR counts,
repository permission, branch policy, current auto-response exemptions, and
override labels; never assume a privileged-role exemption. Reuse or land
existing reviewed work before creating a burst of competing PRs.
- Use the actual PR template and state the user impact, canonical root cause,
rejected alternatives, production LOC delta, exact head SHA, focused/live
proof, autoreview result, independent Codex result, CI state, and credit.
- Read `$agent-transcript` for agent-created PRs, but do not include logs
without the user's explicit transcript approval. During a fully autonomous
sweep, omit transcripts rather than interrupting the user for consent.
- Open new PRs as drafts, wait for a non-null mergeability result, mark them
ready, and verify CI attached to the exact pushed head before landing.
- Autonomously land only a reproduced, high-confidence, bounded-risk repair
or behavior-neutral simplification with clean independent reviews and green
exact-head required proof. Change size alone is not the risk criterion.
- For main-targeted PRs use only the repo-native `scripts/pr` flow: initialize
review, create/validate review artifacts, run
`OPENCLAW_TESTBOX=1 scripts/pr prepare-run <number>`, then
`scripts/pr merge-run <number>`. Verify the canonical merge SHA afterward.
- Keep owner/security/auth/config/public-SDK/protocol/persistent-state/product
decisions outside autonomous landing when the relevant guide requires owner
judgment. Continue with the next issue instead of blocking the whole sweep.
- Close a fixed issue only after live rechecking its open state and matching
the original symptoms to current-main proof. Cite the merged PR/commit and
ask the reporter to reopen if it still reproduces on the current version.
- Never close merely because a repro is difficult, the report is inconvenient,
the behavior might be intentional, or the PR is stale. Product-decision and
won't-implement closures require maintainer judgment.
- If no fix is possible, comment only when supplying new reproducible steps,
an exact failing owner/line, verified dependency behavior, previously
unidentified duplicate/fixing PR, a concrete workaround, or another
meaningful fact absent from prior discussion and ClawSweeper.
- Recheck live state immediately before every mutation; avoid redundant,
speculative, noisy, or duplicate comments. Handle closures individually and
follow repository limits on bulk operations.
## Parent-thread reporting
Send concise progress plus URLs only. Prefer updates such as:
```text
64 agents active · 41 investigated · 3 landed · 5 already-fixed issues closed
Landed: https://github.com/openclaw/openclaw/pull/123
Closed: https://github.com/openclaw/openclaw/issues/456
```
Do not narrate routine reads, pending hypotheses, unchanged CI, or candidate
URLs that are not actually ready. Count only verified merged PRs, confirmed
closures, and comments that were really posted. Continue until the user stops
the sweep, the requested boundary is reached, or the live issue queue is
genuinely exhausted.
@@ -0,0 +1,4 @@
interface:
display_name: "OpenClaw Autonomous Issue Sweep"
short_description: "Autonomously fix, refactor, land, and close issues"
default_prompt: "Use $openclaw-autonomous-issue-sweep to orchestrate 64 subagents through OpenClaw issues newest to oldest; reuse existing PRs, prove and land high-confidence fixes or refactors, close resolved issues, and report concise progress plus URLs."
+1
View File
@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Control UI session refreshes:** preserve explicitly queued list filters and background hydration across later Gateway event invalidation, while keeping append pagination followed by a canonical refresh. Fixes #116697. Thanks @shakkernerd.
- **Gateway device clock skew:** sign device proofs with the Gateway-issued challenge timestamp across TypeScript, Control UI, browser extension, Android, Apple, Linux, and watchOS clients so incorrect local clocks no longer block authentication, while retaining no-challenge compatibility for pre-challenge Control UI servers and older watch-node HTTP endpoints and keeping nonce binding and freshness checks enforced. Fixes #103455.
- **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd.
- **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129.
- **macOS remote tunnel lifecycle:** prevent cancelled or superseded restart backoffs from recreating SSH tunnels, and join a tunnel create that another caller started while the actor was suspended.
+25 -25
View File
@@ -1915,7 +1915,7 @@
},
{
"kind": "ui-call",
"line": 1034,
"line": 1039,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Accept",
"surface": "android",
@@ -1923,7 +1923,7 @@
},
{
"kind": "conditional-branch",
"line": 1834,
"line": 1849,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Connecting…",
"surface": "android",
@@ -1931,7 +1931,7 @@
},
{
"kind": "conditional-branch",
"line": 1834,
"line": 1849,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Reconnecting…",
"surface": "android",
@@ -28683,7 +28683,7 @@
},
{
"kind": "ui-localized-call",
"line": 64,
"line": 84,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Gateway HTTP error (%@)",
"surface": "apple",
@@ -28691,7 +28691,7 @@
},
{
"kind": "ui-localized-call",
"line": 121,
"line": 141,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Ready to connect",
"surface": "apple",
@@ -28699,7 +28699,7 @@
},
{
"kind": "ui-localized-call",
"line": 132,
"line": 152,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Ignored an expired direct connection setup. Send setup again from iPhone.",
"surface": "apple",
@@ -28707,7 +28707,7 @@
},
{
"kind": "ui-localized-call",
"line": 145,
"line": 165,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Direct mode requires a trusted HTTPS Gateway endpoint.",
"surface": "apple",
@@ -28715,7 +28715,7 @@
},
{
"kind": "ui-localized-call",
"line": 163,
"line": 183,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Could not save direct connection securely.",
"surface": "apple",
@@ -28723,7 +28723,7 @@
},
{
"kind": "ui-localized-call",
"line": 180,
"line": 200,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Setup received. Connecting…",
"surface": "apple",
@@ -28731,7 +28731,7 @@
},
{
"kind": "ui-localized-call",
"line": 196,
"line": 216,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Direct connection is off",
"surface": "apple",
@@ -28739,7 +28739,7 @@
},
{
"kind": "ui-localized-call",
"line": 197,
"line": 217,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Use iPhone Settings to enable direct connection.",
"surface": "apple",
@@ -28747,7 +28747,7 @@
},
{
"kind": "ui-localized-call",
"line": 225,
"line": 245,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Reconnects when OpenClaw is active",
"surface": "apple",
@@ -28755,7 +28755,7 @@
},
{
"kind": "ui-localized-call",
"line": 279,
"line": 299,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Direct connection failed: %@",
"surface": "apple",
@@ -28763,7 +28763,7 @@
},
{
"kind": "ui-localized-call",
"line": 281,
"line": 301,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "No usable Gateway endpoint",
"surface": "apple",
@@ -28771,7 +28771,7 @@
},
{
"kind": "ui-localized-call",
"line": 297,
"line": 317,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Connecting directly…",
"surface": "apple",
@@ -28779,7 +28779,7 @@
},
{
"kind": "ui-localized-call",
"line": 301,
"line": 321,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Could not save the watch device identity",
"surface": "apple",
@@ -28787,7 +28787,7 @@
},
{
"kind": "ui-localized-call",
"line": 331,
"line": 351,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "No watch device credential",
"surface": "apple",
@@ -28795,7 +28795,7 @@
},
{
"kind": "ui-localized-call",
"line": 348,
"line": 368,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Could not save the watch device credential",
"surface": "apple",
@@ -28803,7 +28803,7 @@
},
{
"kind": "ui-localized-call",
"line": 362,
"line": 382,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Connected directly",
"surface": "apple",
@@ -28811,7 +28811,7 @@
},
{
"kind": "ui-localized-call",
"line": 466,
"line": 488,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Could not sign watch identity",
"surface": "apple",
@@ -28819,7 +28819,7 @@
},
{
"kind": "ui-localized-call",
"line": 559,
"line": 581,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Invalid Gateway response",
"surface": "apple",
@@ -28827,7 +28827,7 @@
},
{
"kind": "ui-localized-call",
"line": 584,
"line": 606,
"path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift",
"source": "Paired, but could not finish secure setup",
"surface": "apple",
@@ -39939,7 +39939,7 @@
},
{
"kind": "conditional-branch",
"line": 470,
"line": 474,
"path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift",
"source": " [\\(initial)]",
"surface": "apple",
@@ -39947,7 +39947,7 @@
},
{
"kind": "conditional-branch",
"line": 517,
"line": 521,
"path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift",
"source": " — \\(option.hint!)",
"surface": "apple",
@@ -39955,7 +39955,7 @@
},
{
"kind": "conditional-branch",
"line": 524,
"line": 528,
"path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift",
"source": " [\\(initialIndices.map(String.init).joined(separator: \",\"))]",
"surface": "apple",
@@ -907,6 +907,11 @@ class GatewaySession(
val hello: GatewayHelloSummary,
)
private data class ConnectChallenge(
val nonce: String,
val issuedAtMs: Long,
)
private enum class ConnectionState {
CONNECTING,
READY,
@@ -926,7 +931,7 @@ class GatewaySession(
private val state = AtomicReference(ConnectionState.CONNECTING)
private val connectDeferred = CompletableDeferred<ConnectedGateway>()
private val closedDeferred = CompletableDeferred<Unit>()
private val connectNonceDeferred = CompletableDeferred<String>()
private val connectChallengeDeferred = CompletableDeferred<ConnectChallenge>()
private val terminalCallbackClaimed = AtomicBoolean(false)
private val connectResponseAccepted = AtomicBoolean(false)
@@ -1224,7 +1229,7 @@ class GatewaySession(
if (connectResponseAccepted.get()) {
connectHandshakeJob?.join()
} else {
connectNonceDeferred.completeExceptionally(connectError)
connectChallengeDeferred.completeExceptionally(connectError)
}
if (shouldNotify) onDisconnected(message)
} finally {
@@ -1262,8 +1267,8 @@ class GatewaySession(
connectHandshakeJob =
connectionScope.launch {
try {
val nonce = awaitConnectNonce()
sendConnect(nonce)
val challenge = awaitConnectChallenge()
sendConnect(challenge)
} catch (err: Throwable) {
connectDeferred.completeExceptionally(err)
closeQuietly()
@@ -1310,7 +1315,7 @@ class GatewaySession(
}
}
private suspend fun sendConnect(connectNonce: String) {
private suspend fun sendConnect(connectChallenge: ConnectChallenge) {
val identity = identityStore.loadOrCreate()
val storedEntry = deviceAuthStore.loadEntry(endpoint.stableId, identity.deviceId, options.role)
val storedToken = storedEntry?.token?.trim()
@@ -1331,7 +1336,7 @@ class GatewaySession(
val payload =
buildConnectParams(
identity = identity,
connectNonce = connectNonce,
connectChallenge = connectChallenge,
selectedAuth = selectedAuth,
)
val res = request(GatewayMethod.Connect.rawValue, payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
@@ -1512,7 +1517,7 @@ class GatewaySession(
private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String,
connectChallenge: ConnectChallenge,
selectedAuth: SelectedConnectAuth,
): JsonObject {
val client = options.client
@@ -1548,7 +1553,8 @@ class GatewaySession(
}
val connectScopes = resolveConnectScopes(selectedAuth)
val signedAtMs = System.currentTimeMillis()
val signedAtMs = connectChallenge.issuedAtMs
val connectNonce = connectChallenge.nonce
// V3 signatures bind the auth token, nonce, role, and scopes so replayed connect frames fail.
val payload =
DeviceAuthPayload.buildV3(
@@ -1681,9 +1687,15 @@ class GatewaySession(
val payloadJson =
frame["payload"]?.toString() ?: frame["payloadJSON"].asStringOrNull()
if (event == GatewayEvent.ConnectChallenge.rawValue) {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) {
connectNonceDeferred.complete(nonce.trim())
if (!connectChallengeDeferred.isCompleted) {
val challenge = extractConnectChallenge(payloadJson)
if (challenge == null) {
connectChallengeDeferred.completeExceptionally(
IllegalStateException("gateway connect challenge invalid"),
)
} else {
connectChallengeDeferred.complete(challenge)
}
}
return
}
@@ -1696,17 +1708,20 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private suspend fun awaitConnectNonce(): String =
private suspend fun awaitConnectChallenge(): ConnectChallenge =
try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (err: Throwable) {
withTimeout(2_000) { connectChallengeDeferred.await() }
} catch (err: TimeoutCancellationException) {
throw IllegalStateException("connect challenge timeout", err)
}
private fun extractConnectNonce(payloadJson: String?): String? {
private fun extractConnectChallenge(payloadJson: String?): ConnectChallenge? {
if (payloadJson.isNullOrBlank()) return null
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
return obj["nonce"].asStringOrNull()
val nonce = obj["nonce"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val issuedAtMs =
obj["ts"].asJsonIntegerLongOrNull()?.takeIf { it >= 0 } ?: return null
return ConnectChallenge(nonce = nonce, issuedAtMs = issuedAtMs)
}
private fun handleInvokeEvent(payloadJson: String) {
@@ -2215,6 +2230,12 @@ private fun JsonElement?.asLongOrNull(): Long? =
else -> null
}
private fun JsonElement?.asJsonIntegerLongOrNull(): Long? =
when (this) {
is JsonPrimitive -> if (isString) null else content.toLongOrNull()
else -> null
}
private fun JsonElement?.asIntOrNull(): Int? =
when (this) {
is JsonPrimitive -> content.toIntOrNull()
@@ -38,7 +38,7 @@ import java.util.concurrent.atomic.AtomicReference
private const val TEST_TIMEOUT_MS = 8_000L
private const val CONNECT_CHALLENGE_FRAME =
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce","ts":1700000000123}}"""
private class NoopDeviceAuthStore : DeviceAuthTokenStore {
override fun loadEntry(
@@ -41,8 +41,9 @@ import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
private const val TEST_TIMEOUT_MS = 8_000L
private const val CONNECT_CHALLENGE_TS = 1_700_000_000_123L
private const val CONNECT_CHALLENGE_FRAME =
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce","ts":$CONNECT_CHALLENGE_TS}}"""
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
private val tokens = mutableMapOf<String, DeviceAuthEntry>()
@@ -92,6 +93,76 @@ private data class InvokeScenarioResult(
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionInvokeTest {
@Test
fun connect_usesGatewayChallengeTimestamp() =
runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
if (method == "connect") {
assertEquals(
CONNECT_CHALLENGE_TS,
frame["params"]
?.jsonObject
?.get("device")
?.jsonObject
?.get("signedAt")
?.jsonPrimitive
?.content
?.toLong(),
)
webSocket.send(connectResponseFrame(id))
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun connect_rejectsChallengeWithoutTimestamp() =
runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val lastDisconnect = AtomicReference("")
val connectRequests = AtomicInteger()
val server =
startGatewayServer(
json = json,
challengeFrame =
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
) { _, _, method, _ ->
if (method == "connect") connectRequests.incrementAndGet()
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
connectNodeSession(harness.session, server.port)
withTimeout(TEST_TIMEOUT_MS) {
while (lastDisconnect.get().isEmpty()) delay(10)
}
assertFalse(connected.isCompleted)
assertEquals(0, connectRequests.get())
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun canvasRoutePinsOnlyTheConnectedTlsEndpoint() {
val fingerprint = "ab".repeat(32)
@@ -1484,6 +1555,7 @@ class GatewaySessionInvokeTest {
private fun startGatewayServer(
json: Json,
challengeFrame: String = CONNECT_CHALLENGE_FRAME,
onHandshake: ((RecordedRequest) -> Unit)? = null,
onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit,
): MockWebServer =
@@ -1498,7 +1570,7 @@ class GatewaySessionInvokeTest {
webSocket: WebSocket,
response: Response,
) {
webSocket.send(CONNECT_CHALLENGE_FRAME)
webSocket.send(challengeFrame)
}
override fun onMessage(
@@ -45,7 +45,7 @@ import java.util.concurrent.atomic.AtomicInteger
private const val LIFECYCLE_TEST_TIMEOUT_MS = 8_000L
private const val LIFECYCLE_CONNECT_CHALLENGE_FRAME =
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce","ts":1700000000123}}"""
private class ReconnectDeviceAuthStore : DeviceAuthTokenStore {
override fun loadEntry(
@@ -36,6 +36,26 @@ final class WatchDirectNode {
private struct ChallengeResponse: Decodable {
let nonce: String
let ts: Int64?
private enum CodingKeys: String, CodingKey {
case nonce
case ts
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.nonce = try container.decode(String.self, forKey: .nonce)
self.ts = container.contains(.ts)
? try container.decode(Int64.self, forKey: .ts)
: nil
if let ts, ts < 0 {
throw DecodingError.dataCorruptedError(
forKey: .ts,
in: container,
debugDescription: "Gateway challenge timestamp must be non-negative")
}
}
}
private struct PollResponse: Decodable {
@@ -427,6 +447,8 @@ final class WatchDirectNode {
let params = try connectParams(
identity: identity,
nonce: challenge.nonce,
// Older watch-node Gateways omitted ts; retain their original local-clock behavior.
signedAtMs: challenge.ts ?? Int64(Date().timeIntervalSince1970 * 1000),
credential: credential,
notificationsAuthorized: notificationSettings.authorizationStatus == .authorized
|| notificationSettings.authorizationStatus == .provisional)
@@ -442,10 +464,10 @@ final class WatchDirectNode {
private func connectParams(
identity: DeviceIdentity,
nonce: String,
signedAtMs: Int64,
credential: ConnectCredential,
notificationsAuthorized: Bool) throws -> ConnectParams
{
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let payload = GatewayDeviceAuthPayload.buildV3(
fields: .init(
deviceId: identity.deviceId,
+62 -22
View File
@@ -16,7 +16,7 @@ use std::fmt;
use std::io::ErrorKind;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant};
use subtle::ConstantTimeEq;
use tauri::{AppHandle, Emitter, Manager, Webview};
use tokio::sync::{mpsc, oneshot};
@@ -721,8 +721,7 @@ impl GatewayClient {
let mut socket = tokio::time::timeout(CONNECT_TIMEOUT, connect_gateway_socket(config))
.await
.map_err(|_| RequestFailure::transport("Gateway connection timed out."))??;
let nonce = wait_for_connect_challenge(&mut socket).await?;
let signed_at_ms = unix_time_ms().map_err(RequestFailure::transport)?;
let challenge = wait_for_connect_challenge(&mut socket).await?;
// Native child WebViews use platform HTTP trust and cannot bind the optional
// WebSocket leaf pin, so pinned Gateway connections remain capability-free.
let inline_widgets_available = config
@@ -732,8 +731,8 @@ impl GatewayClient {
let params = connect_params(
&identity,
&auth,
&nonce,
signed_at_ms,
&challenge.nonce,
challenge.issued_at_ms,
inline_widgets_available,
)
.map_err(RequestFailure::transport)?;
@@ -1127,22 +1126,42 @@ fn request_frame(id: &str, method: &str, params: Value) -> Value {
})
}
async fn wait_for_connect_challenge(socket: &mut GatewaySocket) -> Result<String, RequestFailure> {
#[derive(Debug, PartialEq, Eq)]
struct ConnectChallenge {
nonce: String,
issued_at_ms: u64,
}
fn parse_connect_challenge(value: &Value) -> Result<ConnectChallenge, RequestFailure> {
let nonce = value
.get("payload")
.and_then(|payload| payload.get("nonce"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|nonce| !nonce.is_empty());
let issued_at_ms = value
.get("payload")
.and_then(|payload| payload.get("ts"))
.and_then(Value::as_u64)
.ok_or_else(|| RequestFailure::transport("Gateway challenge timestamp was invalid."))?;
nonce
.map(|nonce| ConnectChallenge {
nonce: nonce.to_owned(),
issued_at_ms,
})
.ok_or_else(|| RequestFailure::transport("Gateway challenge omitted nonce."))
}
async fn wait_for_connect_challenge(
socket: &mut GatewaySocket,
) -> Result<ConnectChallenge, RequestFailure> {
tokio::time::timeout(HANDSHAKE_TIMEOUT, async {
loop {
let value = next_json(socket).await?;
if value.get("type").and_then(Value::as_str) == Some("event")
&& value.get("event").and_then(Value::as_str) == Some("connect.challenge")
{
let nonce = value
.get("payload")
.and_then(|payload| payload.get("nonce"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|nonce| !nonce.is_empty());
return nonce
.map(ToOwned::to_owned)
.ok_or_else(|| RequestFailure::transport("Gateway challenge omitted nonce."));
return parse_connect_challenge(&value);
}
}
})
@@ -1470,13 +1489,6 @@ fn dispatch_chat_event(app: &AppHandle, frame: &Value) {
}
}
fn unix_time_ms() -> Result<u64, String> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.map_err(|error| format!("Could not read system time: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1665,6 +1677,34 @@ mod tests {
std::fs::remove_dir_all(directory).expect("remove connect fixture");
}
#[test]
fn connect_challenge_uses_gateway_timestamp() {
let Ok(challenge) = parse_connect_challenge(&json!({
"payload": {
"nonce": " fixture-nonce ",
"ts": 1_700_000_000_123_u64
}
})) else {
panic!("expected valid challenge");
};
assert_eq!(
challenge,
ConnectChallenge {
nonce: "fixture-nonce".to_string(),
issued_at_ms: 1_700_000_000_123,
}
);
assert!(parse_connect_challenge(&json!({
"payload": { "nonce": "missing-time" }
}))
.is_err());
assert!(parse_connect_challenge(&json!({
"payload": { "nonce": "fixture-nonce", "ts": "1700000000123" }
}))
.is_err());
}
#[test]
fn hello_tick_policy_sets_two_interval_watchdog() {
let hello = validate_hello(json!({
@@ -156,6 +156,7 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
actor GatewayWizardClient {
private enum ConnectChallengeError: Error {
case invalid
case timeout
}
@@ -271,14 +272,15 @@ actor GatewayWizardClient {
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let connectNonce = try await self.waitForConnectChallenge()
let connectChallenge = try await self.waitForConnectChallenge()
let connectNonce = connectChallenge.nonce
guard let identity = DeviceIdentityStore.loadOrCreatePersisted() else {
throw NSError(
domain: "OpenClawMacCLI",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Could not access the persisted device identity"])
}
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let signedAtMs = connectChallenge.issuedAtMs
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
fields: .init(
deviceId: identity.deviceId,
@@ -320,7 +322,7 @@ actor GatewayWizardClient {
}
}
private func waitForConnectChallenge() async throws -> String {
private func waitForConnectChallenge() async throws -> GatewayConnectChallenge {
guard let task = self.task else { throw ConnectChallengeError.timeout }
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
@@ -329,11 +331,13 @@ actor GatewayWizardClient {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge",
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = GatewayConnectChallengeSupport.nonce(from: payload)
{
return nonce
if case let .event(evt) = frame, evt.event == "connect.challenge" {
guard let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let challenge = GatewayConnectChallengeSupport.challenge(from: payload)
else {
throw ConnectChallengeError.invalid
}
return challenge
}
}
})
@@ -57,7 +57,7 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
if !sentChallenge {
sentChallenge = true
return .string("""
{"type":"event","event":"connect.challenge","payload":{"nonce":"test-nonce"}}
{"type":"event","event":"connect.challenge","payload":{"nonce":"test-nonce","ts":1777777777000}}
""")
}
if let request = latestUnrespondedRequest() {
@@ -9,12 +9,15 @@ extension WebSocketTasking {
}
enum GatewayWebSocketTestSupport {
static func connectChallengeData(nonce: String = "test-nonce") -> Data {
static func connectChallengeData(
nonce: String = "test-nonce",
ts: Int64 = 1_800_000_000_000) -> Data
{
let json = """
{
"type": "event",
"event": "connect.challenge",
"payload": { "nonce": "\(nonce)" }
"payload": { "nonce": "\(nonce)", "ts": \(ts) }
}
"""
return Data(json.utf8)
@@ -510,8 +510,9 @@ public actor GatewayChannelActor {
deviceId: identity?.deviceId,
connectionGeneration: connectionGeneration,
to: &params)
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let connectNonce = try await self.waitForConnectChallenge(task: task, attemptID: attemptID)
let connectChallenge = try await self.waitForConnectChallenge(task: task, attemptID: attemptID)
let signedAtMs = connectChallenge.issuedAtMs
let connectNonce = connectChallenge.nonce
try self.ensureCurrentConnectAttempt(attemptID, task: task)
try self.requireCurrentConnection(connectionGeneration)
if includeDeviceIdentity, let identity {
@@ -1146,7 +1147,10 @@ extension GatewayChannelActor {
}
}
private func waitForConnectChallenge(task: WebSocketTaskBox, attemptID: UUID) async throws -> String {
private func waitForConnectChallenge(
task: WebSocketTaskBox,
attemptID: UUID) async throws -> GatewayConnectChallenge
{
try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
@@ -1157,11 +1161,13 @@ extension GatewayChannelActor {
try await self.ensureCurrentConnectAttempt(attemptID, task: task)
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge",
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = GatewayConnectChallengeSupport.nonce(from: payload)
{
return nonce
if case let .event(evt) = frame, evt.event == "connect.challenge" {
guard let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let challenge = GatewayConnectChallengeSupport.challenge(from: payload)
else {
throw ConnectChallengeError.invalid
}
return challenge
}
}
})
@@ -44,6 +44,7 @@ final class GatewayRequestCancellationGate: @unchecked Sendable {
extension GatewayChannelActor {
enum ConnectChallengeError: Error {
case invalid
case timeout
}
@@ -1,11 +1,40 @@
import Foundation
import OpenClawProtocol
public struct GatewayConnectChallenge: Sendable, Equatable {
public let nonce: String
public let issuedAtMs: Int64
public init(nonce: String, issuedAtMs: Int64) {
self.nonce = nonce
self.issuedAtMs = issuedAtMs
}
}
public enum GatewayConnectChallengeSupport {
public static func nonce(from payload: [String: OpenClawProtocol.AnyCodable]?) -> String? {
public static func challenge(
from payload: [String: OpenClawProtocol.AnyCodable]?) -> GatewayConnectChallenge?
{
guard let nonce = payload?["nonce"]?.value as? String else { return nil }
let trimmed = nonce.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed
guard let rawTimestamp = payload?["ts"]?.value,
let issuedAtMs = self.integerMilliseconds(rawTimestamp),
issuedAtMs >= 0
else { return nil }
return GatewayConnectChallenge(nonce: trimmed, issuedAtMs: issuedAtMs)
}
private static func integerMilliseconds(_ value: Any?) -> Int64? {
switch value {
case let value as Int:
Int64(exactly: value)
case let value as Int64:
value
case let value as Double where value.isFinite && value.rounded() == value:
Int64(exactly: value)
default:
nil
}
}
}
@@ -0,0 +1,34 @@
import Foundation
import OpenClawKit
import OpenClawProtocol
import Testing
struct GatewayConnectChallengeSupportTests {
@Test func `parses gateway issued timestamp`() {
let challenge = GatewayConnectChallengeSupport.challenge(from: [
"nonce": AnyCodable(" nonce-1 "),
"ts": AnyCodable(1_700_000_000_123),
])
#expect(challenge == GatewayConnectChallenge(
nonce: "nonce-1",
issuedAtMs: 1_700_000_000_123))
}
@Test func `rejects malformed challenge`() {
let payloads: [[String: AnyCodable]] = [
["nonce": AnyCodable("nonce-1"), "ts": AnyCodable("1700000000123")],
["nonce": AnyCodable("nonce-1"), "ts": AnyCodable(-1)],
["nonce": AnyCodable(" "), "ts": AnyCodable(1_700_000_000_123)],
]
for payload in payloads {
#expect(GatewayConnectChallengeSupport.challenge(from: payload) == nil)
}
}
@Test func `rejects challenge without timestamp`() {
#expect(GatewayConnectChallengeSupport.challenge(from: [
"nonce": AnyCodable("nonce-1"),
]) == nil)
}
}
@@ -397,7 +397,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
let frame: [String: Any] = [
"type": "event",
"event": "connect.challenge",
"payload": ["nonce": nonce],
"payload": ["nonce": nonce, "ts": 1_800_000_000_000],
]
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
+4 -2
View File
@@ -265,8 +265,10 @@ a single OpenClaw authority tool plus the inert native planning utility. In all
three cases, setup writes remain confined to OpenClaw's audited approval
contract.
Gemini CLI remains available for normal agents, but it cannot enforce the
tool-free probe required by the inference gate, so it cannot host OpenClaw.
Gemini CLI remains available as an explicitly configured runtime for normal
agents, but Gemini CLI and Antigravity are not inference-gate setup routes.
Use AI Studio API-key or Vertex AI for the inference gate. The optional Gemini
CLI runtime specifically requires an AI Studio API-key profile.
## Switching to an agent
+3 -2
View File
@@ -41,8 +41,9 @@ automatic pass. Detected local runtimes are auto-tested after CLI and API-key
candidates; when several local models are available, OpenClaw prefers the
strongest tool-calling instruct family. The selected candidate must answer a
real completion before its provider and model configuration is saved.
Installed Gemini, Antigravity, Pi, and OpenCode CLIs are also reported when
they cannot serve as the reusable inference route for guided setup.
Pi and OpenCode CLIs may also be reported for context when they cannot serve as
the reusable inference route for guided setup. Gemini CLI and Antigravity are
not offered as detected setup routes.
`setup` accepts the same onboarding flags as `openclaw onboard`, including
auth (`--auth-choice`, `--token`, provider key flags), Gateway
+10 -41
View File
@@ -234,49 +234,18 @@ Claude CLI reuse (`claude -p`) is a sanctioned OpenClaw integration path. Anthro
- Thinking: `/think adaptive` uses Google dynamic thinking. Gemini 3/3.1 omit a fixed `thinkingLevel`; Gemini 2.5 sends `thinkingBudget: -1`.
- Direct Gemini runs also accept `agents.defaults.models["google/<model>"].params.cachedContent` (or legacy `cached_content`) to forward a provider-native `cachedContents/...` handle; Gemini cache hits surface as OpenClaw `cacheRead`
### Google Vertex and Gemini CLI
### Google Vertex and Gemini CLI runtime
- Providers: `google-vertex`, `google-gemini-cli`
- Auth: Vertex uses gcloud ADC; Gemini CLI uses its OAuth flow
- `google-vertex`: managed Google Cloud access through gcloud Application
Default Credentials.
- `google-gemini-cli`: optional local runtime for an explicitly configured
canonical `google/*` model.
<Warning>
Gemini CLI OAuth in OpenClaw is an unofficial integration. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed.
</Warning>
Gemini CLI OAuth is shipped as part of the bundled `google` plugin.
<Steps>
<Step title="Install Gemini CLI">
<Tabs>
<Tab title="brew">
```bash
brew install gemini-cli
```
</Tab>
<Tab title="npm">
```bash
npm install -g @google/gemini-cli
```
</Tab>
</Tabs>
</Step>
<Step title="Enable plugin">
```bash
openclaw plugins enable google
```
</Step>
<Step title="Login">
```bash
openclaw models auth login --provider google-gemini-cli --set-default
```
Default model: `google-gemini-cli/gemini-3-flash-preview`. You do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores tokens in auth profiles on the gateway host.
</Step>
<Step title="Set project (if needed)">
If requests fail after login, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host.
</Step>
</Steps>
OpenClaw does not create Gemini CLI OAuth or Antigravity OAuth profiles. Connect
Google through an AI Studio API key or Vertex AI. If you explicitly choose the
Gemini CLI runtime, it can use the selected Google API-key profile. Existing
valid Gemini CLI OAuth profiles remain runtime-compatible, but they are not a
setup or recovery route.
Gemini CLI uses `stream-json` by default. OpenClaw reads assistant stream
messages and normalizes `stats.cached` into `cacheRead`; legacy
+1 -1
View File
@@ -315,7 +315,7 @@ provider-neutral for CLI, app, and Control UI consumers.
- **DeepSeek**: API key via env/config/auth store (`DEEPSEEK_API_KEY`).
Shows each provider-reported currency balance.
- **GitHub Copilot**: OAuth tokens in auth profiles.
- **Gemini CLI**: OAuth tokens in auth profiles.
- **Gemini CLI**: existing OAuth profiles or supported Google API-key profiles.
- **MiniMax**: API key or MiniMax OAuth auth profile. OpenClaw treats
`minimax`, `minimax-cn`, and `minimax-portal` as the same MiniMax quota
surface, prefers stored MiniMax OAuth when present, and otherwise falls back
+1 -1
View File
@@ -2800,7 +2800,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Other subscription-style hosted options
- H3: OpenCode
- H3: Google Gemini (API key)
- H3: Google Vertex and Gemini CLI
- H3: Google Vertex and Gemini CLI runtime
- H3: Z.AI (GLM)
- H3: Vercel AI Gateway
- H3: Other bundled provider plugins
+5 -1
View File
@@ -231,7 +231,11 @@ The bundled Google plugin registers for `google-gemini-cli`:
| `sessionMode` | `existing` |
| `sessionIdFields` | `["session_id", "sessionId"]` |
Prerequisite: the local Gemini CLI must be installed and on `PATH` as `gemini` (`brew install gemini-cli` or `npm install -g @google/gemini-cli`).
Prerequisites: the local Gemini CLI must be installed and on `PATH` as `gemini`
(`brew install gemini-cli` or `npm install -g @google/gemini-cli`), and the
selected model must have a supported Google AI Studio API-key profile. Existing
valid legacy Gemini CLI OAuth profiles remain runtime-compatible, but OpenClaw
does not create or repair them.
Gemini CLI output notes:
+6 -3
View File
@@ -65,9 +65,12 @@ gateway` or the `openclaw onboard --gateway-auth ...` options, then let device
pairing mint the client token:
1. Persist an Ed25519 device identity in the client.
2. Wait for `connect.challenge`, sign the challenge-bound device payload, and send
`connect` with the requested operator role, scopes, and the shared Gateway token
or password for bootstrap authentication.
2. Wait for `connect.challenge`, use its `ts` as the device proof's `signedAt`,
sign the challenge-bound device payload, and send `connect` with the requested
operator role, scopes, and the shared Gateway token or password for bootstrap
authentication. A received WebSocket challenge without a non-negative integer
`ts` is invalid. Clients that explicitly support Gateways from before
`connect.challenge` existed may use local time only on their no-challenge path.
3. If the Gateway returns structured `PAIRING_REQUIRED` details, show the request
ID and pause or retry according to `error.details.recommendedNextStep`.
4. On the Gateway host, review the request with `openclaw devices list`, then
+7
View File
@@ -92,6 +92,12 @@ Gateway sends a pre-connect challenge:
}
```
Device-auth clients use the challenge `ts` as `connect.params.device.signedAt`.
For WebSocket challenges, `ts` must be a non-negative integer. Clients that
explicitly support Gateways from before `connect.challenge` existed may use local
time only when no challenge arrives; a received challenge with an absent or
malformed `ts` is invalid.
Client replies with `connect`:
```json
@@ -1165,6 +1171,7 @@ Common migration failures:
Migration target:
- Always wait for `connect.challenge`.
- Use `connect.challenge.payload.ts` as `connect.params.device.signedAt`.
- Sign the v2 payload that includes the server nonce.
- Send the same nonce in `connect.params.device.nonce`.
- Preferred signature payload is `v3`
+8 -10
View File
@@ -606,18 +606,16 @@ and troubleshooting see the main [FAQ](/help/faq).
</Accordion>
<Accordion title="How do I set up Gemini CLI OAuth?">
Gemini CLI uses a **plugin auth flow**, not a client id or secret in `openclaw.json`.
<Accordion title="Can I use Gemini CLI or Antigravity OAuth?">
OpenClaw does not offer new Gemini CLI OAuth or Antigravity OAuth setup.
Connect Google with an AI Studio API key or Vertex AI instead.
1. Install Gemini CLI locally so `gemini` is on `PATH`:
- Homebrew: `brew install gemini-cli`
- npm: `npm install -g @google/gemini-cli`
2. Enable the plugin: `openclaw plugins enable google`
3. Login: `openclaw models auth login --provider google-gemini-cli --set-default`
4. Default model after login: `google/gemini-3.1-pro-preview` (runtime `google-gemini-cli`)
5. Requests failing after login? Set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host and retry.
The optional `google-gemini-cli` runtime remains available for advanced
setups using a supported Google API-key profile. Existing valid legacy
Gemini CLI OAuth profiles remain executable for compatibility, but OpenClaw
cannot create or repair them.
OAuth tokens are stored in auth profiles on the gateway host. Details: [Google](/providers/google), [Model providers](/concepts/model-providers).
Details: [Google](/providers/google), [Model providers](/concepts/model-providers).
</Accordion>
+2 -1
View File
@@ -37,7 +37,8 @@ If you have not configured models and `tools.media.audio.enabled` is not `false`
Install/link provenance is capability evidence, not execution evidence. It never moves a candidate ahead of CPU sherpa by itself. OpenClaw does not load a model during setup or status checks just to probe a backend.
Auto-detected whisper.cpp keeps its normal model-run logs enabled so OpenClaw can record the upstream `using … backend` line. Explicit CLI entries keep their configured output flags.
Gemini CLI auto-detect for media understanding was replaced by a sandboxed Antigravity CLI (`agy`) fallback for image/video; audio does not use a CLI fallback beyond the local binaries above.
Gemini CLI and Antigravity are not auto-detected for media understanding. Audio
does not use a CLI fallback beyond the local binaries above.
To disable auto-detection, set `tools.media.audio.enabled: false`. To customize, add capability-tagged entries to `tools.media.models`.
-3
View File
@@ -174,9 +174,6 @@ When `tools.media.<capability>.enabled` is not `false` and no models are configu
- Video: Google &rarr; Qwen &rarr; Moonshot
</Step>
<Step title="Antigravity CLI (image/video only)">
First installed `agy` or `antigravity` binary (override with `OPENCLAW_ANTIGRAVITY_CLI`), sandboxed against the media's directory.
</Step>
</Steps>
To disable auto-detection for a capability:
+4 -4
View File
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
67 plugins
66 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -159,8 +159,6 @@ Each entry lists the package, distribution route, and description.
- **[sglang](/plugins/reference/sglang)** (`@openclaw/sglang-provider`) - included in OpenClaw. Adds SGLang model provider support to OpenClaw.
- **[synthetic](/plugins/reference/synthetic)** (`@openclaw/synthetic-provider`) - included in OpenClaw. Adds Synthetic model provider support to OpenClaw.
- **[telegram](/plugins/reference/telegram)** (`@openclaw/telegram`) - included in OpenClaw. Adds the Telegram channel surface for sending and receiving OpenClaw messages.
- **[together](/plugins/reference/together)** (`@openclaw/together-provider`) - included in OpenClaw. Adds Together model provider support to OpenClaw.
@@ -189,7 +187,7 @@ Each entry lists the package, distribution route, and description.
## Official external packages
78 plugins
79 plugins
- **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management.
@@ -319,6 +317,8 @@ Each entry lists the package, distribution route, and description.
- **[synology-chat](/plugins/reference/synology-chat)** (`@openclaw/synology-chat`) - npm; ClawHub. Synology Chat channel plugin for OpenClaw channels and direct messages.
- **[synthetic](/plugins/reference/synthetic)** (`@openclaw/synthetic-provider`) - npm; ClawHub: `clawhub:@openclaw/synthetic-provider`. Adds Synthetic model provider support to OpenClaw.
- **[tavily](/plugins/reference/tavily)** (`@openclaw/tavily-plugin`) - npm; ClawHub: `clawhub:@openclaw/tavily-plugin`. Adds agent-callable tools. Adds web search provider support.
- **[teams-meetings](/plugins/reference/teams-meetings)** (`@openclaw/teams-meetings`) - npm; ClawHub: `clawhub:@openclaw/teams-meetings`. Join Microsoft Teams meetings as a Chrome browser guest.
+1 -1
View File
@@ -12,7 +12,7 @@ Adds Synthetic model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/synthetic-provider`
- Install route: included in OpenClaw
- Install route: npm; ClawHub: `clawhub:@openclaw/synthetic-provider`
## Surface
+49 -50
View File
@@ -1,9 +1,9 @@
---
summary: "Google Gemini setup (API key + OAuth, image generation, media understanding, TTS, web search)"
summary: "Google Gemini setup (AI Studio API key, Vertex AI, optional CLI runtime, and multimodal tools)"
title: "Google (Gemini)"
read_when:
- You want to use Google Gemini models with OpenClaw
- You need the API key or OAuth auth flow
- You need Google AI Studio, Vertex AI, or Gemini CLI runtime guidance
---
The Google plugin provides access to Gemini models through Google AI Studio, plus image generation, media understanding (image/audio/video), text-to-speech, and web search via Gemini Grounding.
@@ -11,15 +11,17 @@ The Google plugin provides access to Gemini models through Google AI Studio, plu
- Provider: `google`
- Auth: `GEMINI_API_KEY` or `GOOGLE_API_KEY`
- API: Google Gemini API
- Runtime option: `agentRuntime.id: "google-gemini-cli"` reuses Gemini CLI OAuth while keeping model refs canonical as `google/*`.
- Managed-cloud provider: `google-vertex` with Google Cloud Application Default Credentials
- Optional runtime: `agentRuntime.id: "google-gemini-cli"` runs an explicitly configured model through the local Gemini CLI
## Getting started
Choose your preferred auth method and follow the setup steps.
For most installations, use a Google AI Studio API key. Use `google-vertex` when
the Gateway already runs inside a managed Google Cloud environment.
<Tabs>
<Tab title="API key">
**Best for:** standard Gemini API access through Google AI Studio.
<Tab title="AI Studio API key">
**Recommended for:** standard Gemini API access.
<Steps>
<Step title="Get an API key">
@@ -70,16 +72,23 @@ Choose your preferred auth method and follow the setup steps.
</Tab>
<Tab title="Gemini CLI (OAuth)">
**Best for:** signing in with your Google account through Gemini CLI OAuth instead of using a separate API key.
<Tab title="Gemini CLI runtime">
**Advanced use only:** run a canonical `google/*` model through an installed
Gemini CLI while keeping authentication on the supported AI Studio API-key
path.
<Warning>
The `google-gemini-cli` provider is an unofficial integration. Some users
report account restrictions when using OAuth this way. Use at your own risk.
</Warning>
OpenClaw does not offer new Gemini CLI OAuth or Antigravity OAuth setup.
[Google ended consumer Gemini CLI Login with Google access on June 18, 2026](https://developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals),
and the [Antigravity terms](https://antigravity.google/terms) prohibit
third-party tools from accessing the service through Antigravity OAuth. Use
an AI Studio API key or Vertex AI instead.
<Steps>
<Step title="Install the Gemini CLI">
<Step title="Configure Google AI Studio">
Complete the API-key setup in the first tab. OpenClaw must have a usable
`google` API-key profile before the CLI runtime can be selected.
</Step>
<Step title="Install Gemini CLI">
The local `gemini` command must be available on `PATH`.
```bash
@@ -93,46 +102,37 @@ Choose your preferred auth method and follow the setup steps.
OpenClaw supports both Homebrew installs and global npm installs, including
common Windows/npm layouts.
</Step>
<Step title="Log in via OAuth">
```bash
openclaw models auth login --provider google-gemini-cli --set-default
```
</Step>
<Step title="Verify the model is available">
```bash
openclaw models list --provider google
<Step title="Select the CLI runtime">
Keep the canonical Google model ref and opt that model into the CLI
runtime:
```json5
{
agents: {
defaults: {
model: { primary: "google/gemini-3.1-pro-preview" },
models: {
"google/gemini-3.1-pro-preview": {
agentRuntime: { id: "google-gemini-cli" },
},
},
},
},
}
```
</Step>
</Steps>
- Default model: `google/gemini-3.1-pro-preview`
- Runtime: `google-gemini-cli`
- Alias: `gemini-cli`
- Auth: selected Google AI Studio API-key profile
- Model refs: canonical `google/*`
Gemini 3.1 Pro's Gemini API model id is `gemini-3.1-pro-preview`. OpenClaw accepts the shorter `google/gemini-3.1-pro` as a convenience alias and normalizes it before provider calls.
Existing valid Gemini CLI OAuth profiles remain executable for compatibility,
but OpenClaw cannot create or repair them. If one breaks, replace it with a
Google AI Studio API-key profile.
**Environment variables:**
- `OPENCLAW_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
- `OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
<Note>
If Gemini CLI OAuth requests fail after login, set `GOOGLE_CLOUD_PROJECT` or
`GOOGLE_CLOUD_PROJECT_ID` on the gateway host and retry.
</Note>
<Note>
If login fails before the browser flow starts, make sure the local `gemini`
command is installed and on `PATH`.
</Note>
Onboarding auto-detection lists an existing Gemini CLI login but never
auto-tests it because Gemini CLI has no tool-free probe. Choose Gemini CLI
OAuth or a Gemini API key to continue.
`google-gemini-cli/*` model refs are legacy compatibility aliases. New
configs should use `google/*` model refs plus the `google-gemini-cli`
runtime when they want local Gemini CLI execution.
`google-gemini-cli/*` refs remain legacy compatibility aliases. New configs
should use `google/*` model refs plus the explicit runtime selection above.
</Tab>
</Tabs>
@@ -468,10 +468,9 @@ roundtrip; pass `--openai-audio-cycles 3` for a short repeated lifecycle soak.
</Accordion>
<Accordion title="Gemini CLI usage notes">
When using the `google-gemini-cli` OAuth provider, OpenClaw uses Gemini
CLI `stream-json` output by default and normalizes usage from the final
`stats` payload. Legacy `--output-format json` overrides still use the
JSON parser.
The optional `google-gemini-cli` runtime uses Gemini CLI `stream-json`
output by default and normalizes usage from the final `stats` payload.
Legacy `--output-format json` overrides still use the JSON parser.
- Streamed reply text comes from assistant `message` events.
- For legacy JSON output, reply text comes from the CLI JSON `response` field.
+1 -1
View File
@@ -87,7 +87,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
## Shared overview pages
- [Additional provider variants](/providers/models#additional-provider-variants) - Anthropic Vertex, Copilot Proxy, and Gemini CLI OAuth
- [Additional provider variants](/providers/models#additional-provider-variants) - Anthropic Vertex, Copilot Proxy, and the optional Gemini CLI runtime
- [Image Generation](/tools/image-generation) - Shared `image_generate` tool, provider selection, and failover
- [Music Generation](/tools/music-generation) - Shared `music_generate` tool, provider selection, and failover
- [Video Generation](/tools/video-generation) - Shared `video_generate` tool, provider selection, and failover
+1 -1
View File
@@ -57,7 +57,7 @@ For the full provider catalog and advanced configuration, see
- `anthropic-vertex` - install `@openclaw/anthropic-vertex-provider` for implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
- `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy`
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
- `google-gemini-cli` - optional explicit runtime for canonical `google/*` models; requires a local `gemini` install and a supported Google AI Studio API-key profile; new Gemini CLI or Antigravity OAuth setup is not offered
## Related
+8 -2
View File
@@ -7,8 +7,8 @@ title: "Synthetic"
---
[Synthetic](https://synthetic.new) exposes Anthropic-compatible endpoints.
OpenClaw bundles it as the `synthetic` provider and uses the Anthropic
Messages API.
OpenClaw provides it through the official `@openclaw/synthetic-provider`
plugin and uses the Anthropic Messages API.
| Property | Value |
| -------- | ------------------------------------- |
@@ -20,6 +20,12 @@ Messages API.
## Getting started
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/synthetic-provider
openclaw gateway restart
```
</Step>
<Step title="Get an API key">
Get a `SYNTHETIC_API_KEY` from your Synthetic account, or let onboarding
prompt you for one.
+4 -2
View File
@@ -93,8 +93,10 @@ offering a verified manual API-key step when nothing is found. Sensitive
credentials use masked input. Once inference passes, OpenClaw starts and
helps configure the rest.
Gemini CLI remains available for normal agents after setup, but it is not
offered for this inference gate because it cannot enforce the tool-free probe.
Gemini CLI remains available as an explicitly configured runtime after setup,
but Gemini CLI and Antigravity are not offered as detected inference routes.
Use Google AI Studio API-key or Vertex AI for guided setup. The optional Gemini
CLI runtime specifically requires an AI Studio API-key profile.
Full reference: [Onboarding (macOS App)](/start/onboarding)
+8 -7
View File
@@ -91,15 +91,16 @@ To use a Claude subscription when the Gateway host has no Claude CLI login, run
printed token as **Anthropic setup-token** under **Connect with an API key or
token**.
Installed Gemini CLI, Antigravity, Pi, and OpenCode CLIs are shown for context
when they cannot be selected as the reusable guided-setup inference route.
Gemini and Antigravity cannot enforce the tool-free inference probe. Pi and
OpenCode are whole-agent harnesses rather than setup inference routes; their
session integrations require separate runtime and plugin setup.
Pi and OpenCode installs may be shown for context when they cannot be selected
as the reusable guided-setup inference route. They are whole-agent harnesses,
not setup inference routes; their session integrations require separate runtime
and plugin setup. Gemini CLI and Antigravity are not offered as detected setup
routes.
You can also sign in through the provider's own OAuth or device-pairing flow.
The built-in choices include OpenAI/ChatGPT, OpenRouter, GitHub Copilot, Google
Gemini CLI, xAI, MiniMax Global and CN, and Chutes. The list comes from the
The built-in choices include OpenAI/ChatGPT, OpenRouter, GitHub Copilot, xAI,
MiniMax Global and CN, and Chutes. Google is available through the supported AI
Studio API-key route. The list comes from the
Gateway's active text-inference provider plugins rather than a fixed app list,
so another provider can opt in without adding provider-specific macOS code.
+3 -4
View File
@@ -92,10 +92,9 @@ Plain `openclaw onboard` follows this path:
2. Detect configured models, API-key environment variables, supported local AI
CLIs, and already installed tool-capable models from reachable Ollama or LM
Studio servers on the Gateway host. This read-only pass never downloads a
model. Gemini CLI, Antigravity, Pi, and OpenCode installs are also reported
when they cannot serve as the reusable inference route for guided setup.
Gemini and Antigravity cannot enforce the tool-free probe; Pi and OpenCode
are whole-agent harnesses rather than setup inference routes.
model. Pi and OpenCode installs may also be reported for context when they
cannot serve as the reusable inference route. Gemini CLI and Antigravity are
not offered as detected setup routes.
3. Test the first detected candidate with a real completion. On failure, show the
reason and continue to the next usable candidate.
4. If detection is exhausted, choose OpenAI, Anthropic, xAI (Grok), Google, or
@@ -158,7 +158,7 @@ export class CopilotGatewayClient {
const protocol = new GatewayProtocolClient({
createSocket: (handlers) => createBrowserSocket(gatewayScope, handlers, this.WebSocketImpl),
createRequestId: () => crypto.randomUUID(),
buildConnectPlan: ({ nonce }) =>
buildConnectPlan: ({ nonce, challengeTs }) =>
lifecycle.buildPlan({
client: {
id: CLIENT_ID,
@@ -170,6 +170,7 @@ export class CopilotGatewayClient {
role: ROLE,
defaultScopes: SCOPES,
nonce,
challengeTs,
}),
buildConnectParams: (plan) => ({
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
@@ -273,7 +273,7 @@ describe("browser copilot Gateway custody", () => {
first?.message({
type: "event",
event: "connect.challenge",
payload: { nonce: "first-nonce" },
payload: { nonce: "first-nonce", ts: 1_777_777_777_000 },
});
await vi.waitFor(() => expect(first?.sent).toHaveLength(1));
const firstConnect = first?.sent[0] as {
@@ -303,7 +303,7 @@ describe("browser copilot Gateway custody", () => {
second?.message({
type: "event",
event: "connect.challenge",
payload: { nonce: "second-nonce" },
payload: { nonce: "second-nonce", ts: 1_777_777_778_000 },
});
await vi.waitFor(() => expect(second?.sent).toHaveLength(1));
const secondConnect = second?.sent[0] as { params?: { auth?: { token?: string } } };
@@ -351,6 +351,37 @@ describe("browser copilot Gateway custody", () => {
}
});
it("rejects a device challenge with a malformed Gateway timestamp", async () => {
FakeWebSocket.instances = [];
vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } });
vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" });
const client = new CopilotGatewayClient({
storage: storageArea(),
WebSocketImpl: FakeWebSocket as never,
});
try {
client.start("ws://127.0.0.1:28789/");
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
const socket = FakeWebSocket.instances[0];
socket?.message({
type: "event",
event: "connect.challenge",
payload: { nonce: "invalid-time", ts: "not-a-number" },
});
await vi.waitFor(() =>
expect(socket?.closeCalls).toContainEqual({
code: 4008,
reason: "connect failed",
}),
);
expect(socket?.sent).toHaveLength(0);
} finally {
client.stop();
vi.unstubAllGlobals();
}
});
it("closes and reconnects when the browser socket never opens", async () => {
vi.useFakeTimers();
FakeWebSocket.instances = [];
File diff suppressed because one or more lines are too long
@@ -205,7 +205,7 @@ async function createGatewayHarness(): Promise<GatewayHarness> {
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "browser-copilot-e2e-nonce" },
payload: { nonce: "browser-copilot-e2e-nonce", ts: 1_777_777_777_000 },
}),
);
socket.on("message", (data) => {
@@ -49,6 +49,8 @@ const GEMINI_CLI_API_KEY_AUTH_ENV = [
];
const GEMINI_CLI_PROFILE_AUTH_ENV = [...GEMINI_CLI_API_KEY_AUTH_ENV, "GEMINI_API_KEY"];
const GEMINI_CLI_PROFILE_SETTINGS_ENV = ["GEMINI_CLI_SYSTEM_SETTINGS_PATH"];
const GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE =
"Open Models settings and connect Google with an AI Studio API key, then select that profile for this model.";
type GeminiAuthProfileCredential = {
type: "api_key" | "oauth" | "token";
@@ -116,14 +118,14 @@ function throwUnstageableSelectedGeminiProfile(
}
if (!credential) {
throw new Error(
"Gemini CLI auth profile was selected but no credential material was found. Re-authenticate with `openclaw models auth login --provider google-gemini-cli --force`.",
`Gemini CLI auth profile was selected but no credential material was found. ${GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE}`,
);
}
if (credential.provider !== GEMINI_CLI_PROVIDER_ID) {
throwUnsupportedGeminiCredential(credential);
}
throw new Error(
"Gemini CLI execution supports google-gemini-cli OAuth or API-key auth profiles. Re-authenticate with `openclaw models auth login --provider google-gemini-cli --force`.",
`Gemini CLI execution requires a Google AI Studio API-key profile or a previously configured valid Gemini CLI OAuth profile. ${GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE}`,
);
}
@@ -149,7 +151,7 @@ function requireGeminiOAuthCredential(
!Number.isFinite(credential.expires)
) {
throw new Error(
"Gemini CLI OAuth profile is missing usable token material. Re-authenticate with `openclaw models auth login --provider google-gemini-cli --force`.",
`Gemini CLI OAuth profile is incomplete and cannot be repaired by OpenClaw. ${GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE}`,
);
}
+52 -2
View File
@@ -637,6 +637,30 @@ describe("google gemini cli backend auth bridge", () => {
}
});
it("keeps expired but refreshable legacy OAuth profiles on the compatibility path", async () => {
await withTempDir("openclaw-test-workspace-", async (workspaceDir) => {
const context = buildGeminiOAuthPrepareContext(workspaceDir);
if (!context.authCredential) {
throw new Error("expected Gemini OAuth test credentials");
}
context.authCredential.expires = Date.now() - 60_000;
const prepared = await buildGoogleGeminiCliBackend().prepareExecution?.(context);
try {
await stageGeminiPreparedExecution(prepared);
const home = prepared?.env?.GEMINI_CLI_HOME;
const raw = await fs.readFile(path.join(home ?? "", ".gemini", "oauth_creds.json"), "utf8");
expect(JSON.parse(raw)).toMatchObject({
access_token: "access-token",
refresh_token: "refresh-token",
expiry_date: context.authCredential.expires,
});
} finally {
await prepared?.cleanup?.();
}
});
});
it("stages Gemini CLI JSON through same-directory atomic renames", async () => {
await withTempDir("openclaw-test-workspace-", async (workspaceDir) => {
const backend = buildGoogleGeminiCliBackend();
@@ -851,7 +875,7 @@ describe("google gemini cli backend auth bridge", () => {
token: "bearer-token",
},
} as never),
).rejects.toThrow(/OAuth or API-key auth profiles/);
).rejects.toThrow(/Google AI Studio API-key profile/);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
@@ -870,7 +894,33 @@ describe("google gemini cli backend auth bridge", () => {
modelId: "gemini-3.1-flash-lite",
authProfileId: "google-gemini-cli:missing",
} as never),
).rejects.toThrow(/no credential material/);
).rejects.toThrow(/Open Models settings and connect Google with an AI Studio API key/);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("routes incomplete legacy Gemini OAuth profiles to supported Google setup", async () => {
const backend = buildGoogleGeminiCliBackend();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-workspace-"));
try {
await expect(
backend.prepareExecution?.({
workspaceDir,
agentDir: path.join(workspaceDir, "agent"),
provider: "google-gemini-cli",
modelId: "gemini-3.1-flash-lite",
authProfileId: "google-gemini-cli:legacy",
authCredential: {
type: "oauth",
provider: "google-gemini-cli",
access: "expired-access-token",
},
} as never),
).rejects.toThrow(
/OAuth profile is incomplete and cannot be repaired by OpenClaw.*AI Studio API key/,
);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
+3 -94
View File
@@ -2,10 +2,8 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Google provider module implements model/runtime integration.
import type {
OpenClawPluginApi,
ProviderAuthContext,
ProviderFetchUsageSnapshotContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
import { GOOGLE_GEMINI_CLI_PROVIDER_ID } from "./gemini-cli-auth-home.js";
@@ -14,14 +12,7 @@ import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
const PROVIDER_ID = GOOGLE_GEMINI_CLI_PROVIDER_ID;
const PROVIDER_LABEL = "Gemini CLI OAuth";
const DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
const ENV_VARS = [
"OPENCLAW_GEMINI_OAUTH_CLIENT_ID",
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_ID",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
] as const;
const PROVIDER_LABEL = "Gemini CLI runtime";
const loadOauthRuntimeModule = createLazyRuntimeModule(() => import("./oauth.runtime.js"));
@@ -35,90 +26,8 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
label: PROVIDER_LABEL,
docsPath: "/providers/models",
aliases: ["gemini-cli"],
envVars: [...ENV_VARS],
auth: [
{
id: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx: ProviderAuthContext) => {
await ctx.prompter.note(
[
"This is an unofficial integration and is not endorsed by Google.",
"Some users have reported account restrictions or suspensions after using third-party Gemini CLI and Antigravity OAuth clients.",
"Proceed only if you understand and accept this risk.",
].join("\n"),
"Google Gemini CLI caution",
);
const proceed = await ctx.prompter.confirm({
message: "Continue with Google Gemini CLI OAuth?",
initialValue: false,
});
if (!proceed) {
await ctx.prompter.note("Skipped Google Gemini CLI OAuth setup.", "Setup skipped");
return { profiles: [] };
}
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
try {
const { loginGeminiCliOAuth } = await loadOauthRuntimeModule();
const result = await loginGeminiCliOAuth({
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
log: (msg) => ctx.runtime.log(msg),
note: (message, title) => ctx.prompter.note(message, title),
prompt: async (message) => ctx.prompter.text({ message }),
progress: spin,
...(ctx.signal ? { signal: ctx.signal } : {}),
});
spin.stop("Gemini CLI OAuth complete");
return buildOauthProviderAuthResult({
providerId: PROVIDER_ID,
defaultModel: DEFAULT_MODEL,
access: result.access,
refresh: result.refresh,
expires: result.expires,
email: result.email,
configPatch: {
agents: {
defaults: {
models: {
[DEFAULT_MODEL]: { agentRuntime: { id: PROVIDER_ID } },
},
},
},
},
...(result.projectId ? { credentialExtra: { projectId: result.projectId } } : {}),
...(result.projectId
? {
notes: [
"If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
],
}
: {}),
});
} catch (err) {
spin.stop("Gemini CLI OAuth failed");
await ctx.prompter.note(
"Trouble with OAuth? Ensure your Google account has Gemini CLI access.",
"OAuth help",
);
throw err;
}
},
},
],
wizard: {
setup: {
choiceId: "google-gemini-cli",
choiceLabel: "Gemini CLI OAuth",
choiceHint: "Sign in with your Google account (opens a browser)",
methodId: "oauth",
},
},
envVars: [],
auth: [],
resolveDynamicModel: (ctx) =>
resolveGoogleGeminiForwardCompatModel({
providerId: PROVIDER_ID,
+15
View File
@@ -129,6 +129,21 @@ describe("google provider plugin hooks", () => {
).toBe("tagged");
});
it("keeps the Gemini CLI runtime without offering new OAuth setup", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const cliProvider = requireRegisteredProvider(providers, "google-gemini-cli");
expect(cliProvider.label).toBe("Gemini CLI runtime");
expect(cliProvider.auth).toEqual([]);
expect(cliProvider.envVars).toEqual([]);
expect(cliProvider.wizard).toBeUndefined();
expect(cliProvider.refreshOAuth).toBeTypeOf("function");
});
it("keeps google-antigravity hook aliases on tagged reasoning mode", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
+22
View File
@@ -3,6 +3,13 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type GoogleManifest = {
providerAuthChoices?: Array<{
provider?: string;
method?: string;
choiceLabel?: string;
choiceHint?: string;
groupHint?: string;
}>;
modelIdNormalization?: {
providers?: Record<
string,
@@ -67,6 +74,21 @@ function loadManifest(): GoogleManifest {
}
describe("google manifest model catalog", () => {
it("offers Google AI Studio API keys without consumer CLI OAuth", () => {
const choices = loadManifest().providerAuthChoices ?? [];
expect(choices).toEqual([
expect.objectContaining({
provider: "google",
method: "api-key",
choiceLabel: "Google AI Studio API key",
choiceHint: "Supported API-key access from aistudio.google.com/apikey",
groupHint: "Supported API-key setup",
}),
]);
expect(choices.some((choice) => choice.provider === "google-gemini-cli")).toBe(false);
});
it("suppresses retired Gemini chat model identifiers for all Google chat providers", () => {
const manifest = loadManifest();
const suppressionRefs = new Set(
+3 -15
View File
@@ -692,28 +692,16 @@
"method": "api-key",
"choiceId": "gemini-api-key",
"appGuidedSecret": true,
"choiceLabel": "Google Gemini API key",
"choiceHint": "Free API key from aistudio.google.com/apikey",
"choiceLabel": "Google AI Studio API key",
"choiceHint": "Supported API-key access from aistudio.google.com/apikey",
"groupId": "google",
"groupLabel": "Google",
"groupHint": "Gemini API key + OAuth",
"groupHint": "Supported API-key setup",
"onboardingFeatured": true,
"optionKey": "geminiApiKey",
"cliFlag": "--gemini-api-key",
"cliOption": "--gemini-api-key <key>",
"cliDescription": "Gemini API key"
},
{
"provider": "google-gemini-cli",
"method": "oauth",
"choiceId": "google-gemini-cli",
"appGuidedAuth": "oauth",
"choiceLabel": "Gemini CLI OAuth",
"choiceHint": "Sign in with your Google account (opens a browser)",
"groupId": "google",
"groupLabel": "Google",
"groupHint": "Gemini API key + OAuth",
"onboardingFeatured": true
}
],
"uiHints": {
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { createGoogleGeminiCliProvider, createGoogleProvider } from "./provider-contract-api.js";
describe("google provider contract", () => {
it("exposes Google AI Studio API-key setup", () => {
const provider = createGoogleProvider();
expect(provider.auth).toEqual([
expect.objectContaining({
id: "api-key",
label: "Google AI Studio API key",
hint: "Supported API-key access from aistudio.google.com/apikey",
wizard: expect.objectContaining({
choiceLabel: "Google AI Studio API key",
groupHint: "Supported API-key setup",
}),
}),
]);
});
it("keeps Gemini CLI as a runtime-only compatibility provider", () => {
const provider = createGoogleGeminiCliProvider();
expect(provider.label).toBe("Gemini CLI runtime");
expect(provider.auth).toEqual([]);
expect(provider.envVars).toEqual([]);
expect(provider.wizard).toBeUndefined();
});
});
+7 -28
View File
@@ -14,15 +14,15 @@ export function createGoogleProvider(): ProviderPlugin {
{
id: "api-key",
kind: "api_key",
label: "Google Gemini API key",
hint: "Free API key from aistudio.google.com/apikey",
label: "Google AI Studio API key",
hint: "Supported API-key access from aistudio.google.com/apikey",
run: noopAuth,
wizard: {
choiceId: "gemini-api-key",
choiceLabel: "Google Gemini API key",
choiceLabel: "Google AI Studio API key",
groupId: "google",
groupLabel: "Google",
groupHint: "Gemini API key + OAuth",
groupHint: "Supported API-key setup",
},
},
],
@@ -48,31 +48,10 @@ export function createGoogleVertexProvider(): ProviderPlugin {
export function createGoogleGeminiCliProvider(): ProviderPlugin {
return {
id: "google-gemini-cli",
label: "Gemini CLI OAuth",
label: "Gemini CLI runtime",
docsPath: "/providers/models",
aliases: ["gemini-cli"],
envVars: [
"OPENCLAW_GEMINI_OAUTH_CLIENT_ID",
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_ID",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
],
auth: [
{
id: "oauth",
kind: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
run: noopAuth,
},
],
wizard: {
setup: {
choiceId: "google-gemini-cli",
choiceLabel: "Gemini CLI OAuth",
choiceHint: "Sign in with your Google account (opens a browser)",
methodId: "oauth",
},
},
envVars: [],
auth: [],
};
}
+5 -5
View File
@@ -48,21 +48,21 @@ export function buildGoogleProvider(): ProviderPlugin {
createProviderApiKeyAuthMethod({
providerId: "google",
methodId: "api-key",
label: "Google Gemini API key",
hint: "Free API key from aistudio.google.com/apikey",
label: "Google AI Studio API key",
hint: "Supported API-key access from aistudio.google.com/apikey",
optionKey: "geminiApiKey",
flagName: "--gemini-api-key",
envVar: "GEMINI_API_KEY",
promptMessage: "Enter Gemini API key",
promptMessage: "Enter Google AI Studio API key",
defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL,
expectedProviders: ["google"],
applyConfig: (cfg) => applyGoogleGeminiModelDefault(cfg).next,
wizard: {
choiceId: "gemini-api-key",
choiceLabel: "Google Gemini API key",
choiceLabel: "Google AI Studio API key",
groupId: "google",
groupLabel: "Google",
groupHint: "Gemini API key + OAuth",
groupHint: "Supported API-key setup",
},
}),
],
@@ -48,6 +48,7 @@ const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
function createGoogleChatSendReceipt(params: {
messageId?: string;
chatId: string;
threadId?: string;
kind: MessageReceiptPartKind;
}) {
const messageId = params.messageId?.trim();
@@ -62,7 +63,7 @@ function createGoogleChatSendReceipt(params: {
},
]
: [],
threadId: params.chatId,
threadId: params.threadId,
kind: params.kind,
});
}
@@ -251,7 +252,12 @@ export const googlechatOutboundAdapter = {
return {
messageId,
chatId: space,
receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "text" }),
receipt: createGoogleChatSendReceipt({
messageId,
chatId: space,
threadId: result?.threadName ?? thread,
kind: "text",
}),
};
},
},
+43
View File
@@ -229,6 +229,49 @@ describe("googlechatPlugin outbound", () => {
]);
});
it("records the API thread separately from the containing space", async () => {
const cfg = createGoogleChatCfg();
sendGoogleChatMessageMock.mockResolvedValueOnce({
messageName: "spaces/AAA/messages/msg-canonical",
threadName: "spaces/AAA/threads/canonical",
});
const canonical = await googlechatOutboundAdapter.attachedResults.sendText({
cfg,
to: "spaces/AAA",
text: "canonical",
threadId: "threads/requested",
});
expect(canonical.receipt.threadId).toBe("spaces/AAA/threads/canonical");
expect(canonical.receipt.parts[0]?.threadId).toBe("spaces/AAA/threads/canonical");
expect(canonical.receipt.raw?.[0]).toMatchObject({
chatId: "spaces/AAA",
conversationId: "spaces/AAA",
});
sendGoogleChatMessageMock.mockResolvedValueOnce({
messageName: "spaces/AAA/messages/msg-fallback",
});
const fallback = await googlechatOutboundAdapter.attachedResults.sendText({
cfg,
to: "spaces/AAA",
text: "fallback",
threadId: "threads/requested",
});
expect(fallback.receipt.threadId).toBe("threads/requested");
sendGoogleChatMessageMock.mockResolvedValueOnce({
messageName: "spaces/AAA/messages/msg-top-level",
});
const topLevel = await googlechatOutboundAdapter.attachedResults.sendText({
cfg,
to: "spaces/AAA",
text: "top level",
});
expect(topLevel.receipt.threadId).toBeUndefined();
});
it("renders and chunks outbound text without requiring Google Chat runtime initialization", () => {
const chunker = googlechatOutboundAdapter.base.chunker;
@@ -364,6 +364,100 @@ describe("llama.cpp inference provider", () => {
expect(mocks.llama.createGrammarForJsonSchema).not.toHaveBeenCalled();
});
it.each([
{
format: "Harmony",
text: '<|channel|>commentary to=weather code<|message|>{"city":"Paris"}<|call|>',
},
{
format: "bracketed",
text: '[weather]\n{"city":"Paris"}\n[END_TOOL_REQUEST]',
},
])("promotes $format plaintext tool calls into native tool events", async ({ text }) => {
mocks.generateResponse.mockImplementationOnce(async (_history, options) => {
options.onTextChunk(text.slice(0, 12));
options.onTextChunk(text.slice(12));
return {
response: text,
functionCalls: undefined,
metadata: { stopReason: "eogToken" },
};
});
const stream = await createLlamaCppStreamFn({})(model, {
messages: [{ role: "user", content: "Weather?", timestamp: 1 }],
tools: [
{
name: "weather",
description: "Get weather",
parameters: { type: "object", properties: { city: { type: "string" } } },
},
],
});
const events = await collectEvents(stream);
expect(events.map((event) => event.type)).toEqual([
"start",
"toolcall_start",
"toolcall_delta",
"toolcall_end",
"done",
]);
expect(events.at(-1)).toMatchObject({
type: "done",
reason: "toolUse",
message: {
stopReason: "toolUse",
content: [
{
type: "toolCall",
name: "weather",
arguments: { city: "Paris" },
},
],
},
});
});
it("preserves plaintext calls for tools that are not registered", async () => {
const text = '[tool:calendar] {"city":"Paris"}';
mocks.generateResponse.mockImplementationOnce(async (_history, options) => {
options.onTextChunk(text);
return {
response: text,
functionCalls: undefined,
metadata: { stopReason: "eogToken" },
};
});
const stream = await createLlamaCppStreamFn({})(model, {
messages: [{ role: "user", content: "Weather?", timestamp: 1 }],
tools: [
{
name: "weather",
description: "Get weather",
parameters: { type: "object", properties: { city: { type: "string" } } },
},
],
});
const events = await collectEvents(stream);
expect(events.map((event) => event.type)).toEqual([
"start",
"text_start",
"text_delta",
"text_end",
"done",
]);
expect(events.at(-1)).toMatchObject({
type: "done",
reason: "stop",
message: { content: [{ type: "text", text }] },
});
});
it("lets tools win when responseFormat is also present", async () => {
const stream = await createLlamaCppStreamFn({})(
model,
@@ -17,6 +17,7 @@ import type {
} from "openclaw/plugin-sdk/llm";
import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { createPlainTextToolCallCompatWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
import {
DEFAULT_LLAMA_CPP_CONTEXT_SIZE,
resolveLlamaCppModelCacheDir,
@@ -293,7 +294,7 @@ async function clearLlamaCppInferenceCacheForTests(): Promise<void> {
}
export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderConfig }): StreamFn {
return (model, context, options) => {
return createPlainTextToolCallCompatWrapper((model, context, options) => {
const stream = createAssistantMessageEventStream();
let streamedText = "";
let generationAborted = false;
@@ -453,7 +454,7 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC
queueMicrotask(() => void serialize(run));
}
return stream;
};
});
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
+2 -2
View File
@@ -1901,7 +1901,7 @@ describe("ollama plugin", () => {
expect(rows).toHaveLength(8);
});
it("keeps unknown requested Ollama models unresolved when show has no metadata", async () => {
it("keeps unknown requested Ollama models unresolved when show inspection fails", async () => {
const provider = registerProvider();
const previous = process.env.OLLAMA_API_KEY;
process.env.OLLAMA_API_KEY = "ollama-local";
@@ -1910,7 +1910,7 @@ describe("ollama plugin", () => {
api: "ollama",
models: [],
});
queryOllamaModelShowInfoMock.mockResolvedValueOnce({});
queryOllamaModelShowInfoMock.mockResolvedValueOnce({ showInspectionFailed: true });
try {
await provider.prepareDynamicModel?.({
+4 -2
View File
@@ -323,7 +323,7 @@ describe("Ollama provider", () => {
const fetchMock = vi.fn(async (input: unknown) => {
const url = String(input);
if (url.endsWith("/api/tags")) {
return tagsResponse(["qwen3:32b"]);
return tagsResponse(["deepseek-r1:14b"]);
}
if (url.endsWith("/api/show")) {
return jsonResponse({}, 500);
@@ -335,8 +335,10 @@ describe("Ollama provider", () => {
const provider = await runOllamaCatalog({
env: { OLLAMA_API_KEY: "test-key", VITEST: "", NODE_ENV: "development" },
});
const model = provider?.models?.find((entry) => entry.id === "qwen3:32b");
const model = provider?.models?.find((entry) => entry.id === "deepseek-r1:14b");
expect(model?.contextWindow).toBe(128000);
expect(model?.compat?.supportsTools).toBe(false);
expect(model?.reasoning).toBe(true);
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 1 });
});
@@ -0,0 +1,47 @@
/**
* CJK-aware character weighting for Ollama usage fallback estimates.
*
* This stays plugin-private because exposing it through the Plugin SDK would
* create a stable public contract for one provider-specific fallback. Keep
* the weighting aligned with normalization-core's CJK budget heuristic.
*/
const CHARS_PER_TOKEN_ESTIMATE = 4;
const NON_ASCII_RE = /[\u0080-\u{10FFFF}]/u;
const COMMON_CJK_RE = /[\u00B7\u3000-\u319F\u4E00-\u9FA5\uAC00-\uD7AF\uFF01-\uFF60]/gu;
const RARE_BMP_CJK_RE =
/[\u1100-\u11FF\u2E80-\u2FFF\u31A0-\u4DFF\u9FA6-\u9FFF\uA000-\uA4FF\uA700-\uA707\uA960-\uA97F\uD7B0-\uD7FF\uF900-\uFAFF]/gu;
const TWO_TOKEN_CJK_RE =
/[\u{02C7}\u{02C9}-\u{02CB}\u{02D9}\u{02EA}-\u{02EB}\uFE10-\uFE4F\uFF61-\uFFDC\uFFE0-\uFFE6]|\u{0305}|\u{0323}/gu;
const THREE_TOKEN_SUPPLEMENTARY_CJK_RE = /[\u{1D360}-\u{1D371}]/gu;
const SUPPLEMENTARY_CJK_RE =
/[\u{16FE0}-\u{16FFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B16F}\u{1F200}-\u{1F2FF}\u{20000}-\u{2FA1F}\u{30000}-\u{3347F}]/gu;
const SPECIAL_CJK_RE =
/[\u{02C7}\u{02C9}-\u{02CB}\u{02D9}\u{02EA}-\u{02EB}\u1100-\u11FF\u2E80-\u2FFF\u31A0-\u4DFF\u9FA6-\u9FFF\uA000-\uA4FF\uA700-\uA707\uA960-\uA97F\uD7B0-\uD7FF\uF900-\uFAFF\uFE10-\uFE4F\uFF61-\uFFDC\uFFE0-\uFFE6\u{16FE0}-\u{16FFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B16F}\u{1D360}-\u{1D371}\u{1F200}-\u{1F2FF}\u{20000}-\u{2FA1F}\u{30000}-\u{3347F}]|\u{0305}|\u{0323}/u;
function countMatches(text: string, pattern: RegExp): number {
return (text.match(pattern) ?? []).length;
}
export function estimateStringChars(text: string): number {
if (!NON_ASCII_RE.test(text)) {
return text.length;
}
const commonCjkCount = countMatches(text, COMMON_CJK_RE);
const commonEstimate = text.length + commonCjkCount * (CHARS_PER_TOKEN_ESTIMATE - 1);
if (!SPECIAL_CJK_RE.test(text)) {
return commonEstimate;
}
const rareBmpCjkCount = countMatches(text, RARE_BMP_CJK_RE);
const twoTokenCjkCount = countMatches(text, TWO_TOKEN_CJK_RE);
const threeTokenSupplementaryCjkCount = countMatches(text, THREE_TOKEN_SUPPLEMENTARY_CJK_RE);
const supplementaryCjkCount = countMatches(text, SUPPLEMENTARY_CJK_RE);
return (
commonEstimate +
rareBmpCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 3 - 1) +
twoTokenCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 2 - 1) +
threeTokenSupplementaryCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 3 - 2) +
supplementaryCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 4 - 2)
);
}
+72 -5
View File
@@ -471,6 +471,27 @@ describe("ollama provider models", () => {
expect(model.compat?.supportsUsageInStreaming).toBe(true);
});
it("keeps failed inspection distinct from omitted and empty capabilities", () => {
const uninspected = buildOllamaModelDefinition("deepseek-r1:14b", 65536);
const authoritativeEmpty = buildOllamaModelDefinition("deepseek-r1:14b", 65536, []);
const inspectionFailed = buildOllamaModelDefinition("deepseek-r1:14b", 65536, undefined, {
showInspectionFailed: true,
});
expect(uninspected).toMatchObject({
reasoning: true,
compat: { supportsTools: true },
});
expect(authoritativeEmpty).toMatchObject({
reasoning: false,
compat: { supportsTools: false },
});
expect(inspectionFailed).toMatchObject({
reasoning: true,
compat: { supportsTools: false },
});
});
it.each([
{ parameters: "num_ctx 8192\nnum_ctx 32768", expected: 32768 },
{ parameters: "temperature 0.8\nnum_ctx -1\nnum_ctx 0", expected: undefined },
@@ -506,9 +527,9 @@ describe("ollama provider models", () => {
vi.fn(async () => showResponse.response),
);
await expect(queryOllamaModelShowInfo("http://127.0.0.1:11434", "llama3:8b")).resolves.toEqual(
{},
);
await expect(queryOllamaModelShowInfo("http://127.0.0.1:11434", "llama3:8b")).resolves.toEqual({
showInspectionFailed: true,
});
expect(showResponse.wasCanceled()).toBe(true);
});
@@ -609,7 +630,9 @@ describe("ollama provider models", () => {
});
await waitForSocketClose("/api/tags");
await expect(queryOllamaModelShowInfo(baseUrl, "llama3:8b")).resolves.toEqual({});
await expect(queryOllamaModelShowInfo(baseUrl, "llama3:8b")).resolves.toEqual({
showInspectionFailed: true,
});
await waitForSocketClose("/api/show");
mode = "success";
@@ -639,6 +662,50 @@ describe("ollama provider models", () => {
}
});
it("keeps tools off after a live /api/show failure", async () => {
const server = createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
if (request.url === "/api/tags") {
response.end(
JSON.stringify({
models: [{ name: "deepseek-r1:14b", digest: "sha256:show-failure" }],
}),
);
return;
}
if (request.url === "/api/show") {
response.statusCode = 500;
response.end(JSON.stringify({ error: "show failed" }));
return;
}
response.statusCode = 404;
response.end(JSON.stringify({ error: "not found" }));
});
const listening = once(server, "listening");
try {
server.listen(0, "127.0.0.1");
await listening;
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Ollama test server did not expose a TCP address");
}
const provider = await buildOllamaProvider(`http://127.0.0.1:${address.port}`);
const model = expectDefined(provider.models?.[0], "show-failed Ollama model");
expect(model.id).toBe("deepseek-r1:14b");
expect(model.compat?.supportsTools).toBe(false);
expect(model.reasoning).toBe(true);
} finally {
if (server.listening) {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}
});
it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => {
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels
// the stream mid-flight; if the cap were removed the reader would buffer the whole payload.
@@ -687,7 +754,7 @@ describe("ollama provider models", () => {
vi.fn(async () => makeOversizedJsonResponse()),
);
const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest");
expect(showInfo).toEqual({});
expect(showInfo).toEqual({ showInspectionFailed: true });
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
+15 -7
View File
@@ -37,6 +37,7 @@ export type OllamaTagsResponse = {
export type OllamaModelWithContext = OllamaTagModel & {
contextWindow?: number;
capabilities?: string[];
showInspectionFailed?: boolean;
};
const OLLAMA_SHOW_CONCURRENCY = 8;
@@ -81,8 +82,14 @@ export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
export type OllamaModelShowInfo = {
contextWindow?: number;
capabilities?: string[];
/** Distinguishes a failed request from a successful response that omitted capabilities. */
showInspectionFailed?: boolean;
};
const OLLAMA_FAILED_SHOW_INFO: OllamaModelShowInfo = Object.freeze({
showInspectionFailed: true,
});
type OllamaModelRequestOptions = {
apiKey?: string;
timeoutMs?: number;
@@ -227,7 +234,7 @@ export async function queryOllamaModelShowInfo(
return await readOllamaModelShowInfo(apiBase, modelName, opts);
} catch {
throwIfOllamaRequestAborted(opts?.signal);
return {};
return OLLAMA_FAILED_SHOW_INFO;
}
}
@@ -279,10 +286,7 @@ export async function enrichOllamaModelsWithContext(
const batchResults = await Promise.all(
batch.map(async (model) => {
const showInfo = await queryOllamaModelShowInfoCached(apiBase, model, opts);
return Object.assign({}, model, {
contextWindow: showInfo.contextWindow,
capabilities: showInfo.capabilities,
});
return Object.assign({}, model, showInfo);
}),
);
enriched.push(...batchResults);
@@ -343,6 +347,7 @@ export function buildOllamaModelDefinition(
modelId: string,
contextWindow?: number,
capabilities?: string[],
opts?: { showInspectionFailed?: boolean },
): ModelDefinitionConfig {
const hasVision = capabilities?.includes("vision") ?? false;
const input: ("text" | "image")[] = hasVision ? ["text", "image"] : ["text"];
@@ -352,7 +357,8 @@ export function buildOllamaModelDefinition(
? isReasoningModelHeuristic(modelId)
: capabilities.includes("thinking"));
const compat = {
supportsTools: capabilities?.includes("tools") ?? true,
supportsTools:
opts?.showInspectionFailed === true ? false : (capabilities?.includes("tools") ?? true),
supportsUsageInStreaming: true,
supportsJsonSchemaResponseFormat: !isOllamaCloudModel(modelId),
};
@@ -467,7 +473,9 @@ export async function buildOllamaProvider(
baseUrl: apiBase,
api: "ollama",
models: discovered.map((model) =>
buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities),
buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities, {
showInspectionFailed: model.showInspectionFailed,
}),
),
};
}
@@ -0,0 +1,210 @@
import { once } from "node:events";
import { createServer } from "node:http";
import type { Socket } from "node:net";
import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pullOllamaModel } from "./setup-pull.js";
import { checkOllamaCloudAuth } from "./setup.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
};
});
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(body, init),
wasCanceled: () => canceled,
};
}
function createPullPrompter(): WizardPrompter {
return {
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
} as unknown as WizardPrompter;
}
async function waitForSocketClose(closed: Promise<void> | undefined): Promise<void> {
if (!closed) {
throw new Error("Ollama test server did not receive a request");
}
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
closed,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error("Ollama response socket was not closed"));
}, 2_000);
}),
]);
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
describe("Ollama setup response cleanup", () => {
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
it.each([200, 503])("cancels the /api/me body for HTTP %s", async (status) => {
const tracked = cancelTrackedResponse('{"status":"unused"}\n', { status });
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: tracked.response,
finalUrl: "https://ollama.com/api/me",
release,
});
await checkOllamaCloudAuth("https://ollama.com");
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledOnce();
});
it.each([
{
name: "non-OK /api/pull response",
response: () => cancelTrackedResponse("ollama unavailable", { status: 503 }),
},
{
name: "streamed /api/pull error",
response: () => cancelTrackedResponse('{"error":"disk full"}\n', { status: 200 }),
},
])("cancels a $name body before returning", async ({ response: createResponse }) => {
const tracked = createResponse();
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: tracked.response,
finalUrl: "http://127.0.0.1:11434/api/pull",
release,
});
await expect(
pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", createPullPrompter()),
).resolves.toBe(false);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledOnce();
});
it.each([
{
name: "successful auth probe",
path: "/api/me",
status: 200,
body: '{"status":"unused"}\n',
run: async (baseUrl: string) => {
await checkOllamaCloudAuth(baseUrl);
},
},
{
name: "failed auth probe",
path: "/api/me",
status: 503,
body: "ollama unavailable",
run: async (baseUrl: string) => {
await checkOllamaCloudAuth(baseUrl);
},
},
{
name: "failed pull response",
path: "/api/pull",
status: 503,
body: "ollama unavailable",
run: async (baseUrl: string) => {
await pullOllamaModel(baseUrl, "gemma4:e2b", createPullPrompter());
},
},
{
name: "streamed pull error",
path: "/api/pull",
status: 200,
body: '{"error":"disk full"}\n',
run: async (baseUrl: string) => {
await pullOllamaModel(baseUrl, "gemma4:e2b", createPullPrompter());
},
},
])("closes the real socket after a $name", async ({ path, status, body, run }) => {
const sockets = new Set<Socket>();
let requestSocketClosed: Promise<void> | undefined;
const server = createServer((request, response) => {
if (request.url !== path) {
response.writeHead(404);
response.end();
return;
}
requestSocketClosed = new Promise<void>((resolve) => {
request.socket.once("close", () => resolve());
});
response.writeHead(status, { "content-type": "application/json" });
response.write(body);
});
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
fetchWithSsrFGuardMock.mockImplementation(
async (params: { url: string; init?: RequestInit; signal?: AbortSignal }) => ({
response: await globalThis.fetch(params.url, {
...params.init,
...(params.signal ? { signal: params.signal } : {}),
}),
finalUrl: params.url,
release: async () => {},
}),
);
const listening = once(server, "listening");
try {
server.listen(0, "127.0.0.1");
await listening;
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Ollama test server did not expose a TCP address");
}
await run(`http://127.0.0.1:${address.port}`);
await waitForSocketClose(requestSocketClosed);
} finally {
for (const socket of sockets) {
socket.destroy();
}
if (server.listening) {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
});
});
@@ -23,11 +23,12 @@ describe("Ollama onboarding model selection", () => {
it("keeps failed model inspections distinct from uninspected models", () => {
const models = buildOllamaModelsConfig(
["broken", "uninspected"],
new Map([["broken", { name: "broken", capabilities: [] }]]),
["deepseek-r1:14b", "uninspected"],
new Map([["deepseek-r1:14b", { name: "deepseek-r1:14b", showInspectionFailed: true }]]),
);
expect(models[0]?.compat?.supportsTools).toBe(false);
expect(models[0]?.reasoning).toBe(true);
expect(models[1]?.compat?.supportsTools).toBe(true);
});
@@ -78,6 +78,7 @@ export function buildOllamaModelsConfig(
name,
discovered?.contextWindow ?? defaultModel?.contextWindow,
capabilities,
{ showInspectionFailed: discovered?.showInspectionFailed },
);
});
}
@@ -105,9 +106,11 @@ export async function inspectOllamaModelsForSetup(
} catch (error) {
signal?.throwIfAborted();
// A failed inspection must not inherit the optimistic tools default
// reserved for models that were never inspected.
// reserved for models that were never inspected. Keep the failure
// distinct from authoritative empty capabilities so name-based
// reasoning detection still applies.
inspectionFailures.push(`${model.name}: ${formatErrorMessage(error)}`);
return Object.assign({}, model, { capabilities: [] as string[] });
return Object.assign({}, model, { showInspectionFailed: true as const });
}
}),
);
+3
View File
@@ -78,6 +78,7 @@ async function pullOllamaModelCore(params: {
clearTimeout(responseTimeout);
try {
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
return { ok: false, message: `Failed to download ${modelName} (HTTP ${response.status})` };
}
if (!response.body) {
@@ -135,6 +136,8 @@ async function pullOllamaModelCore(params: {
for (const line of lines) {
const parsed = parseLine(line);
if (!parsed.ok) {
// Ollama can report an error before closing the stream; discard the unread tail.
await reader.cancel().catch(() => undefined);
return parsed;
}
}
+1
View File
@@ -142,6 +142,7 @@ export async function checkOllamaCloudAuth(
}
return { signedIn: true };
} finally {
await response.body?.cancel().catch(() => undefined);
await release();
}
} catch {
+103
View File
@@ -694,4 +694,107 @@ describe("createOllamaStreamFn thinking events", () => {
error: { stopReason: "aborted" },
});
});
it("uses CJK-aware fallback usage while preserving missing cache provenance", async () => {
const events = await streamOllamaEvents(
[
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant", content: "你好世界测试" },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:01Z",
message: { role: "assistant", content: "" },
done: true,
done_reason: "stop",
},
],
{},
{ messages: [{ role: "user", content: "这是一个测试用的句子呢" }] } as never,
);
const done = events.find((event) => event.type === "done") as {
message?: {
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
cacheTelemetry?: { state: string };
};
};
};
expect(done?.message?.usage).toMatchObject({
input: 12,
output: 6,
cacheRead: 0,
cacheWrite: 0,
cacheTelemetry: { state: "unavailable" },
});
});
it("keeps provider usage authoritative over the CJK fallback", async () => {
const events = await streamOllamaEvents(
[
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant", content: "你好世界测试" },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:01Z",
message: { role: "assistant", content: "" },
done: true,
done_reason: "stop",
prompt_eval_count: 77,
eval_count: 19,
},
],
{},
{ messages: [{ role: "user", content: "这是一个测试用的句子呢" }] } as never,
);
const done = events.find((event) => event.type === "done") as {
message?: { usage?: { input?: number; output?: number; cacheTelemetry?: { state: string } } };
};
expect(done?.message?.usage).toMatchObject({
input: 77,
output: 19,
cacheTelemetry: { state: "unavailable" },
});
});
it("keeps the existing fallback estimate for ASCII-only usage", async () => {
const events = await streamOllamaEvents(
[
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant", content: "Hello world" },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:01Z",
message: { role: "assistant", content: "" },
done: true,
done_reason: "stop",
},
],
{},
{
messages: [{ role: "user", content: "The quick brown fox jumps over the lazy dog" }],
} as never,
);
const done = events.find((event) => event.type === "done") as {
message?: { usage?: { input?: number; output?: number } };
};
expect(done?.message?.usage).toMatchObject({ input: 11, output: 3 });
});
});
+11 -7
View File
@@ -33,6 +33,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard, isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { estimateStringChars } from "./cjk-char-estimate.js";
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js";
import { normalizeOllamaWireModelId } from "./model-id.js";
@@ -683,7 +684,7 @@ interface OllamaChatResponse {
function safeJsonLength(value: unknown): number {
try {
const serialized = JSON.stringify(value);
return typeof serialized === "string" ? serialized.length : 0;
return typeof serialized === "string" ? estimateStringChars(serialized) : 0;
} catch {
return 0;
}
@@ -714,10 +715,10 @@ function estimateOllamaPromptTokens(params: {
}): number {
let chars = 0;
for (const message of params.messages) {
chars += message.content.length;
chars += estimateStringChars(message.content);
chars += safeJsonLength(message.images);
chars += safeJsonLength(message.tool_calls);
chars += message.tool_name?.length ?? 0;
chars += message.tool_name ? estimateStringChars(message.tool_name) : 0;
}
chars += safeJsonLength(params.tools);
return estimateTokensFromChars(chars);
@@ -729,9 +730,9 @@ function estimateOllamaCompletionTokens(
): number {
const chars =
extraOutputChars +
response.message.content.length +
(response.message.thinking?.length ?? 0) +
(response.message.reasoning?.length ?? 0) +
estimateStringChars(response.message.content) +
(response.message.thinking ? estimateStringChars(response.message.thinking) : 0) +
(response.message.reasoning ? estimateStringChars(response.message.reasoning) : 0) +
safeJsonLength(response.message.tool_calls);
return estimateTokensFromChars(chars);
}
@@ -1473,7 +1474,10 @@ function createRawOllamaStreamFn(
const usageFallback = {
input: estimateOllamaPromptTokens({ messages: ollamaMessages, tools: ollamaTools }),
output: estimateOllamaCompletionTokens(finalResponse, suppressedThinking.length),
output: estimateOllamaCompletionTokens(
finalResponse,
estimateStringChars(suppressedThinking),
),
};
const assistantMessage = buildAssistantMessage(finalResponse, modelInfo, usageFallback, {
...toolCallNameOptions,
+67 -1
View File
@@ -156,7 +156,7 @@ describe("openai plugin", () => {
vi.restoreAllMocks();
});
it("registers the native GPT-Live offer route and cleanup lifecycle", () => {
it("registers the native GPT-Live offer route and cleanup lifecycle", async () => {
const registerHttpRoute = vi.fn();
const registerRuntimeLifecycle = vi.fn();
plugin.register(
@@ -183,6 +183,72 @@ describe("openai plugin", () => {
cleanup: expect.any(Function),
}),
);
await registerRuntimeLifecycle.mock.calls[0]?.[0].cleanup({ reason: "disable" });
});
it("shares one GPT-Live broker across full registrations and ignores late old cleanup", async () => {
const register = () => {
const registerHttpRoute = vi.fn();
const registerRuntimeLifecycle = vi.fn();
plugin.register(
createTestPluginApi({
id: "openai",
name: "OpenAI Provider",
source: "test",
config: {},
runtime: { config: { current: vi.fn(() => ({})) } } as never,
registerHttpRoute,
registerRuntimeLifecycle,
}),
);
return {
handler: registerHttpRoute.mock.calls[0]?.[0].handler as unknown,
cleanup: registerRuntimeLifecycle.mock.calls[0]?.[0].cleanup as (ctx: {
reason: string;
}) => Promise<void> | void,
};
};
const first = register();
const second = register();
expect(second.handler).toBe(first.handler);
await first.cleanup({ reason: "disable" });
const replacement = register();
expect(replacement.handler).not.toBe(first.handler);
await second.cleanup({ reason: "disable" });
const afterLateCleanup = register();
expect(afterLateCleanup.handler).toBe(replacement.handler);
await replacement.cleanup({ reason: "disable" });
});
it("only cleans up the GPT-Live broker on plugin disable, not session reset/delete/restart", async () => {
const registerRuntimeLifecycle = vi.fn();
plugin.register(
createTestPluginApi({
id: "openai",
name: "OpenAI Provider",
source: "test",
config: {},
runtime: { config: { current: vi.fn(() => ({})) } } as never,
registerHttpRoute: vi.fn(),
registerRuntimeLifecycle,
}),
);
const lifecycle = registerRuntimeLifecycle.mock.calls[0]?.[0] as {
cleanup: (ctx: { reason: string }) => Promise<void> | void;
};
expect(lifecycle).toBeDefined();
for (const reason of ["reset", "delete", "restart"]) {
const result = lifecycle.cleanup({ reason });
expect(result).toBeUndefined();
}
const disableResult = lifecycle.cleanup({ reason: "disable" });
await expect(disableResult).resolves.toBeUndefined();
});
it("generates PNG buffers from the OpenAI Images API", async () => {
+11 -5
View File
@@ -12,9 +12,10 @@ import {
resolveOpenAISystemPromptContribution,
} from "./prompt-overlay.js";
import {
createOpenAIQuicksilverBrowserSessionBroker,
OPENAI_QUICKSILVER_OFFER_PATH,
} from "./realtime-quicksilver-session.js";
acquireOpenAIQuicksilverBrowserSessionBroker,
releaseOpenAIQuicksilverBrowserSessionBroker,
} from "./realtime-quicksilver-session-owner.js";
import { OPENAI_QUICKSILVER_OFFER_PATH } from "./realtime-quicksilver-session.js";
import { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js";
import { buildOpenAISpeechProvider } from "./speech-provider.js";
@@ -27,7 +28,7 @@ export default definePluginEntry({
register(api) {
const quicksilverSession =
api.registrationMode === "full"
? createOpenAIQuicksilverBrowserSessionBroker({
? acquireOpenAIQuicksilverBrowserSessionBroker({
getConfig: () => api.runtime.config.current() as OpenClawConfig,
logger: api.logger,
})
@@ -42,7 +43,12 @@ export default definePluginEntry({
api.lifecycle.registerRuntimeLifecycle({
id: "openai-quicksilver-realtime-browser-session",
description: "Close GPT-Live browser sidebands when the OpenAI plugin stops",
cleanup: () => quicksilverSession.cleanup(),
cleanup: (ctx) => {
if (ctx.reason !== "disable") {
return undefined;
}
return releaseOpenAIQuicksilverBrowserSessionBroker(quicksilverSession);
},
});
}
const openAIToolCompatHooks = buildProviderToolCompatFamilyHooks("openai");
@@ -0,0 +1,55 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton";
import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
import { createOpenAIQuicksilverBrowserSessionBroker } from "./realtime-quicksilver-session.js";
const OPENAI_QUICKSILVER_SESSION_OWNER_KEY = Symbol.for(
"openclaw.openai.quicksilverBrowserSessionOwner.v1",
);
type BrokerSession = ReturnType<typeof createOpenAIQuicksilverBrowserSessionBroker>;
type BrokerParams = {
getConfig: () => OpenClawConfig | undefined;
logger: Pick<PluginLogger, "debug" | "warn">;
};
type BrokerOwner = {
current?: {
params: BrokerParams;
session: BrokerSession;
};
};
function resolveBrokerOwner(): BrokerOwner {
return resolveGlobalSingleton<BrokerOwner>(OPENAI_QUICKSILVER_SESSION_OWNER_KEY, () => ({}));
}
export function acquireOpenAIQuicksilverBrowserSessionBroker(params: BrokerParams): BrokerSession {
const owner = resolveBrokerOwner();
if (owner.current) {
owner.current.params.getConfig = params.getConfig;
owner.current.params.logger = params.logger;
return owner.current.session;
}
// Full plugin registration can run more than once in one process. The provider and
// HTTP route must share one reservation map or an offer reserved by one rejects at another.
const mutableParams = { ...params };
const session = createOpenAIQuicksilverBrowserSessionBroker(mutableParams);
owner.current = { params: mutableParams, session };
return session;
}
export async function releaseOpenAIQuicksilverBrowserSessionBroker(
session: BrokerSession,
): Promise<void> {
const owner = resolveBrokerOwner();
if (owner.current?.session !== session) {
return;
}
// Release ownership before async teardown so a later registration can install a replacement.
owner.current = undefined;
await session.cleanup();
}
@@ -56,6 +56,16 @@ describe("Telegram QA profiles", () => {
).toThrow("execution.kind=flow");
});
it("selects the native queue-validation regression as an explicit live scenario", () => {
expect(
resolveTelegramQaScenarioIds({
profile: "release",
providerMode: "live-frontier",
scenarioIds: ["telegram-queue-invalid-mode"],
}),
).toEqual(["telegram-queue-invalid-mode"]);
});
it("rejects unknown profiles and channel-ineligible explicit scenarios", () => {
expect(() =>
resolveTelegramQaScenarioIds({ providerMode: "live-frontier", profile: "transport" }),
+16
View File
@@ -0,0 +1,16 @@
# OpenClaw Synthetic Provider
Official OpenClaw provider plugin for Synthetic's hosted Anthropic-compatible
API.
Install from OpenClaw:
```bash
openclaw plugins install @openclaw/synthetic-provider
openclaw gateway restart
```
Configure `SYNTHETIC_API_KEY`, then select a `synthetic/<model-id>` model.
See https://docs.openclaw.ai/providers/synthetic for model and configuration
details.
+1 -1
View File
@@ -9,7 +9,7 @@ const PROVIDER_ID = "synthetic";
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Synthetic Provider",
description: "Bundled Synthetic provider plugin",
description: "Synthetic provider plugin",
manifest,
provider: {
label: "Synthetic",
+23 -3
View File
@@ -1,8 +1,11 @@
{
"name": "@openclaw/synthetic-provider",
"version": "2026.7.2",
"private": true,
"description": "OpenClaw Synthetic provider plugin",
"description": "OpenClaw Synthetic provider plugin.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
@@ -10,6 +13,23 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"install": {
"clawhubSpec": "clawhub:@openclaw/synthetic-provider",
"npmSpec": "@openclaw/synthetic-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.7.2"
},
"compat": {
"pluginApi": ">=2026.7.2"
},
"build": {
"openclawVersion": "2026.7.2",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}
+2 -3
View File
@@ -46,6 +46,7 @@ import { apiThrottler, Bot, sequentialize, type ApiClientOptions } from "./bot.r
import type { TelegramBotOptions } from "./bot.types.js";
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
import { setTelegramCallbackQueryAnswerPromise } from "./callback-query-answer-state.js";
import { TELEGRAM_CHAT_ACTION_INTERVAL_MS } from "./chat-action-timing.js";
import {
asTelegramClientFetch,
createTelegramClientFetch,
@@ -77,8 +78,6 @@ const DEFAULT_TELEGRAM_BOT_RUNTIME: TelegramBotRuntime = {
sequentialize,
apiThrottler,
};
const TELEGRAM_TYPING_COALESCE_MS = 4_000;
export function createTelegramBotCore(
opts: TelegramBotOptions & { telegramDeps: TelegramBotDeps },
): TelegramBotInstance {
@@ -354,7 +353,7 @@ export function createTelegramBotCore(
sendChatActionFn: (chatId, action, threadParams) =>
bot.api.sendChatAction(chatId, action, threadParams),
logger: (message) => logVerbose(`telegram: ${message}`),
minIntervalMs: TELEGRAM_TYPING_COALESCE_MS,
minIntervalMs: TELEGRAM_CHAT_ACTION_INTERVAL_MS,
});
const processMessage = createTelegramMessageProcessor({
@@ -19,6 +19,7 @@ import type { TelegramProgressController } from "./bot-message-dispatch-progress
import type { TelegramReplyDelivery } from "./bot-message-dispatch-reply.js";
import type { TelegramDispatchTurnState } from "./bot-message-dispatch.types.js";
import type { TelegramStreamMode } from "./bot/types.js";
import { TELEGRAM_CHAT_ACTION_INTERVAL_MS } from "./chat-action-timing.js";
import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js";
const TELEGRAM_MAX_CONSECUTIVE_TYPING_FAILURES = 5;
@@ -70,6 +71,10 @@ export async function runTelegramDispatchTurn(params: {
accountId: context.route.accountId,
typing: {
start: context.sendTyping,
keepaliveIntervalMs: TELEGRAM_CHAT_ACTION_INTERVAL_MS,
// ReplyOperation owns terminal cleanup; a per-inbound TTL would kill
// feedback while the same long-running task is still active.
maxDurationMs: 0,
maxConsecutiveFailures: TELEGRAM_MAX_CONSECUTIVE_TYPING_FAILURES,
onStartError: (err) => {
logTypingFailure({
@@ -11,6 +11,19 @@ import type { TelegramMessageContext } from "./bot-message-dispatch.test-harness
import { notifyTelegramInboundEventOutboundSuccess } from "./inbound-event-delivery.js";
describeTelegramDispatch("dispatchTelegramMessage pipeline-init", () => {
it("keeps Telegram typing below its client expiry without a per-message cutoff", async () => {
await dispatchWithContext({ context: createContext() });
expect(createChannelMessageReplyPipeline).toHaveBeenCalledWith(
expect.objectContaining({
typing: expect.objectContaining({
keepaliveIntervalMs: 4_000,
maxDurationMs: 0,
}),
}),
);
});
it("cleans delivery correlation when reply-pipeline initialization fails", async () => {
const sessionKey = "agent:main:telegram:direct:pipeline-init-failure";
const statusReactionController = createStatusReactionController();
@@ -15,6 +15,7 @@ import {
type NativeCommandTestParams,
} from "./bot-native-commands.fixture-test-support.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import { runWithTelegramUpdateProcessingFrame } from "./bot-processing-outcome.js";
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
@@ -731,6 +732,39 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(turnPlan?.record?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey);
});
it("records a completed outcome after a native slash command", async () => {
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
const { result } = await runWithTelegramUpdateProcessingFrame(async () => {
await handler(createTelegramPrivateCommandContext());
});
expect(result).toEqual({ kind: "completed" });
});
it("preserves every argument on native queue command turns", async () => {
const { handler } = registerAndResolveCommandHandler({
commandName: "queue",
cfg: {},
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext({ match: "Can you diagnose this?" }));
expect(dispatchChannelInboundTurnMock).toHaveBeenCalledWith(
expect.objectContaining({
ctxPayload: expect.objectContaining({
Body: "/queue Can you diagnose this?",
CommandBody: "/queue Can you diagnose this?",
CommandTurn: expect.objectContaining({
kind: "native",
body: "/queue Can you diagnose this?",
}),
}),
}),
);
});
it("keeps one live config snapshot through native command execution", async () => {
const startupCfg: OpenClawConfig = { session: { store: "/tmp/startup-sessions.json" } };
const runtimeCfg: OpenClawConfig = { session: { store: "/tmp/runtime-sessions.json" } };
+20 -3
View File
@@ -75,7 +75,10 @@ import {
syncTelegramMenuCommands as syncTelegramMenuCommandsRuntime,
type TelegramMenuCommand,
} from "./bot-native-command-menu.js";
import type { TelegramMessageProcessingResult } from "./bot-processing-outcome.js";
import {
recordTelegramMessageProcessingResult,
type TelegramMessageProcessingResult,
} from "./bot-processing-outcome.js";
import type { TelegramUpdateKeyContext } from "./bot-updates.js";
import type { TelegramBotOptions } from "./bot.types.js";
import {
@@ -122,6 +125,20 @@ const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
const activeTelegramCodexLoginFlows = new Map<string, { expiresAt: number }>();
type TelegramNativeCommandContext = Context & { match?: string };
function registerTelegramNativeCommandHandler(
bot: Bot,
command: string,
handler: (ctx: TelegramNativeCommandContext) => Promise<void>,
): void {
bot.command(command, async (ctx: TelegramNativeCommandContext) => {
await handler(ctx);
// Native commands bypass processMessage, so their terminal outcome must be
// recorded here for every built-in, plugin, and direct-delivery branch.
recordTelegramMessageProcessingResult({ kind: "completed" });
});
}
type TelegramChunkMode = ReturnType<
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").resolveChunkMode
>;
@@ -1210,7 +1227,7 @@ export const registerTelegramNativeCommands = ({
if (commandsToRegister.length > 0 || pluginCatalog.commands.length > 0) {
for (const command of nativeCommands) {
const normalizedCommandName = normalizeTelegramCommandName(command.name);
bot.command(normalizedCommandName, async (ctx: TelegramNativeCommandContext) => {
registerTelegramNativeCommandHandler(bot, normalizedCommandName, async (ctx) => {
const msg = ctx.message;
if (!msg) {
return;
@@ -1800,7 +1817,7 @@ export const registerTelegramNativeCommands = ({
}
for (const pluginCommand of pluginCatalog.commands) {
bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => {
registerTelegramNativeCommandHandler(bot, pluginCommand.command, async (ctx) => {
const msg = ctx.message;
if (!msg) {
return;
@@ -0,0 +1,3 @@
// Telegram typing expires after five seconds; renew before that without
// fighting the account-scoped sendChatAction coalescing window.
export const TELEGRAM_CHAT_ACTION_INTERVAL_MS = 4_000;
@@ -14,6 +14,7 @@ import type { CallManager } from "../manager.js";
import type { VoiceCallProvider } from "../providers/base.js";
import type { CallRecord, NormalizedEvent } from "../types.js";
import { connectWs, startUpgradeWsServer, waitForClose } from "../websocket-test-support.js";
import { RealtimeAudioPacer } from "./realtime-audio-pacer.js";
import { RealtimeCallHandler } from "./realtime-handler.js";
const realtimeVoiceHarnessTestHooks = vi.hoisted(() => ({
@@ -201,6 +202,17 @@ function requireFirstMockCall(calls: readonly unknown[][], label: string): unkno
return call;
}
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
type RealtimeBridgeRequest = Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0];
type RecentTalkEvent = { turnId?: string; type: string };
@@ -1480,6 +1492,359 @@ describe("RealtimeCallHandler path routing", () => {
}
});
it("does not deliver a forced consult after its realtime session closes", async () => {
let callbacks:
| {
onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
}
| undefined;
const sendUserMessage = vi.fn();
const closeBridge = vi.fn();
const bridge = makeBridge({ close: closeBridge, sendUserMessage });
const createBridge = vi.fn(
(request: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0]) => {
callbacks = request;
return bridge;
},
);
const handler = makeHandler(
{ consultPolicy: "always" },
{
manager: {
getCallByProviderCallId: vi.fn(() => makeCallRecord("CA-forced-close")),
},
realtimeProvider: makeRealtimeProvider(createBridge),
},
);
const consultResult = createDeferred<{ text: string }>();
const consult = vi.fn(() => consultResult.promise);
handler.registerToolHandler("openclaw_agent_consult", consult);
const clearAudio = vi.spyOn(RealtimeAudioPacer.prototype, "clearAudio");
const server = await startRealtimeServer(handler);
try {
const ws = await connectWs(server.url);
ws.send(
JSON.stringify({
event: "start",
start: { streamSid: "MZ-forced-close", callSid: "CA-forced-close" },
}),
);
await waitForRealtimeTest(() => {
expect(createBridge).toHaveBeenCalledTimes(1);
});
callbacks?.onTranscript?.("user", "Check the deployment.", true);
await waitForRealtimeTest(() => {
expect(consult).toHaveBeenCalledTimes(1);
});
expect(clearAudio).toHaveBeenCalledTimes(1);
const closed = waitForClose(ws);
ws.close();
await closed;
await waitForRealtimeTest(() => {
expect(closeBridge).toHaveBeenCalledTimes(1);
});
consultResult.resolve({ text: "The deployment is healthy." });
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(clearAudio).toHaveBeenCalledTimes(1);
expect(sendUserMessage).not.toHaveBeenCalled();
} finally {
clearAudio.mockRestore();
await server.close();
}
});
it("keeps a replacement session's forced consult when the old result resolves late", async () => {
const sessionHarnesses: RealtimeVoiceSessionHarness[] = [];
realtimeVoiceHarnessTestHooks.onCreate = (harness) => {
sessionHarnesses.push(harness);
};
const callbacks: RealtimeBridgeRequest[] = [];
const oldSendUserMessage = vi.fn();
const replacementSendUserMessage = vi.fn();
const oldSubmitToolResult = vi.fn();
const oldCloseBridge = vi.fn();
const replacementCloseBridge = vi.fn();
const bridges = [
makeBridge({
close: oldCloseBridge,
sendUserMessage: oldSendUserMessage,
submitToolResult: oldSubmitToolResult,
}),
makeBridge({
close: replacementCloseBridge,
sendUserMessage: replacementSendUserMessage,
}),
];
const createBridge = vi.fn(
(request: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0]) => {
callbacks.push(request);
const bridge = bridges[callbacks.length - 1];
if (!bridge) {
throw new Error("unexpected replacement bridge");
}
return bridge;
},
);
const handler = makeHandler(
{ consultPolicy: "always" },
{
manager: {
getCallByProviderCallId: vi.fn((providerCallId: string) =>
makeCallRecord(providerCallId),
),
},
realtimeProvider: makeRealtimeProvider(createBridge),
},
);
const oldResult = createDeferred<{ text: string }>();
const replacementResult = createDeferred<{ text: string }>();
const consult = vi
.fn()
.mockImplementationOnce(() => oldResult.promise)
.mockImplementationOnce(() => replacementResult.promise);
handler.registerToolHandler("openclaw_agent_consult", consult);
const clearAudio = vi.spyOn(RealtimeAudioPacer.prototype, "clearAudio");
const oldServer = await startRealtimeServer(handler);
let replacementServer: Awaited<ReturnType<typeof startRealtimeServer>> | undefined;
let oldWs: WebSocket | undefined;
try {
oldWs = await connectWs(oldServer.url);
oldWs.send(
JSON.stringify({
event: "start",
start: { streamSid: "MZ-forced-old", callSid: "CA-forced-old" },
}),
);
await waitForRealtimeTest(() => {
expect(callbacks).toHaveLength(1);
});
callbacks[0]?.onTranscript?.("user", "Check the old deployment.", true);
await waitForRealtimeTest(() => {
expect(consult).toHaveBeenCalledTimes(1);
});
const oldCoordinator = expectDefined(
sessionHarnesses[0],
"old voice-call realtime session harness",
).forcedConsults;
const oldForcedHandle = expectDefined(
oldCoordinator.handles().find((handle) => handle.question === "Check the old deployment."),
"old forced consult handle",
);
const stalePendingHandle = expectDefined(
oldCoordinator.prepare("Pending work from the old session."),
"stale pending forced consult handle",
);
const stalePendingRun = vi.fn();
oldCoordinator.schedule(stalePendingHandle, 60_000, stalePendingRun);
callbacks[0]?.onToolCall?.({
itemId: "item-old-native",
callId: "old-native-consult",
name: "openclaw_agent_consult",
args: { question: "Check the old deployment." },
});
expect(consult).toHaveBeenCalledTimes(1);
replacementServer = await startRealtimeServer(handler);
const replacementWs = await connectWs(replacementServer.url);
try {
replacementWs.send(
JSON.stringify({
event: "start",
start: { streamSid: "MZ-forced-replacement", callSid: "CA-forced-replacement" },
}),
);
await waitForRealtimeTest(() => {
expect(callbacks).toHaveLength(2);
});
expect(oldCoordinator.handles()).not.toContainEqual(stalePendingHandle);
expect(stalePendingRun).not.toHaveBeenCalled();
callbacks[1]?.onTranscript?.("user", "Check the new deployment.", true);
await waitForRealtimeTest(() => {
expect(consult).toHaveBeenCalledTimes(2);
});
expect(clearAudio).toHaveBeenCalledTimes(2);
callbacks[0]?.onToolCall?.({
itemId: "item-stale-native",
callId: "stale-native-consult",
name: "openclaw_agent_consult",
args: { question: "Check the old deployment." },
});
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(oldSubmitToolResult).not.toHaveBeenCalled();
expect(consult).toHaveBeenCalledTimes(2);
oldResult.resolve({ text: "The old deployment is healthy." });
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(clearAudio).toHaveBeenCalledTimes(2);
expect(oldSendUserMessage).not.toHaveBeenCalled();
expect(oldCoordinator.handles()).toContainEqual(oldForcedHandle);
expect(oldCoordinator.isCancelled(oldForcedHandle)).toBe(true);
const oldClosed = waitForClose(oldWs);
oldWs.close();
await oldClosed;
await waitForRealtimeTest(() => {
expect(oldCloseBridge).toHaveBeenCalledTimes(1);
});
replacementResult.resolve({ text: "The new deployment is healthy." });
await waitForRealtimeTest(() => {
expect(replacementSendUserMessage).toHaveBeenCalledTimes(1);
});
expect(clearAudio).toHaveBeenCalledTimes(3);
} finally {
if (
replacementWs.readyState !== WebSocket.CLOSED &&
replacementWs.readyState !== WebSocket.CLOSING
) {
replacementWs.close();
}
}
} finally {
if (
oldWs &&
oldWs.readyState !== WebSocket.CLOSED &&
oldWs.readyState !== WebSocket.CLOSING
) {
oldWs.close();
}
clearAudio.mockRestore();
await replacementServer?.close();
await oldServer.close();
}
});
it("does not share a native consult with a replacement realtime session", async () => {
const callbacks: RealtimeBridgeRequest[] = [];
const oldSubmitToolResult = vi.fn();
const replacementSubmitToolResult = vi.fn();
const bridges = [
makeBridge({
supportsToolResultContinuation: true,
submitToolResult: oldSubmitToolResult,
}),
makeBridge({
supportsToolResultContinuation: true,
submitToolResult: replacementSubmitToolResult,
}),
];
const createBridge = vi.fn((request: RealtimeBridgeRequest) => {
callbacks.push(request);
const bridge = bridges[callbacks.length - 1];
if (!bridge) {
throw new Error("unexpected replacement bridge");
}
return bridge;
});
const handler = makeHandler(undefined, {
manager: {
getCallByProviderCallId: vi.fn((providerCallId: string) => makeCallRecord(providerCallId)),
},
realtimeProvider: makeRealtimeProvider(createBridge),
});
const oldResult = createDeferred<{ text: string }>();
const replacementResult = createDeferred<{ text: string }>();
const consult = vi
.fn()
.mockImplementationOnce(() => oldResult.promise)
.mockImplementationOnce(() => replacementResult.promise);
handler.registerToolHandler("openclaw_agent_consult", consult);
const oldServer = await startRealtimeServer(handler);
let replacementServer: Awaited<ReturnType<typeof startRealtimeServer>> | undefined;
let oldWs: WebSocket | undefined;
try {
oldWs = await connectWs(oldServer.url);
oldWs.send(
JSON.stringify({
event: "start",
start: { streamSid: "MZ-native-old", callSid: "CA-native-old" },
}),
);
await waitForRealtimeTest(() => {
expect(callbacks).toHaveLength(1);
});
callbacks[0]?.onToolCall?.({
itemId: "item-native-old",
callId: "native-old",
name: "openclaw_agent_consult",
args: { question: "Check the old deployment." },
});
await waitForRealtimeTest(() => {
expect(consult).toHaveBeenCalledTimes(1);
expect(oldSubmitToolResult).toHaveBeenCalledTimes(1);
});
replacementServer = await startRealtimeServer(handler);
const replacementWs = await connectWs(replacementServer.url);
try {
replacementWs.send(
JSON.stringify({
event: "start",
start: { streamSid: "MZ-native-replacement", callSid: "CA-native-replacement" },
}),
);
await waitForRealtimeTest(() => {
expect(callbacks).toHaveLength(2);
});
callbacks[1]?.onToolCall?.({
itemId: "item-native-replacement",
callId: "native-replacement",
name: "openclaw_agent_consult",
args: { question: "Check the new deployment." },
});
await waitForRealtimeTest(() => {
expect(consult).toHaveBeenCalledTimes(2);
});
oldResult.resolve({ text: "The old deployment is healthy." });
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(oldSubmitToolResult).toHaveBeenCalledTimes(1);
replacementResult.resolve({ text: "The new deployment is healthy." });
await waitForRealtimeTest(() => {
expect(replacementSubmitToolResult).toHaveBeenLastCalledWith(
"native-replacement",
{ text: "The new deployment is healthy." },
undefined,
);
});
} finally {
if (
replacementWs.readyState !== WebSocket.CLOSED &&
replacementWs.readyState !== WebSocket.CLOSING
) {
replacementWs.close();
}
}
} finally {
if (
oldWs &&
oldWs.readyState !== WebSocket.CLOSED &&
oldWs.readyState !== WebSocket.CLOSING
) {
oldWs.close();
}
await replacementServer?.close();
await oldServer.close();
}
});
it("does not carry a final transcript into the next direct voice turn", async () => {
let callbacks:
| {
@@ -267,11 +267,19 @@ type RealtimeSpeakResult = {
};
type ForcedConsultState = {
owner: ActiveRealtimeVoiceBridge;
promise: Promise<unknown>;
sendSpeechPrompt: boolean;
cancelled: boolean;
cancel: () => void;
completedAt?: number;
};
type RealtimeConsultSession = {
owner: ActiveRealtimeVoiceBridge;
coordinator: RealtimeVoiceSessionHarness["forcedConsults"];
};
type NativeConsultState = {
owner: ActiveRealtimeVoiceBridge;
startedAt: number;
@@ -342,6 +350,7 @@ export class RealtimeCallHandler {
ReturnType<typeof setTimeout>
>();
private readonly forcedConsultsByCallId = new Map<string, ForcedConsultState>();
private readonly consultSessionsByCallId = new Map<string, RealtimeConsultSession>();
private readonly nativeConsultsInFlightByCallId = new Map<string, NativeConsultState>();
private closePromise: Promise<void> | null = null;
private closing = false;
@@ -934,12 +943,9 @@ export class RealtimeCallHandler {
});
},
onClose: (reason) => {
this.activeBridgesByCallId.delete(callId);
this.activeBridgesByCallId.delete(callSid);
this.activeTelephonyClosersByCallId.delete(callId);
this.activeTelephonyClosersByCallId.delete(callSid);
if (nativeConsultOwner.current) {
this.cancelNativeConsult(callId, nativeConsultOwner.current);
this.clearActiveBridgeMappings(callId, callSid, nativeConsultOwner.current);
this.cancelConsultSession(callId, nativeConsultOwner.current);
}
this.clearUserTranscriptState(callId);
harness.finishOutputAudio(reason);
@@ -976,6 +982,14 @@ export class RealtimeCallHandler {
emitCallEnd(reason);
}
};
const previousConsultSession = this.consultSessionsByCallId.get(callId);
if (previousConsultSession && previousConsultSession.owner !== session) {
this.cancelConsultSession(callId, previousConsultSession.owner);
}
this.consultSessionsByCallId.set(callId, {
owner: session,
coordinator: harness.forcedConsults,
});
this.activeBridgesByCallId.set(callId, session);
this.activeBridgesByCallId.set(callSid, session);
this.activeTelephonyClosersByCallId.set(callId, closeTelephony);
@@ -1007,13 +1021,9 @@ export class RealtimeCallHandler {
try {
closeSession();
} finally {
this.activeBridgesByCallId.delete(callId);
this.activeBridgesByCallId.delete(callSid);
this.activeTelephonyClosersByCallId.delete(callId);
this.activeTelephonyClosersByCallId.delete(callSid);
this.cancelNativeConsult(callId, session);
this.clearActiveBridgeMappings(callId, callSid, session);
this.cancelConsultSession(callId, session);
this.clearUserTranscriptState(callId);
this.forcedConsultsByCallId.delete(callId);
harness.close();
audioPacer.close();
}
@@ -1084,6 +1094,47 @@ export class RealtimeCallHandler {
state.cancel();
}
private cancelForcedConsult(callId: string, owner: ActiveRealtimeVoiceBridge): void {
const state = this.forcedConsultsByCallId.get(callId);
if (!state || state.owner !== owner) {
return;
}
state.cancelled = true;
state.sendSpeechPrompt = false;
state.cancel();
this.forcedConsultsByCallId.delete(callId);
}
private cancelConsultSession(callId: string, owner: ActiveRealtimeVoiceBridge | undefined): void {
if (!owner) {
return;
}
const session = this.consultSessionsByCallId.get(callId);
if (!session || session.owner !== owner) {
return;
}
// Forced and native consults share bridge ownership. Replacement or close
// must invalidate both before a newer bridge can observe call-scoped state.
session.coordinator.clearPending();
this.cancelForcedConsult(callId, owner);
this.cancelNativeConsult(callId, owner);
this.consultSessionsByCallId.delete(callId);
}
private clearActiveBridgeMappings(
callId: string,
callSid: string,
owner: ActiveRealtimeVoiceBridge,
): void {
for (const key of [callId, callSid]) {
if (this.activeBridgesByCallId.get(key) !== owner) {
continue;
}
this.activeBridgesByCallId.delete(key);
this.activeTelephonyClosersByCallId.delete(key);
}
}
private resolveUserTranscriptContext(callId: string): string | undefined {
return (
this.partialUserTranscriptsByCallId.get(callId) ??
@@ -1161,7 +1212,10 @@ export class RealtimeCallHandler {
transcript: string;
clearAudio: () => void;
}): void {
if (this.config.consultPolicy !== "always") {
if (
this.config.consultPolicy !== "always" ||
this.activeBridgesByCallId.get(params.callId) !== params.session
) {
return;
}
const question = params.transcript.trim();
@@ -1218,7 +1272,10 @@ export class RealtimeCallHandler {
);
params.clearAudio();
const state: ForcedConsultState = {
owner: params.session,
sendSpeechPrompt: true,
cancelled: false,
cancel: () => coordinator.markCancelled(params.handle),
promise: Promise.resolve().then(() =>
params.handler(
{
@@ -1232,6 +1289,9 @@ export class RealtimeCallHandler {
this.forcedConsultsByCallId.set(params.callId, state);
try {
const result = await state.promise;
if (state.cancelled || this.forcedConsultsByCallId.get(params.callId) !== state) {
return;
}
state.completedAt = Date.now();
coordinator.markDelivered(params.handle);
const text = readSpeakableRealtimeVoiceToolResult(result, {
@@ -1257,13 +1317,19 @@ export class RealtimeCallHandler {
`[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`,
);
} finally {
const cleanupTimer = setTimeout(() => {
if (this.forcedConsultsByCallId.get(params.callId) === state) {
this.forcedConsultsByCallId.delete(params.callId);
if (!state.cancelled) {
if (this.forcedConsultsByCallId.get(params.callId) !== state) {
coordinator.remove(params.handle);
} else {
const cleanupTimer = setTimeout(() => {
if (this.forcedConsultsByCallId.get(params.callId) === state) {
this.forcedConsultsByCallId.delete(params.callId);
coordinator.remove(params.handle);
}
}, FORCED_CONSULT_NATIVE_DEDUPE_MS);
cleanupTimer.unref?.();
}
}, FORCED_CONSULT_NATIVE_DEDUPE_MS);
cleanupTimer.unref?.();
}
}
}
@@ -1401,6 +1467,9 @@ export class RealtimeCallHandler {
}
};
if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
if (this.activeBridgesByCallId.get(callId) !== bridge) {
return;
}
const coordinator = harness.forcedConsults;
const forcedMatch = coordinator.recordNativeConsult(args, bridgeCallId);
if (forcedMatch.kind === "none") {
@@ -1409,7 +1478,11 @@ export class RealtimeCallHandler {
coordinator.remove(pending);
}
}
const forcedConsult = this.forcedConsultsByCallId.get(callId);
const forcedConsultState = this.forcedConsultsByCallId.get(callId);
const forcedConsult =
forcedConsultState?.owner === bridge && !forcedConsultState.cancelled
? forcedConsultState
: undefined;
if (forcedMatch.kind === "already_delivered" && coordinator.isCancelled(forcedMatch.handle)) {
if (forcedConsult) {
forcedConsult.sendSpeechPrompt = false;
@@ -1432,6 +1505,13 @@ export class RealtimeCallHandler {
const result = await forcedConsult.promise.catch((error: unknown) => ({
error: formatErrorMessage(error),
}));
if (
forcedConsult.cancelled ||
forcedConsult.owner !== bridge ||
this.forcedConsultsByCallId.get(callId) !== forcedConsult
) {
return;
}
await submitFinalToolResult(result);
return;
}
+1
View File
@@ -304,6 +304,7 @@
"!dist/extensions/slack/**",
"!dist/extensions/sms/**",
"!dist/extensions/stepfun/**",
"!dist/extensions/synthetic/**",
"!dist/extensions/synology-chat/**",
"!dist/extensions/tavily/**",
"!dist/extensions/teams-meetings/**",
+1
View File
@@ -88,6 +88,7 @@ The host is responsible for:
- creating a `GatewayProtocolSocket` adapter around the browser WebSocket;
- loading and storing browser device identity and issued device tokens;
- signing the challenge-bound device payload;
- using the Gateway challenge `ts` as the device proof's `signedAt` value;
- supplying the client identity, role, scopes, and authentication selection;
- choosing close and reconnect behavior for product-specific errors.
@@ -28,6 +28,7 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => {
role: "operator",
defaultScopes: ["operator.read", "operator.write"],
nonce: "nonce",
challengeTs: 456,
});
expect(plan.auth).toEqual({
@@ -40,7 +41,7 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => {
});
expect(plan.scopes).toEqual(["operator.read"]);
expect(sign).toHaveBeenCalledWith(
"v3|device|openclaw-browser-copilot|ui|operator|operator.read|123|test-token-placeholder|nonce|chrome|extension",
"v3|device|openclaw-browser-copilot|ui|operator|operator.read|456|test-token-placeholder|nonce|chrome|extension",
);
await lifecycle.acceptHello(
@@ -56,6 +57,46 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => {
});
});
it("rejects the protocol's malformed-timestamp signal", async () => {
const lifecycle = new GatewayBrowserDeviceAuthLifecycle({
loadIdentity: async () => ({
deviceId: "device",
publicKey: "public",
sign: async () => "signature",
}),
tokenStore: { load: () => null, store: vi.fn(), clear: vi.fn() },
nowMs: () => 123,
});
await expect(
lifecycle.buildPlan({
client,
role: "operator",
defaultScopes: ["operator.read"],
nonce: "nonce",
challengeTs: null,
}),
).rejects.toThrow("gateway connect challenge timestamp invalid");
});
it("keeps the local-clock fallback for callers that received no challenge", async () => {
const sign = vi.fn(async () => "signature");
const lifecycle = new GatewayBrowserDeviceAuthLifecycle({
loadIdentity: async () => ({ deviceId: "device", publicKey: "public", sign }),
tokenStore: { load: () => null, store: vi.fn(), clear: vi.fn() },
nowMs: () => 123,
});
const plan = await lifecycle.buildPlan({
client,
role: "operator",
defaultScopes: ["operator.read"],
nonce: "nonce",
});
expect(plan.device?.signedAt).toBe(123);
});
it("never persists bootstrap or shared-secret credentials", async () => {
const store = vi.fn();
const lifecycle = new GatewayBrowserDeviceAuthLifecycle({
@@ -68,6 +68,7 @@ export class GatewayBrowserDeviceAuthLifecycle {
trustedDeviceTokenRetry?: boolean;
preferBootstrapToken?: boolean;
nonce: string | null;
challengeTs?: number | null;
}): Promise<GatewayBrowserDeviceAuthPlan> {
const identity = await this.deps.loadIdentity();
const stored = identity
@@ -109,7 +110,12 @@ export class GatewayBrowserDeviceAuthLifecycle {
auth: buildGatewayConnectAuth(selectedAuth),
};
}
const signedAtMs = this.deps.nowMs?.() ?? Date.now();
// Undefined is reserved for an explicit no-challenge fallback; a received invalid challenge is null.
const signedAtMs =
params.challengeTs === undefined ? (this.deps.nowMs?.() ?? Date.now()) : params.challengeTs;
if (typeof signedAtMs !== "number" || !Number.isSafeInteger(signedAtMs) || signedAtMs < 0) {
throw new Error("gateway connect challenge timestamp invalid");
}
const nonce = params.nonce ?? "";
const { authBootstrapToken: primary, signatureToken: signed } = selectedAuth;
let token: string | null = null;
+15 -5
View File
@@ -404,11 +404,18 @@ export class GatewayClient {
createRequestError: (error) => new GatewayClientRequestError(error),
createRequestTimeoutError: (method) => new Error(`gateway request timeout for ${method}`),
createRequestAbortError: createGatewayRequestAbortError,
buildConnectPlan: ({ nonce }) => {
buildConnectPlan: ({ nonce, challengeTs }) => {
if (!nonce) {
throw new Error("gateway connect challenge missing nonce");
}
return this.assembleConnectParams({ role: this.opts.role ?? "operator", nonce });
if (this.opts.deviceIdentity && challengeTs == null) {
throw new Error("gateway connect challenge timestamp invalid");
}
return this.assembleConnectParams({
role: this.opts.role ?? "operator",
nonce,
signedAtMs: challengeTs ?? Date.now(),
});
},
buildConnectParams: (assembled) => assembled.params,
onConnectPlanError: (error) => {
@@ -715,8 +722,12 @@ export class GatewayClient {
this.deps.logError(this.deps.redactForLog(message));
}
private assembleConnectParams(params: { role: string; nonce: string }): AssembledConnect {
const { role, nonce } = params;
private assembleConnectParams(params: {
role: string;
nonce: string;
signedAtMs: number;
}): AssembledConnect {
const { role, nonce, signedAtMs } = params;
// Auth selection is intentionally centralized: retry decisions depend on
// whether a token was explicit, cached, or compatibility-derived.
const selectedAuth = this.selectConnectAuth(role);
@@ -736,7 +747,6 @@ export class GatewayClient {
}
const auth = buildGatewayConnectAuth(selectedAuth);
const signedAtMs = Date.now();
const scopes = resolveGatewayConnectScopes({
requestedScopes: this.opts.scopes,
usingStoredDeviceToken,
@@ -181,7 +181,7 @@ function completeSyntheticGatewayProtocolHandshake(
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "synthetic-nonce" },
payload: { nonce: "synthetic-nonce", ts: 1_777_777_777_000 },
}),
);
const connectFrame = JSON.parse(String(connection.send.mock.calls[0]?.[0])) as {
@@ -660,7 +660,7 @@ describe("GatewayClient", () => {
type: "event",
event: "connect.challenge",
seq: connectionNumber,
payload: { nonce: `nonce-${connectionNumber}` },
payload: { nonce: `nonce-${connectionNumber}`, ts: 1_777_777_777_000 },
}),
);
socket.on("message", (data) => {
@@ -34,13 +34,13 @@ function createHandshakeClient(
return { client, connections };
}
function receiveConnectChallenge(connection: HandshakeConnection): void {
function receiveConnectChallenge(connection: HandshakeConnection, ts = 1_800_000_000_000): void {
connection.handlers.open();
connection.handlers.message(
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "synthetic-nonce" },
payload: { nonce: "synthetic-nonce", ts },
}),
);
}
@@ -68,6 +68,73 @@ describe("GatewayProtocolClient connect handshake", () => {
client.stop();
});
it("passes the Gateway challenge timestamp into connect planning", () => {
const buildConnectPlan = vi.fn(() => ({}));
const { client, connections } = createHandshakeClient(buildConnectPlan);
client.start();
const connection = connections[0];
expect(connection).toBeDefined();
if (!connection) {
return;
}
receiveConnectChallenge(connection, 1_700_000_000_123);
expect(buildConnectPlan).toHaveBeenCalledWith({
nonce: "synthetic-nonce",
challengeTs: 1_700_000_000_123,
generation: 1,
});
client.stop();
});
it("marks omitted and malformed challenge timestamps as invalid", () => {
const buildConnectPlan = vi.fn(() => ({}));
const { client, connections } = createHandshakeClient(buildConnectPlan);
client.start();
const first = connections[0];
expect(first).toBeDefined();
if (!first) {
return;
}
first.handlers.open();
first.handlers.message(
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "legacy-nonce" },
}),
);
expect(buildConnectPlan).toHaveBeenLastCalledWith({
nonce: "legacy-nonce",
challengeTs: null,
generation: 1,
});
client.stop();
const secondClient = createHandshakeClient(buildConnectPlan);
secondClient.client.start();
const second = secondClient.connections[0];
expect(second).toBeDefined();
if (!second) {
return;
}
second.handlers.open();
second.handlers.message(
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "malformed-nonce", ts: "not-a-number" },
}),
);
expect(buildConnectPlan).toHaveBeenLastCalledWith({
nonce: "malformed-nonce",
challengeTs: null,
generation: 1,
});
secondClient.client.stop();
});
it("retires device preparation that outlives the connect handshake", async () => {
vi.useFakeTimers();
let resolvePlan: (plan: Record<string, never>) => void = () => undefined;
+23 -27
View File
@@ -6,8 +6,14 @@ import {
import { RetrySupervisor, sleepWithAbort } from "@openclaw/retry";
import { GatewayEventListeners } from "./event-listeners.js";
import type { GatewayPendingRequest } from "./pending-request.js";
import {
GatewayProtocolRequestError,
type GatewayProtocolRequestOptions,
} from "./protocol-request.js";
import { clearGatewayConnectTimeout, startGatewayConnectTimeout } from "./timeouts.js";
export { GatewayProtocolRequestError, type GatewayProtocolRequestOptions };
export type GatewayProtocolSocket = {
isOpen: () => boolean;
send: (data: string) => void;
@@ -19,16 +25,10 @@ export type GatewayProtocolSocketHandlers = {
close: (code: number, reason: string) => void;
error: (error: Error) => void;
};
export type GatewayProtocolRequestOptions = {
timeoutMs?: number | null;
expectFinal?: boolean;
onSent?: () => void;
onAccepted?: (payload: unknown) => void;
signal?: AbortSignal;
};
type GatewayProtocolConnectContext<TPlan> = {
generation: number;
nonce: string | null;
challengeTs: number | null | undefined;
plan: TPlan;
};
export type GatewayProtocolCloseContext = {
@@ -88,6 +88,7 @@ type GatewayProtocolClientOptions<TPlan> = {
createRequestAbortError?: (method: string) => Error;
buildConnectPlan: (params: {
nonce: string | null;
challengeTs: number | null | undefined;
generation: number;
}) => TPlan | Promise<TPlan>;
buildConnectParams: (plan: TPlan) => unknown;
@@ -123,24 +124,6 @@ type GatewayProtocolClientOptions<TPlan> = {
shouldRetrySocketFactoryError?: (error: Error) => boolean;
rethrowSocketFactoryError?: (error: Error) => boolean;
};
export class GatewayProtocolRequestError extends Error {
readonly code: string;
readonly gatewayCode: string;
readonly details?: unknown;
readonly retryable: boolean;
readonly retryAfterMs?: number;
constructor(error: Partial<ErrorShape>) {
super(error.message ?? "request failed");
this.name = "GatewayProtocolRequestError";
this.code = error.code ?? "UNAVAILABLE";
this.gatewayCode = this.code;
this.details = error.details;
this.retryable = error.retryable === true;
this.retryAfterMs = error.retryAfterMs;
}
}
type ConnectTimingState = {
generation: number;
startedAtMs: number;
@@ -162,6 +145,7 @@ export class GatewayProtocolClient<TPlan> {
private generation = 0;
private lastSeq: number | null = null;
private connectNonce: string | null = null;
private connectChallengeTs: number | null | undefined;
private connectSent = false;
private connectRequestSent = false;
private handshakeTimer: ReturnType<typeof setTimeout> | null = null;
@@ -354,6 +338,7 @@ export class GatewayProtocolClient<TPlan> {
const generation = this.generation + 1;
this.lastSeq = null; // Outer event sequences belong to one WebSocket generation.
this.connectNonce = null;
this.connectChallengeTs = undefined;
this.connectSent = this.connectRequestSent = false;
this.socketOpened = false;
this.helloReceived = false;
@@ -450,6 +435,7 @@ export class GatewayProtocolClient<TPlan> {
try {
planOrPromise = this.opts.buildConnectPlan({
nonce: this.connectNonce,
challengeTs: this.connectChallengeTs,
generation,
});
} catch (error) {
@@ -489,7 +475,12 @@ export class GatewayProtocolClient<TPlan> {
if (!this.isActive(socket, generation) || !socket.isOpen()) {
return;
}
const context = { generation, nonce: this.connectNonce, plan };
const context = {
generation,
nonce: this.connectNonce,
challengeTs: this.connectChallengeTs,
plan,
};
this.recordTiming("connect-plan-ready", generation, plan);
this.recordTiming("request-sent", generation, plan);
this.connectRequestSent = true;
@@ -543,7 +534,7 @@ export class GatewayProtocolClient<TPlan> {
if (isGatewayEventFrame(parsed)) {
this.opts.onActivity?.();
if (parsed.event === "connect.challenge") {
const payload = parsed.payload as { nonce?: unknown } | undefined;
const payload = parsed.payload as { nonce?: unknown; ts?: unknown } | undefined;
const nonce = typeof payload?.nonce === "string" ? payload.nonce.trim() : "";
if (!nonce) {
if (this.opts.handshake.mode === "require-challenge") {
@@ -554,6 +545,11 @@ export class GatewayProtocolClient<TPlan> {
return;
}
this.connectNonce = nonce;
const challengeTs = payload?.ts;
this.connectChallengeTs =
typeof challengeTs === "number" && Number.isSafeInteger(challengeTs) && challengeTs >= 0
? challengeTs
: null;
this.recordTiming("challenge", generation);
this.sendConnect(socket, generation);
return;
@@ -0,0 +1,27 @@
import type { ErrorShape } from "@openclaw/gateway-protocol";
export type GatewayProtocolRequestOptions = {
timeoutMs?: number | null;
expectFinal?: boolean;
onSent?: () => void;
onAccepted?: (payload: unknown) => void;
signal?: AbortSignal;
};
export class GatewayProtocolRequestError extends Error {
readonly code: string;
readonly gatewayCode: string;
readonly details?: unknown;
readonly retryable: boolean;
readonly retryAfterMs?: number;
constructor(error: Partial<ErrorShape>) {
super(error.message ?? "request failed");
this.name = "GatewayProtocolRequestError";
this.code = error.code ?? "UNAVAILABLE";
this.gatewayCode = this.code;
this.details = error.details;
this.retryable = error.retryable === true;
this.retryAfterMs = error.retryAfterMs;
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ async function createFakeGateway(port = 0): Promise<FakeGateway> {
type: "event",
event: "connect.challenge",
seq: seq++,
payload: { nonce: "sdk-e2e-nonce" },
payload: { nonce: "sdk-e2e-nonce", ts: Date.now() },
});
socket.on("message", (raw) => {
@@ -0,0 +1,69 @@
title: Telegram native queue command rejects ordinary prompt text
scenario:
id: telegram-queue-invalid-mode
surface: channels
category: channels.channel-actions-commands-and-approvals
coverage:
primary:
- telegram.built-in-commands
regressionRefs:
- openclaw/openclaw#116688
objective: Verify a native Telegram queue command with ordinary trailing text returns its queue-mode validation error without invoking the model or synthesizing a model-failure fallback.
successCriteria:
- Telegram accepts the native queue command with its complete trailing argument text.
- The reply identifies the invalid queue mode and lists supported queue modes.
- The reply never blames the model, and a mock provider receives no request for the command.
codeRefs:
- extensions/telegram/src/bot-native-commands.ts
- src/auto-reply/reply/get-reply-directives.ts
- src/auto-reply/reply/directive-handling.queue-validation.ts
execution:
kind: flow
channel: telegram
summary: Send the reported native queue command and verify its explicit validation reply.
config:
commandText: /queue Can you diagnose this?
invalidModeNeedle: Unrecognized queue mode "Can"
validModesNeedle: "Valid modes: steer, followup, collect, interrupt."
falseFallbackNeedle: temporary model failure
flow:
steps:
- name: invalid native queue arguments produce a visible command error
actions:
- resetTransport: true
- set: requestCursorBefore
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor : 0"
- set: startIndex
value:
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
- sendInbound:
conversation: { id: telegram-command-room, kind: channel }
senderId: qa-command-operator
senderName: QA Command Operator
text: { ref: config.commandText }
nativeCommand: { name: queue }
- waitForOutbound:
conversation: { id: telegram-command-room, kind: channel }
sinceIndex: { ref: startIndex }
textIncludes: { ref: config.invalidModeNeedle }
timeoutMs: 60000
saveAs: reply
- assert:
expr: "reply.text.includes(config.validModesNeedle)"
message:
expr: "`queue validation reply omitted the valid modes: ${reply.text}`"
- assert:
expr: "!reply.text.includes(config.falseFallbackNeedle)"
message:
expr: "`queue validation emitted the false model fallback: ${reply.text}`"
- set: scenarioRequests
value:
expr: "env.mock ? await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBefore}`) : []"
- assert:
expr: "!env.mock || scenarioRequests.length === 0"
message:
expr: "`native queue validation unexpectedly invoked the model ${String(scenarioRequests.length)} time(s)`"
detailsExpr: reply.text
+14 -7
View File
@@ -213,6 +213,10 @@ function transcriptIncludesMarker(transcripts: string[], marker: string): boolea
return normalizeTranscript(transcripts.join(" ")).includes(normalizeTranscript(marker));
}
function resolveGatewayRelayModulePath(repoRoot = process.cwd()): string {
return `/@fs/${repoRoot.replaceAll("\\", "/")}/ui/src/pages/chat/realtime-talk-gateway-relay.ts`;
}
async function sendPcmAudioInChunks(
bridge: RealtimeVoiceBridge,
audio: Buffer,
@@ -899,10 +903,8 @@ async function smokeGatewayRelayBrowser(browser: Browser): Promise<SmokeResult>
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-realtime-talk-"));
try {
const { createServer } = await import("vite");
const repoRoot = process.cwd().replaceAll("\\", "/");
const relayModulePath = JSON.stringify(
`/@fs/${repoRoot}/ui/src/ui/chat/realtime-talk-gateway-relay.ts`,
);
const repoRoot = process.cwd();
const relayModulePath = JSON.stringify(resolveGatewayRelayModulePath(repoRoot));
await writeFile(
path.join(dir, "index.html"),
'<!doctype html><meta charset="utf-8"><script type="module" src="/main.ts"></script>',
@@ -910,8 +912,6 @@ async function smokeGatewayRelayBrowser(browser: Browser): Promise<SmokeResult>
await writeFile(
path.join(dir, "main.ts"),
`
const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath});
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const listeners = new Set();
const requests = [];
@@ -951,6 +951,7 @@ const client = {
};
try {
const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath});
const transport = new GatewayRelayRealtimeTalkTransport(
{
provider: "smoke",
@@ -1013,9 +1014,14 @@ try {
`,
);
server = await createServer({
configFile: path.join(repoRoot, "ui/vite.config.ts"),
root: dir,
logLevel: "silent",
server: { host: "127.0.0.1", port: 0 },
server: {
host: "127.0.0.1",
port: 0,
fs: { allow: [dir, repoRoot] },
},
});
await server.listen();
const address = server.httpServer?.address();
@@ -1154,6 +1160,7 @@ export const testing = {
parseRealtimeSmokeArgs,
readOpenAIRealtimeBrowserResponseText,
readBoundedText,
resolveGatewayRelayModulePath,
resolveOpenAIHttpTimeoutMs,
sendPcmAudioInChunks,
transcriptIncludesMarker,
@@ -1659,6 +1659,55 @@
}
}
},
{
"name": "@openclaw/synthetic-provider",
"description": "OpenClaw Synthetic provider plugin.",
"source": "official",
"kind": "provider",
"openclaw": {
"plugin": {
"id": "synthetic",
"label": "Synthetic"
},
"providers": [
{
"id": "synthetic",
"name": "Synthetic",
"docs": "/providers/synthetic",
"categories": [
"cloud",
"llm"
],
"envVars": [
"SYNTHETIC_API_KEY"
],
"authChoices": [
{
"method": "api-key",
"choiceId": "synthetic-api-key",
"choiceLabel": "Synthetic API key",
"groupId": "synthetic",
"groupLabel": "Synthetic",
"groupHint": "Anthropic-compatible (multi-model)",
"optionKey": "syntheticApiKey",
"cliFlag": "--synthetic-api-key",
"cliOption": "--synthetic-api-key <key>",
"cliDescription": "Synthetic API key",
"onboardingScopes": [
"text-inference"
]
}
]
}
],
"install": {
"clawhubSpec": "clawhub:@openclaw/synthetic-provider",
"npmSpec": "@openclaw/synthetic-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.7.2"
}
}
},
{
"name": "@openclaw/stepfun-provider",
"description": "OpenClaw StepFun provider plugin.",
@@ -3,6 +3,7 @@ import {
isSilentReplyText,
SILENT_REPLY_TOKEN,
} from "../../auto-reply/tokens.js";
import { resolveAssistantMessagePhase } from "../../shared/chat-message-content.js";
type AgentPayloadLike = {
text?: unknown;
@@ -184,6 +185,33 @@ export function isMeaningfulTranscriptMessage(message: unknown): boolean {
return Boolean(role && role !== "system");
}
/** Recognizes persisted progress without mistaking an ordinary assistant answer for completion. */
export function isIntermediateAssistantTranscriptMessage(message: unknown): boolean {
if (
!message ||
typeof message !== "object" ||
getTranscriptMessageRole(message) !== "assistant"
) {
return false;
}
const record = message as Record<string, unknown>;
if (record.stopReason !== undefined && record.stopReason !== "stop") {
return false;
}
const phase = resolveAssistantMessagePhase(message);
if (phase !== undefined) {
return phase === "commentary";
}
const fallback = record.openclawStreamFallback;
if (!fallback || typeof fallback !== "object" || Array.isArray(fallback)) {
return false;
}
const { itemId, source } = fallback as { itemId?: unknown; source?: unknown };
// Keyed segments are durable progress items; unkeyed/current fallbacks can
// become the final answer and must never bypass restart completion checks.
return source === "segment" && typeof itemId === "string" && itemId.trim().length > 0;
}
/** Returns whether a stopped assistant turn contains only reasoning and a silent marker. */
export function isTerminalSilentAssistantMessage(message: unknown): boolean {
if (
@@ -102,8 +102,15 @@ export function projectMainSessionRecoveryLifecycle(params: {
lifecycleGeneration &&
runs?.some((run) => run.runId === runId && run.lifecycleGeneration === lifecycleGeneration),
);
// The current owner retires stale generations of its own run id. An older
// delayed event consumes only its matching fence and cannot settle its replacement.
const remaining = matchesFence
? runs?.filter((run) => run.runId !== runId || run.lifecycleGeneration !== lifecycleGeneration)
? runs?.filter(
(run) =>
run.runId !== runId ||
(lifecycleGeneration !== params.currentLifecycleGeneration &&
run.lifecycleGeneration !== lifecycleGeneration),
)
: runs;
if (settlesRecovery) {
const foregroundClaims = params.entry?.mainRestartRecovery?.foregroundClaims;
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js";
import { projectMainSessionRecoveryLifecycle } from "./main-session-recovery-lifecycle.js";
function recoveryEntry(params?: { hasCurrentOwner?: boolean }): SessionEntry {
return {
sessionId: "session-1",
updatedAt: 100,
status: "running",
abortedLastRun: false,
restartRecoveryRuns: [
{ runId: "recovery", lifecycleGeneration: "generation-old" },
{ runId: "recovery", lifecycleGeneration: "generation-current" },
],
mainRestartRecovery: {
cycleId: "cycle-1",
revision: 5,
chargedAttempts: 2,
...(params?.hasCurrentOwner
? {
foregroundClaims: {
lifecycleGeneration: "generation-current",
tokens: ["current-owner"],
},
}
: {}),
},
};
}
describe("main-session recovery run ownership", () => {
it("settles a resumed run once when older generations retain the same run id", () => {
expect(
projectMainSessionRecoveryLifecycle({
currentLifecycleGeneration: "generation-current",
entry: recoveryEntry(),
event: {
runId: "recovery",
lifecycleGeneration: "generation-current",
data: { phase: "end" },
},
snapshotPatch: { status: "done", abortedLastRun: false },
}),
).toEqual({
action: "apply",
patch: {
status: "done",
abortedLastRun: false,
restartRecoveryRuns: undefined,
mainRestartRecovery: undefined,
},
});
});
it("does not let an older same-id terminal settle its replacement generation", () => {
expect(
projectMainSessionRecoveryLifecycle({
currentLifecycleGeneration: "generation-current",
entry: recoveryEntry({ hasCurrentOwner: true }),
event: {
runId: "recovery",
lifecycleGeneration: "generation-old",
data: { phase: "end" },
},
snapshotPatch: { status: "done", abortedLastRun: false },
}),
).toEqual({
action: "apply",
patch: {
restartRecoveryRuns: [{ runId: "recovery", lifecycleGeneration: "generation-current" }],
restartRecoveryTerminalRunIds: ["recovery"],
},
});
});
});
@@ -92,7 +92,7 @@ describe("main session recovery state", () => {
expect(entry).toEqual(before);
});
it("marks without charging and preserves generation-scoped lifecycle fences", () => {
it("marks without charging and replaces an older lifecycle owner for the same run", () => {
const entry = interruptedEntry({
restartRecoveryRuns: [
{ runId: "older-run", lifecycleGeneration: "generation-old" },
@@ -123,7 +123,6 @@ describe("main session recovery state", () => {
expect(entry.restartRecoveryRuns).toEqual([
{ runId: "new-run", lifecycleGeneration: "generation-2" },
{ runId: "older-run", lifecycleGeneration: "generation-old" },
{ runId: "shared-run", lifecycleGeneration: "generation-1" },
{ runId: "shared-run", lifecycleGeneration: "generation-2" },
]);
});
@@ -422,6 +421,7 @@ describe("main session recovery state", () => {
pendingFinalDelivery: { kind: "replayable", text: " captured reply ", createdAt: 1 },
restartRecoveryDeliveryRunId: "recovery-1",
restartRecoveryDeliverySourceRunId: "source-1",
restartRecoveryRuns: [{ runId: "recovery-1", lifecycleGeneration: "generation-old" }],
mainRestartRecovery: recoveryState({
revision: 2,
chargedAttempts: 1,

Some files were not shown because too many files have changed in this diff Show More