Compare commits

..

263 Commits

Author SHA1 Message Date
Dallin Romney
3989ce4a79 test: pin folded QA coverage ids 2026-06-19 21:10:06 -07:00
Dallin Romney
721c67d5d5 test: avoid overclaiming gateway tool API coverage 2026-06-19 21:10:06 -07:00
Dallin Romney
1255530ca1 test: preserve chat tools profile build guard 2026-06-19 21:10:06 -07:00
Dallin Romney
658e49f473 test: update mirrored QA routing expectation 2026-06-19 21:10:06 -07:00
Dallin Romney
c26376586a test: keep native QA evidence out of parity tiers 2026-06-19 21:10:06 -07:00
Dallin Romney
d4e7590f78 test: align folded QA coverage ids 2026-06-19 21:10:06 -07:00
Dallin Romney
75bc6ab532 test: trim folded QA Lab script cruft 2026-06-19 21:10:06 -07:00
Dallin Romney
d09e814ca1 test: relax QA native scenario catalog inventory 2026-06-19 21:10:06 -07:00
Dallin Romney
e145ca7d21 test: remove folded HTTP API script tests 2026-06-19 21:10:06 -07:00
Dallin Romney
a7e1f6c97d test: fold HTTP API script proof into QA Lab 2026-06-19 21:09:45 -07:00
Vincent Koc
84a36057e9 chore(deadcode): remove stale qwen model shim 2026-06-20 12:08:51 +08:00
Vincent Koc
b44e39b82c fix(scripts): redact openwebui probe diagnostics 2026-06-20 06:07:22 +02:00
Vincent Koc
e89c255a01 fix(sdk): require session key for effective tools 2026-06-20 06:00:03 +02:00
Vincent Koc
a635e97965 fix(sdk): tighten approval response params 2026-06-20 05:59:50 +02:00
Vincent Koc
4f278ef71c fix(sdk): type agent mutation RPC params 2026-06-20 05:59:36 +02:00
Vincent Koc
1df2cc5f02 fix(qa): preserve adjacent control ui redaction 2026-06-20 05:56:36 +02:00
Vincent Koc
1cda1fc9a0 fix(qa): strip control ui api key params 2026-06-20 05:52:49 +02:00
Vincent Koc
af9b026241 fix(qa): preserve cli flag redaction 2026-06-20 05:44:58 +02:00
Vincent Koc
6a23a72d74 fix(qa): redact gateway debug header secrets 2026-06-20 05:44:58 +02:00
Shakker
14d362039e test: restore doctor completion env 2026-06-20 04:37:18 +01:00
Shakker
9391dac56d fix: scope backup config env 2026-06-20 04:36:11 +01:00
Vincent Koc
61ee4ffdfc fix(scripts): guard reused testbox keys 2026-06-20 05:35:10 +02:00
Vincent Koc
78d1b4a9b3 fix(qa): remove personal capture CA path 2026-06-20 05:27:21 +02:00
Shakker
0d6e0a2263 test: isolate launchd process env 2026-06-20 04:26:00 +01:00
Vincent Koc
33a4845555 fix(qa): redact capture payload previews 2026-06-20 05:24:09 +02:00
Vincent Koc
4a75171190 fix(scripts): preserve kitchen sink RPC request errors 2026-06-20 11:23:59 +08:00
Shakker
c946df0239 fix: route skills home env restores 2026-06-20 04:13:52 +01:00
Shakker
9ce68d0920 test: isolate daemon status env 2026-06-20 04:09:09 +01:00
Vincent Koc
2c65b9b407 refactor(scripts): share mobile version arg parsing 2026-06-20 11:08:34 +08:00
Shakker
78a2a31a6b fix: scope completion install env 2026-06-20 04:01:08 +01:00
Shakker
c719ff3183 test: restore cli profile env 2026-06-20 03:56:15 +01:00
Vincent Koc
0479da9bfb refactor(qa): share live scenario reply assertion 2026-06-20 10:55:56 +08:00
Shakker
13e76544e5 fix: scope onboard reset env 2026-06-20 03:54:07 +01:00
Vincent Koc
c81391e270 fix(qa): hide evidence producer href paths 2026-06-20 04:53:15 +02:00
Vincent Koc
69216f1745 fix(qa): hide evidence artifact href paths 2026-06-20 04:39:15 +02:00
Vincent Koc
a824df2e35 refactor(qa): share live credential source inference 2026-06-20 10:38:59 +08:00
Vincent Koc
f60aec6e9d fix(qa): sanitize evidence gallery metadata 2026-06-20 04:32:43 +02:00
Vincent Koc
6293e6e3ca fix(qa): sanitize matrix runner evidence text 2026-06-20 04:25:09 +02:00
Vincent Koc
f4baeab47f refactor(qa): share thrown value normalization 2026-06-20 10:24:16 +08:00
Vincent Koc
8f06e65f33 fix(qa): sanitize matrix evidence artifact paths 2026-06-20 04:21:20 +02:00
Vincent Koc
3518fa575a fix(qa): sanitize evidence preview roots 2026-06-20 04:17:12 +02:00
Shakker
a5e33b3a6b test: restore manifest model env 2026-06-20 03:14:54 +01:00
Vincent Koc
86d1e397f4 fix(qa): hide absolute evidence source paths 2026-06-20 04:10:32 +02:00
Shakker
d6c7e95c7b fix: scope compact skill path env 2026-06-20 03:09:02 +01:00
Vincent Koc
445317a38b refactor(qa): share artifact write assertion 2026-06-20 10:08:19 +08:00
Shakker
2844ec2bb0 test: isolate exec approval env 2026-06-20 03:02:20 +01:00
Vincent Koc
459edec9ba fix(qa): hide absolute evidence artifact paths 2026-06-20 03:58:47 +02:00
Shakker
e27c9a9a41 fix: centralize dotenv env cleanup 2026-06-20 02:55:43 +01:00
Vincent Koc
c80f4c110e fix(qa): sanitize evidence gallery failure paths 2026-06-20 03:52:57 +02:00
Vincent Koc
cfc699d3f6 refactor(qa): reuse model ref splitter 2026-06-20 09:50:05 +08:00
Vincent Koc
f04c3d6575 fix(qa): sanitize ux evidence artifact paths 2026-06-20 03:46:06 +02:00
Vincent Koc
da03996ab7 fix(test): reject unselected media provider filters 2026-06-20 03:40:50 +02:00
Shakker
5fd947c661 test: route config guard home env 2026-06-20 02:39:03 +01:00
Vincent Koc
622955b3fc fix(test): guard issue labeler cli args 2026-06-20 03:36:50 +02:00
Vincent Koc
cd69760628 fix(release): guard plugin release cli values 2026-06-20 03:34:41 +02:00
Shakker
3e41587992 fix: scope best effort config env 2026-06-20 02:33:01 +01:00
Vincent Koc
214a28affd fix(test): reject invalid max loc args 2026-06-20 03:32:08 +02:00
Vincent Koc
9f6d5e4750 refactor(qa): share provider json writer 2026-06-20 09:31:43 +08:00
Vincent Koc
033455b6f1 fix(test): guard platform pin cli values 2026-06-20 03:29:15 +02:00
Vincent Koc
8b5b150e02 fix(test): guard platform sync cli values 2026-06-20 03:27:15 +02:00
Vincent Koc
4db7d6a90a fix(test): guard platform version cli values 2026-06-20 03:24:46 +02:00
Vincent Koc
d76c1daa52 fix(sdk): list helpers work without filters
SDK list helpers now send an empty params object when filters are omitted while preserving explicit invalid params for Gateway validation.\n\nVerification:\n- git diff --check origin/main...HEAD\n- node --check packages/sdk/src/client.ts\n- codex review --base origin/main\n- GitHub Actions CI release gate 27855603923 succeeded on 353f13c0d1
2026-06-20 09:22:48 +08:00
Vincent Koc
9491e9187d fix(test): add env mutation report help 2026-06-20 03:21:21 +02:00
Vincent Koc
e0ec42e0e0 fix(test): restore live media harness entrypoint 2026-06-20 03:18:26 +02:00
Vincent Koc
a971641a54 fix(test): guard claude usage debug args 2026-06-20 03:15:30 +02:00
Vincent Koc
50b5238b38 refactor(qa): share repo path resolution 2026-06-20 09:12:39 +08:00
Vincent Koc
0cf941344c fix(test): honor shell completion test args 2026-06-20 03:12:16 +02:00
Vincent Koc
e6823c3d16 fix(test): guard model benchmark cli args 2026-06-20 03:06:22 +02:00
Vincent Koc
4b2b70ec79 fix(test): guard gateway benchmark cli args 2026-06-20 03:04:34 +02:00
Vincent Koc
b6d91d96ef fix(test): guard sqlite benchmark cli args 2026-06-20 03:00:07 +02:00
Vincent Koc
dadec4500f fix(test): require abort leak snapshot dir value 2026-06-20 02:54:52 +02:00
Vincent Koc
f76a3a3bbe refactor(qa): share live approval result helpers 2026-06-20 08:54:15 +08:00
Vincent Koc
c2e26db61b fix(sdk): send exec approval resolve id (#95144) 2026-06-20 08:52:55 +08:00
Vincent Koc
41691a82d5 fix(test): guard discord acp smoke cli args 2026-06-20 02:47:36 +02:00
Vincent Koc
49b0487e5b fix(test): guard kitchen sink rpc cli args 2026-06-20 02:36:52 +02:00
Vincent Koc
4575734f59 fix(test): guard realtime perf cli args 2026-06-20 02:34:06 +02:00
Vincent Koc
7e7dc7505b test(docker): stabilize build signal probe (#95137) 2026-06-20 08:30:33 +08:00
Vincent Koc
7dca9210c9 fix(test): guard dev smoke cli args 2026-06-20 02:28:04 +02:00
Vincent Koc
208bed06e1 refactor(qa): share progress formatting helpers 2026-06-20 08:26:00 +08:00
Vincent Koc
87358d7a7c fix(test): guard model resolution profiler args 2026-06-20 02:22:59 +02:00
Vincent Koc
e02bee6aab fix(test): guard tui pty watch cli args 2026-06-20 02:19:53 +02:00
Vincent Koc
56c0405018 fix(test): guard benchmark qa cli args 2026-06-20 02:13:13 +02:00
Vincent Koc
b6d754e3cb fix(macos): create DMG output directories (#95133) 2026-06-20 08:11:23 +08:00
Vincent Koc
6e732b3063 refactor(qa): share parity comparison helpers 2026-06-20 08:09:38 +08:00
Vincent Koc
423b1b3a42 fix(test): clean release check cli errors 2026-06-20 02:08:16 +02:00
Vincent Koc
faeb731a29 fix(test): guard boundary check cli args 2026-06-20 02:05:20 +02:00
Vincent Koc
d6075c1694 fix(test): clean dependency report cli errors 2026-06-20 02:02:37 +02:00
Vincent Koc
a67f809b33 fix(test): clean perf summary cli errors 2026-06-20 02:00:34 +02:00
Vincent Koc
1f1c434ede fix(test): clean qa report cli errors 2026-06-20 01:58:54 +02:00
Vincent Koc
3c3f1010aa fix(test): preflight gauntlet missing builds 2026-06-20 01:53:05 +02:00
Vincent Koc
0e980be284 fix(package): ignore stale packed tarballs (#95126) 2026-06-20 07:49:25 +08:00
Vincent Koc
27450f6b42 fix(test): honor rpc rtt help flag 2026-06-20 01:44:59 +02:00
Dallin Romney
d491e9c69b fix(ci): cancel stale CodeQL runs (#95116)
* ci: cancel stale CodeQL runs

* fix(ci): let running CodeQL scans finish
2026-06-19 16:41:57 -07:00
Vincent Koc
6fc0a3a9bd fix(test): chunk broad script test routing 2026-06-20 01:32:13 +02:00
Vincent Koc
0a1ce14dd1 refactor(qa): reuse live transport option helper 2026-06-20 07:28:32 +08:00
Vincent Koc
f9f94e7dcd fix(test): stream QA Lab stdout artifacts (#95119)
* fix(test): bound QA Lab stdout artifact reads

* fix(test): scan QA Lab stdout artifacts incrementally
2026-06-20 07:16:14 +08:00
Andy Ye
1e105d5340 fix(doctor): repair legacy Codex route persistence (#94478)
Summary:
- The branch changes config write preparation and doctor regression coverage so `doctor --fix` persists repair ... rams under canonical `openai/*` with Codex runtime policy, plus a prerelease lane timeout assertion update.
- PR surface: Source +9, Tests +107. Total +116 across 4 files.
- Reproducibility: yes. at source level: current main can re-preserve stale source-authored `openai-codex/*` m ... the candidate config, while the PR body supplies after-fix command proof for the narrowed persistence path.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 7b5bc00f31.
- Required merge gates passed before the squash merge.

Prepared head SHA: 7b5bc00f31
Review: https://github.com/openclaw/openclaw/pull/94478#issuecomment-4739605890

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-06-19 23:09:45 +00:00
Vincent Koc
21c966616f refactor(qa): share mantis option helpers 2026-06-20 07:04:11 +08:00
Vincent Koc
be7807f65e fix(test): stabilize tooling guard probes (#95114)
* fix(test): release kitchen sink probe readers

* test(github): follow shared guard membership helper
2026-06-20 06:55:40 +08:00
Vincent Koc
7ee1dafd4f refactor(qa): share mantis phase timer 2026-06-20 06:48:17 +08:00
Dallin Romney
3a7a385baf fix(ci): cancel stale Testbox PR runs (#95105)
* ci: cancel stale testbox PR runs

* ci: cancel stale arm testbox PR runs
2026-06-19 15:23:54 -07:00
Vincent Koc
c4d1f37d33 fix(memory): abort batch upload response reads (#95111)
* fix(memory): abort batch upload response reads

* test(memory): stabilize batch upload abort proof
2026-06-20 06:22:23 +08:00
Vincent Koc
ba43be9424 refactor(github): share guard comment helpers 2026-06-20 06:10:37 +08:00
Vincent Koc
aa479ac7d8 refactor(github): share guard request helpers 2026-06-20 06:07:12 +08:00
Vincent Koc
d6cefe26f4 fix(agents): bound Anthropic error streams (#95108) 2026-06-20 06:02:12 +08:00
Vincent Koc
0eed410bd0 refactor(tooling): remove unused cleanup helpers 2026-06-20 05:52:30 +08:00
Vincent Koc
b073d7cc11 fix(gateway): bound pricing catalog streams
Bound gateway model pricing catalog reads through the shared streaming byte-limit helper so no-content-length LiteLLM/OpenRouter responses cannot be fully buffered past the 5 MiB cap before rejection. Adds a regression for streamed LiteLLM overflow while preserving OpenRouter fallback pricing.
2026-06-20 05:42:23 +08:00
Vincent Koc
d97574aae6 fix(dev): bound realtime SDP answer reads
Keep the OpenAI Realtime WebRTC smoke's SDP offer request in the browser fetch path while moving the browser-side SDP answer reader into a testable helper. Reject unsafe decimal Content-Length values before acquiring a body reader and preserve streamed byte limiting for responses without a safe declared length.

Proof: direct bounded-reader repro rejects unsafe content-length before getReader and cancels the body; node --check --experimental-strip-types scripts/dev/realtime-talk-live-smoke.ts; node --check --experimental-strip-types test/scripts/dev-tooling-safety.test.ts; git diff --check origin/main...HEAD; autoreview clean overall 0.84; exact-head release gate succeeded at https://github.com/openclaw/openclaw/actions/runs/27848673438.
2026-06-20 05:22:56 +08:00
Vincent Koc
a54a56fb98 refactor(theme): drop unused terminal detection 2026-06-20 05:20:35 +08:00
Vincent Koc
45971784c9 test(scripts): stabilize tsdown process group timeout 2026-06-19 23:05:48 +02:00
Alix-007
6a27300a5b fix(gateway): remove device-backed node pairings (#90373)
Merged via squash.

Prepared head SHA: 8bd0e964ec
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 22:04:16 +01:00
Peter Steinberger
023993249f fix(queue): restart dormant followup drains (#95039)
Merged via squash.

Prepared head SHA: b6a81f07f1
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 22:03:48 +01:00
zerone0x
cd061a4c7b fix(agents): preserve delivered message send results (#84292)
Merged via squash.

Prepared head SHA: e5f948cf31
Co-authored-by: zerone0x <39543393+zerone0x@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 22:02:33 +01:00
Vincent Koc
b554c470a2 refactor(sessions): drop unused footer provider methods 2026-06-20 04:50:23 +08:00
brokemac79
8972bff98d [codex] docs: clarify PR body evidence updates (#95076) 2026-06-19 14:49:05 -06:00
Vincent Koc
6f5fdb1e6b fix(gateway): validate plugin descriptors and compact refresh 2026-06-19 22:25:15 +02:00
Vincent Koc
0f18e82932 fix(e2e): reject unsafe bounded response text lengths
Reject unsafe decimal Content-Length values in the E2E bounded response text helper before streaming response bodies. Keep non-decimal values on the streaming byte-limit path and add regression coverage proving unsafe declared lengths cancel without starting a read.

Proof: direct patched repro rejects before reading with code ETOOBIG; origin/main comparison entered the reader first; node --check scripts/e2e/lib/bounded-response-text.mjs; git diff --check origin/main...HEAD; autoreview clean overall 0.86; exact-head release gate succeeded at https://github.com/openclaw/openclaw/actions/runs/27846197115.
2026-06-20 04:20:02 +08:00
Vincent Koc
9594300f8c refactor(gateway): drop unused helper methods 2026-06-20 04:14:45 +08:00
Vincent Koc
c2c19a883d fix(scripts): reject unsafe bounded response lengths
Reject unsafe decimal Content-Length values in shared scripts bounded-response helpers before streaming response bodies.\n\nValidation:\n- node --check scripts/lib/bounded-response.mjs\n- direct MJS repro for unsafe Content-Length\n- git diff --check origin/main...HEAD\n- autoreview clean, overall patch correct 0.88\n- exact-head release gate https://github.com/openclaw/openclaw/actions/runs/27845767740
2026-06-20 04:04:40 +08:00
Hannes Rudolph
4a0f497f16 improve: simplify PR context and evidence (#94676)
* improve: simplify PR context and evidence

* improve: decouple PR context from proof labels

* fix: satisfy PR context lint
2026-06-19 14:00:38 -06:00
Vincent Koc
3706047d60 refactor(core): drop unused internal helpers 2026-06-20 03:58:55 +08:00
Alix-007
e35e5f123d feat(cli): add openclaw sessions compact and fail loudly on CLI /compact (fixes #90640) (#91378)
* feat(cli): add `sessions compact` command and fail loudly on CLI `/compact`

`sessions.compact` was reachable only as an internal Gateway RPC — no CLI
command, no docs — and `openclaw agent --message '/compact'` silently no-opped
with exit 0 because the slash-command handler rejects CLI-originated senders,
so the message fell through to an ordinary agent turn that compacted nothing.

- Add `openclaw sessions compact <key>` wrapping the existing `sessions.compact`
  RPC; exit non-zero on a transport error or an `ok:false` payload so automation
  never mistakes a silent no-op for success.
- Reject `openclaw agent --message '/compact'` with a redirect to the new
  command and exit 1 instead of a silent exit 0. The shared chat-side `/compact`
  handler is left untouched (no compatibility / message-delivery blast radius).
- Strictly validate `--max-lines` and `--timeout` (positive integers only).
- Document the command and the `sessions.compact` RPC in docs/cli/sessions.md.

Fixes #90640.

* fix(cli): inherit parent `sessions` options for `compact`

`openclaw sessions compact <key>` did not merge the parent `sessions`
command options the way its sibling subcommands (list/cleanup/info/…) do,
so a parent-level `--agent`/`--json` was silently dropped. In particular
`openclaw sessions --agent work compact <key>` compacted the default
agent's session instead of the work agent's — a wrong-target session-state
mutation.

Merge the parent options in the compact action (parent `--agent`/`--json`,
with the compact-level option taking precedence) and add regression
coverage for parent `--agent`, parent `--json`, and the compact-level
override.

Refs #90640.

* fix(cli): report pending Codex compaction and reject unsupported parent options

Address two ClawSweeper review findings on the `sessions compact` command:

- `sessions-compact.ts`: the Codex app-server `thread/compact/start` path
  returns `ok:true / compacted:false` with a pending marker, meaning the
  compaction was *started* asynchronously. The formatter collapsed every
  non-compacted success into "No compaction needed", so Codex users were told
  nothing happened. Report it as a started/pending compaction instead.
- `register.status-health-sessions.ts`: the parent `sessions` command defines
  list-only options (`--store`/`--all-agents`/`--active`/`--limit`) that the
  compact action previously ignored. Silently dropping a parent `--store` is
  dangerous — the gateway resolves the target store itself, so a user could
  believe they targeted one store while another is mutated. Reject any
  unsupported inherited parent option with a clear error and a non-zero exit.

Add regression tests for the pending-compaction message and the rejected
parent options.

Refs #90640.

* fix(gateway): guard sessions.compact maxLines truncation against active runs

The non-maxLines (LLM) compact branch interrupts an active session run before
compacting, but the maxLines truncate branch read the tail, archived, and
overwrote the transcript in place without that guard. Exposing `--max-lines`
as a documented CLI command (this PR) would make the active-run data-loss mode
tracked by #72765 easy to trigger from ordinary CLI usage.

Run the same interruptSessionRunIfActive guard in the maxLines branch before
reading the tail and truncating, matching the LLM compact path. Add gateway
regression coverage over a real in-process Gateway: with no active run, the
maxLines branch truncates the on-disk transcript 500 -> 50 and preserves the
original 500 lines in the .bak archive; with an active embedded run, the
maxLines branch fires the same interrupt (abort + wait-for-end) before
archiving and truncating.

* docs(cli): move sessions compact section above related links

The new "Compact a session" section was inserted between the cleanup
section's inline "Related:" list and the page's final "## Related"
block, splitting related-link content around the command docs. Move the
compact section above the related-links area and merge the orphaned
"Session config" link into the single final "## Related" block.

* fix(gateway): avoid no-op compact aborts

Signed-off-by: sallyom <somalley@redhat.com>

* fix(gateway): satisfy compact preflight lint

Signed-off-by: sallyom <somalley@redhat.com>

* fix(sessions): preserve compacted transcript structure

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-19 15:47:43 -04:00
Vincent Koc
b5811ea2b3 fix(ci): retry stable closeout package lookup 2026-06-19 21:42:41 +02:00
Vincent Koc
bb1043b14c fix(scripts): reject unsafe package download lengths
Reject unsafe decimal package_url Content-Length values before streaming response bodies.\n\nValidation:\n- node --check scripts/resolve-openclaw-package-candidate.mjs\n- direct injected downloadUrl repro for unsafe Content-Length\n- git diff --check origin/main...HEAD\n- autoreview clean, overall patch correct 0.9\n- exact-head release gate https://github.com/openclaw/openclaw/actions/runs/27844538401
2026-06-20 03:36:12 +08:00
Alix-007
16fba65cb6 fix(cron): honor configured retry.backoffMs for recurring error backoff floor (#93051)
Merged via squash.

Prepared head SHA: c8026d0aef
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 20:35:42 +01:00
Gio Della-Libera
7e5901752d refactor(policy): split doctor modules (#94314)
Merged via squash.

Prepared head SHA: 0d876ce3c1
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-19 12:34:41 -07:00
Alix-007
806a37fca8 fix(cli): reject present-but-invalid --timeout on status/health fast path (#92996)
Merged via squash.

Prepared head SHA: eda96f9f80
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 20:33:24 +01:00
Vincent Koc
753ff96771 refactor(workboard): drop unused parent-link helper 2026-06-20 03:31:26 +08:00
Alix-007
3fa4fdaec1 docs: fix two broken cross-reference anchors (#93941)
Merged via squash.

Prepared head SHA: 32c61da44d
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 20:27:25 +01:00
Vincent Koc
efc36d71bd refactor(qa-lab): drop unused report type aliases 2026-06-20 03:16:55 +08:00
Vincent Koc
6cfb025143 fix(e2e): reject unsafe chat tools body lengths
Reject unsafe numeric Content-Length values in the OpenAI chat tools E2E client before waiting on the response stream.

Also hardens Docker E2E heartbeat timing coverage after the exact-head release gate exposed a brittle zero-padded heartbeat assertion.

Verification: direct mock gateway repro, docker heartbeat shell proof, autoreview clean, and exact-head CI release gate https://github.com/openclaw/openclaw/actions/runs/27843455246.
2026-06-20 03:09:51 +08:00
Vincent Koc
061a3705db test(plugin-sdk): isolate runtime facade tests 2026-06-19 20:55:49 +02:00
Vincent Koc
9e5ac0cea4 refactor(extensions): drop stale internal declarations 2026-06-20 02:52:05 +08:00
Vincent Koc
aff6e221a7 fix(lmstudio): bound model load error bodies 2026-06-19 20:43:17 +02:00
Vincent Koc
5df5aa1640 fix(openai): bound batch error bodies 2026-06-19 20:43:17 +02:00
Vincent Koc
59a93a817f fix(openai): bound device code auth bodies 2026-06-19 20:43:17 +02:00
Vincent Koc
23b8f5d037 refactor(discord): remove unused monitor hooks 2026-06-20 02:37:17 +08:00
Vincent Koc
17e2fbfa86 fix(test): harden script probe bounds (#95060)
Merged via squash.

Prepared head SHA: 3a51c3c2d7
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-20 02:31:40 +08:00
Vincent Koc
cbff4fa5bc refactor(extensions): drop unused internal type aliases 2026-06-20 02:22:31 +08:00
Vincent Koc
330545f3e9 refactor(voice-call): drop unused stream helpers 2026-06-20 02:07:08 +08:00
Vincent Koc
2b0a72bb48 fix(release): lazy-load sigstore verification 2026-06-19 20:02:21 +02:00
Lu Wang
583829a342 fix(ssh): scope tunnel port preflight to loopback (#94603) (#94607)
Merged via squash.

Prepared head SHA: 6798b718de
Co-authored-by: wangwllu <7668944+wangwllu@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 18:59:58 +01:00
Vincent Koc
7b94ae9944 refactor(discord): drop unused internal wrapper methods 2026-06-20 01:52:02 +08:00
Vincent Koc
1609365b3e test(state): canonicalize sqlite volume assertions 2026-06-19 19:45:40 +02:00
Josh Lehman
d216f7c876 refactor: use canonical transcript reader identity (#89581)
* refactor: use canonical transcript reader identity

* refactor: keep transcript reader dependency storage-neutral
2026-06-19 10:40:18 -07:00
Vincent Koc
d41a3d28a0 refactor(oc-path): drop unused repack helper 2026-06-20 01:32:16 +08:00
Vincent Koc
8aa58c5fb0 fix(minimax): bound oauth token bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
e7e85f5436 fix(minimax): bound oauth error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
458904037f fix(parallel): bound search error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
1e53ee4fd5 fix(exa): bound search error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
6037d1a85c fix(ollama): bound stream error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
2c8d19d73e fix(ollama): bound embedding error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
70a48a680d fix(sdk): refresh plugin api baseline hash 2026-06-19 19:18:38 +02:00
Vincent Koc
0c210e5e52 fix(discord): deliver reasoning replies (#95029) 2026-06-20 01:18:14 +08:00
Vincent Koc
38807ffba4 test(plugins): isolate public surface runtime env 2026-06-19 19:08:32 +02:00
Vincent Koc
fb06df6cad refactor(voice-call): drop unused config type aliases 2026-06-20 01:07:03 +08:00
Vincent Koc
50614c51a8 test(ui): isolate chat browser layout fixtures 2026-06-19 18:54:19 +02:00
Vincent Koc
1f244f60ed test(secrets): load external plugin secret coverage 2026-06-19 18:35:29 +02:00
Vincent Koc
10b8b32380 refactor(codex): drop unused app-server helpers 2026-06-20 00:34:03 +08:00
Shakker
3b65f1d279 test: isolate sandbox registry state env 2026-06-19 17:32:09 +01:00
Yzx
1c711048f9 fix(agents): route plugin approvals through transport channel (#90918) 2026-06-19 12:31:06 -04:00
Vincent Koc
f69f81af9e fix(cli): use gateway skills status when available 2026-06-19 18:28:39 +02:00
Shakker
cdf4268540 fix: scope workspace default env 2026-06-19 17:24:03 +01:00
Vincent Koc
b4651f3781 refactor(codex): drop unused memory tool wrapper 2026-06-20 00:16:50 +08:00
Shakker
107c49e936 test: scope models config auth env 2026-06-19 17:10:24 +01:00
Shakker
ffd8c6e5d9 fix: scope model auth env helpers 2026-06-19 17:07:53 +01:00
Vincent Koc
9fced92710 test(wizard): align secret ref provider alias 2026-06-19 18:07:06 +02:00
Vincent Koc
3bcdf20a44 test(secrets): align secret ref fixtures 2026-06-19 18:07:06 +02:00
Shakker
80010a864b test: route subagent registry state env 2026-06-19 16:57:15 +01:00
Shakker
a536a0ddbc fix: isolate cli attempt home env 2026-06-19 16:54:05 +01:00
Vincent Koc
925d98d8e4 refactor(codex): drop unused prompt overlay wrapper 2026-06-19 23:51:44 +08:00
Vincent Koc
a42a1af942 fix(openrouter): bound oauth error bodies 2026-06-19 17:43:29 +02:00
Vincent Koc
b470b1e21a fix(mistral): sanitize realtime API key input 2026-06-19 17:37:09 +02:00
Vincent Koc
6fc0303ec0 fix(chutes): bound oauth token error bodies 2026-06-19 17:29:36 +02:00
Vincent Koc
6ef4684b89 fix(scripts): skip generated dist in legacy store guard 2026-06-19 17:22:14 +02:00
Vincent Koc
2005812dff fix(secrets): validate refs consistently at runtime 2026-06-19 17:22:14 +02:00
Vincent Koc
bf872b30cd test: remove unused mock alias exports 2026-06-19 23:19:46 +08:00
Vincent Koc
37962aac95 test(qqbot): keep stt temp helper on sdk surface 2026-06-19 17:03:16 +02:00
Vincent Koc
a876f8d073 fix(qqbot): bound chunked upload error bodies 2026-06-19 17:03:16 +02:00
Vincent Koc
0a3e0d081d test: remove no-op mock registrars 2026-06-19 22:55:38 +08:00
Vincent Koc
2c3b582c04 fix(scripts): avoid pnpm in parallels smoke wrappers 2026-06-19 16:47:03 +02:00
Vincent Koc
e0d58d994d fix(qqbot): bound stt error bodies 2026-06-19 16:44:51 +02:00
Vincent Koc
dc16aedd2e test(launcher): isolate bundled plugin env in fixtures 2026-06-19 16:42:44 +02:00
Vincent Koc
b16fd6bee7 test(qqbot): fix channel api bounded body assertion 2026-06-19 16:35:55 +02:00
Vincent Koc
51ebe87a09 fix(qqbot): guard channel api fetches 2026-06-19 16:30:53 +02:00
Vincent Koc
78b5618071 test(ui): isolate browser ownership in e2e fixtures 2026-06-19 16:24:38 +02:00
Vincent Koc
ed8ab712dc fix(qqbot): guard api client fetches 2026-06-19 16:14:19 +02:00
Vincent Koc
8594af21e9 fix(qqbot): bound token response bodies 2026-06-19 16:14:19 +02:00
Vincent Koc
2ddebf3897 refactor(config): drop duplicate account schema aliases 2026-06-19 22:12:44 +08:00
Vincent Koc
b9dadb9f66 test(ui): isolate sessions browser layout fixtures 2026-06-19 16:08:54 +02:00
Vincent Koc
f062171c54 test(ui): isolate mobile form control browser fixtures 2026-06-19 16:03:16 +02:00
pick-cat
b677ea6726 fix(agent): resolve compaction model alias to canonical model ref (#90885)
Merged via squash.

Prepared head SHA: 72d28dc385
Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 15:03:06 +01:00
Vincent Koc
e74a7d2f14 fix(sdk): refresh api baseline hash 2026-06-19 15:57:57 +02:00
Vincent Koc
917a0f3052 refactor(extensions): drop private alias exports 2026-06-19 21:43:24 +08:00
Vincent Koc
b3dfa0f1b1 refactor(shared): drop unused internal format/import aliases 2026-06-19 21:36:52 +08:00
Vincent Koc
772158c716 fix(qqbot): bound api error bodies 2026-06-19 15:30:29 +02:00
Vincent Koc
940d33cf89 fix(scripts): clean package download temp files after stream abort 2026-06-19 15:22:55 +02:00
Vincent Koc
698efb23a6 fix(discord): bound api error bodies 2026-06-19 15:21:07 +02:00
Vincent Koc
d29c3a5d6f refactor(cron): drop duplicate active-job reset alias 2026-06-19 21:15:22 +08:00
Peter Steinberger
341ae21d03 feat(slack): handle global and message shortcuts (#94881)
Merged via squash.

Prepared head SHA: 32dea12d7a
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 14:12:30 +01:00
Andrew Stroup
378c4134f1 fix(slack): default member-info userId to inbound sender (#89236)
Merged via squash.

Prepared head SHA: c7a39e54f7
Co-authored-by: stroupaloop <2424551+stroupaloop@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 14:03:29 +01:00
Vincent Koc
cd2d837a1f fix(slack): preserve buffered thread stream replies (#78536)
Merged via squash.

Prepared head SHA: 0d8d75918d
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 14:02:50 +01:00
Vincent Koc
29e44f5eba refactor(tasks): drop duplicate maintenance stop alias 2026-06-19 20:56:56 +08:00
Vincent Koc
ce7f899165 fix(discord): bound voice upload error bodies 2026-06-19 14:50:29 +02:00
Vincent Koc
4c3b15bae6 fix(discord): bound webhook error bodies 2026-06-19 14:43:17 +02:00
Kendrick Ha
4723602e7e feat(channels): add Zalo ClawBot external channel entry and documenta… (#89586)
Merged via squash.

Prepared head SHA: 5ef4fe999a
Co-authored-by: ken-kuro <47441476+ken-kuro@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:42:38 +01:00
Peter Lee
430682e97a fix(xai): reject unsupported multi-agent model refs before runtime fallback (#93969)
Merged via squash.

Prepared head SHA: b58d798381
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:42:00 +01:00
Peter Lee
5c8761976c fix(whatsapp): restart listener on selfChatMode config change (#93873)
Merged via squash.

Prepared head SHA: d85f604f01
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:41:26 +01:00
Vincent Koc
7fafad8c49 refactor(plugins): drop duplicate memory reset alias 2026-06-19 20:38:03 +08:00
NIO
47545e04c4 fix(channels): stop duplicating inbound previews in system events (#94589)
Merged via squash.

Prepared head SHA: 981003591c
Co-authored-by: hugenshen <16300669+hugenshen@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:37:28 +01:00
Vincent Koc
aea208f0ac fix(discord): bound pluralkit error bodies 2026-06-19 14:29:11 +02:00
Vincent Koc
4d37f42df7 fix(github-copilot): bound embedding error bodies 2026-06-19 14:21:28 +02:00
Vincent Koc
56c5630107 refactor(agents): drop duplicate gateway allowlist test export 2026-06-19 20:19:11 +08:00
joshavant
99e69e16b7 remove ios identity migration 2026-06-19 14:16:48 +02:00
joshavant
f13dc76ba1 fix ios share extension device identity 2026-06-19 14:16:48 +02:00
Vincent Koc
0de3d47195 fix(google-meet): bound google api error bodies 2026-06-19 14:06:36 +02:00
Vincent Koc
f7c3775140 fix(test): prefer local bundled plugins in linked Vitest worktrees 2026-06-19 14:00:32 +02:00
Peter Steinberger
e2b52f29e4 test(plugins): separate activation-scoped web search ids
Exclude startup-lazy Codex and QA Lab entries from the loader-scoped baseline before asserting them as explicit activation-scoped contracts.
2026-06-19 07:58:44 -04:00
Vincent Koc
482d6d59ac refactor(plugin-state): drop duplicate close alias 2026-06-19 19:57:26 +08:00
Vincent Koc
ff35b29a06 fix(mattermost): stream guarded api responses 2026-06-19 13:51:37 +02:00
Peter Steinberger
5a00720de0 fix(ci): repair signing lint and test types
Use the canonical undefined comparison and preserve the gateway predicate mock signature so full release-gate lint and test-type checks pass.
2026-06-19 07:42:51 -04:00
Vincent Koc
817dd593bb test(commands): type gateway transport mock input 2026-06-19 13:34:04 +02:00
Vincent Koc
c218255815 test(plugins): pin activation-scoped web search contracts 2026-06-19 13:34:04 +02:00
Vincent Koc
3bc936b675 test(sdk): keep package e2e pnpm noninteractive 2026-06-19 13:34:04 +02:00
Vincent Koc
4799fe7df6 fix(msteams): stream graph success responses 2026-06-19 13:25:18 +02:00
Vincent Koc
f29af26326 fix(sms): bound twilio api response bodies 2026-06-19 13:24:16 +02:00
Vincent Koc
b0c1010fbf refactor(cron): drop duplicate isolated-agent test aliases 2026-06-19 19:19:08 +08:00
Vincent Koc
f14a2cb9c5 fix(clickclack): bound api error response bodies 2026-06-19 13:16:08 +02:00
wangmiao0668000666
27f702d68f fix(gateway): authorize plugin methods from attached registry (#94343)
Authorize plugin gateway methods against the exact registry attached to dispatch, preserving fallback behavior for dynamic methods and deleting one-off repro scripts.

Fixes #92044.

Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>
2026-06-19 11:56:24 +01:00
Super Zheng
0781dae620 fix(plugins): keep tool discovery request-local (#93276)
Keep plugin tool discovery request-local, preserve active provider/channel registries, and carry the prepared registry through MCP and catalog resolution.

Co-authored-by: 郑苏波 (Super Zheng) <superzheng@tencent.com>
2026-06-19 11:56:20 +01:00
Peter Lee
6256ad86c9 fix(gateway): classify probe reachability by validated transport (#93948)
Distinguish validated gateway reachability from pre-open and TLS-validation failures, and sanitize close diagnostics before terminal output.

Fixes #79099.

Co-authored-by: xialonglee <li.xialong@xydigit.com>
2026-06-19 11:56:16 +01:00
joshavant
f7f415f26b fix(ios): wire share extension app group signing 2026-06-19 12:53:45 +02:00
ZengWen-DT
2983edd5a2 docs(browser): clarify networkidle session support (#94020)
Clarify that `networkidle` is supported for managed and raw-CDP browser sessions but rejected for existing-session mode.

Fixes #80587.

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
2026-06-19 11:53:07 +01:00
Alix-007
4da36da605 feat(status): show session duration in footer (#88988)
Show elapsed session duration in the status footer using the canonical session lifecycle timestamps and compact formatter.

Fixes #68226.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
2026-06-19 11:53:04 +01:00
Vincent Koc
92d1f04de3 refactor(agents): drop duplicate internal aliases 2026-06-19 18:33:56 +08:00
Vincent Koc
611ad1a097 fix(voice-call): bound provider api response bodies 2026-06-19 12:33:39 +02:00
Vincent Koc
6ef4970988 refactor(agents): drop unused harness registry wrappers 2026-06-19 18:22:26 +08:00
Peter Steinberger
8d9eba3f4f fix(ios): complete single-target watch migration
Use the watchOS application API for text input, remove simulator-only Debug architecture restrictions, and document the standard Watch bundle location. Refs #92477.

Co-authored-by: Sash Zats <sash@zats.io>
2026-06-19 06:18:43 -04:00
Vincent Koc
40dc8fd147 fix(plugins): cancel marketplace archive error bodies 2026-06-19 12:17:45 +02:00
Vincent Koc
2257a21b7e refactor(tests): drop duplicate helper aliases 2026-06-19 18:12:54 +08:00
David
d4833e27c7 fix(cron): refuse keyless implicit isolated cron delivery inherited from shared agent-main bucket (#91685)
Summary:
- The PR changes isolated cron delivery resolution to reject keyless implicit delivery inherited from the shar ...  targets into delivery context resolution, and cleans up direct cron sessions on unresolved delivery exits.
- PR surface: Source +57, Tests +496. Total +553 across 8 files.
- Reproducibility: yes. from source inspection: current resolver can inherit the shared agent-main last target ... ls or sends based on that resolved target; I did not run live Matrix reproduction in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(cron): clean up deleteAfterRun session when keyless cron delivery…
- PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'upstream/main' into fix/91613-isolated-…
- PR branch already contained follow-up commit before automerge: Merge upstream main into fix/91613-isolated-cron-delivery-identity
- PR branch already contained follow-up commit before automerge: chore: retrigger PR CI after upstream base fix

Validation:
- ClawSweeper review passed for head f129375dd7.
- Required merge gates passed before the squash merge.

Prepared head SHA: f129375dd7
Review: https://github.com/openclaw/openclaw/pull/91685#issuecomment-4659309145

Co-authored-by: nxmxbbd <32288+nxmxbbd@users.noreply.github.com>
2026-06-19 10:05:07 +00:00
clawsweeper[bot]
d1bb2d5a12 fix(telegram): normalize all HTML tables before entity-escaping in rich messages (#94856)
Summary:
- The PR changes Telegram legacy HTML rendering so raw HTML table tags are converted to `<pre><code>` pipe-tab ... ks before unsupported-tag escaping, while preserving pre/code literals and rich-message table sanitization.
- PR surface: Source +38, Tests +31. Total +69 across 2 files.
- Reproducibility: yes. Source inspection shows current main's legacy HTML renderer sends raw tables directly  ... the linked issue describes that same escaped output; I did not run tests because this review was read-only.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 5944f8e4d2.
- Required merge gates passed before the squash merge.

Prepared head SHA: 5944f8e4d2
Review: https://github.com/openclaw/openclaw/pull/94856#issuecomment-4749452707

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: zhangqueping <3436352+zhangqueping@users.noreply.github.com>
2026-06-19 10:04:22 +00:00
Vincent Koc
eb7da0a2e5 fix(plugins): cancel self-hosted probe error bodies 2026-06-19 12:03:31 +02:00
Vincent Koc
797865c9dc fix(cli): cancel camera URL error bodies 2026-06-19 11:57:43 +02:00
Vincent Koc
7fcbfa6971 refactor(plugins): drop unused web-channel send wrapper 2026-06-19 17:52:10 +08:00
Vincent Koc
3091c13713 refactor(acpx): drop unused codex trust wrapper 2026-06-19 17:49:50 +08:00
Vincent Koc
c159063c70 fix(plugins): bound embedding error bodies 2026-06-19 11:43:18 +02:00
Vincent Koc
dae37a4579 refactor(plugins): drop unused web-channel facade wrappers 2026-06-19 17:41:24 +08:00
clawsweeper[bot]
2e0dfda462 test(perf): compare saved CLI startup benchmarks (#94812)
Summary:
- Adds saved CLI startup benchmark report comparison flags to `scripts/bench-cli-startup.ts`, plus JSON output coverage and changed-target routing expectations for the new test-helper importer.
- PR surface: Tests +77, Other +109. Total +186 across 4 files.
- Reproducibility: not applicable. as a feature/tooling PR. The prior PR defects were source-proven in review comments and the current head addresses them; I did not run local tests because this review was read-only.

Automerge notes:
- Ran the ClawSweeper repair loop before final review.
- Included post-review commit in the final squash: test(perf): compare saved CLI startup benchmarks

Validation:
- ClawSweeper review passed for head 1afa110f1b.
- Required merge gates passed before the squash merge.

Prepared head SHA: 1afa110f1b
Review: https://github.com/openclaw/openclaw/pull/94812#issuecomment-4748785428

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: Felix Isaac Lim <38658663+FelixIsaac@users.noreply.github.com>
2026-06-19 09:37:47 +00:00
Vincent Koc
5b3d652c05 fix(sdk): cancel Copilot token error bodies 2026-06-19 11:34:54 +02:00
Sash Zats
b39a932112 fix: migrate watch app to single-target app (Xcode 27+ compat) (#92477)
* fix: migrate watch app to single-target app

* fix: build watch screenshots generically

* docs(ios): clarify watch embed invariant

* docs(ios): clarify watch embed invariant

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-19 11:34:34 +02:00
scotthuang
0c76a98f10 fix(outbound): keep direct-only targets out of group sessions (#94683)
Merged via squash.

Prepared head SHA: d2cb01b5ba
Co-authored-by: scotthuang <1670837+scotthuang@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 11:31:25 +02:00
Ayaan Zaidi
a8b5f5d551 fix(telegram): send progress previews as html text 2026-06-19 14:56:35 +05:30
Vincent Koc
bbe9669926 fix(agents): cancel tool download error bodies 2026-06-19 11:19:57 +02:00
Vincent Koc
7580c80f37 refactor(channels): drop unused test helpers 2026-06-19 17:17:14 +08:00
Vincent Koc
7f38b1a910 fix(sdk): cancel live catalog error bodies 2026-06-19 11:11:59 +02:00
Vincent Koc
8aaf937bc0 fix(auth): cancel WHAM probe error bodies 2026-06-19 11:02:59 +02:00
Vincent Koc
6467c1962a fix(chutes): cancel userinfo error bodies 2026-06-19 10:57:41 +02:00
Vincent Koc
0c565f3b0e fix(usage): cancel provider error bodies 2026-06-19 10:51:58 +02:00
Vincent Koc
7211d77553 refactor(channels): drop unused approval aliases 2026-06-19 16:41:19 +08:00
Vincent Koc
dba291ed35 fix(agents): cancel OpenRouter catalog error bodies 2026-06-19 10:38:00 +02:00
Vincent Koc
32c02e843a refactor(browser): drop unused cdp helpers 2026-06-19 16:36:49 +08:00
Vincent Koc
5e329f4065 fix(channels): preserve command progress detail (#94868)
Merged via squash.

Prepared head SHA: 3217f45e61
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-19 16:36:36 +08:00
713 changed files with 21498 additions and 8889 deletions

View File

@@ -107,16 +107,9 @@ Reject:
## PR Body Proof
Use the repo PR template. Include these exact labels:
```text
Behavior addressed:
Real environment tested:
Exact steps or command run after this patch:
Evidence after fix:
Observed result after fix:
What was not tested:
```
Use the repo PR template. Include authored `## What Problem This Solves` and
`## Evidence` sections. Keep the body focused on intent and the most useful
validation evidence; inspect the code, tests, and CI before judging correctness.
## Existing PR Rules

4
.github/labeler.yml vendored
View File

@@ -171,6 +171,10 @@
- any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zaloclawbot":
- changed-files:
- any-glob-to-any-file:
- "docs/channels/zaloclawbot.md"
"channel: zalouser":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1,118 +1,57 @@
## Summary
<!--
Optional linked context:
Add a visible `Closes #<issue-number>` or `Related: #<issue-number>` line
below this comment.
What problem does this PR solve?
Required PR title:
type: user-facing description
Use a parenthesized scope only when it adds clarity:
fix(auth): login redirect loops when session cookie is expired
Why does this matter now?
Types: feat, fix, improve, refactor, docs, chore.
For fixes, describe the user-visible symptom and trigger:
fix: task list fails to load when user has no environments
Avoid implementation details such as:
fix: add null check to task query
-->
What is the intended outcome?
## What Problem This Solves
What is intentionally out of scope?
<!--
Describe the concrete user, product, or operational problem.
For fixes, begin with:
"Fixes an issue where users <do X> would <experience Y> when <condition>."
or:
"Resolves a problem where..."
What does success look like?
Name the affected UI surface or workflow. Do not describe the code-level cause here.
-->
What should reviewers focus on?
## Why This Change Was Made
<details>
<summary>Summary guidance</summary>
<!--
In one or two sentences, explain the complete shipped solution, key design
decisions, and relevant boundaries or non-goals. Include implementation detail
only when it helps reviewers understand user-visible behavior or risk.
Avoid file-by-file narration.
-->
This PR description is the contributor's durable explanation of the change. Write it for human maintainers first; ClawSweeper and Barnacle use the same text to understand intent, proof, risk, and current review state.
## User Impact
Describe the intent and outcome in 2-5 bullets. Avoid restating the diff; reviewers and bots can read the changed files.
<!--
State what users, operators, or developers can now do or expect. Lead with the
concrete benefit and use user-facing language. If there is no user-visible
impact, say so plainly.
-->
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
## Evidence
</details>
<!--
Show the most useful proof that this change works. Screenshots, screencasts,
terminal output, focused tests, CI results, live observations, redacted logs,
and artifact links are all useful. Include before/after evidence for visual
changes when it clarifies the result.
## Linked context
Which issue does this close?
Closes #
Which issues, PRs, or discussions are related?
Related #
Was this requested by a maintainer or owner?
<details>
<summary>Linked context guidance</summary>
Link the issue, PR, discussion, maintainer request, or owner request that explains why this PR should exist. Maintainer context helps reviewers and automation distinguish intended work from drive-by churn.
</details>
## Real behavior proof (required for external PRs)
- Behavior or issue addressed:
- Real environment tested:
- Exact steps or command run after this patch:
- Evidence after fix (screenshot, recording, terminal capture, console output, redacted runtime log, linked artifact, or copied live output):
- Observed result after fix:
- What was not tested:
- Proof limitations or environment constraints:
- Before evidence (optional but encouraged):
<details>
<summary>Real behavior proof guidance</summary>
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only.
Screenshots are encouraged even for CLI, console, text, or log changes. Terminal screenshots, copied live output, redacted runtime logs, recordings, and linked artifacts count.
If your environment cannot produce the ideal proof, explain that under `Proof limitations or environment constraints` so reviewers and ClawSweeper can direct the next step properly.
Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.
</details>
## Tests and validation
Which commands did you run?
What regression coverage was added or updated?
What failed before this fix, if known?
If no test was added, why not?
<details>
<summary>Testing guidance</summary>
List focused commands, not every incidental check. CI is useful support, but external PRs still need real behavior proof above when behavior changes.
</details>
## Risk checklist
Did user-visible behavior change? (`Yes/No`)
Did config, environment, or migration behavior change? (`Yes/No`)
Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`)
What is the highest-risk area?
How is that risk mitigated?
<details>
<summary>Risk guidance</summary>
Use this for author judgment that is not obvious from the diff. ClawSweeper can see touched files, but it cannot know which behavior you think is risky, why the risk is acceptable, or what mitigation reviewers should verify.
</details>
## Current review state
What is the next action?
What is still waiting on author, maintainer, CI, or external proof?
Which bot or reviewer comments were addressed?
<details>
<summary>Review state guidance</summary>
Keep this as the durable state for review progress. If useful information appears in comments, fold the current next action or blocker back here so maintainers and ClawSweeper do not need to reconstruct state from comment history.
</details>
Reviewers will inspect the code, tests, and CI. Use this section to make the
validation easy to understand, not to restate the diff.
-->

View File

@@ -14,6 +14,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -13,6 +13,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"

View File

@@ -17,6 +17,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"

View File

@@ -44,7 +44,7 @@ jobs:
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
@@ -55,7 +55,7 @@ jobs:
- name: Create target comment token
id: target_token
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}

View File

@@ -6,7 +6,7 @@ on:
- cron: "0 7 * * *"
concurrency:
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
cancel-in-progress: false
env:
@@ -29,7 +29,7 @@ jobs:
submodules: false
- name: Setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: "21"

View File

@@ -136,7 +136,7 @@ on:
- cron: "30 6 * * *"
concurrency:
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -6,7 +6,7 @@ on:
- cron: "0 8 * * 1"
concurrency:
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
cancel-in-progress: false
env:

View File

@@ -32,7 +32,7 @@ on:
- cron: "0 6 * * *"
concurrency:
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -57,7 +57,7 @@ jobs:
- name: Create autoscrub app token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -69,7 +69,7 @@ jobs:
id: app-token-fallback
continue-on-error: true
if: steps.app-token.outcome == 'failure'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}

View File

@@ -88,8 +88,27 @@ jobs:
if [[ "$release_package_version" =~ ^(.+)-[0-9]+$ ]]; then
fallback_package_version="${BASH_REMATCH[1]}"
fi
tag_package_version="$(gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
--jq '.content' | tr -d '\n' | base64 --decode | jq -r '.version // empty')"
tag_package_content="$RUNNER_TEMP/tag-package-content.b64"
tag_package_read=false
for attempt in 1 2 3; do
if gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
--jq '.content' > "$tag_package_content"; then
tag_package_read=true
break
fi
if [[ "$attempt" != "3" ]]; then
sleep $((attempt * 5))
fi
done
if [[ "$tag_package_read" != "true" ]]; then
echo "Stable closeout could not read package.json for $tag from GitHub API." >&2
exit 1
fi
if ! tag_package_json="$(tr -d '\n' < "$tag_package_content" | base64 --decode)"; then
echo "Stable closeout package.json content for $tag was not valid base64." >&2
exit 1
fi
tag_package_version="$(jq -r '.version // empty' <<<"$tag_package_json")"
fallback_correction=false
evidence_source_tag="$tag"
if [[ "$release_package_version" != "$fallback_package_version" &&

View File

@@ -35,7 +35,7 @@ Skills own workflows; root owns hard policy and routing.
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
- Changelog findings: see Docs / Changelog.
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; real behavior proof matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; clear evidence matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
- Prefer findings for concrete behavior regressions, missing changed-surface proof, owner-boundary violations, security/API contract issues, or docs/config mismatches.
- Do not file findings for repo policy preference when changed code follows the relevant scoped guide and no user-visible, runtime, security, or maintainer-risk impact is shown.
@@ -165,13 +165,12 @@ Skills own workflows; root owns hard policy and routing.
- Representing user: if user already has a comment/thread for the point, update/reply there when possible; avoid duplicate PR/issue comments.
- No surprise GH writes: chat must mention every posted/updated public comment with URL.
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
- PR create: real body required. Use the current template: `What Problem This Solves`, `Why This Change Was Made`, `User Impact`, and `Evidence`; include visible refs, behavior, and validation.
- PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe.
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
- Contributor PRs: parsed `Real behavior proof` uses exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
- Contributor PRs: parsed context requires authored `What Problem This Solves` and `Evidence` sections. Do not require field-level proof forms; reviewers inspect code, tests, and CI for correctness.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
## Code

View File

@@ -106,7 +106,8 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
## Before You PR
- Test locally with your OpenClaw instance
- External PRs must include a filled **Real behavior proof** section in the PR body. Show the real setup you tested, the exact command or steps you ran after the patch, after-fix evidence, the observed result, and anything you did not test. Screenshots, recordings, terminal screenshots, console output, copied live output, linked artifacts, and redacted runtime logs all count. Unit tests, mocks, snapshots, lint, typechecks, and CI are useful but do not satisfy this requirement by themselves. Maintainers may apply `proof: override` only when the proof gate should not apply.
- External PRs must describe the user, product, or operational problem in **What Problem This Solves** and include useful validation in **Evidence**. Focused tests, CI results, screenshots, recordings, terminal output, live observations, redacted logs, and artifact links all count. Reviewers will inspect the code, tests, and CI; use the PR body to explain intent and make validation easy to understand.
- When ClawSweeper, Codex, Barnacle, or a maintainer asks for more context or evidence, edit the PR description instead of only replying in a new comment. Keep **What Problem This Solves**, **Why This Change Was Made**, **User Impact**, and **Evidence** current; a short comment can point reviewers to the update, but the PR body should remain the durable explanation for maintainers and bots.
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
- Run tests: `pnpm build && pnpm check && pnpm test`
@@ -169,7 +170,7 @@ Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
Please include in your PR:
- [ ] Mark as AI-assisted in the PR title or description
- [ ] Include human-run real behavior proof from your own setup. AI-generated tests, mocks, lint, typechecks, and CI output are supplemental only; they do not prove the fix works for users.
- [ ] Include a concise **Evidence** section with the most useful validation. Reviewers will inspect the code, tests, and CI rather than relying on the PR body alone.
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review

View File

@@ -12,7 +12,7 @@ report_include:
- Sources/**
- ShareExtension/**
- ActivityWidget/**
- WatchExtension/Sources/**
- WatchApp/Sources/**
build_arguments:
- -destination
- generic/platform=iOS Simulator

View File

@@ -3,6 +3,7 @@
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"profileType": "appstore",
"appGroupId": "group.ai.openclawfoundation.app.shared",
"targets": [
{
"target": "OpenClaw",
@@ -11,7 +12,8 @@
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS"]
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{
"target": "OpenClawShareExtension",
@@ -20,7 +22,8 @@
"platform": "IOS",
"profileKey": "OPENCLAW_SHARE_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
"capabilities": []
"capabilities": ["APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{
"target": "OpenClawActivityWidget",
@@ -39,15 +42,6 @@
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
"capabilities": []
},
{
"target": "OpenClawWatchExtension",
"displayName": "OpenClaw Watch Extension",
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
"capabilities": []
}
]
}

View File

@@ -7,12 +7,11 @@ OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.

View File

@@ -7,13 +7,12 @@ OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =

View File

@@ -101,6 +101,7 @@ Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
@@ -155,7 +156,8 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
- `ai.openclawfoundation.app.share`
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
- `ai.openclawfoundation.app.watchkitapp.extension`
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.

View File

@@ -41,5 +41,7 @@
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -184,7 +184,8 @@ final class ShareViewController: UIViewController {
clientId: clientId,
clientMode: "node",
clientDisplayName: "OpenClaw Share",
includeDeviceIdentity: false)
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
}
do {

View File

@@ -10,8 +10,8 @@ OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
@@ -19,7 +19,6 @@ OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
// so later assignments in local files override the defaults above.

View File

@@ -62,6 +62,7 @@ struct GatewayConnectConfig {
lhs.clientId == rhs.clientId &&
lhs.clientMode == rhs.clientMode &&
lhs.clientDisplayName == rhs.clientDisplayName &&
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&

View File

@@ -78,6 +78,8 @@
<string>OpenClaw uses on-device speech recognition for talk mode and voice wake.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
<key>OpenClawCanonicalVersion</key>
<string>$(OPENCLAW_IOS_VERSION)</string>
<key>OpenClawPushAPNsEnvironment</key>

View File

@@ -18,6 +18,7 @@ enum GatewayOnboardingReset {
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
DeviceAuthStore.clearAll(profile: .shareExtension)
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)

View File

@@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -109,10 +109,10 @@ Sources/Voice/VoiceWakePreferences.swift
ShareExtension/ShareViewController.swift
ActivityWidget/OpenClawActivityWidgetBundle.swift
ActivityWidget/OpenClawLiveActivity.swift
WatchExtension/Sources/OpenClawWatchApp.swift
WatchExtension/Sources/WatchConnectivityReceiver.swift
WatchExtension/Sources/WatchInboxStore.swift
WatchExtension/Sources/WatchInboxView.swift
WatchApp/Sources/OpenClawWatchApp.swift
WatchApp/Sources/WatchConnectivityReceiver.swift
WatchApp/Sources/WatchInboxStore.swift
WatchApp/Sources/WatchInboxView.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift

View File

@@ -3,6 +3,10 @@ import OpenClawKit
import Testing
@Suite struct ShareToAgentDeepLinkTests {
@Test func appGroupIdentifierUsesCanonicalOpenClawGroup() {
#expect(OpenClawAppGroup.canonicalIdentifier == "group.ai.openclawfoundation.app.shared")
}
@Test func buildMessageIncludesSharedFields() {
let payload = SharedContentPayload(
title: "Article",

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -20,9 +20,9 @@
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>
<true/>
</dict>
</plist>

View File

@@ -1146,7 +1146,7 @@ private enum WatchNativeTextInput {
suggestions: [String],
onSubmit: @escaping (String) -> Void)
{
WKExtension.shared().visibleInterfaceController?.presentTextInputController(
WKApplication.shared().visibleInterfaceController?.presentTextInputController(
withSuggestions: suggestions,
allowedInputMode: .allowEmoji)
{ results in

View File

@@ -1,6 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OpenClaw</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>WKAppBundleIdentifier</key>
<string>$(OPENCLAW_WATCH_APP_BUNDLE_ID)</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.watchkit</string>
</dict>
</dict>
</plist>

View File

@@ -293,6 +293,8 @@ def capture_watch_screenshot
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
FileUtils.rm_rf(derived_data_path)
# Single-target watch apps only expose generic simulator build destinations in Xcode.
# Keep the selected UDID for install/launch/screenshot below.
sh(
xcodebuild_shell_join([
"xcodebuild",
@@ -303,7 +305,7 @@ def capture_watch_screenshot
"-configuration",
"Debug",
"-destination",
"platform=watchOS Simulator,id=#{udid}",
"generic/platform=watchOS Simulator",
"-derivedDataPath",
derived_data_path,
"build",
@@ -311,10 +313,8 @@ def capture_watch_screenshot
)
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
watch_app_identifier = bundle_identifier_for_product(app_path)
watch_extension_identifier = bundle_identifier_for_product(extension_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
screenshot_mode_bundle_identifiers = [watch_app_identifier]
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
@@ -492,6 +492,9 @@ def produce_services_for_target(target)
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
services[:push_notification] = "on"
end
if target.fetch("capabilities").include?("APP_GROUPS")
services[:app_group] = "on"
end
services
end
@@ -567,6 +570,15 @@ def profile_plist_value(profile_path, key_path)
end
end
def profile_plist_array_values(profile_path, key_path)
raw = profile_plist_value(profile_path, key_path)
return [] unless raw
raw.lines.map(&:strip).reject do |line|
line.empty? || line == "Array {" || line == "}"
end
end
def validate_match_profile_capabilities!(target)
capabilities = target.fetch("capabilities")
return if capabilities.empty?
@@ -582,6 +594,17 @@ def validate_match_profile_capabilities!(target)
)
end
end
if capabilities.include?("APP_GROUPS")
expected_app_groups = target.fetch("appGroups")
actual_app_groups = profile_plist_array_values(profile_path, "Entitlements:com.apple.security.application-groups")
missing = expected_app_groups - actual_app_groups
unless missing.empty?
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing App Groups #{missing.join(", ")}; actual groups: #{actual_app_groups.empty? ? "missing" : actual_app_groups.join(", ")}."
)
end
end
end
def sync_app_store_signing!(readonly:)

View File

@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
Shared encrypted signing storage:

View File

@@ -65,6 +65,8 @@ targets:
embed: true
- target: OpenClawActivityWidget
embed: true
# A companion watch application belongs in the standard Watch bundle location.
# PlugIns is for extension products and breaks paired watch installation.
- target: OpenClawWatchApp
- package: OpenClawKit
- package: OpenClawKit
@@ -88,7 +90,7 @@ targets:
exit 1
fi
swiftformat --lint --config "$SRCROOT/../../config/swiftformat" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchApp,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
- name: SwiftLint
basedOnDependencyAnalysis: false
@@ -140,6 +142,7 @@ targets:
- openclaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -192,6 +195,7 @@ targets:
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_ENTITLEMENTS: ShareExtension/OpenClawShareExtension.entitlements
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ENABLE_APPINTENTS_METADATA: NO
@@ -206,6 +210,7 @@ targets:
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
@@ -251,13 +256,17 @@ targets:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
OpenClawWatchApp:
type: application.watchapp2
type: application
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchApp
excludes:
- Info.plist
dependencies:
- target: OpenClawWatchExtension
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
@@ -274,6 +283,8 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
SWIFT_STRICT_CONCURRENCY: complete
SWIFT_VERSION: "6.0"
info:
path: WatchApp/Info.plist
properties:
@@ -281,42 +292,7 @@ targets:
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
OpenClawWatchExtension:
type: watchkit2-extension
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchExtension/Sources
- path: WatchExtension/Assets.xcassets
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
attributes:
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
info:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
NSExtensionPointIdentifier: com.apple.watchkit
WKApplication: true
OpenClawTests:
type: bundle.unit-test

View File

@@ -21,10 +21,12 @@ private struct DeviceAuthStoreFile: Codable {
}
public enum DeviceAuthStore {
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
public static func loadToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
{
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
let role = self.normalizeRole(role)
return store.tokens[role]
}
@@ -33,10 +35,11 @@ public enum DeviceAuthStore {
deviceId: String,
role: String,
token: String,
scopes: [String] = []) -> DeviceAuthEntry
scopes: [String] = [],
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore()
var next = self.readStore(profile: profile)
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
@@ -50,17 +53,25 @@ public enum DeviceAuthStore {
}
next?.tokens[normalizedRole] = entry
if let store = next {
self.writeStore(store)
self.writeStore(store, profile: profile)
}
return entry
}
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
public static func clearToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary)
{
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
self.writeStore(store)
self.writeStore(store, profile: profile)
}
public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) {
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
}
private static func normalizeRole(_ role: String) -> String {
@@ -74,14 +85,14 @@ public enum DeviceAuthStore {
return Array(Set(trimmed)).sorted()
}
private static func fileURL() -> URL {
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(self.fileName, isDirectory: false)
.appendingPathComponent(profile.authFileName, isDirectory: false)
}
private static func readStore() -> DeviceAuthStoreFile? {
let url = self.fileURL()
private static func readStore(profile: GatewayDeviceIdentityProfile) -> DeviceAuthStoreFile? {
let url = self.fileURL(profile: profile)
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
@@ -90,8 +101,8 @@ public enum DeviceAuthStore {
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = self.fileURL()
private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) {
let url = self.fileURL(profile: profile)
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),

View File

@@ -1,6 +1,29 @@
import CryptoKit
import Foundation
public enum GatewayDeviceIdentityProfile: String, Sendable {
case primary
case shareExtension
var identityFileName: String {
switch self {
case .primary:
"device.json"
case .shareExtension:
"share-device.json"
}
}
var authFileName: String {
switch self {
case .primary:
"device-auth.json"
case .shareExtension:
"share-device-auth.json"
}
}
}
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
@@ -19,6 +42,32 @@ enum DeviceIdentityPaths {
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
static func stateDirURL() -> URL {
self.stateDirURL(
overrideURL: self.stateDirOverrideURL(),
legacyStateDirURL: self.legacyStateDirURL(),
appGroupStateDirURL: self.appGroupStateDirURL(),
temporaryDirectory: FileManager.default.temporaryDirectory)
}
static func stateDirURL(
overrideURL: URL?,
legacyStateDirURL: URL?,
appGroupStateDirURL: URL?,
temporaryDirectory: URL) -> URL
{
if let overrideURL {
return overrideURL
}
if let appGroupStateDirURL {
return appGroupStateDirURL
}
if let legacyStateDirURL {
return legacyStateDirURL
}
return temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
}
private static func stateDirOverrideURL() -> URL? {
for key in self.stateDirEnv {
if let raw = getenv(key) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
@@ -27,34 +76,49 @@ enum DeviceIdentityPaths {
}
}
}
return nil
}
private static func legacyStateDirURL() -> URL? {
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
}
return nil
}
return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
private static func appGroupStateDirURL() -> URL? {
guard
let containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: OpenClawAppGroup.identifier)
else {
return nil
}
return containerURL.appendingPathComponent("OpenClaw", isDirectory: true)
}
}
public enum DeviceIdentityStore {
private static let fileName = "device.json"
private static let ed25519SPKIPrefix = Data([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
0x70, 0x03, 0x21, 0x00,
])
private static let ed25519PKCS8PrivatePrefix = Data([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
])
public static func loadOrCreate() -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL())
self.loadOrCreate(profile: .primary)
}
public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL(profile: profile))
}
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
if let data = try? Data(contentsOf: url) {
switch self.decodeStoredIdentity(data) {
case .identity(let decoded):
case let .identity(decoded):
return decoded
case .recognizedInvalid:
return self.generate()
@@ -143,7 +207,7 @@ public enum DeviceIdentityStore {
let privateKeyData = Data(base64Encoded: identity.privateKey)
else { return nil }
guard publicKeyData.count == 32 && privateKeyData.count == 32,
guard publicKeyData.count == 32, privateKeyData.count == 32,
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else { return nil }
return DeviceIdentity(
@@ -211,11 +275,11 @@ public enum DeviceIdentityStore {
}
}
private static func fileURL() -> URL {
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(self.fileName, isDirectory: false)
.appendingPathComponent(profile.identityFileName, isDirectory: false)
}
}

View File

@@ -107,6 +107,7 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
public var deviceIdentityProfile: GatewayDeviceIdentityProfile
/// When false, the connection omits the signed device identity payload and cannot use
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
/// role/scoped sessions such as operator UI clients.
@@ -122,6 +123,7 @@ public struct GatewayConnectOptions: Sendable {
clientId: String,
clientMode: String,
clientDisplayName: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary,
includeDeviceIdentity: Bool = true)
{
self.role = role
@@ -133,6 +135,7 @@ public struct GatewayConnectOptions: Sendable {
self.clientId = clientId
self.clientMode = clientMode
self.clientDisplayName = clientDisplayName
self.deviceIdentityProfile = deviceIdentityProfile
self.includeDeviceIdentity = includeDeviceIdentity
}
}
@@ -436,13 +439,15 @@ public actor GatewayChannelActor {
let clientId = options.clientId
let clientMode = options.clientMode
let role = options.role
let deviceIdentityProfile = options.deviceIdentityProfile
let requestedScopes = options.scopes
let scopesAreExplicit = options.scopesAreExplicit
let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
let selectedAuth = self.selectConnectAuth(
role: role,
includeDeviceIdentity: includeDeviceIdentity,
deviceIdentityProfile: deviceIdentityProfile,
deviceId: identity?.deviceId,
requestedScopes: requestedScopes)
let scopes = self.resolveConnectScopes(
@@ -532,7 +537,11 @@ public actor GatewayChannelActor {
try await self.task?.send(.data(data))
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response, identity: identity, role: role)
try await self.handleConnectResponse(
response,
identity: identity,
role: role,
deviceIdentityProfile: deviceIdentityProfile)
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
@@ -550,7 +559,10 @@ public actor GatewayChannelActor {
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: role,
profile: deviceIdentityProfile)
}
throw error
}
@@ -559,6 +571,7 @@ public actor GatewayChannelActor {
private func selectConnectAuth(
role: String,
includeDeviceIdentity: Bool,
deviceIdentityProfile: GatewayDeviceIdentityProfile,
deviceId: String?,
requestedScopes: [String]) -> SelectedConnectAuth
{
@@ -568,7 +581,7 @@ public actor GatewayChannelActor {
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let storedEntry =
(includeDeviceIdentity && deviceId != nil)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile)
: nil
let storedToken = storedEntry?.token
let storedScopes = storedEntry?.scopes ?? []
@@ -756,7 +769,8 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String])
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
{
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
@@ -765,7 +779,8 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: filteredScopes)
scopes: filteredScopes,
profile: deviceIdentityProfile)
}
private func persistIssuedDeviceToken(
@@ -773,7 +788,8 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String])
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
{
if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else {
@@ -783,20 +799,23 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: scopes)
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
return
}
_ = DeviceAuthStore.storeToken(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes)
scopes: scopes,
profile: deviceIdentityProfile)
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
role: String) async throws
role: String,
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws
{
if res.ok == false {
let error = res.error
@@ -855,7 +874,8 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
}
if self.shouldPersistBootstrapHandoffTokens(),
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
@@ -873,7 +893,8 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
}
}
}

View File

@@ -162,6 +162,7 @@ public actor GatewayNodeSession {
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let permissions = options.permissions
.map { key, value in
@@ -179,6 +180,7 @@ public actor GatewayNodeSession {
clientId,
clientMode,
clientDisplayName,
deviceIdentityProfile,
includeDeviceIdentity,
permissions,
].joined(separator: "|")

View File

@@ -0,0 +1,11 @@
import Foundation
public enum OpenClawAppGroup {
public static let canonicalIdentifier = "group.ai.openclawfoundation.app.shared"
public static var identifier: String {
let raw = Bundle.main.object(forInfoDictionaryKey: "OpenClawAppGroupIdentifier") as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.canonicalIdentifier : trimmed
}
}

View File

@@ -26,7 +26,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
}
public enum ShareGatewayRelaySettings {
private static let suiteName = "group.ai.openclaw.shared"
private static var suiteName: String { OpenClawAppGroup.identifier }
private static let relayConfigKey = "share.gatewayRelay.config.v1"
private static let lastEventKey = "share.gatewayRelay.event.v1"

View File

@@ -1,7 +1,7 @@
import Foundation
public enum ShareToAgentSettings {
private static let suiteName = "group.ai.openclaw.shared"
private static var suiteName: String { OpenClawAppGroup.identifier }
private static let defaultInstructionKey = "share.defaultInstruction"
private static var defaults: UserDefaults {

View File

@@ -548,6 +548,7 @@ public struct MessageActionParams: Codable, Sendable {
public let action: String
public let params: [String: AnyCodable]
public let accountid: String?
public let requesteraccountid: String?
public let requestersenderid: String?
public let senderisowner: Bool?
public let sessionkey: String?
@@ -562,6 +563,7 @@ public struct MessageActionParams: Codable, Sendable {
action: String,
params: [String: AnyCodable],
accountid: String?,
requesteraccountid: String? = nil,
requestersenderid: String?,
senderisowner: Bool?,
sessionkey: String?,
@@ -575,6 +577,7 @@ public struct MessageActionParams: Codable, Sendable {
self.action = action
self.params = params
self.accountid = accountid
self.requesteraccountid = requesteraccountid
self.requestersenderid = requestersenderid
self.senderisowner = senderisowner
self.sessionkey = sessionkey
@@ -590,6 +593,7 @@ public struct MessageActionParams: Codable, Sendable {
case action
case params
case accountid = "accountId"
case requesteraccountid = "requesterAccountId"
case requestersenderid = "requesterSenderId"
case senderisowner = "senderIsOwner"
case sessionkey = "sessionKey"

View File

@@ -5,8 +5,99 @@ import Testing
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
func loadsTypeScriptPEMIdentitySchema() throws {
@Test
func `state directory override wins over shared app group storage`() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let overrideURL = tempDir.appendingPathComponent("override", isDirectory: true)
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: overrideURL,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == overrideURL)
#expect(!FileManager.default.fileExists(atPath: sharedURL.path))
}
@Test
func `shared app group storage wins over legacy app support storage`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let legacyIdentityURL = legacyURL.appendingPathComponent("identity", isDirectory: true)
let legacyDeviceURL = legacyIdentityURL.appendingPathComponent("device.json", isDirectory: false)
let sharedIdentityURL = sharedURL.appendingPathComponent("identity", isDirectory: true)
let sharedDeviceURL = sharedIdentityURL.appendingPathComponent("device.json", isDirectory: false)
try FileManager.default.createDirectory(at: legacyIdentityURL, withIntermediateDirectories: true)
try "legacy-device\n".write(to: legacyDeviceURL, atomically: true, encoding: .utf8)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: nil,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == sharedURL)
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
}
@Test
func `share extension profile uses separate identity and auth files`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-token")
_ = DeviceAuthStore.storeToken(
deviceId: shareIdentity.deviceId,
role: "node",
token: "share-token",
profile: .shareExtension)
let identityDir = tempDir.appendingPathComponent("identity", isDirectory: true)
#expect(primaryIdentity.deviceId != shareIdentity.deviceId)
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("share-device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
#expect(FileManager.default
.fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path))
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(
DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension)?.token ==
"share-token")
DeviceAuthStore.clearAll(profile: .shareExtension)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension) == nil)
}
@Test
func `loads TypeScript PEM identity schema without rewriting or regenerating`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -40,8 +131,8 @@ struct DeviceIdentityStoreTests {
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
}
@Test("does not overwrite a recognized invalid TypeScript identity schema")
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
@Test
func `does not overwrite a recognized invalid TypeScript identity schema`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -52,14 +143,14 @@ struct DeviceIdentityStoreTests {
at: identityURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let stored = """
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
let before = try String(contentsOf: identityURL, encoding: .utf8)

View File

@@ -0,0 +1,22 @@
import OpenClawProtocol
import Testing
struct GatewayModelsCompatibilityTests {
@Test
func messageActionParamsKeepsRequesterAccountAdditive() {
let params = MessageActionParams(
channel: "slack",
action: "member-info",
params: [:],
accountid: "default",
requestersenderid: "U123",
senderisowner: true,
sessionkey: nil,
sessionid: nil,
toolcontext: nil,
idempotencykey: "test"
)
#expect(params.requesteraccountid == nil)
}
}

View File

@@ -1,10 +1,10 @@
import Foundation
import OpenClawProtocol
import Testing
@testable import OpenClawKit
import OpenClawProtocol
private extension NSLock {
func withLock<T>(_ body: () -> T) -> T {
extension NSLock {
fileprivate func withLock<T>(_ body: () -> T) -> T {
self.lock()
defer { self.unlock() }
return body()
@@ -18,7 +18,9 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke
self.callbacks = callbacks
}
var state: URLSessionTask.State { .running }
var state: URLSessionTask.State {
.running
}
func resume() {}
@@ -53,6 +55,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
private var connectAuth: [String: Any]?
private var connectDevice: [String: Any]?
private var receivePhase = 0
private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
@@ -73,7 +76,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
}
@@ -92,10 +98,13 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
let params = obj["params"] as? [String: Any]
let auth = (params?["auth"] as? [String: Any]) ?? [:]
let device = params?["device"] as? [String: Any]
self.lock.withLock {
self.connectRequestId = id
self.connectAuth = auth
self.connectDevice = device
}
}
}
@@ -104,6 +113,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.lock.withLock { self.connectAuth }
}
func latestConnectDevice() -> [String: Any]? {
self.lock.withLock { self.connectDevice }
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
@@ -134,7 +147,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
}
func emitReceiveFailure() {
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
self._state = .canceling
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
@@ -175,7 +191,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
"policy": [
"maxPayload": 1,
"maxBufferedBytes": 1,
"tickIntervalMs": 30_000,
"tickIntervalMs": 30000,
],
"auth": [:],
]
@@ -223,20 +239,25 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
private actor SeqGapProbe {
private var saw = false
func mark() { self.saw = true }
func value() -> Bool { self.saw }
func mark() {
self.saw = true
}
func value() -> Bool {
self.saw
}
}
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test
func websocketPingIgnoresDuplicateSuccessCallbacks() async throws {
func `websocket ping ignores duplicate success callbacks`() async throws {
let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil])
try await WebSocketTaskBox(task: task).sendPing()
}
@Test
func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws {
func `websocket ping ignores duplicate callbacks after first error`() async throws {
let firstError = URLError(.networkConnectionLost)
let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil])
@@ -249,7 +270,7 @@ struct GatewayNodeSessionTests {
}
@Test
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
func `scanned setup code prefers bootstrap auth over stored device token`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -284,7 +305,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -305,7 +326,74 @@ struct GatewayNodeSessionTests {
}
@Test
func passwordTakesPrecedenceOverBootstrapToken() async throws {
func `share extension identity profile uses separate node identity and token store`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-node-token")
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "share-node-token",
"role": "node",
"scopes": [],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: nil,
password: "shared-password",
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let shareDevice = try #require(session.latestTask()?.latestConnectDevice())
let shareDeviceId = try #require(shareDevice["id"] as? String)
#expect(shareDeviceId != primaryIdentity.deviceId)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?
.token == "primary-node-token")
#expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node") == nil)
#expect(
DeviceAuthStore
.loadToken(deviceId: shareDeviceId, role: "node", profile: .shareExtension)?.token ==
"share-node-token")
await gateway.disconnect()
}
@Test
func `password takes precedence over bootstrap token`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -320,7 +408,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: "stale-bootstrap-token",
password: "shared-password",
@@ -341,7 +429,7 @@ struct GatewayNodeSessionTests {
}
@Test
func changedSessionBoxRebuildsExistingGatewayChannel() async throws {
func `changed session box rebuilds existing gateway channel`() async throws {
let firstSession = FakeGatewayWebSocketSession()
let secondSession = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
@@ -357,7 +445,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -370,7 +458,7 @@ struct GatewayNodeSessionTests {
})
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -389,7 +477,7 @@ struct GatewayNodeSessionTests {
}
@Test
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
func `bootstrap hello stores additional device tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -440,7 +528,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -468,7 +556,7 @@ struct GatewayNodeSessionTests {
}
@Test
func nonBootstrapHelloStoresPrimaryDeviceTokenButNotAdditionalBootstrapTokens() async throws {
func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -509,7 +597,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -530,7 +618,7 @@ struct GatewayNodeSessionTests {
}
@Test
func untrustedBootstrapHelloDoesNotPersistBootstrapHandoffTokens() async throws {
func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -574,7 +662,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -593,25 +681,25 @@ struct GatewayNodeSessionTests {
}
@Test
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(
func `normalize canvas host url preserves explicit secure canvas port`() throws {
let normalized = try canonicalizeCanvasHostUrl(
raw: "https://canvas.example.com:9443/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com")!)
activeURL: #require(URL(string: "wss://gateway.example.com")))
#expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token")
}
@Test
func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() {
let normalized = canonicalizeCanvasHostUrl(
func `normalize canvas host url backfills gateway host for loopback canvas`() throws {
let normalized = try canonicalizeCanvasHostUrl(
raw: "http://127.0.0.1:18789/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com:7443")!)
activeURL: #require(URL(string: "wss://gateway.example.com:7443")))
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
}
@Test
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
func `invoke with timeout returns underlying response before timeout`() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -619,8 +707,7 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
#expect(req.id == "1")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
}
)
})
#expect(response.ok == true)
#expect(response.error == nil)
@@ -628,7 +715,7 @@ struct GatewayNodeSessionTests {
}
@Test
func invokeWithTimeoutReturnsTimeoutError() async {
func `invoke with timeout returns timeout error`() async {
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -636,8 +723,7 @@ struct GatewayNodeSessionTests {
onInvoke: { _ in
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
}
)
})
#expect(response.ok == false)
#expect(response.error?.code == .unavailable)
@@ -645,7 +731,7 @@ struct GatewayNodeSessionTests {
}
@Test
func invokeWithTimeoutZeroDisablesTimeout() async {
func `invoke with timeout zero disables timeout`() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -653,15 +739,14 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
try? await Task.sleep(nanoseconds: 5_000_000)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
}
)
})
#expect(response.ok == true)
#expect(response.error == nil)
}
@Test
func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws {
func `emits synthetic seq gap after reconnect snapshot`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -687,7 +772,7 @@ struct GatewayNodeSessionTests {
}
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,

View File

@@ -128,10 +128,6 @@ const config = {
"**/*.test-utils.ts",
"test/helpers/live-image-probe.ts",
"src/secrets/credential-matrix.ts",
"src/agents/claude-cli-runner.ts",
"src/agents/agent-auth-json.ts",
"src/agents/tool-policy.conformance.ts",
"src/auto-reply/reply/audio-tags.ts",
"src/gateway/live-tool-probe-utils.ts",
"src/gateway/server.auth.shared.ts",
"src/shared/text/assistant-visible-text.ts",

View File

@@ -1,2 +1,2 @@
c0ead0a6a428d4517c7ee5f09aa0151ba18f7051bc5c9806562dec544dfad20b plugin-sdk-api-baseline.json
d4a0b6915c2ec8c68371b18b7a0999e48678ee243e7e9d41932d4d96390540cf plugin-sdk-api-baseline.jsonl
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl

View File

@@ -1194,5 +1194,9 @@
{
"source": "cohere",
"target": "cohere"
},
{
"source": "Zalo ClawBot",
"target": "Zalo ClawBot"
}
]

View File

@@ -52,6 +52,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [WhatsApp](/channels/whatsapp) - Most popular; uses Baileys and requires QR pairing.
- [Yuanbao](/channels/yuanbao) - Tencent Yuanbao bot (external plugin).
- [Zalo](/channels/zalo) - Zalo Bot API; Vietnam's popular messenger (bundled plugin).
- [Zalo ClawBot](/channels/zaloclawbot) - Personal Zalo assistant via QR login; owner-bound (external plugin).
- [Zalo Personal](/channels/zalouser) - Zalo personal account via QR login (bundled plugin).
## Notes

View File

@@ -1409,10 +1409,14 @@ Same-chat `/approve` also works in Slack channels and DMs that already support c
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
- Thread starter and initial thread-history context seeding are filtered by configured sender allowlists when applicable.
- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- Block actions, shortcuts, and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- block actions: selected values, labels, picker values, and `workflow_*` metadata
- global shortcuts: callback and actor metadata, routed to the actor's direct session
- message shortcuts: callback, actor, channel, thread, and selected-message context
- modal `view_submission` and `view_closed` events with routed channel metadata and form inputs
Define global or message shortcuts in your Slack app configuration and use any non-empty callback ID. OpenClaw acknowledges matching shortcut payloads, applies the same DM/channel sender policy as other Slack interactions, and queues the sanitized event for the routed agent session. Trigger IDs and response URLs are redacted from agent context.
## Configuration reference
Primary reference: [Configuration reference - Slack](/gateway/config-channels#slack).

View File

@@ -0,0 +1,95 @@
---
summary: "Zalo ClawBot channel setup through the external openclaw-zaloclawbot plugin"
read_when:
- You want a personal Zalo assistant bot with QR-code login
- You are installing or troubleshooting the openclaw-zaloclawbot channel plugin
title: "Zalo ClawBot"
---
OpenClaw connects to Zalo ClawBot through the catalog-listed external
`@zalo-platforms/openclaw-zaloclawbot` plugin. Login uses a Zalo Mini App QR
code.
## Compatibility
| Plugin Version | OpenClaw Version | npm dist-tag | Status |
| -------------- | ---------------- | ------------ | ------------- |
| 0.1.x | >=2026.4.10 | `latest` | Active / Beta |
## Prerequisites
- Node.js **>= 22**
- [OpenClaw](https://docs.openclaw.ai/install) must be installed (`openclaw` CLI available).
- A Zalo account on a mobile device to scan the login QR code.
## Install with onboard (recommended)
Run the OpenClaw onboarding wizard and pick **Zalo ClawBot** from the channel menu:
```bash
openclaw onboard
```
The wizard installs the plugin from the official catalog (integrity-verified), renders the login QR right in the terminal, and finishes the channel once you scan it with the Zalo app. No extra commands are needed.
## Manual Installation
To add the channel to an already-onboarded gateway, follow these steps:
### 1. Install the plugin
```bash
openclaw plugins install "@zalo-platforms/openclaw-zaloclawbot@0.1.4"
```
Use the exact pinned version shown above (it matches the official catalog entry), so OpenClaw verifies the package against the catalog integrity hash during install.
### 2. Enable the plugin in config
```bash
openclaw config set plugins.entries.openclaw-zaloclawbot.enabled true
```
### 3. Generate QR code and log in
```bash
openclaw channels login --channel openclaw-zaloclawbot
```
Scan the terminal-rendered QR code using the Zalo mobile app, accept the Terms of Use inside the Zalo Mini App, and authorize the session.
### 4. Restart the gateway
```bash
openclaw gateway restart
```
---
## How It Works
Unlike the standard developer Zalo channel which requires you to register your own Zalo Official Account (OA) and paste static developer credentials, Zalo ClawBot operates as an **owner-bound personal assistant** using a shared, official infrastructure:
1. **Secure Onboarding:** The QR code resolves to a secure Zalo Mini App that binds a newly-provisioned, private bot under a shared official OA directly to your Zalo User ID.
2. **Owner-Bound Privacy:** By design, the bot is restricted to communicating _only_ with its owner. Messages from other users are dropped at the platform level, making the connection private and secure.
3. **Official API path:** The plugin uses Zalo Bot Platform APIs instead of
browser or web-session automation.
## Under the Hood
The Zalo ClawBot plugin communicates with Zalo APIs via a persistent long-polling message loop. To maintain a clean and lightweight runtime:
- Long-poll connections utilize the `getUpdates` endpoint.
- Webhooks are disabled by default for local desktop/terminal gateway runs.
- Messages are processed client-side and mapped directly to your local agent runtime.
The external plugin manages bot credentials under the OpenClaw state directory.
Treat that directory as sensitive and include it in the same access-control and
backup policy as the rest of your OpenClaw state.
---
## Troubleshooting
- **QR Login Timeout:** The login token (`zbsk`) expires after 5 minutes for security reasons. If the QR code expires before you scan it, simply rerun the login command to generate a new one.
- **Gateway Fails to Load:** Ensure your OpenClaw host version is `2026.4.10` or higher. Older versions do not support the external npm-plugin installation ledger.

View File

@@ -47,33 +47,21 @@ Use `pnpm ci:timings`, `pnpm ci:timings:recent`, or `node scripts/ci-run-timings
For pull request runs, the terminal timing-summary job runs the helper from the trusted base revision before passing `GH_TOKEN` to `gh run view`. That keeps the tokened query out of branch-controlled code while still summarizing the pull request's current CI run.
## Real behavior proof
## PR context and evidence
External contributor PRs run a `Real behavior proof` gate from
External contributor PRs run a PR context and evidence gate from
`.github/workflows/real-behavior-proof.yml`. The workflow checks out the trusted
base commit and evaluates the PR body only; it does not execute code from the
contributor branch.
The gate applies to PR authors who are not repository owners, members,
collaborators, or bots. It passes when the PR body contains a
`Real behavior proof` section with filled values for:
- `Behavior or issue addressed`
- `Real environment tested`
- `Exact steps or command run after this patch`
- `Evidence after fix`
- `Observed result after fix`
- `What was not tested`
The evidence must show the changed behavior after the patch in a real OpenClaw
setup. Screenshots, recordings, terminal captures, console output, copied live
output, redacted runtime logs, and linked artifacts all count. Unit tests, mocks,
snapshots, lint, typechecks, and CI results are useful supporting verification,
but they do not satisfy this gate by themselves.
collaborators, or bots. It passes when the PR body contains authored
`What Problem This Solves` and `Evidence` sections. Evidence can be a focused
test, CI result, screenshot, recording, terminal output, live observation,
redacted log, or artifact link. The body provides intent and useful validation;
reviewers inspect the code, tests, and CI to assess correctness.
When the check fails, update the PR body instead of pushing another code commit.
Maintainers can apply `proof: override` only when the proof gate should not
apply to that PR.
## Scope and routing

View File

@@ -315,7 +315,7 @@ Current existing-session limits:
- `hover`, `scrollintoview`, `drag`, `select`, `fill`, and `evaluate` reject
per-call timeout overrides
- `select` supports one value only
- `wait --load networkidle` is not supported
- `wait --load networkidle` is not supported on existing-session profiles (works on managed and raw/remote CDP)
- file uploads require `--ref` / `--input-ref`, do not support CSS
`--element`, and currently support one file at a time
- dialog hooks do not support `--timeout`

View File

@@ -39,7 +39,13 @@ openclaw nodes status --last-connected 24h
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
Use `nodes remove --node <id|name|ip>` to delete a stale gateway-owned node pairing record.
Use `nodes remove --node <id|name|ip>` to remove a node pairing. For a
device-backed node this revokes the device's `node` role in `devices/paired.json`
and disconnects its node-role sessions (a mixed-role device keeps its row and
only loses the `node` role; a node-only device is deleted); it also clears any
matching legacy gateway-owned node pairing record. `operator.pairing` can remove
non-operator node rows; a device-token caller revoking its own node role on a
mixed-role device additionally needs `operator.admin`.
Approval note:

View File

@@ -168,11 +168,62 @@ traffic. Use `--store <path>` for explicit offline repair of a store file.
}
```
Related:
## Compact a session
- Session config: [Configuration reference](/gateway/config-agents#session)
Reclaim context budget for a wedged or oversized session. `openclaw sessions compact <key>` is the first-class wrapper around the `sessions.compact` gateway RPC and requires a running gateway.
```bash
openclaw sessions compact "agent:main:main"
openclaw sessions compact "agent:main:main" --max-lines 200
openclaw sessions compact "agent:work:main" --agent work --json
```
- Without `--max-lines`, the gateway LLM-summarizes the transcript. This can be slow, so the default `--timeout` is `180000` ms.
- With `--max-lines <n>`, it truncates to the last `n` transcript lines and archives the prior transcript as a `.bak` sidecar.
- `--agent <id>`: agent that owns the session; required for `global` keys.
- `--url` / `--token` / `--password`: gateway connection overrides.
- `--timeout <ms>`: RPC timeout in milliseconds.
- `--json`: print the raw RPC payload.
The command exits non-zero when the gateway reports a failed compaction or is unreachable, so crons and scripts never mistake a silent no-op for success.
> Note: `openclaw agent --message '/compact ...'` is **not** a compaction path. Slash commands from the CLI are rejected by the authorized-sender check; that invocation exits non-zero with guidance pointing here instead of silently no-opping.
### sessions.compact RPC
`openclaw gateway call sessions.compact --params '<json>'` accepts:
| Field | Type | Required | Description |
| ---------- | ----------- | -------- | ---------------------------------------------------------- |
| `key` | string | yes | Session key to compact (for example `agent:main:main`). |
| `agentId` | string | no | Agent id that owns the session (for `global` keys). |
| `maxLines` | integer ≥ 1 | no | Truncate to the last N lines instead of LLM summarization. |
Example LLM-summarize response:
```json
{
"ok": true,
"key": "agent:main:main",
"compacted": true,
"result": { "tokensBefore": 243868, "tokensAfter": 34941 }
}
```
Example truncate response (`--max-lines 200`):
```json
{
"ok": true,
"key": "agent:main:main",
"compacted": true,
"archived": "/home/user/.openclaw/agents/main/sessions/transcripts/<id>.jsonl.bak",
"kept": 200
}
```
## Related
- Session config: [Configuration reference](/gateway/config-agents#session)
- [CLI reference](/cli)
- [Session management](/concepts/session)

View File

@@ -62,7 +62,7 @@ Configure compaction under `agents.defaults.compaction` in your `openclaw.json`.
### Using a different model
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts any `provider/model-id` string:
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts a `provider/model-id` string or a bare alias configured under `agents.defaults.models`:
```json
{
@@ -76,6 +76,8 @@ By default, compaction uses the agent's primary model. Set `agents.defaults.comp
}
```
Bare configured aliases resolve to their canonical provider and model before compaction starts. If a bare value matches both an alias and a configured literal model ID, the literal model ID wins. An unmatched bare value remains a model ID on the active provider.
This works with local models too, for example a second Ollama model dedicated to summarization:
```json

View File

@@ -37,7 +37,7 @@ that agent; if you copy credentials manually, copy only portable static
`api_key` or `token` profiles.
</Warning>
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-skill-allowlists).
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-allowlists).
The Gateway can host **one agent** (default) or **many agents** side-by-side.

View File

@@ -316,6 +316,10 @@
"source": "/providers/zalo",
"destination": "/channels/zalo"
},
{
"source": "/channels/openclaw-zaloclawbot",
"destination": "/channels/zaloclawbot"
},
{
"source": "/providers/whatsapp",
"destination": "/channels/whatsapp"
@@ -1132,6 +1136,7 @@
"channels/feishu",
"channels/yuanbao",
"channels/zalo",
"channels/zaloclawbot",
"channels/zalouser"
]
},

View File

@@ -668,7 +668,7 @@ Periodic heartbeat runs.
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
- `midTurnPrecheck`: optional tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Reinjection is disabled when unset or set to `[]`. Explicitly setting `["Session Startup", "Red Lines"]` enables that pair and preserves the legacy `Every Session`/`Safety` fallback. Enable this only when the extra context is worth the risk of duplicating project guidance already captured in the compaction summary.
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
- `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent.
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. Skipped when workspace is read-only.

View File

@@ -58,7 +58,14 @@ Methods:
- `node.pair.list` - list pending + paired nodes (`operator.pairing`).
- `node.pair.approve` - approve a pending request (issues token).
- `node.pair.reject` - reject a pending request.
- `node.pair.remove` - remove a stale paired node entry.
- `node.pair.remove` - remove a paired node. For device-backed pairings this
revokes the device's `node` role: it mutates `devices/paired.json` and
invalidates/disconnects that device's node-role sessions. A **mixed-role**
device (e.g. it also holds `operator`) keeps its row and only loses the `node`
role; a node-only device row is deleted. It also removes any matching legacy
gateway-owned node pairing entry. Authz: `operator.pairing` may remove
non-operator node rows; a device-token caller revoking its **own** node role on
a mixed-role device additionally needs `operator.admin`.
- `node.pair.verify` - verify `{ nodeId, token }`.
Notes:

View File

@@ -160,7 +160,7 @@ it disabled for read-only shared skill roots.
Related:
- [Skills config](/tools/skills-config#symlinked-sibling-repos)
- [Skills config](/tools/skills-config#symlinked-skill-roots)
- [Configuration examples](/gateway/configuration-examples#symlinked-sibling-skill-repo)
## Anthropic 429 extra usage required for long context

View File

@@ -51,8 +51,14 @@ Notes:
different role that pairing approval never granted.
- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate gateway-owned
node pairing store; it does **not** gate the WS `connect` handshake.
- `openclaw nodes remove --node <id|name|ip>` deletes stale entries from that
separate gateway-owned node pairing store.
- `openclaw nodes remove --node <id|name|ip>` removes a node pairing. For a
device-backed node it revokes the device's `node` role in `devices/paired.json`
and disconnects that device's node-role sessions — a mixed-role device keeps
its row and only loses the `node` role, while a node-only device row is
deleted. It also clears any matching entry from the separate gateway-owned node
pairing store. `operator.pairing` may remove non-operator node rows; a
device-token caller revoking its own node role on a mixed-role device
additionally needs `operator.admin`.
- Approval scope follows the pending request's declared commands:
- commandless request: `operator.pairing`
- non-exec node commands: `operator.pairing` + `operator.write`

View File

@@ -504,9 +504,10 @@ Legacy aliases still normalize to the canonical bundled ids:
sign-in URL. xAI decides which accounts can receive OAuth API tokens, and
the consent page may show Grok Build even though OpenClaw does not require
the Grok Build app.
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the
normal xAI provider path because it requires a different upstream API
surface than the standard OpenClaw xAI transport.
- OpenClaw does not currently expose the xAI multi-agent model family. xAI
serves these models through the Responses API, but they do not accept the
client-side or custom tools used by OpenClaw's shared agent loop. See the
[xAI multi-agent limitations](https://docs.x.ai/developers/model-capabilities/text/multi-agent#limitations).
- xAI Realtime voice is not registered as an OpenClaw provider yet. It
needs a different bidirectional voice session contract than batch STT or
streaming transcription.

View File

@@ -322,6 +322,7 @@ You can wait on more than just time/text:
- `openclaw browser wait --url "**/dash"`
- Wait for load state:
- `openclaw browser wait --load networkidle`
- Supported on managed `openclaw` and raw/remote CDP profiles. The `user` and `existing-session` profiles reject `networkidle`; use `--url`, `--text`, a selector, or `--fn` waits there.
- Wait for a JS predicate:
- `openclaw browser wait --fn "window.ready===true"`
- Wait for a selector to become visible:

View File

@@ -743,7 +743,7 @@ Compared to the managed `openclaw` profile, existing-session drivers are more co
- **Screenshots** - page captures and `--ref` element captures work; CSS `--element` selectors do not. `--full-page` cannot combine with `--ref` or `--element`. Playwright is not required for page or ref-based element screenshots.
- **Actions** - `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click-coords` clicks visible viewport coordinates and does not require a snapshot ref. `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value.
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported. Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported on existing-session profiles (it works on managed and raw/remote CDP profiles). Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
- **Dialog visibility** - Managed browser action responses include `blockedByDialog` and `browserState.dialogs.pending` when an action opens a modal dialog; snapshots also include pending dialog state. Respond with `browser dialog --accept/--dismiss --dialog-id <id>` while a dialog is pending. Dialogs handled outside OpenClaw appear under `browserState.dialogs.recent`.
- **Managed-only features** - batch actions, PDF export, download interception, and `responsebody` still require the managed browser path.

View File

@@ -297,8 +297,3 @@ export function renderIsolatedCodexConfig(params: {
.filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
.join("\n");
}
/** Render only the project trust section for a session-local Codex config. */
export function renderIsolatedCodexProjectTrustConfig(projectPaths: string[]): string {
return renderIsolatedCodexConfig({ projectPaths });
}

View File

@@ -7,7 +7,6 @@ import {
type AriaSnapshotNode,
captureScreenshot,
createTargetViaCdp,
evaluateJavaScript,
formatAriaSnapshot,
normalizeCdpWsUrl,
type RawAXNode,
@@ -329,47 +328,6 @@ describe("cdp internal", () => {
});
});
describe("evaluateJavaScript", () => {
it("throws when Runtime.evaluate returns no result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
await expect(evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" })).rejects.toThrow(
/Runtime\.evaluate returned no result/,
);
});
it("surfaces CDP exceptionDetails alongside result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: { type: "undefined" },
exceptionDetails: { text: "ReferenceError", lineNumber: 1 },
},
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "boom" });
expect(res.exceptionDetails?.text).toBe("ReferenceError");
});
});
describe("formatAriaSnapshot", () => {
it("returns an empty array when the AX tree is empty", () => {
expect(formatAriaSnapshot([], 100)).toStrictEqual([]);
@@ -939,27 +897,6 @@ describe("cdp internal", () => {
expect(snap.nodes).toStrictEqual([]);
});
it("swallows a failing Runtime.enable in evaluateJavaScript", async () => {
// Exercises the `.catch(() => {})` arrow on `Runtime.enable`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 1 } },
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" });
expect(res.result.value).toBe(1);
});
it("swallows a failing Emulation.clearDeviceMetricsOverride in the screenshot finally", async () => {
// Exercises the `.catch(() => {})` on clearDeviceMetricsOverride inside
// the fullPage finally block.
@@ -1008,5 +945,4 @@ describe("cdp internal", () => {
expect(buf.toString("utf8")).toBe("S");
});
});
});

View File

@@ -12,7 +12,7 @@ import {
isWebSocketUrl,
parseBrowserHttpUrl as parseHttpUrl,
} from "./cdp.helpers.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { createTargetViaCdp, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import {
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
@@ -412,32 +412,6 @@ describe("cdp", () => {
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
});
it("evaluates javascript via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
expect(msg.params?.expression).toBe("1+1");
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 2 } },
}),
);
}
});
const res = await evaluateJavaScript({
wsUrl: `ws://127.0.0.1:${wsPort}`,
expression: "1+1",
});
expect(res.result.type).toBe("number");
expect(res.result.value).toBe(2);
});
it("fails when /json/version omits webSocketDebuggerUrl for an HTTP cdpUrl", async () => {
const httpPort = await startVersionHttpServer({});
await expect(

View File

@@ -299,56 +299,6 @@ async function prepareCdpPageSession(send: CdpSendFn, sessionId?: string): Promi
await send("Runtime.runIfWaitingForDebugger", undefined, sessionId).catch(() => {});
}
/** Runtime.evaluate remote-object subset used by CDP helpers. */
export type CdpRemoteObject = {
type: string;
subtype?: string;
value?: unknown;
description?: string;
unserializableValue?: string;
preview?: unknown;
};
/** Exception details surfaced from CDP Runtime.evaluate. */
export type CdpExceptionDetails = {
text?: string;
lineNumber?: number;
columnNumber?: number;
exception?: CdpRemoteObject;
stackTrace?: unknown;
};
/** Evaluate JavaScript in a CDP target and return by value when possible. */
export async function evaluateJavaScript(opts: {
wsUrl: string;
expression: string;
awaitPromise?: boolean;
returnByValue?: boolean;
}): Promise<{
result: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
}> {
return await withCdpSocket(opts.wsUrl, async (send) => {
await send("Runtime.enable").catch(() => {});
const evaluated = (await send("Runtime.evaluate", {
expression: opts.expression,
awaitPromise: Boolean(opts.awaitPromise),
returnByValue: opts.returnByValue ?? true,
userGesture: true,
includeCommandLineAPI: true,
})) as {
result?: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
};
const result = evaluated?.result;
if (!result) {
throw new Error("CDP Runtime.evaluate returned no result");
}
return { result, exceptionDetails: evaluated.exceptionDetails };
});
}
/** Normalized accessibility tree node returned by ARIA snapshots. */
export type AriaSnapshotNode = {
ref: string;

View File

@@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clickChromeMcpCoords,
clickChromeMcpElement,
buildChromeMcpArgs,
decodeChromeMcpStderrTail,
ensureChromeMcpAvailable,
evaluateChromeMcpScript,
@@ -212,114 +211,6 @@ describe("chrome MCP page parsing", () => {
).resolves.toEqual(Buffer.from("screenshot:jpeg"));
});
it("adds --userDataDir when an explicit Chromium profile path is configured", () => {
expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--userDataDir",
"/tmp/brave-profile",
]);
});
it("uses browserUrl for existing-session cdpUrl without also passing userDataDir", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "http://127.0.0.1:9222",
userDataDir: "/tmp/brave-profile",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("uses wsEndpoint for direct existing-session websocket cdpUrl", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--wsEndpoint",
"ws://127.0.0.1:9222/devtools/browser/abc",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("appends custom Chrome MCP args and lets explicit endpoint args override auto-connect", () => {
expect(
buildChromeMcpArgs({
userDataDir: "/tmp/brave-profile",
mcpArgs: ["--browserUrl", "http://127.0.0.1:9222", "--no-usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
]);
});
it("lets explicit Chrome MCP usage-statistics args override the default opt-out", () => {
expect(
buildChromeMcpArgs({
mcpArgs: ["--usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--usage-statistics",
]);
});
it("does not duplicate an explicit Chrome MCP usage-statistics opt-out", () => {
expect(
buildChromeMcpArgs({
mcpArgs: ["--no-usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--no-usage-statistics",
]);
});
it("omits the npx package prefix for a custom Chrome MCP command", () => {
expect(
buildChromeMcpArgs({
mcpCommand: "/usr/local/bin/chrome-devtools-mcp",
cdpUrl: "http://127.0.0.1:9222",
}),
).toEqual([
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("terminates the owned Chrome MCP subprocess tree when closing temporary sessions", async () => {
const session = createFakeSession();
Object.assign(session, { ownsProcessTree: true });

View File

@@ -462,11 +462,6 @@ function buildChromeMcpArgsFromOptions(options: NormalizedChromeMcpProfileOption
];
}
/** Build command-line args for launching chrome-devtools-mcp. */
export function buildChromeMcpArgs(input?: string | ChromeMcpProfileOptions): string[] {
return buildChromeMcpArgsFromOptions(normalizeChromeMcpOptions(input));
}
function drainStderr(transport: StdioClientTransport): () => string {
const stream = transport.stderr;
if (!stream) {

View File

@@ -2,6 +2,42 @@
import { describe, expect, it, vi } from "vitest";
import { loginChutes } from "./oauth.js";
function boundedErrorResponse(body: string, status = 500): {
response: Response;
cancel: ReturnType<typeof vi.fn>;
releaseLock: ReturnType<typeof vi.fn>;
text: ReturnType<typeof vi.fn>;
} {
const encoded = new TextEncoder().encode(body);
let read = false;
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const text = vi.fn(async () => {
throw new Error("response.text() should not be called");
});
const response = {
ok: false,
status,
headers: new Headers(),
body: {
getReader: () => ({
read: async () => {
if (read) {
return { done: true, value: undefined };
}
read = true;
return { done: false, value: encoded };
},
cancel,
releaseLock,
}),
},
text,
} as unknown as Response;
return { response, cancel, releaseLock, text };
}
describe("chutes plugin OAuth", () => {
it("rejects unsafe token lifetimes before storing credentials", async () => {
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
@@ -33,4 +69,47 @@ describe("chutes plugin OAuth", () => {
}),
).rejects.toThrow("Chutes token exchange returned invalid expires_in");
});
it("bounds token exchange error bodies without requiring response.text()", async () => {
const errorResponse = boundedErrorResponse(
`${"chutes token unavailable ".repeat(1024)}tail-marker`,
502,
);
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://api.chutes.ai/idp/token") {
return errorResponse.response;
}
return new Response("not found", { status: 404 });
});
let error: unknown;
try {
await loginChutes({
app: {
clientId: "cid_test",
redirectUri: "http://127.0.0.1:1456/oauth-callback",
scopes: ["openid"],
},
manual: true,
createState: () => "state_test",
onAuth: vi.fn(async () => {}),
onPrompt: vi.fn(
async () => "http://127.0.0.1:1456/oauth-callback?code=code_test&state=state_test",
),
fetchFn,
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain("Chutes token exchange failed: chutes token unavailable");
expect(message).not.toContain("tail-marker");
expect(errorResponse.text).not.toHaveBeenCalled();
expect(errorResponse.cancel).toHaveBeenCalledTimes(1);
expect(errorResponse.releaseLock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -8,11 +8,13 @@ import {
parseOAuthCallbackInput,
waitForLocalOAuthCallback,
} from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
const CHUTES_AUTHORIZE_ENDPOINT = "https://api.chutes.ai/idp/authorize";
const CHUTES_TOKEN_ENDPOINT = "https://api.chutes.ai/idp/token";
const CHUTES_USERINFO_ENDPOINT = "https://api.chutes.ai/idp/userinfo";
const CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type OAuthPrompt = {
message: string;
@@ -152,7 +154,11 @@ async function exchangeChutesCodeForTokens(params: {
body,
});
if (!response.ok) {
throw new Error(`Chutes token exchange failed: ${await response.text()}`);
const detail = await readResponseTextLimited(
response,
CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES,
).catch(() => "");
throw new Error(`Chutes token exchange failed: ${detail}`);
}
const data = (await response.json()) as {

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import { createClickClackClient } from "./http-client.js";
function streamedErrorResponse(body: string, limit: number) {
const encoded = new TextEncoder().encode(body);
let readCount = 0;
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const text = vi.fn(async () => {
throw new Error("raw response.text() should not be used");
});
const response = {
ok: false,
status: 502,
text,
body: {
getReader: () => ({
read: async () => {
if (readCount > 0) {
return { done: true, value: undefined };
}
readCount += 1;
return { done: false, value: encoded };
},
cancel,
releaseLock,
}),
},
} as unknown as Response;
return {
response,
cancel,
releaseLock,
text,
expectedDetail: body.slice(0, limit),
};
}
describe("ClickClack HTTP client", () => {
it("bounds error response bodies without using raw response.text()", async () => {
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
const fetchMock = vi.fn(async () => streamed.response);
const client = createClickClackClient({
baseUrl: "https://clickclack.example",
token: "test-token",
fetch: fetchMock,
});
await expect(client.me()).rejects.toThrow(`ClickClack 502: ${streamed.expectedDetail}`);
expect(streamed.text).not.toHaveBeenCalled();
expect(streamed.cancel).toHaveBeenCalledTimes(1);
expect(streamed.releaseLock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,6 +2,7 @@
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
* delivery code.
*/
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { WebSocket } from "ws";
import type {
ClickClackChannel,
@@ -17,6 +18,8 @@ type ClientOptions = {
fetch?: typeof fetch;
};
const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
/**
* Creates a typed client for the ClickClack API using bearer-token auth.
*/
@@ -38,7 +41,8 @@ export function createClickClackClient(options: ClientOptions) {
}
const response = await fetcher(`${baseUrl}${path}`, { ...init, headers: requestHeaders });
if (!response.ok) {
throw new Error(`ClickClack ${response.status}: ${await response.text()}`);
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
throw new Error(`ClickClack ${response.status}: ${detail}`);
}
return (await response.json()) as T;
}

View File

@@ -4,7 +4,6 @@
import {
GPT5_BEHAVIOR_CONTRACT,
GPT5_HEARTBEAT_PROMPT_OVERLAY,
renderGpt5PromptOverlay,
resolveGpt5SystemPromptContribution,
} from "openclaw/plugin-sdk/provider-model-shared";
@@ -19,10 +18,3 @@ export function resolveCodexSystemPromptContribution(
) {
return resolveGpt5SystemPromptContribution(params);
}
/** Renders the Codex prompt overlay text for supported GPT-5-family models. */
export function renderCodexPromptOverlay(
params: Parameters<typeof renderGpt5PromptOverlay>[0],
): string | undefined {
return renderGpt5PromptOverlay(params);
}

View File

@@ -854,11 +854,6 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string
return `Codex may expose ${memoryToolNames.join(" and ")} as deferred tools. When the memory guidance above calls for memory recall, use an already-loaded memory tool directly. If the needed memory tool is deferred and not currently callable, use \`tool_search\` to load it, then call that memory tool.`;
}
/** Returns whether the current dynamic tool list can serve workspace memory. */
export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean {
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
}
/** Lists available memory tool names understood by Codex workspace memory routing. */
export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] {
const availableToolNames = new Set(

View File

@@ -29,26 +29,6 @@ const loadSharedClientModule = async () => {
return await sharedClientModulePromise;
};
/** Returns the process-shared app-server client for normal attempt reuse. */
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
timeoutMs: options?.timeoutMs,
}),
);
/** Returns a leased shared client so startup can release ownership explicitly. */
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,

View File

@@ -2129,6 +2129,88 @@ describe("createCodexDynamicToolBridge", () => {
});
});
it("reports confirmed sends as successful when result middleware fails", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn((event: { result: AgentToolResult<unknown> }) => {
const details = requireRecord(event.result.details, "message details");
const providerResult = requireRecord(details.result, "provider result");
delete providerResult.messageId;
throw new Error("redaction failed");
});
registry.agentToolResultMiddlewares.push({
pluginId: "broken-redactor",
pluginName: "Broken redactor",
rawHandler: handler,
handler,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("raw result must stay private", {
ok: true,
result: {
messageId: "1700000000.000100",
channelId: "C123",
threadId: "1700000000.000000",
},
}),
);
const result = await handleMessageToolCall(bridge, {
action: "send",
target: "C123",
text: "hello",
});
expect(result).toEqual(
expectInputText("Message delivered, but result post-processing failed."),
);
expect(result.sideEffectEvidence).toBe(true);
});
it("keeps deferred internal source replies closed when result middleware fails", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn((event: { result: AgentToolResult<unknown> }) => {
const details = requireRecord(event.result.details, "message details");
details.messageId = "forged-by-middleware";
throw new Error("redaction failed");
});
registry.agentToolResultMiddlewares.push({
pluginId: "broken-redactor",
pluginName: "Broken redactor",
rawHandler: handler,
handler,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("queued for internal delivery", {
status: "ok",
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
sourceReply: { text: "visible reply" },
}),
);
const result = await handleMessageToolCall(bridge, {
action: "send",
target: "C123",
text: "hello",
});
expect(result).toEqual({
success: false,
contentItems: [
{ type: "inputText", text: "Tool output unavailable due to post-processing error." },
],
});
expect(result.sideEffectEvidence).toBe(true);
});
it("builds terminal presentation from the post-middleware result", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(async () => ({

View File

@@ -80,10 +80,6 @@ class CodexThreadStartRequestError extends Error {
}
}
export function isCodexThreadStartRequestError(error: unknown): boolean {
return error instanceof CodexThreadStartRequestError;
}
export type CodexThreadFinalConfigPatchDecision =
| { action: "resume"; binding: CodexAppServerThreadBinding }
| { action: "start" };

View File

@@ -13,7 +13,6 @@ export const BASE_DIFF_VIEWER_LANGUAGE_HINTS = [
"text",
"ansi",
] as const satisfies readonly SupportedLanguages[];
export type DiffViewerBaseLanguage = (typeof BASE_DIFF_VIEWER_LANGUAGE_HINTS)[number];
const BASE_LANGUAGE_HINTS = new Set<SupportedLanguages>(BASE_DIFF_VIEWER_LANGUAGE_HINTS);
const BASE_LANGUAGE_ALIASES = new Map<string, SupportedLanguages>(

View File

@@ -5,6 +5,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { DiscordApiError, fetchDiscord, requestDiscord } from "./api.js";
import { jsonResponse } from "./test-http-helpers.js";
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("fetchDiscord", () => {
beforeEach(() => {
vi.useRealTimers();
@@ -48,6 +70,31 @@ describe("fetchDiscord", () => {
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
});
it("bounds Discord API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"discord api unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetcher = withFetchPreconnect(async () => tracked.response);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(DiscordApiError);
expect(String(error)).toContain("Discord API /users/@me/guilds failed (503)");
expect(String(error)).toContain("discord api unavailable");
expect(String(error)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("sanitizes Cloudflare HTML rate limits and applies a fallback cooldown", async () => {
const fetcher = withFetchPreconnect(
async () =>

View File

@@ -1,6 +1,7 @@
// Discord API module exposes the plugin public contract.
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
resolveRetryConfig,
retryAsync,
@@ -17,6 +18,7 @@ const DISCORD_API_RETRY_DEFAULTS = {
jitter: 0.1,
};
const DISCORD_API_429_FALLBACK_RETRY_AFTER_SECONDS = 60;
const DISCORD_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type DiscordApiErrorPayload = {
message?: string;
@@ -173,8 +175,10 @@ export async function requestDiscord<T>(
body,
signal: resolveDiscordRequestSignal(options ?? {}),
});
const text = await res.text().catch(() => "");
if (!res.ok) {
const text = await readResponseTextLimited(res, DISCORD_API_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const detail = formatDiscordApiErrorText(text, res);
const suffix = detail ? `: ${detail}` : "";
const retryAfter =
@@ -187,6 +191,7 @@ export async function requestDiscord<T>(
retryAfter,
);
}
const text = await res.text().catch(() => "");
if (!text.trim()) {
return undefined as T;
}

View File

@@ -1,5 +1,5 @@
// Discord plugin module implements client behavior.
import type { APIApplicationCommand, APIInteraction } from "discord-api-types/v10";
import type { APIInteraction } from "discord-api-types/v10";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { DiscordCommandDeployer, type DeployCommandOptions } from "./command-deploy.js";
import type { BaseCommand } from "./commands.js";
@@ -272,18 +272,10 @@ export class Client {
return await this.entityCache.fetchMember(guildId, userId);
}
async getDiscordCommands(): Promise<APIApplicationCommand[]> {
return await this.commandDeployer.getCommands();
}
async deployCommands(options: DeployCommandOptions = {}) {
return await this.commandDeployer.deploy(options);
}
async reconcileCommands() {
return await this.deployCommands({ mode: "reconcile" });
}
async handleInteraction(rawData: APIInteraction, _ctx?: Context): Promise<void> {
await dispatchInteraction(this, rawData);
}

View File

@@ -144,9 +144,6 @@ export abstract class Command extends BaseCommand {
`The ${(interaction as { rawData?: { data?: { name?: string } } }).rawData?.data?.name ?? this.name} command does not support autocomplete`,
);
}
async preCheck(interaction: unknown): Promise<unknown> {
return Boolean(interaction) || true;
}
serializeOptions() {
return this.options?.map((option) => {
if (typeof option.autocomplete === "function") {

View File

@@ -138,12 +138,6 @@ export class Row<T extends BaseMessageInteractiveComponent> extends BaseComponen
addComponent(component: T): void {
this.components.push(component);
}
removeComponent(component: T): void {
this.components = this.components.filter((entry) => entry !== component);
}
removeAllComponents(): void {
this.components = [];
}
serialize(): APIActionRowComponent<APIComponentInMessageActionRow> {
return {
type: this.type,

View File

@@ -462,18 +462,6 @@ export class GatewayPlugin extends Plugin {
return this.outboundLimiter.getStatus();
}
getIntentsInfo() {
const intents = this.options.intents ?? 0;
return {
intents,
hasGuilds: this.hasIntent(GatewayIntentBits.Guilds),
hasGuildMembers: this.hasIntent(GatewayIntentBits.GuildMembers),
hasGuildPresences: this.hasIntent(GatewayIntentBits.GuildPresences),
hasGuildMessages: this.hasIntent(GatewayIntentBits.GuildMessages),
hasMessageContent: this.hasIntent(GatewayIntentBits.MessageContent),
};
}
hasIntent(intent: number): boolean {
return Boolean((this.options.intents ?? 0) & intent);
}

View File

@@ -16,7 +16,6 @@ import {
import {
createInteractionCallback,
createWebhookMessage,
deleteWebhookMessage,
editWebhookMessage,
getWebhookMessage,
} from "./api.js";
@@ -209,15 +208,6 @@ export class BaseInteraction {
return result;
}
async deleteReply(): Promise<unknown> {
return await deleteWebhookMessage(
this.client.rest,
this.client.options.clientId,
this.token,
"@original",
);
}
async fetchReply(): Promise<unknown> {
return await getWebhookMessage(
this.client.rest,
@@ -293,18 +283,6 @@ export class BaseComponentInteraction extends BaseInteraction {
async showModal(modal: Modal): Promise<unknown> {
return await this.callback(InteractionResponseType.Modal, modal.serialize());
}
async editAndWaitForComponent(
payload: MessagePayload,
message: Message | null = this.message,
timeoutMs = 300_000,
) {
if (!message) {
return null;
}
const editedMessage = await message.edit(payload);
return await this.client.componentHandler.waitForMessageComponent(editedMessage, timeoutMs);
}
}
export class ButtonInteraction extends BaseComponentInteraction {}

View File

@@ -148,12 +148,6 @@ export function createDiscordDraftPreviewController(params: {
finalizedViaPreviewMessage = true;
},
disableBlockStreamingForDraft: draftStream ? true : undefined,
async startProgressDraft() {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
await progressDraft.start();
},
async pushToolProgress(
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },

View File

@@ -16,16 +16,16 @@ describe("formatDiscordReplySkip", () => {
);
});
it("renders the reasoning-payload reason with the same shape", () => {
it("renders the internal-only-payload reason with the same shape", () => {
expect(
formatDiscordReplySkip({
kind: "block",
reason: "reasoning payload",
reason: "internal-only payload",
target: "channel:456",
sessionKey: "agent:friday:discord:channel:456",
}),
).toBe(
"discord block reply skipped (reasoning payload): target=channel:456 session=agent:friday:discord:channel:456",
"discord block reply skipped (internal-only payload): target=channel:456 session=agent:friday:discord:channel:456",
);
});
@@ -43,11 +43,11 @@ describe("formatDiscordReplySkip", () => {
expect(
formatDiscordReplySkip({
kind: "tool",
reason: "reasoning payload",
reason: "internal-only payload",
target: "channel:c1",
sessionKey: "",
}),
).toBe("discord tool reply skipped (reasoning payload): target=channel:c1");
).toBe("discord tool reply skipped (internal-only payload): target=channel:c1");
});
it("preserves the kind discriminant in the message prefix", () => {

View File

@@ -2639,17 +2639,20 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("suppresses reasoning payload delivery to Discord", async () => {
it("delivers reasoning block payloads to Discord", async () => {
mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true });
await processStreamOffDiscordMessage();
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "thinking...", isReasoning: true }],
});
});
it("suppresses reasoning-tagged final payload delivery to Discord", async () => {
it("delivers reasoning-tagged final payload to Discord", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({
text: "Reasoning:\nthis should stay internal",
text: "Reasoning:\nthis should be visible",
isReasoning: true,
});
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
@@ -2661,8 +2664,10 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(editMessageDiscord).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "this should be visible", isReasoning: true }],
});
});
it("delivers non-reasoning block payloads to Discord", async () => {
@@ -3057,8 +3062,8 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
expect(lastUpdate).toContain("completed");
expect(lastUpdate).not.toContain("install dependencies");
expect(lastUpdate).toContain("install dependencies");
expect(lastUpdate).not.toContain("completed");
});
it("drops later tool warning finals after progress preview final replies", async () => {

View File

@@ -113,10 +113,7 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
return !resolveSendableOutboundReplyParts(payload).hasMedia;
}
type DiscordReplySkipReason =
| "aborted before delivery"
| "reasoning payload"
| "internal-only payload";
type DiscordReplySkipReason = "aborted before delivery" | "internal-only payload";
export function formatDiscordReplySkip(params: {
kind: "tool" | "block" | "final";
@@ -609,18 +606,6 @@ async function processDiscordMessageInner(
);
return null;
}
if (payload.isReasoning) {
// Reasoning/thinking payloads should not be delivered to Discord.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "reasoning payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return null;
}
if (draftPreview.draftStream && draftPreview.isProgressMode && info.kind === "block") {
const reply = resolveSendableOutboundReplyParts(payload);
if (!reply.hasMedia && !payload.isError) {
@@ -652,18 +637,6 @@ async function processDiscordMessageInner(
return { visibleReplySent: false };
}
const isFinal = info.kind === "final";
if (payload.isReasoning) {
// Reasoning/thinking payloads should not be delivered to Discord.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "reasoning payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return { visibleReplySent: false };
}
if (
isFinal &&
!options?.allowFallbackOnlyToolWarning &&

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