Compare commits

...

189 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
436 changed files with 15813 additions and 7491 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

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

@@ -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:

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

@@ -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

@@ -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 @@
b29fdf14b8b6bd3f8f61699754bd3269e54a6452f0430784f0e42c0bbf6d2be3 plugin-sdk-api-baseline.json
d3a9400a6eb7b9e22ff7264dfe5afdda5bd694a6f8fa6427d146a4c4b1506d3e plugin-sdk-api-baseline.jsonl
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl

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

@@ -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

@@ -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

@@ -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

@@ -299,25 +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;
};
/** Normalized accessibility tree node returned by ARIA snapshots. */
export type AriaSnapshotNode = {
ref: string;

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

@@ -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

@@ -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 () => {

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 &&

View File

@@ -90,8 +90,6 @@ let discordProviderSessionRuntimePromise: Promise<DiscordProviderSessionRuntimeM
let fetchDiscordApplicationIdForTesting: typeof fetchDiscordApplicationId | undefined;
let createDiscordNativeCommandForTesting: typeof createDiscordNativeCommand | undefined;
let runDiscordGatewayLifecycleForTesting: typeof runDiscordGatewayLifecycle | undefined;
let createDiscordGatewayPluginForTesting: typeof createDiscordGatewayPlugin | undefined;
let createDiscordGatewaySupervisorForTesting: typeof createDiscordGatewaySupervisor | undefined;
let loadDiscordVoiceRuntimeForTesting: (() => Promise<DiscordVoiceRuntimeModule>) | undefined;
let loadDiscordProviderSessionRuntimeForTesting:
| (() => Promise<DiscordProviderSessionRuntimeModule>)
@@ -437,9 +435,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
discordConfig: discordCfg,
runtime,
createClient: createClientForTesting ?? ((...args) => new Client(...args)),
createGatewayPlugin: createDiscordGatewayPluginForTesting ?? createDiscordGatewayPlugin,
createGatewaySupervisor:
createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor,
createGatewayPlugin: createDiscordGatewayPlugin,
createGatewaySupervisor: createDiscordGatewaySupervisor,
createAutoPresenceController: createDiscordAutoPresenceController,
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
});
@@ -643,12 +640,6 @@ export const testing = {
setRunDiscordGatewayLifecycle(mock?: typeof runDiscordGatewayLifecycle) {
runDiscordGatewayLifecycleForTesting = mock;
},
setCreateDiscordGatewayPlugin(mock?: typeof createDiscordGatewayPlugin) {
createDiscordGatewayPluginForTesting = mock;
},
setCreateDiscordGatewaySupervisor(mock?: typeof createDiscordGatewaySupervisor) {
createDiscordGatewaySupervisorForTesting = mock;
},
setLoadDiscordVoiceRuntime(mock?: () => Promise<DiscordVoiceRuntimeModule>) {
loadDiscordVoiceRuntimeForTesting = mock;
},

View File

@@ -141,6 +141,21 @@ describe("deliverDiscordReply", () => {
expect(sendOptions.rest).toBe(rest);
});
it("formats reasoning replies as visible Discord payloads before shared outbound", async () => {
await deliverDiscordReply({
replies: [{ text: "Because it helps", isReasoning: true }],
target: "channel:101",
token: "token",
accountId: "default",
runtime,
cfg,
textLimit: 2000,
kind: "block",
});
expect(firstDeliverParams().payloads).toEqual([{ text: "Thinking\n\n_Because it helps_" }]);
});
it("fails when shared outbound accepts a final reply but delivers no Discord message", async () => {
sendDurableMessageBatchMock.mockResolvedValueOnce({ status: "sent", results: [] });

View File

@@ -1,5 +1,5 @@
// Discord plugin module implements reply delivery behavior.
import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
import { formatReasoningMessage, resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
import {
buildOutboundSessionContext,
sendDurableMessageBatch,
@@ -156,6 +156,19 @@ function resolveDiscordDeliveryOptions(params: {
};
}
function formatDiscordReasoningPayload(payload: ReplyPayload): ReplyPayload {
if (payload.isReasoning !== true) {
return payload;
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
const nextPayload: ReplyPayload = {
...payload,
text: formatReasoningMessage(text),
};
delete nextPayload.isReasoning;
return nextPayload;
}
export async function deliverDiscordReply(params: {
cfg: OpenClawConfig;
replies: ReplyPayload[];
@@ -178,7 +191,9 @@ export async function deliverDiscordReply(params: {
void params.runtime;
const delivery = resolveDiscordDeliveryOptions(params);
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, { kind: params.kind });
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, {
kind: params.kind,
}).map(formatDiscordReasoningPayload);
if (payloads.length === 0) {
return;
}

View File

@@ -27,11 +27,6 @@ export type PersistedThreadBindingRecord = ThreadBindingRecord & {
expiresAt?: number;
};
export type PersistedThreadBindingsPayload = {
version: 1;
bindings: Record<string, PersistedThreadBindingRecord>;
};
export type ThreadBindingManager = {
accountId: string;
getIdleTimeoutMs: () => number;

View File

@@ -1,5 +1,6 @@
// Exa provider module implements model/runtime integration.
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
@@ -28,6 +29,7 @@ const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type ExaConfig = {
apiKey?: string;
@@ -76,6 +78,10 @@ async function readExaSearchResults(response: Response): Promise<ExaSearchResult
}
}
async function readExaErrorDetail(response: Response): Promise<string> {
return await readResponseTextLimited(response, EXA_ERROR_BODY_LIMIT_BYTES);
}
function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
const trimmed = normalizeOptionalLowercaseString(value);
if (!trimmed) {
@@ -407,7 +413,7 @@ async function runExaSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
const detail = await readExaErrorDetail(res);
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
}
return readExaSearchResults(res);
@@ -607,6 +613,7 @@ export const testing = {
resolveExaSearchCount,
resolveExaSearchEndpoint,
resolveFreshnessStartDate,
readExaErrorDetail,
readExaSearchResults,
} as const;
export { testing as __testing };

View File

@@ -1,9 +1,31 @@
// Exa tests cover exa web search provider plugin behavior.
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { testing } from "../test-api.js";
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
import { createExaWebSearchProvider } from "./exa-web-search-provider.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("exa web search provider", () => {
it("exposes the expected metadata and selection wiring", () => {
const provider = createExaWebSearchProvider();
@@ -242,4 +264,20 @@ describe("exa web search provider", () => {
"Exa API returned malformed JSON",
);
});
it("bounds Exa API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const detail = await testing.readExaErrorDetail(tracked.response);
expect(detail).toContain("exa upstream unavailable");
expect(detail).not.toContain("tail");
expect(await testing.readExaErrorDetail(new Response("short"))).toBe("short");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,10 @@
// Lmstudio plugin module implements models.fetch behavior.
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import {
readProviderJsonArrayFieldResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -17,6 +20,7 @@ import {
import { buildLmstudioAuthHeaders } from "./runtime.js";
const log = createSubsystemLogger("extensions/lmstudio/models");
const LMSTUDIO_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type LmstudioLoadResponse = {
status?: string;
@@ -253,7 +257,7 @@ export async function ensureLmstudioModelLoaded(params: {
});
try {
if (!response.ok) {
const body = await response.text();
const body = await readResponseTextLimited(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES);
throw new Error(`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`);
}
let payload: LmstudioLoadResponse;

View File

@@ -44,6 +44,27 @@ describe("lmstudio-models", () => {
}
return JSON.parse(init.body) as unknown;
};
const 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,
};
};
const createModelLoadFetchMock = (params?: {
loadedContextLength?: number;
maxContextLength?: number;
@@ -486,6 +507,39 @@ describe("lmstudio-models", () => {
).rejects.toThrow("LM Studio model load returned malformed JSON");
});
it("bounds model load error bodies", async () => {
const body = `${"lmstudio load unavailable ".repeat(512)}tail`;
const tracked = cancelTrackedResponse(body, { status: 503 });
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return {
ok: true,
json: async () => ({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return tracked.response;
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
vi.stubGlobal("fetch", asFetch(fetchMock));
const error = await ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/LM Studio model load failed \(503\): lmstudio load unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reloads model to the clamped default target when already loaded below the default window", async () => {
const fetchMock = createModelLoadFetchMock({
loadedContextLength: 4096,

View File

@@ -3,6 +3,28 @@ import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loginMiniMaxPortalOAuth, normalizeOAuthExpires } from "./oauth.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,
};
}
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
@@ -30,6 +52,116 @@ describe("normalizeOAuthExpires", () => {
});
describe("loginMiniMaxPortalOAuth", () => {
it("bounds authorization error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(
`${"minimax authorization unavailable ".repeat(1024)}tail`,
{
status: 503,
headers: { "Content-Type": "text/plain" },
},
);
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => tracked.response),
);
const error = await loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/MiniMax OAuth authorization failed: minimax authorization unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds token error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"minimax token unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
let callCount = 0;
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
callCount += 1;
const body =
init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
if (callCount === 1) {
return new Response(
JSON.stringify({
user_code: "CODE",
verification_uri: "https://example.com/device",
expired_in: Date.now() + 10_000,
state: body.get("state"),
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return tracked.response;
});
vi.stubGlobal("fetch", fetchMock);
const error = await loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("minimax token unavailable");
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds HTTP 200 token bodies before app-level parsing", async () => {
const tracked = cancelTrackedResponse(`${'{"status":"error","detail":"'.repeat(512)}tail`, {
status: 200,
headers: { "Content-Type": "application/json" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
let callCount = 0;
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
callCount += 1;
const body =
init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
if (callCount === 1) {
return new Response(
JSON.stringify({
user_code: "CODE",
verification_uri: "https://example.com/device",
expired_in: Date.now() + 10_000,
state: body.get("state"),
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return tracked.response;
});
vi.stubGlobal("fetch", fetchMock);
const error = await loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("MiniMax OAuth failed to parse response.");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("uses MiniMax account OAuth endpoints directly for global and CN login", async () => {
for (const [region, expectedHosts] of [
[

View File

@@ -7,6 +7,7 @@ import {
resolvePositiveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -29,6 +30,7 @@ const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion";
const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code";
const MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD = 1_000_000_000;
const MINIMAX_ABSOLUTE_EXPIRY_MS_THRESHOLD = 1_000_000_000_000;
const MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
function getOAuthEndpoints(region: MiniMaxRegion) {
const config = MINIMAX_OAUTH_CONFIG[region];
@@ -115,7 +117,7 @@ async function requestOAuthCode(params: {
});
try {
if (!response.ok) {
const text = await response.text();
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`);
}
@@ -171,7 +173,7 @@ async function pollOAuthToken(params: {
}
async function parseMiniMaxOAuthTokenResponse(response: Response): Promise<TokenResult> {
const text = await response.text();
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
let payload:
| {
status?: string;

View File

@@ -38,6 +38,22 @@ describe("buildMistralRealtimeTranscriptionProvider", () => {
});
});
it("normalizes pasted API key artifacts for realtime auth headers", () => {
const provider = buildMistralRealtimeTranscriptionProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as OpenClawConfig,
rawConfig: {
providers: {
mistral: {
apiKey: " sk-\r\nmistral│ ",
},
},
},
});
expect(resolved?.apiKey).toBe("sk-mistral");
});
it("builds a Mistral realtime websocket URL", () => {
const url = testing.toMistralRealtimeWsUrl({
apiKey: "mistral-key",

View File

@@ -7,7 +7,10 @@ import {
type RealtimeTranscriptionSessionCreateRequest,
type RealtimeTranscriptionWebSocketTransport,
} from "openclaw/plugin-sdk/realtime-transcription";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import {
normalizeResolvedSecretInputString,
normalizeSecretInput,
} from "openclaw/plugin-sdk/secret-input";
import {
asOptionalRecord as readRecord,
normalizeOptionalString,
@@ -125,10 +128,7 @@ function normalizeProviderConfig(
): MistralRealtimeTranscriptionProviderConfig {
const raw = readNestedMistralConfig(config);
return {
apiKey: normalizeResolvedSecretInputString({
value: raw.apiKey,
path: "plugins.entries.voice-call.config.streaming.providers.mistral.apiKey",
}),
apiKey: normalizeMistralApiKey(raw.apiKey),
baseUrl: normalizeOptionalString(raw.baseUrl),
model: normalizeOptionalString(raw.model ?? raw.sttModel),
sampleRate: readFiniteNumber(raw.sampleRate ?? raw.sample_rate),
@@ -139,6 +139,14 @@ function normalizeProviderConfig(
};
}
function normalizeMistralApiKey(value: unknown): string | undefined {
const resolved = normalizeResolvedSecretInputString({
value,
path: "plugins.entries.voice-call.config.streaming.providers.mistral.apiKey",
});
return normalizeSecretInput(resolved) || undefined;
}
function readErrorDetail(event: MistralRealtimeTranscriptionEvent): string {
const message = event.error?.message;
if (typeof message === "string") {
@@ -241,10 +249,13 @@ export function buildMistralRealtimeTranscriptionProvider(): RealtimeTranscripti
autoSelectOrder: 45,
resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig),
isConfigured: ({ providerConfig }) =>
Boolean(normalizeProviderConfig(providerConfig).apiKey || process.env.MISTRAL_API_KEY),
Boolean(
normalizeProviderConfig(providerConfig).apiKey ||
normalizeMistralApiKey(process.env.MISTRAL_API_KEY),
),
createSession: (req) => {
const config = normalizeProviderConfig(req.providerConfig);
const apiKey = config.apiKey || process.env.MISTRAL_API_KEY;
const apiKey = config.apiKey || normalizeMistralApiKey(process.env.MISTRAL_API_KEY);
if (!apiKey) {
throw new Error("Mistral API key missing");
}

View File

@@ -65,7 +65,7 @@ export {
// `evaluatePredicate`, `getPathLayout`, `parseOrdinalSeg`,
// `parsePredicateSeg`, `parseUnionSeg`, `quoteSeg`, `unquoteSeg`,
// `repackPath`, `resolvePositionalSeg`, `splitRespectingBrackets`
// `resolvePositionalSeg`, `splitRespectingBrackets`
// were exported from earlier prototypes. They're substrate-internal
// helpers — used by `find.ts`, the per-kind resolvers, and the parser
// itself, but not part of the upstream-portable public surface.

View File

@@ -546,7 +546,7 @@ export interface PathSegmentLayout {
export function getPathLayout(path: OcPath): PathSegmentLayout {
// Quote-aware split — `.split('.')` would shred a quoted segment
// containing a literal `.` (e.g. `"a.b"`) and break repackPath.
// containing a literal `.` (e.g. `"a.b"`).
const sectionSubs = path.section === undefined ? [] : splitRespectingBrackets(path.section, ".");
const itemSubs = path.item === undefined ? [] : splitRespectingBrackets(path.item, ".");
const fieldSubs = path.field === undefined ? [] : splitRespectingBrackets(path.field, ".");
@@ -558,31 +558,6 @@ export function getPathLayout(path: OcPath): PathSegmentLayout {
};
}
/**
* Re-pack a concrete sub-segment list into an `OcPath` preserving the
* pattern's slot boundaries. Throws on length mismatch.
*/
export function repackPath(pattern: OcPath, subs: readonly string[]): OcPath {
const layout = getPathLayout(pattern);
if (subs.length !== layout.subs.length) {
fail(
`repack length mismatch: pattern has ${layout.subs.length} sub-segments, got ${subs.length}`,
formatOcPath(pattern),
"OC_PATH_REPACK_LENGTH",
);
}
const sectionSubs = subs.slice(0, layout.sectionLen);
const itemSubs = subs.slice(layout.sectionLen, layout.sectionLen + layout.itemLen);
const fieldSubs = subs.slice(layout.sectionLen + layout.itemLen);
return {
file: pattern.file,
...(sectionSubs.length > 0 ? { section: sectionSubs.join(".") } : {}),
...(itemSubs.length > 0 ? { item: itemSubs.join(".") } : {}),
...(fieldSubs.length > 0 ? { field: fieldSubs.join(".") } : {}),
...(pattern.session !== undefined ? { session: pattern.session } : {}),
};
}
function extractSession(queryPart: string, input: string): string | undefined {
if (queryPart.length === 0) {
return undefined;

View File

@@ -83,6 +83,28 @@ function firstGuardedFetchCall(): Record<string, unknown> {
return call as Record<string, unknown>;
}
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,
};
}
function expectEmbeddingFetch(
fetchMock: ReturnType<typeof mockEmbeddingFetch>,
url: string,
@@ -317,6 +339,39 @@ describe("ollama embedding provider", () => {
});
});
it("bounds embed error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"ollama embed unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => tracked.response),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
let error: unknown;
try {
await provider.embedQuery("hello");
} catch (err) {
error = err;
}
expect(String(error)).toContain("Ollama embed HTTP 503");
expect(String(error)).toContain("ollama embed unavailable");
expect(String(error)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reports malformed embed JSON with a provider-owned error", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -6,6 +6,7 @@ import {
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
hasConfiguredSecretInput,
@@ -57,6 +58,7 @@ export type OllamaEmbeddingClient = {
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
const OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const QUERY_INSTRUCTION_TEMPLATES = [
{
@@ -340,7 +342,11 @@ export async function createOllamaEmbeddingProvider(
},
onResponse: async (response) => {
if (!response.ok) {
throw new Error(`Ollama embed HTTP ${response.status}: ${await response.text()}`);
const detail = await readResponseTextLimited(
response,
OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
throw new Error(`Ollama embed HTTP ${response.status}: ${detail}`);
}
return await readOllamaEmbeddingJsonResponse(response);
},

View File

@@ -1534,6 +1534,28 @@ function getGuardedFetchCall(fetchMock: typeof fetchWithSsrFGuardMock): GuardedF
return (fetchMock.mock.calls.at(0)?.[0] as GuardedFetchCall | undefined) ?? { url: "" };
}
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,
};
}
async function createOllamaTestStream(params: {
baseUrl: string;
defaultHeaders?: Record<string, string>;
@@ -2684,12 +2706,14 @@ describe("createOllamaStreamFn", () => {
);
});
it("surfaces non-2xx HTTP response as status-prefixed error", async () => {
it("surfaces bounded non-2xx HTTP response text as a status-prefixed error", async () => {
const tracked = cancelTrackedResponse(`${"Service Unavailable ".repeat(1024)}tail`, {
status: 503,
statusText: "Service Unavailable",
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
}),
response: tracked.response,
release: vi.fn(async () => undefined),
});
try {
@@ -2705,6 +2729,10 @@ describe("createOllamaStreamFn", () => {
// The error message must start with the HTTP status code so that
// extractLeadingHttpStatus can parse it for failover/retry logic.
expect(errorEvent.error.errorMessage).toMatch(/^503\b/);
expect(errorEvent.error.errorMessage).toContain("Service Unavailable");
expect(errorEvent.error.errorMessage).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
} finally {
fetchWithSsrFGuardMock.mockReset();
}

View File

@@ -18,6 +18,7 @@ import type {
ProviderWrapStreamFnContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CONTEXT_TOKENS,
normalizeProviderId,
@@ -54,6 +55,7 @@ export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL;
const OLLAMA_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12;
const OLLAMA_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64;
const OLLAMA_STREAM_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const GARBLED_VISIBLE_TEXT_MODEL_RE = /\b(?:glm|kimi)\b/i;
const GARBLED_VISIBLE_TEXT_MIN_CHARS = 80;
const GARBLED_VISIBLE_TEXT_SYMBOL_RE = /[$#%&="'_~`^|\\/*+\-[\]{}()<>:;,.!?]/gu;
@@ -1211,7 +1213,10 @@ function createRawOllamaStreamFn(
try {
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
const errorText = await readResponseTextLimited(
response,
OLLAMA_STREAM_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
throw new Error(`${response.status} ${errorText}`);
}
if (!response.body) {

View File

@@ -15,6 +15,28 @@ function jsonlBytes(value: string): number {
return jsonlEncoder.encode(value).byteLength;
}
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,
};
}
function fetchInputUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
@@ -243,4 +265,56 @@ describe("OpenAI embedding batch output", () => {
["3", [4]],
]);
});
it("bounds batch resource error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"batch status unavailable ".repeat(1024)}tail`, {
status: 400,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
let batchStatusReturned = false;
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = fetchInputUrl(input);
if (url.endsWith("/files") && init?.method === "POST") {
return jsonResponse({ id: "file-0" });
}
if (url.endsWith("/batches") && init?.method === "POST") {
return jsonResponse({ id: "batch-0", status: "in_progress" });
}
if (url.endsWith("/batches/batch-0") && !batchStatusReturned) {
batchStatusReturned = true;
return tracked.response;
}
return new Response("unexpected request", { status: 500 });
});
await expect(
runOpenAiEmbeddingBatches({
openAi: {
baseUrl: "https://openai-compatible.example/v1",
headers: { Authorization: "Bearer test" },
model: "text-embedding-3-small",
fetchImpl,
},
agentId: "main",
requests: [
{
custom_id: "0",
method: "POST",
url: "/v1/embeddings",
body: {
model: "text-embedding-3-small",
input: "payload",
},
},
],
wait: true,
concurrency: 1,
pollIntervalMs: 1000,
timeoutMs: 60_000,
}),
).rejects.toThrow(/openai batch status failed: 400 batch status unavailable/);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -18,6 +18,7 @@ import {
uploadBatchJsonlFile,
withRemoteHttpResponse,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { OpenAiEmbeddingClient } from "./embedding-provider.js";
@@ -55,6 +56,7 @@ const OPENAI_BATCH_MAX_REQUESTS = 50000;
// splitter avoids boundary-size uploads while preserving source-wide batching.
const OPENAI_BATCH_MAX_JSONL_BYTES = 190 * 1024 * 1024;
const OPENAI_BATCH_MAX_POLL_BACKOFF_MS = 5 * 60_000;
const OPENAI_BATCH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
async function submitOpenAiBatch(params: {
openAi: OpenAiEmbeddingClient;
@@ -126,7 +128,7 @@ async function fetchOpenAiBatchResource<T>(params: {
},
onResponse: async (res) => {
if (!res.ok) {
const text = await res.text();
const text = await readResponseTextLimited(res, OPENAI_BATCH_ERROR_BODY_LIMIT_BYTES);
throw new Error(`${params.errorPrefix} failed: ${res.status} ${text}`);
}
return await params.parse(res);

View File

@@ -18,6 +18,28 @@ function createJsonResponse(body: unknown, init?: { status?: number }) {
});
}
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,
};
}
function fetchCall(fetchMock: ReturnType<typeof vi.fn<typeof fetch>>, index: number) {
const call = fetchMock.mock.calls[index];
if (!call) {
@@ -172,6 +194,44 @@ describe("loginOpenAICodexDeviceCode", () => {
expect(credentials.expires).toBe(expectedExpiry);
});
it("accepts token exchange JSON above the diagnostic preview limit", async () => {
const accessToken = createJwt({
exp: Math.floor(Date.now() / 1000) + 600,
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_123",
},
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
createJsonResponse({
device_auth_id: "device-auth-123",
user_code: "CODE-12345",
interval: "0",
}),
)
.mockResolvedValueOnce(
createJsonResponse({
authorization_code: "authorization-code-123",
code_verifier: "code-verifier-123",
}),
)
.mockResolvedValueOnce(
createJsonResponse({
access_token: accessToken,
refresh_token: "refresh-token-123",
id_token: "x".repeat(10_000),
}),
);
const credentials = await loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification: async () => {},
});
expect(credentials.refresh).toBe("refresh-token-123");
});
it("falls back when device-code intervals and token lifetimes overflow safe milliseconds", async () => {
vi.useFakeTimers();
try {
@@ -241,6 +301,28 @@ describe("loginOpenAICodexDeviceCode", () => {
).rejects.toThrow("OpenAI device code request failed: HTTP 503 down now");
});
it("bounds user-code error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"device code unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn().mockResolvedValueOnce(tracked.response);
const error = await loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification: async () => {},
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/OpenAI device code request failed: HTTP 503 device code unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("surfaces device authorization failures with sanitized payload details", async () => {
const fetchMock = vi
.fn()

View File

@@ -3,6 +3,7 @@ import {
positiveSecondsToSafeMilliseconds,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { resolveCodexAccessTokenExpiry } from "./openai-chatgpt-auth-identity.js";
import { trimNonEmptyString } from "./openai-chatgpt-shared.js";
@@ -12,6 +13,8 @@ const OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS = 15 * 60_000;
const OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000;
const OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS = 1_000;
const OPENAI_CODEX_DEVICE_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`;
const OPENAI_CODEX_DEVICE_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const OPENAI_CODEX_DEVICE_JSON_BODY_LIMIT_BYTES = 256 * 1024;
function resolveOpenAICodexDeviceCodeHeaders(contentType: string): Record<string, string> {
const version = process.env.OPENCLAW_VERSION?.trim();
@@ -120,6 +123,15 @@ function formatDeviceCodeError(params: {
: `${params.prefix}: HTTP ${params.status}`;
}
async function readOpenAICodexDeviceBody(response: Response): Promise<string> {
return await readResponseTextLimited(
response,
response.ok
? OPENAI_CODEX_DEVICE_JSON_BODY_LIMIT_BYTES
: OPENAI_CODEX_DEVICE_ERROR_BODY_LIMIT_BYTES,
);
}
async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise<RequestedDeviceCode> {
const response = await fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/usercode`, {
method: "POST",
@@ -129,7 +141,7 @@ async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise<Requ
}),
});
const bodyText = await response.text();
const bodyText = await readOpenAICodexDeviceBody(response);
if (!response.ok) {
if (response.status === 404) {
throw new Error(
@@ -180,7 +192,7 @@ async function pollOpenAICodexDeviceCode(params: {
}),
});
const bodyText = await response.text();
const bodyText = await readOpenAICodexDeviceBody(response);
if (response.ok) {
const body = parseJsonObject(bodyText) as DeviceCodeTokenPayload | null;
const authorizationCode = trimNonEmptyString(body?.authorization_code);
@@ -230,7 +242,7 @@ async function exchangeOpenAICodexDeviceCode(params: {
}),
});
const bodyText = await response.text();
const bodyText = await readOpenAICodexDeviceBody(response);
if (!response.ok) {
throw new Error(
formatDeviceCodeError({

View File

@@ -24,6 +24,42 @@ function jsonResponse(value: unknown, init?: ResponseInit): Response {
});
}
function boundedTextErrorResponse(body: string, status = 502): {
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 };
}
function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
@@ -180,6 +216,33 @@ describe("OpenRouter OAuth", () => {
).rejects.toThrow("OpenRouter OAuth key exchange failed (400): Invalid code");
});
it("bounds OpenRouter OAuth exchange error bodies without requiring response.text()", async () => {
const errorResponse = boundedTextErrorResponse(
`${"openrouter denied ".repeat(1024)}tail-marker`,
502,
);
const fetchImpl = vi.fn<typeof fetch>(async () => errorResponse.response);
let error: unknown;
try {
await exchangeOpenRouterOAuthCode({
code: "bad-code",
codeVerifier: "bad-verifier",
fetchImpl,
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain("OpenRouter OAuth key exchange failed (502): openrouter denied");
expect(message).not.toContain("tail-marker");
expect(errorResponse.text).not.toHaveBeenCalled();
expect(errorResponse.cancel).toHaveBeenCalledTimes(1);
expect(errorResponse.releaseLock).toHaveBeenCalledTimes(1);
});
it("stores a browser OAuth result as the default OpenRouter API-key profile", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
jsonResponse({ key: "sk-or-v1-test", user_id: "user-1" }),

View File

@@ -8,6 +8,7 @@ import {
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import { generateOAuthState } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
const PROVIDER_ID = "openrouter";
@@ -23,6 +24,7 @@ export const OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD = "S256";
const OPENROUTER_OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
const OPENROUTER_OAUTH_FETCH_TIMEOUT_MS = 30 * 1000;
const OPENROUTER_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const OPENROUTER_OAUTH_PROFILE_ID = "openrouter:default";
type OpenRouterOAuthCallbackResult = {
@@ -72,7 +74,11 @@ function extractOpenRouterError(value: unknown): string | undefined {
}
async function readResponseBody(response: Response): Promise<unknown> {
const text = await response.text();
const text = response.ok
? await response.text()
: await readResponseTextLimited(response, OPENROUTER_OAUTH_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
if (!text.trim()) {
return null;
}

View File

@@ -44,6 +44,28 @@ function jsonResponse(body: unknown, headers?: Record<string, string>): Response
});
}
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,
};
}
function readBody(call: EndpointCall): Record<string, unknown> {
if (typeof call.init.body !== "string") {
throw new Error("Expected a JSON string body.");
@@ -270,4 +292,23 @@ describe("runParallelMcpSearch", () => {
/initialize failed \(500\)/,
);
});
it("bounds initialize error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"parallel mcp unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(tracked.response);
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
(cause: unknown) => cause,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/initialize failed \(503\): parallel mcp unavailable/);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search";
// Free hosted Search MCP. This keyless transport is used only after the user
@@ -11,6 +12,7 @@ export const PARALLEL_MCP_SEARCH_URL = "https://search.parallel.ai/mcp";
// the server negotiates back on every follow-up request.
const MCP_PROTOCOL_VERSION = "2025-06-18";
const MCP_TIMEOUT_SECONDS = 30;
const PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const require = createRequire(import.meta.url);
const PLUGIN_VERSION = readPluginPackageVersion({ require });
@@ -215,7 +217,9 @@ async function postMcp(params: {
ok: response.ok,
status: response.status,
statusText: response.statusText,
text: await response.text(),
text: response.ok
? await response.text()
: await readResponseTextLimited(response, PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES),
sessionIdHeader: response.headers.get("mcp-session-id"),
}),
);

View File

@@ -1,5 +1,6 @@
import { createRequire } from "node:module";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_SEARCH_COUNT,
mergeScopedSearchConfig,
@@ -34,6 +35,7 @@ import {
const PARALLEL_BASE_URL = "https://api.parallel.ai";
const PARALLEL_SEARCH_PATHNAME = "/v1/search";
const PARALLEL_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const require = createRequire(import.meta.url);
const PLUGIN_VERSION = readPluginPackageVersion({ require });
@@ -144,7 +146,9 @@ async function runParallelSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await res.text().catch(() => "");
const detail = await readResponseTextLimited(res, PARALLEL_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`);
}
try {
@@ -277,6 +281,7 @@ export const testing = {
resolveParallelConfig,
resolveParallelSearchCount,
resolveParallelSearchEndpoint,
PARALLEL_ERROR_BODY_LIMIT_BYTES,
USER_AGENT,
} as const;

View File

@@ -37,6 +37,28 @@ function readMockedBody(call: EndpointCall | undefined): unknown {
return JSON.parse(call.init.body);
}
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,
};
}
import { testing } from "../test-api.js";
import { createParallelWebSearchProvider as createContractParallelWebSearchProvider } from "../web-search-contract-api.js";
import { createParallelWebSearchProvider } from "./parallel-web-search-provider.js";
@@ -529,6 +551,38 @@ describe("parallel web search provider", () => {
expect(body.advanced_settings?.max_results).toBe(5);
});
it("bounds Parallel API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"parallel upstream unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(tracked.response);
const provider = createParallelWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: { parallel: { apiKey: "par-secret" } },
});
if (!tool) {
throw new Error("Expected tool definition");
}
const error = await tool
.execute({
objective: `parallel-error-body-${Date.now()}`,
search_queries: ["openclaw"],
})
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/Parallel API error \(503\): parallel upstream unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("does not surface a Parallel-generated sessionId on a cache hit", async () => {
// Unique objective so this test does not collide with the SDK's
// module-level web-search cache across other cases.

View File

@@ -0,0 +1,26 @@
// Policy doctor health-check catalog.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { createPolicyChannelProviderChecks, createPolicyIngressChecks } from "./scopes/channels.js";
import { createPolicyCoreChecks } from "./scopes/core.js";
import { createPolicyDataAuthChecks } from "./scopes/data-auth.js";
import { createPolicyExecApprovalChecks } from "./scopes/exec-approvals.js";
import { createPolicyGatewayChecks } from "./scopes/gateway.js";
import { createPolicyModelNetworkChecks } from "./scopes/model-network.js";
import { createPolicySandboxChecks } from "./scopes/sandbox.js";
import { createPolicyAgentToolChecks, createPolicyToolMetadataChecks } from "./scopes/tools.js";
import type { PolicyDoctorCheckDeps } from "./types.js";
export function createPolicyDoctorChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
return [
...createPolicyCoreChecks(deps),
...createPolicyChannelProviderChecks(deps),
...createPolicyModelNetworkChecks(deps),
...createPolicyIngressChecks(deps),
...createPolicyGatewayChecks(deps),
...createPolicyAgentToolChecks(deps),
...createPolicySandboxChecks(deps),
...createPolicyDataAuthChecks(deps),
...createPolicyExecApprovalChecks(deps),
...createPolicyToolMetadataChecks(deps),
];
}

View File

@@ -0,0 +1,161 @@
// Policy doctor metadata tests cover rule metadata.
import { describe, expect, it } from "vitest";
import { POLICY_RULE_METADATA, type PolicyRuleMetadata } from "./metadata.js";
describe("policy doctor metadata", () => {
it("describes strictness for agent-scoped policy fields", () => {
expect(
(POLICY_RULE_METADATA as readonly PolicyRuleMetadata[])
.filter(
(rule) =>
rule.scopeSelectors?.includes("agentIds") ||
rule.scopeSelectors?.includes("channelIds"),
)
.map((rule) => {
const description: {
path: string;
strictness: PolicyRuleMetadata["strictness"];
selectors: PolicyRuleMetadata["scopeSelectors"];
emptyList?: PolicyRuleMetadata["emptyList"];
} = {
path: rule.policyPath.join("."),
strictness: rule.strictness,
selectors: rule.scopeSelectors,
};
if (rule.emptyList !== undefined) {
description.emptyList = rule.emptyList;
}
return description;
}),
).toEqual([
{
path: "agents.workspace.allowedAccess",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "agents.workspace.denyTools",
strictness: "denylist-superset",
selectors: ["agentIds"],
},
{
path: "tools.profiles.allow",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "tools.fs.requireWorkspaceOnly",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "tools.exec.allowSecurity",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "tools.exec.requireAsk",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "tools.exec.allowHosts",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{ path: "tools.elevated.allow", strictness: "requires-false", selectors: ["agentIds"] },
{
path: "tools.alsoAllow.expected",
strictness: "exact-list",
emptyList: "meaningful",
selectors: ["agentIds"],
},
{ path: "tools.denyTools", strictness: "denylist-superset", selectors: ["agentIds"] },
{
path: "sandbox.requireMode",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "sandbox.allowBackends",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyHostNetwork",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyContainerNamespaceJoin",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.requireReadOnlyMounts",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyContainerRuntimeSocketMounts",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyUnconfinedProfiles",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.browser.requireCdpSourceRange",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "ingress.channels.allowDmPolicies",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["channelIds"],
},
{
path: "ingress.channels.denyOpenGroups",
strictness: "requires-true",
selectors: ["channelIds"],
},
{
path: "ingress.channels.requireMentionInGroups",
strictness: "requires-true",
selectors: ["channelIds"],
},
{
path: "dataHandling.memory.denySessionTranscriptIndexing",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowSecurity",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowAutoAllowSkills",
strictness: "requires-false",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowlist.expected",
strictness: "exact-list",
emptyList: "meaningful",
selectors: ["agentIds"],
},
]);
});
});

View File

@@ -0,0 +1,543 @@
// Policy doctor check IDs and rule metadata.
export const CHECK_IDS = {
policyAttestationMismatch: "policy/attestation-hash-mismatch",
policyDeniedChannelProvider: "policy/channels-denied-provider",
policyHashMismatch: "policy/policy-hash-mismatch",
policyInvalidFile: "policy/policy-jsonc-invalid",
policyMissingFile: "policy/policy-jsonc-missing",
policyDeniedMcpServer: "policy/mcp-denied-server",
policyUnapprovedMcpServer: "policy/mcp-unapproved-server",
policyDeniedModelProvider: "policy/models-denied-provider",
policyUnapprovedModelProvider: "policy/models-unapproved-provider",
policyPrivateNetworkAccess: "policy/network-private-access-enabled",
policyIngressDmPolicyUnapproved: "policy/ingress-dm-policy-unapproved",
policyIngressDmScopeUnapproved: "policy/ingress-dm-scope-unapproved",
policyIngressOpenGroupsDenied: "policy/ingress-open-groups-denied",
policyIngressGroupMentionRequired: "policy/ingress-group-mention-required",
policyGatewayNonLoopbackBind: "policy/gateway-non-loopback-bind",
policyGatewayAuthDisabled: "policy/gateway-auth-disabled",
policyGatewayRateLimitMissing: "policy/gateway-rate-limit-missing",
policyGatewayControlUiInsecure: "policy/gateway-control-ui-insecure",
policyGatewayTailscaleFunnel: "policy/gateway-tailscale-funnel",
policyGatewayRemoteEnabled: "policy/gateway-remote-enabled",
policyGatewayHttpEndpointEnabled: "policy/gateway-http-endpoint-enabled",
policyGatewayHttpUrlFetchUnrestricted: "policy/gateway-http-url-fetch-unrestricted",
policyAgentsWorkspaceAccessDenied: "policy/agents-workspace-access-denied",
policyAgentsToolNotDenied: "policy/agents-tool-not-denied",
policyToolsElevatedEnabled: "policy/tools-elevated-enabled",
policyToolsAlsoAllowMissing: "policy/tools-also-allow-missing",
policyToolsAlsoAllowUnexpected: "policy/tools-also-allow-unexpected",
policyToolsExecAskUnapproved: "policy/tools-exec-ask-unapproved",
policyToolsExecHostUnapproved: "policy/tools-exec-host-unapproved",
policyToolsExecSecurityUnapproved: "policy/tools-exec-security-unapproved",
policyToolsFsWorkspaceOnlyRequired: "policy/tools-fs-workspace-only-required",
policyToolsProfileUnapproved: "policy/tools-profile-unapproved",
policyToolsRequiredDenyMissing: "policy/tools-required-deny-missing",
policySandboxModeUnapproved: "policy/sandbox-mode-unapproved",
policySandboxBackendUnapproved: "policy/sandbox-backend-unapproved",
policySandboxContainerPostureUnobservable: "policy/sandbox-container-posture-unobservable",
policySandboxContainerHostNetworkDenied: "policy/sandbox-container-host-network-denied",
policySandboxContainerNamespaceJoinDenied: "policy/sandbox-container-namespace-join-denied",
policySandboxContainerMountModeRequired: "policy/sandbox-container-mount-mode-required",
policySandboxContainerRuntimeSocketMount: "policy/sandbox-container-runtime-socket-mount",
policySandboxContainerUnconfinedProfile: "policy/sandbox-container-unconfined-profile",
policySandboxBrowserCdpSourceRangeMissing: "policy/sandbox-browser-cdp-source-range-missing",
policyDataHandlingRedactionDisabled: "policy/data-handling-redaction-disabled",
policyDataHandlingTelemetryContentCapture: "policy/data-handling-telemetry-content-capture",
policyDataHandlingSessionRetentionNotEnforced:
"policy/data-handling-session-retention-not-enforced",
policyDataHandlingSessionTranscriptMemory:
"policy/data-handling-session-transcript-memory-enabled",
policySecretsUnmanagedProvider: "policy/secrets-unmanaged-provider",
policySecretsDeniedProviderSource: "policy/secrets-denied-provider-source",
policySecretsInsecureProvider: "policy/secrets-insecure-provider",
policyAuthProfileInvalidMetadata: "policy/auth-profile-invalid-metadata",
policyAuthProfileUnapprovedMode: "policy/auth-profile-unapproved-mode",
policyExecApprovalsMissing: "policy/exec-approvals-missing",
policyExecApprovalsInvalid: "policy/exec-approvals-invalid",
policyExecApprovalsDefaultSecurityUnapproved: "policy/exec-approvals-default-security-unapproved",
policyExecApprovalsAgentSecurityUnapproved: "policy/exec-approvals-agent-security-unapproved",
policyExecApprovalsAutoAllowSkillsEnabled: "policy/exec-approvals-auto-allow-skills-enabled",
policyExecApprovalsAllowlistMissing: "policy/exec-approvals-allowlist-missing",
policyExecApprovalsAllowlistUnexpected: "policy/exec-approvals-allowlist-unexpected",
policyMissingToolOwner: "policy/tools-missing-owner",
policyMissingToolRisk: "policy/tools-missing-risk-level",
policyMissingToolSensitivity: "policy/tools-missing-sensitivity-token",
policyUnknownToolRisk: "policy/tools-unknown-risk-level",
policyUnknownToolSensitivity: "policy/tools-unknown-sensitivity-token",
} as const;
export const POLICY_CHECK_IDS = [
CHECK_IDS.policyMissingFile,
CHECK_IDS.policyInvalidFile,
CHECK_IDS.policyHashMismatch,
CHECK_IDS.policyAttestationMismatch,
CHECK_IDS.policyDeniedChannelProvider,
CHECK_IDS.policyDeniedMcpServer,
CHECK_IDS.policyUnapprovedMcpServer,
CHECK_IDS.policyDeniedModelProvider,
CHECK_IDS.policyUnapprovedModelProvider,
CHECK_IDS.policyPrivateNetworkAccess,
CHECK_IDS.policyIngressDmPolicyUnapproved,
CHECK_IDS.policyIngressDmScopeUnapproved,
CHECK_IDS.policyIngressOpenGroupsDenied,
CHECK_IDS.policyIngressGroupMentionRequired,
CHECK_IDS.policyGatewayNonLoopbackBind,
CHECK_IDS.policyGatewayAuthDisabled,
CHECK_IDS.policyGatewayRateLimitMissing,
CHECK_IDS.policyGatewayControlUiInsecure,
CHECK_IDS.policyGatewayTailscaleFunnel,
CHECK_IDS.policyGatewayRemoteEnabled,
CHECK_IDS.policyGatewayHttpEndpointEnabled,
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
CHECK_IDS.policyAgentsToolNotDenied,
CHECK_IDS.policyToolsProfileUnapproved,
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
CHECK_IDS.policyToolsExecSecurityUnapproved,
CHECK_IDS.policyToolsExecAskUnapproved,
CHECK_IDS.policyToolsExecHostUnapproved,
CHECK_IDS.policyToolsElevatedEnabled,
CHECK_IDS.policyToolsAlsoAllowMissing,
CHECK_IDS.policyToolsAlsoAllowUnexpected,
CHECK_IDS.policyToolsRequiredDenyMissing,
CHECK_IDS.policySandboxModeUnapproved,
CHECK_IDS.policySandboxBackendUnapproved,
CHECK_IDS.policySandboxContainerPostureUnobservable,
CHECK_IDS.policySandboxContainerHostNetworkDenied,
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
CHECK_IDS.policySandboxContainerMountModeRequired,
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
CHECK_IDS.policySandboxContainerUnconfinedProfile,
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
CHECK_IDS.policyDataHandlingRedactionDisabled,
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
CHECK_IDS.policySecretsUnmanagedProvider,
CHECK_IDS.policySecretsDeniedProviderSource,
CHECK_IDS.policySecretsInsecureProvider,
CHECK_IDS.policyAuthProfileInvalidMetadata,
CHECK_IDS.policyAuthProfileUnapprovedMode,
CHECK_IDS.policyExecApprovalsMissing,
CHECK_IDS.policyExecApprovalsInvalid,
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
CHECK_IDS.policyExecApprovalsAllowlistMissing,
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
CHECK_IDS.policyMissingToolRisk,
CHECK_IDS.policyUnknownToolRisk,
CHECK_IDS.policyMissingToolSensitivity,
CHECK_IDS.policyMissingToolOwner,
CHECK_IDS.policyUnknownToolSensitivity,
] as const;
export type PolicyStrictnessKind =
| "allowlist-subset"
| "denylist-superset"
| "ordered-string"
| "requires-true"
| "requires-false"
| "exact-list";
export type PolicyEmptyListSemantics = "disabled" | "meaningful";
export type PolicyScopeSelectorKind = "agentIds" | "channelIds";
export type PolicyRuleMetadata = {
readonly policyPath: readonly string[];
readonly strictness: PolicyStrictnessKind;
readonly valueType: "boolean" | "channel-provider-deny-rules" | "string" | "string-list";
readonly checkIds: readonly (typeof POLICY_CHECK_IDS)[number][];
readonly emptyList?: PolicyEmptyListSemantics;
readonly allowedValues?: readonly string[];
readonly caseSensitive?: boolean;
readonly normalizeValues?: "model-provider";
readonly orderedValues?: readonly string[];
readonly scopeSelectors?: readonly PolicyScopeSelectorKind[];
};
export const SANDBOX_CONTAINER_POLICY_RULES = [
{
key: "denyHostNetwork",
label: "host network posture",
checkIds: [CHECK_IDS.policySandboxContainerHostNetworkDenied],
},
{
key: "denyContainerNamespaceJoin",
label: "container namespace posture",
checkIds: [CHECK_IDS.policySandboxContainerNamespaceJoinDenied],
},
{
key: "requireReadOnlyMounts",
label: "container mount mode posture",
checkIds: [CHECK_IDS.policySandboxContainerMountModeRequired],
},
{
key: "denyContainerRuntimeSocketMounts",
label: "container runtime socket mount posture",
checkIds: [CHECK_IDS.policySandboxContainerRuntimeSocketMount],
},
{
key: "denyUnconfinedProfiles",
label: "container security profile posture",
checkIds: [CHECK_IDS.policySandboxContainerUnconfinedProfile],
},
] as const;
const SANDBOX_POLICY_RULE_METADATA = [
{
policyPath: ["sandbox", "requireMode"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policySandboxModeUnapproved],
emptyList: "disabled",
allowedValues: ["off", "non-main", "all"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["sandbox", "allowBackends"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policySandboxBackendUnapproved],
emptyList: "disabled",
scopeSelectors: ["agentIds"],
},
...SANDBOX_CONTAINER_POLICY_RULES.map((rule) => ({
policyPath: ["sandbox", "containers", rule.key] as const,
strictness: "requires-true" as const,
valueType: "boolean" as const,
checkIds: rule.checkIds,
scopeSelectors: ["agentIds"] as const,
})),
{
policyPath: ["sandbox", "browser", "requireCdpSourceRange"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing],
scopeSelectors: ["agentIds"],
},
] as const satisfies readonly PolicyRuleMetadata[];
export const POLICY_RULE_METADATA = [
{
policyPath: ["channels", "denyRules"],
strictness: "denylist-superset",
valueType: "channel-provider-deny-rules",
checkIds: [CHECK_IDS.policyDeniedChannelProvider],
emptyList: "meaningful",
caseSensitive: true,
},
{
policyPath: ["mcp", "servers", "allow"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyUnapprovedMcpServer],
emptyList: "disabled",
caseSensitive: true,
},
{
policyPath: ["mcp", "servers", "deny"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyDeniedMcpServer],
caseSensitive: true,
},
{
policyPath: ["models", "providers", "allow"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyUnapprovedModelProvider],
emptyList: "disabled",
normalizeValues: "model-provider",
},
{
policyPath: ["models", "providers", "deny"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyDeniedModelProvider],
normalizeValues: "model-provider",
},
{
policyPath: ["network", "privateNetwork", "allow"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyPrivateNetworkAccess],
},
{
policyPath: ["ingress", "session", "requireDmScope"],
strictness: "ordered-string",
valueType: "string",
orderedValues: ["main", "per-peer", "per-channel-peer", "per-account-channel-peer"],
checkIds: [CHECK_IDS.policyIngressDmScopeUnapproved],
},
{
policyPath: ["gateway", "exposure", "allowNonLoopbackBind"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayNonLoopbackBind],
},
{
policyPath: ["gateway", "exposure", "allowTailscaleFunnel"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayTailscaleFunnel],
},
{
policyPath: ["gateway", "auth", "requireAuth"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayAuthDisabled],
},
{
policyPath: ["gateway", "auth", "requireExplicitRateLimit"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayRateLimitMissing],
},
{
policyPath: ["gateway", "controlUi", "allowInsecure"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayControlUiInsecure],
},
{
policyPath: ["gateway", "remote", "allow"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayRemoteEnabled],
},
{
policyPath: ["gateway", "http", "denyEndpoints"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyGatewayHttpEndpointEnabled],
allowedValues: ["chatCompletions", "responses"],
caseSensitive: true,
},
{
policyPath: ["gateway", "http", "requireUrlAllowlists"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted],
},
{
policyPath: ["agents", "workspace", "allowedAccess"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAgentsWorkspaceAccessDenied],
emptyList: "disabled",
allowedValues: ["none", "ro", "rw"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["agents", "workspace", "denyTools"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAgentsToolNotDenied],
allowedValues: ["exec", "process", "write", "edit", "apply_patch"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "profiles", "allow"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsProfileUnapproved],
emptyList: "disabled",
allowedValues: ["minimal", "coding", "messaging", "full"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "fs", "requireWorkspaceOnly"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyToolsFsWorkspaceOnlyRequired],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "exec", "allowSecurity"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsExecSecurityUnapproved],
emptyList: "disabled",
allowedValues: ["deny", "allowlist", "full"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "exec", "requireAsk"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsExecAskUnapproved],
emptyList: "disabled",
allowedValues: ["off", "on-miss", "always"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "exec", "allowHosts"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsExecHostUnapproved],
emptyList: "disabled",
allowedValues: ["auto", "sandbox", "gateway", "node"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "elevated", "allow"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyToolsElevatedEnabled],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "alsoAllow", "expected"],
strictness: "exact-list",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsAlsoAllowMissing, CHECK_IDS.policyToolsAlsoAllowUnexpected],
emptyList: "meaningful",
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "denyTools"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsRequiredDenyMissing],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "requireMetadata"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [
CHECK_IDS.policyMissingToolRisk,
CHECK_IDS.policyMissingToolSensitivity,
CHECK_IDS.policyMissingToolOwner,
],
allowedValues: ["risk", "sensitivity", "owner"],
},
...SANDBOX_POLICY_RULE_METADATA,
{
policyPath: ["ingress", "channels", "allowDmPolicies"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyIngressDmPolicyUnapproved],
emptyList: "disabled",
allowedValues: ["pairing", "allowlist", "open", "disabled"],
scopeSelectors: ["channelIds"],
},
{
policyPath: ["ingress", "channels", "denyOpenGroups"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyIngressOpenGroupsDenied],
scopeSelectors: ["channelIds"],
},
{
policyPath: ["ingress", "channels", "requireMentionInGroups"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyIngressGroupMentionRequired],
scopeSelectors: ["channelIds"],
},
{
policyPath: ["dataHandling", "sensitiveLogging", "requireRedaction"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyDataHandlingRedactionDisabled],
},
{
policyPath: ["dataHandling", "telemetry", "denyContentCapture"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyDataHandlingTelemetryContentCapture],
},
{
policyPath: ["dataHandling", "retention", "requireSessionMaintenance"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced],
},
{
policyPath: ["dataHandling", "memory", "denySessionTranscriptIndexing"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyDataHandlingSessionTranscriptMemory],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["secrets", "requireManagedProviders"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policySecretsUnmanagedProvider],
},
{
policyPath: ["secrets", "denySources"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policySecretsDeniedProviderSource],
},
{
policyPath: ["secrets", "allowInsecureProviders"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policySecretsInsecureProvider],
},
{
policyPath: ["execApprovals", "requireFile"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyExecApprovalsMissing],
},
{
policyPath: ["execApprovals", "defaults", "allowSecurity"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved],
emptyList: "disabled",
allowedValues: ["deny", "allowlist", "full"],
},
{
policyPath: ["execApprovals", "agents", "allowSecurity"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved],
emptyList: "disabled",
allowedValues: ["deny", "allowlist", "full"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["execApprovals", "agents", "allowAutoAllowSkills"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["execApprovals", "agents", "allowlist", "expected"],
strictness: "exact-list",
valueType: "string-list",
checkIds: [
CHECK_IDS.policyExecApprovalsAllowlistMissing,
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
],
emptyList: "meaningful",
caseSensitive: true,
scopeSelectors: ["agentIds"],
},
{
policyPath: ["auth", "profiles", "requireMetadata"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAuthProfileInvalidMetadata],
allowedValues: ["provider", "mode"],
},
{
policyPath: ["auth", "profiles", "allowModes"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAuthProfileUnapprovedMode],
emptyList: "disabled",
allowedValues: ["api_key", "aws-sdk", "oauth", "token"],
},
] as const satisfies readonly PolicyRuleMetadata[];

View File

@@ -19,13 +19,7 @@ import {
scanPolicyIngress,
scanPolicyMcpServers,
} from "../policy-state.js";
import {
POLICY_RULE_METADATA,
isPolicyValueAtLeastAsStrict,
registerPolicyDoctorChecks,
resetPolicyDoctorChecksForTest,
type PolicyRuleMetadata,
} from "./register.js";
import { registerPolicyDoctorChecks, resetPolicyDoctorChecksForTest } from "./register.js";
let workspaceDir: string;
let originalOpenClawHome: string | undefined;
@@ -142,200 +136,6 @@ describe("registerPolicyDoctorChecks", () => {
resetPolicyDoctorChecksForTest();
});
it("describes strictness for agent-scoped policy fields", () => {
expect(
(POLICY_RULE_METADATA as readonly PolicyRuleMetadata[])
.filter(
(rule) =>
rule.scopeSelectors?.includes("agentIds") ||
rule.scopeSelectors?.includes("channelIds"),
)
.map((rule) => {
const description: {
path: string;
strictness: PolicyRuleMetadata["strictness"];
selectors: PolicyRuleMetadata["scopeSelectors"];
emptyList?: PolicyRuleMetadata["emptyList"];
} = {
path: rule.policyPath.join("."),
strictness: rule.strictness,
selectors: rule.scopeSelectors,
};
if (rule.emptyList !== undefined) {
description.emptyList = rule.emptyList;
}
return description;
}),
).toEqual([
{
path: "agents.workspace.allowedAccess",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "agents.workspace.denyTools",
strictness: "denylist-superset",
selectors: ["agentIds"],
},
{
path: "tools.profiles.allow",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "tools.fs.requireWorkspaceOnly",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "tools.exec.allowSecurity",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "tools.exec.requireAsk",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "tools.exec.allowHosts",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{ path: "tools.elevated.allow", strictness: "requires-false", selectors: ["agentIds"] },
{
path: "tools.alsoAllow.expected",
strictness: "exact-list",
emptyList: "meaningful",
selectors: ["agentIds"],
},
{ path: "tools.denyTools", strictness: "denylist-superset", selectors: ["agentIds"] },
{
path: "sandbox.requireMode",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "sandbox.allowBackends",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyHostNetwork",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyContainerNamespaceJoin",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.requireReadOnlyMounts",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyContainerRuntimeSocketMounts",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyUnconfinedProfiles",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.browser.requireCdpSourceRange",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "ingress.channels.allowDmPolicies",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["channelIds"],
},
{
path: "ingress.channels.denyOpenGroups",
strictness: "requires-true",
selectors: ["channelIds"],
},
{
path: "ingress.channels.requireMentionInGroups",
strictness: "requires-true",
selectors: ["channelIds"],
},
{
path: "dataHandling.memory.denySessionTranscriptIndexing",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowSecurity",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowAutoAllowSkills",
strictness: "requires-false",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowlist.expected",
strictness: "exact-list",
emptyList: "meaningful",
selectors: ["agentIds"],
},
]);
});
it("compares policy values through strictness metadata", () => {
const allowHosts = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.exec.allowHosts",
);
const denyTools = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.denyTools",
);
const fsWorkspaceOnly = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.fs.requireWorkspaceOnly",
);
const denyHostNetwork = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "sandbox.containers.denyHostNetwork",
);
const alsoAllow = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.alsoAllow.expected",
);
expect(allowHosts).toBeDefined();
expect(denyTools).toBeDefined();
expect(fsWorkspaceOnly).toBeDefined();
expect(denyHostNetwork).toBeDefined();
expect(alsoAllow).toBeDefined();
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], ["sandbox", "node"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox", "node"], ["sandbox"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(allowHosts!, [], ["sandbox"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], [])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec", "write"], ["exec"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["write"], ["exec"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["group:runtime"], ["exec"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec"], ["group:runtime"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, true, true)).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, false, true)).toBe(false);
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, true, true)).toBe(true);
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, false, true)).toBe(false);
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, ["read"], ["read"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, [], ["read"])).toBe(false);
});
it("allows scoped overrides that are stricter than top-level policy", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyChannelProviderChecks(
deps: PolicyDoctorCheckDeps,
): readonly HealthCheck[] {
const {
channelIdsFromFindings,
disableChannels,
evaluatePolicy,
findingsForCheck,
workspaceRepairsDisabledResult,
workspaceRepairsEnabled,
} = deps;
const policyChannelsDeniedProviderCheck: HealthCheck = {
id: CHECK_IDS.policyDeniedChannelProvider,
kind: "plugin",
description: "Configured channels satisfy policy deny rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedChannelProvider);
},
async repair(ctx, findings) {
if (!workspaceRepairsEnabled(ctx)) {
return workspaceRepairsDisabledResult("channel config");
}
const channelIds = channelIdsFromFindings(findings);
if (channelIds.length === 0) {
return {
status: "skipped",
reason: "no channel findings matched a configurable channel",
changes: [],
};
}
const next = disableChannels(ctx.cfg, channelIds);
if (next.changed.length === 0) {
return {
status: "skipped",
reason: "matching channels were already disabled or missing",
changes: [],
};
}
return {
config: next.config,
changes: next.changed.map(
(id) => `Disabled channels.${id}.enabled for policy conformance.`,
),
};
},
};
return [policyChannelsDeniedProviderCheck];
}
export function createPolicyIngressChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyIngressDmPolicyUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyIngressDmPolicyUnapproved,
kind: "plugin",
description: "Channel direct-message access policy matches ingress requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmPolicyUnapproved);
},
};
const policyIngressDmScopeUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyIngressDmScopeUnapproved,
kind: "plugin",
description: "Direct-message sessions use the policy-required isolation scope.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmScopeUnapproved);
},
};
const policyIngressOpenGroupsDeniedCheck: HealthCheck = {
id: CHECK_IDS.policyIngressOpenGroupsDenied,
kind: "plugin",
description: "Channel group access does not use open group policy when denied.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressOpenGroupsDenied);
},
};
const policyIngressGroupMentionRequiredCheck: HealthCheck = {
id: CHECK_IDS.policyIngressGroupMentionRequired,
kind: "plugin",
description: "Channel group access keeps mention gates enabled when required.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyIngressGroupMentionRequired,
);
},
};
return [
policyIngressDmPolicyUnapprovedCheck,
policyIngressDmScopeUnapprovedCheck,
policyIngressOpenGroupsDeniedCheck,
policyIngressGroupMentionRequiredCheck,
];
}

View File

@@ -0,0 +1,52 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyCoreChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyMissingFileCheck: HealthCheck = {
id: CHECK_IDS.policyMissingFile,
kind: "plugin",
description: "The enabled Policy plugin has a policy file to verify.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingFile);
},
};
const policyHashMismatchCheck: HealthCheck = {
id: CHECK_IDS.policyHashMismatch,
kind: "plugin",
description: "The policy file matches the configured expected hash.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyHashMismatch);
},
};
const policyAttestationMismatchCheck: HealthCheck = {
id: CHECK_IDS.policyAttestationMismatch,
kind: "plugin",
description: "The current policy check matches the accepted attestation.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAttestationMismatch);
},
};
const policyInvalidFileCheck: HealthCheck = {
id: CHECK_IDS.policyInvalidFile,
kind: "plugin",
description: "The enabled policy file parses before policy checks run.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyInvalidFile);
},
};
return [
policyMissingFileCheck,
policyInvalidFileCheck,
policyHashMismatchCheck,
policyAttestationMismatchCheck,
];
}

View File

@@ -0,0 +1,123 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyDataHandlingRedactionDisabledCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingRedactionDisabled,
kind: "plugin",
description: "Sensitive logging redaction remains enabled when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyDataHandlingRedactionDisabled,
);
},
};
const policyDataHandlingTelemetryContentCaptureCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingTelemetryContentCapture,
kind: "plugin",
description: "Telemetry content capture remains disabled when policy denies it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
);
},
};
const policyDataHandlingSessionRetentionNotEnforcedCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
kind: "plugin",
description: "Session retention maintenance is enforced when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
);
},
};
const policyDataHandlingSessionTranscriptMemoryCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
kind: "plugin",
description: "Session transcript memory indexing remains disabled when policy denies it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
);
},
};
const policySecretsUnmanagedProviderCheck: HealthCheck = {
id: CHECK_IDS.policySecretsUnmanagedProvider,
kind: "plugin",
description:
"OpenClaw config SecretRefs use configured secret providers when policy requires managed providers.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsUnmanagedProvider);
},
};
const policySecretsDeniedProviderSourceCheck: HealthCheck = {
id: CHECK_IDS.policySecretsDeniedProviderSource,
kind: "plugin",
description:
"OpenClaw config secret providers and SecretRefs do not use sources denied by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySecretsDeniedProviderSource,
);
},
};
const policySecretsInsecureProviderCheck: HealthCheck = {
id: CHECK_IDS.policySecretsInsecureProvider,
kind: "plugin",
description:
"Configured secret providers do not opt into insecure posture unless policy allows it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsInsecureProvider);
},
};
const policyAuthProfileInvalidMetadataCheck: HealthCheck = {
id: CHECK_IDS.policyAuthProfileInvalidMetadata,
kind: "plugin",
description: "OpenClaw config auth profiles declare required provider and mode metadata.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyAuthProfileInvalidMetadata,
);
},
};
const policyAuthProfileUnapprovedModeCheck: HealthCheck = {
id: CHECK_IDS.policyAuthProfileUnapprovedMode,
kind: "plugin",
description: "OpenClaw config auth profile modes stay within the policy allowlist.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAuthProfileUnapprovedMode);
},
};
return [
policyDataHandlingRedactionDisabledCheck,
policyDataHandlingTelemetryContentCaptureCheck,
policyDataHandlingSessionRetentionNotEnforcedCheck,
policyDataHandlingSessionTranscriptMemoryCheck,
policySecretsUnmanagedProviderCheck,
policySecretsDeniedProviderSourceCheck,
policySecretsInsecureProviderCheck,
policyAuthProfileInvalidMetadataCheck,
policyAuthProfileUnapprovedModeCheck,
];
}

View File

@@ -0,0 +1,100 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyExecApprovalChecks(
deps: PolicyDoctorCheckDeps,
): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyExecApprovalsMissingCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsMissing,
kind: "plugin",
description: "Required exec approvals artifact is present for policy conformance.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsMissing);
},
};
const policyExecApprovalsInvalidCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsInvalid,
kind: "plugin",
description: "Exec approvals artifact parses before policy checks run.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsInvalid);
},
};
const policyExecApprovalsDefaultSecurityUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
kind: "plugin",
description: "Exec approval defaults use a policy-approved security mode.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
);
},
};
const policyExecApprovalsAgentSecurityUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
kind: "plugin",
description: "Per-agent exec approval settings use policy-approved security modes.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
);
},
};
const policyExecApprovalsAutoAllowSkillsEnabledCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
kind: "plugin",
description:
"Exec approval agents do not implicitly auto-allow skill CLIs unless policy allows it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
);
},
};
const policyExecApprovalsAllowlistMissingCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsAllowlistMissing,
kind: "plugin",
description: "Exec approval allowlists include every pattern required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsAllowlistMissing,
);
},
};
const policyExecApprovalsAllowlistUnexpectedCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
kind: "plugin",
description: "Exec approval allowlists do not contain patterns outside policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
);
},
};
return [
policyExecApprovalsMissingCheck,
policyExecApprovalsInvalidCheck,
policyExecApprovalsDefaultSecurityUnapprovedCheck,
policyExecApprovalsAgentSecurityUnapprovedCheck,
policyExecApprovalsAutoAllowSkillsEnabledCheck,
policyExecApprovalsAllowlistMissingCheck,
policyExecApprovalsAllowlistUnexpectedCheck,
];
}

View File

@@ -0,0 +1,333 @@
// Policy doctor checks and findings for gateway exposure policy.
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
import type { PolicyEvidence } from "../../policy-state.js";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
import { readPolicyBoolean, readStringList } from "../utils.js";
export function createPolicyGatewayChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyGatewayNonLoopbackBindCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayNonLoopbackBind,
kind: "plugin",
description: "Gateway bind posture matches policy exposure requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNonLoopbackBind);
},
};
const policyGatewayAuthDisabledCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayAuthDisabled,
kind: "plugin",
description: "Gateway authentication remains enabled when required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayAuthDisabled);
},
};
const policyGatewayRateLimitMissingCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayRateLimitMissing,
kind: "plugin",
description: "Gateway authentication rate-limit posture is explicit when required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRateLimitMissing);
},
};
const policyGatewayControlUiInsecureCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayControlUiInsecure,
kind: "plugin",
description: "Gateway Control UI insecure exposure toggles remain disabled by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayControlUiInsecure);
},
};
const policyGatewayTailscaleFunnelCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayTailscaleFunnel,
kind: "plugin",
description: "Gateway Tailscale Funnel exposure matches policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayTailscaleFunnel);
},
};
const policyGatewayRemoteEnabledCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayRemoteEnabled,
kind: "plugin",
description: "Remote gateway mode matches policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRemoteEnabled);
},
};
const policyGatewayHttpEndpointEnabledCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayHttpEndpointEnabled,
kind: "plugin",
description: "Gateway HTTP API endpoints match policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyGatewayHttpEndpointEnabled,
);
},
};
const policyGatewayHttpUrlFetchUnrestrictedCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
kind: "plugin",
description: "Gateway HTTP URL-fetch inputs have allowlists when required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
);
},
};
return [
policyGatewayNonLoopbackBindCheck,
policyGatewayAuthDisabledCheck,
policyGatewayRateLimitMissingCheck,
policyGatewayControlUiInsecureCheck,
policyGatewayTailscaleFunnelCheck,
policyGatewayRemoteEnabledCheck,
policyGatewayHttpEndpointEnabledCheck,
policyGatewayHttpUrlFetchUnrestrictedCheck,
];
}
export function gatewayExposureFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
return [
...gatewayNonLoopbackBindFindings(policy, policyDocName, evidence),
...gatewayAuthFindings(policy, policyDocName, evidence),
...gatewayControlUiFindings(policy, policyDocName, evidence),
...gatewayTailscaleFindings(policy, policyDocName, evidence),
...gatewayRemoteFindings(policy, policyDocName, evidence),
...gatewayHttpEndpointFindings(policy, policyDocName, evidence),
...gatewayHttpUrlFetchFindings(policy, policyDocName, evidence),
];
}
function gatewayNonLoopbackBindFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "exposure", "allowNonLoopbackBind"]) !== false) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "bind" && entry.nonLoopback === true)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayNonLoopbackBind,
severity: "error",
message:
entry.explicit === false
? "Gateway bind is omitted while the runtime default can permit non-loopback exposure."
: `Gateway bind setting '${entry.id}' permits non-loopback exposure.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/exposure/allowNonLoopbackBind`,
fixHint: "Use gateway.bind=loopback or update policy after review.",
};
});
}
function gatewayAuthFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const findings: HealthFinding[] = [];
if (readPolicyBoolean(policy, ["gateway", "auth", "requireAuth"]) === true) {
findings.push(
...(evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "auth" && entry.value === "none")
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayAuthDisabled,
severity: "error",
message: "Gateway authentication is disabled.",
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/auth/requireAuth`,
fixHint: "Set gateway.auth.mode to token, password, or trusted-proxy.",
};
}),
);
}
if (readPolicyBoolean(policy, ["gateway", "auth", "requireExplicitRateLimit"]) === true) {
findings.push(
...(evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "authRateLimit" && entry.explicit !== true)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayRateLimitMissing,
severity: "error",
message: "Gateway authentication rate-limit posture is not explicit.",
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/auth/requireExplicitRateLimit`,
fixHint: "Configure gateway.auth.rateLimit or update policy after review.",
};
}),
);
}
return findings;
}
function gatewayControlUiFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "controlUi", "allowInsecure"]) !== false) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter(
(entry) =>
entry.kind === "controlUi" &&
entry.value === true &&
(entry.id === "gateway-control-ui-insecure-auth" ||
entry.id === "gateway-control-ui-device-auth-disabled" ||
entry.id === "gateway-control-ui-host-origin-fallback"),
)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayControlUiInsecure,
severity: "error",
message: `Gateway Control UI insecure toggle '${entry.id}' is enabled.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/controlUi/allowInsecure`,
fixHint: "Disable the insecure Control UI toggle or update policy after review.",
};
});
}
function gatewayTailscaleFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "exposure", "allowTailscaleFunnel"]) !== false) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "tailscale" && entry.value === "funnel")
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayTailscaleFunnel,
severity: "error",
message: "Gateway Tailscale Funnel exposure is enabled.",
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/exposure/allowTailscaleFunnel`,
fixHint: "Use tailscale serve/off or update policy after review.",
};
});
}
function gatewayRemoteFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "remote", "allow"]) !== false) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "remote")
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayRemoteEnabled,
severity: "error",
message: `Gateway remote posture '${entry.id}' is enabled.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/remote/allow`,
fixHint: "Disable remote gateway mode/config or update policy after review.",
};
});
}
function gatewayHttpEndpointFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const denied = new Set(
readStringList(policy, ["gateway", "http", "denyEndpoints"]).map((endpoint) =>
endpoint.toLowerCase(),
),
);
if (denied.size === 0) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter(
(entry) =>
entry.kind === "httpEndpoint" &&
entry.endpoint !== undefined &&
denied.has(entry.endpoint.toLowerCase()),
)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayHttpEndpointEnabled,
severity: "error",
message: `Gateway HTTP endpoint '${entry.endpoint ?? entry.id}' is denied by policy.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/http/denyEndpoints`,
fixHint: "Disable the HTTP endpoint or update policy after review.",
};
});
}
function gatewayHttpUrlFetchFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "http", "requireUrlAllowlists"]) !== true) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "httpUrlFetch" && entry.hasAllowlist !== true)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
severity: "error",
message: `Gateway HTTP URL-fetch input '${entry.id}' has no URL allowlist.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/http/requireUrlAllowlists`,
fixHint: "Add a urlAllowlist for this URL-fetch input or update policy after review.",
};
});
}

View File

@@ -0,0 +1,232 @@
// Policy doctor checks and findings for MCP, model provider, and network policy.
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import type { PolicyEvidence } from "../../policy-state.js";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
import { readPolicyBoolean, readStringList } from "../utils.js";
export function createPolicyModelNetworkChecks(
deps: PolicyDoctorCheckDeps,
): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyMcpDeniedServerCheck: HealthCheck = {
id: CHECK_IDS.policyDeniedMcpServer,
kind: "plugin",
description: "Configured MCP servers do not match policy deny rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedMcpServer);
},
};
const policyMcpUnapprovedServerCheck: HealthCheck = {
id: CHECK_IDS.policyUnapprovedMcpServer,
kind: "plugin",
description: "Configured MCP servers do not match policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedMcpServer);
},
};
const policyModelsDeniedProviderCheck: HealthCheck = {
id: CHECK_IDS.policyDeniedModelProvider,
kind: "plugin",
description: "Configured model providers do not match policy deny rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedModelProvider);
},
};
const policyModelsUnapprovedProviderCheck: HealthCheck = {
id: CHECK_IDS.policyUnapprovedModelProvider,
kind: "plugin",
description: "Configured model providers do not match policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedModelProvider);
},
};
const policyNetworkPrivateAccessCheck: HealthCheck = {
id: CHECK_IDS.policyPrivateNetworkAccess,
kind: "plugin",
description: "Network SSRF policy settings match private-network requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyPrivateNetworkAccess);
},
};
return [
policyMcpDeniedServerCheck,
policyMcpUnapprovedServerCheck,
policyModelsDeniedProviderCheck,
policyModelsUnapprovedProviderCheck,
policyNetworkPrivateAccessCheck,
];
}
export function mcpServerFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const denied = new Set(readStringList(policy, ["mcp", "servers", "deny"], { lowercase: false }));
const allowed = readStringList(policy, ["mcp", "servers", "allow"], { lowercase: false });
const allowedSet = new Set(allowed);
const findings: HealthFinding[] = [];
for (const server of evidence.mcpServers) {
if (denied.has(server.id)) {
findings.push({
checkId: CHECK_IDS.policyDeniedMcpServer,
severity: "error",
message: `MCP server '${server.id}' is denied by policy.`,
source: "policy",
path: "openclaw config",
ocPath: server.source,
target: server.source,
requirement: `oc://${policyDocName}/mcp/servers/deny`,
fixHint: "Remove this configured MCP server or update the policy after review.",
});
continue;
}
if (allowedSet.size > 0 && !allowedSet.has(server.id)) {
findings.push({
checkId: CHECK_IDS.policyUnapprovedMcpServer,
severity: "error",
message: `MCP server '${server.id}' is not in the policy allowlist.`,
source: "policy",
path: "openclaw config",
ocPath: server.source,
target: server.source,
requirement: `oc://${policyDocName}/mcp/servers/allow`,
fixHint: "Use an approved MCP server or update the policy after review.",
});
}
}
return findings;
}
export function modelProviderFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const denied = new Set(readModelProviderPolicyList(policy, ["models", "providers", "deny"]));
const allowed = readModelProviderPolicyList(policy, ["models", "providers", "allow"]);
const allowedSet = new Set(allowed);
const findings: HealthFinding[] = [];
for (const provider of evidence.modelProviders) {
findings.push(...modelProviderConformanceFindings(provider, denied, allowedSet, policyDocName));
}
for (const modelRef of evidence.modelRefs) {
findings.push(...modelRefConformanceFindings(modelRef, denied, allowedSet, policyDocName));
}
return findings;
}
function readModelProviderPolicyList(policy: unknown, path: readonly string[]): readonly string[] {
return readStringList(policy, path).map((provider) => normalizeProviderId(provider));
}
function modelProviderConformanceFindings(
provider: PolicyEvidence["modelProviders"][number],
denied: ReadonlySet<string>,
allowed: ReadonlySet<string>,
policyDocName: string,
): readonly HealthFinding[] {
const findings: HealthFinding[] = [];
if (denied.has(provider.id)) {
findings.push({
checkId: CHECK_IDS.policyDeniedModelProvider,
severity: "error",
message: `Model provider '${provider.id}' is denied by policy.`,
source: "policy",
path: "openclaw config",
ocPath: provider.source,
target: provider.source,
requirement: `oc://${policyDocName}/models/providers/deny`,
fixHint: "Remove this configured provider or update the policy after review.",
});
}
if (!denied.has(provider.id) && allowed.size > 0 && !allowed.has(provider.id)) {
findings.push({
checkId: CHECK_IDS.policyUnapprovedModelProvider,
severity: "error",
message: `Model provider '${provider.id}' is not in the policy allowlist.`,
source: "policy",
path: "openclaw config",
ocPath: provider.source,
target: provider.source,
requirement: `oc://${policyDocName}/models/providers/allow`,
fixHint: "Use an approved model provider or update the policy after review.",
});
}
return findings;
}
function modelRefConformanceFindings(
modelRef: PolicyEvidence["modelRefs"][number],
denied: ReadonlySet<string>,
allowed: ReadonlySet<string>,
policyDocName: string,
): readonly HealthFinding[] {
const findings: HealthFinding[] = [];
if (denied.has(modelRef.provider)) {
findings.push({
checkId: CHECK_IDS.policyDeniedModelProvider,
severity: "error",
message: `Model ref '${modelRef.ref}' uses denied provider '${modelRef.provider}'.`,
source: "policy",
path: "openclaw config",
ocPath: modelRef.source,
target: modelRef.source,
requirement: `oc://${policyDocName}/models/providers/deny`,
fixHint: "Select an approved model provider or update the policy after review.",
});
}
if (!denied.has(modelRef.provider) && allowed.size > 0 && !allowed.has(modelRef.provider)) {
findings.push({
checkId: CHECK_IDS.policyUnapprovedModelProvider,
severity: "error",
message: `Model ref '${modelRef.ref}' uses unapproved provider '${modelRef.provider}'.`,
source: "policy",
path: "openclaw config",
ocPath: modelRef.source,
target: modelRef.source,
requirement: `oc://${policyDocName}/models/providers/allow`,
fixHint: "Select an approved model provider or update the policy after review.",
});
}
return findings;
}
export function networkFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const allowPrivateNetwork = readPolicyBoolean(policy, ["network", "privateNetwork", "allow"]);
if (allowPrivateNetwork !== false) {
return [];
}
return evidence.network
.filter((setting) => setting.value)
.map((setting): HealthFinding => {
return {
checkId: CHECK_IDS.policyPrivateNetworkAccess,
severity: "error",
message: `Network setting '${setting.id}' allows private-network access.`,
source: "policy",
path: "openclaw config",
ocPath: setting.source,
target: setting.source,
requirement: `oc://${policyDocName}/network/privateNetwork/allow`,
fixHint: "Disable this private-network access setting or update policy after review.",
};
});
}

View File

@@ -0,0 +1,123 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicySandboxChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policySandboxModeUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policySandboxModeUnapproved,
kind: "plugin",
description: "Sandbox mode config satisfies policy requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxModeUnapproved);
},
};
const policySandboxBackendUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policySandboxBackendUnapproved,
kind: "plugin",
description: "Sandbox backend config satisfies policy requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxBackendUnapproved);
},
};
const policySandboxContainerPostureUnobservableCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerPostureUnobservable,
kind: "plugin",
description: "Sandbox container posture policy only targets observable container backends.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerPostureUnobservable,
);
},
};
const policySandboxContainerHostNetworkDeniedCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerHostNetworkDenied,
kind: "plugin",
description: "Sandbox container config avoids host network mode.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerHostNetworkDenied,
);
},
};
const policySandboxContainerNamespaceJoinDeniedCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
kind: "plugin",
description: "Sandbox container config avoids joining another container network namespace.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
);
},
};
const policySandboxContainerMountModeRequiredCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerMountModeRequired,
kind: "plugin",
description: "Sandbox container mounts are read-only when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerMountModeRequired,
);
},
};
const policySandboxContainerRuntimeSocketMountCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerRuntimeSocketMount,
kind: "plugin",
description: "Sandbox container mounts avoid host container runtime sockets.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
);
},
};
const policySandboxContainerUnconfinedProfileCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerUnconfinedProfile,
kind: "plugin",
description: "Sandbox container profile config avoids unconfined profiles.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerUnconfinedProfile,
);
},
};
const policySandboxBrowserCdpSourceRangeMissingCheck: HealthCheck = {
id: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
kind: "plugin",
description: "Sandbox browser CDP config includes a source range when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
);
},
};
return [
policySandboxModeUnapprovedCheck,
policySandboxBackendUnapprovedCheck,
policySandboxContainerPostureUnobservableCheck,
policySandboxContainerHostNetworkDeniedCheck,
policySandboxContainerNamespaceJoinDeniedCheck,
policySandboxContainerMountModeRequiredCheck,
policySandboxContainerRuntimeSocketMountCheck,
policySandboxContainerUnconfinedProfileCheck,
policySandboxBrowserCdpSourceRangeMissingCheck,
];
}

View File

@@ -0,0 +1,191 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyAgentsWorkspaceAccessDeniedCheck: HealthCheck = {
id: CHECK_IDS.policyAgentsWorkspaceAccessDenied,
kind: "plugin",
description: "Agent sandbox workspace access matches policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
);
},
};
const policyAgentsToolNotDeniedCheck: HealthCheck = {
id: CHECK_IDS.policyAgentsToolNotDenied,
kind: "plugin",
description: "Agent workspace mutation/runtime tools are denied when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAgentsToolNotDenied);
},
};
const policyToolsProfileUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsProfileUnapproved,
kind: "plugin",
description: "Configured tool profiles match policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsProfileUnapproved);
},
};
const policyToolsFsWorkspaceOnlyRequiredCheck: HealthCheck = {
id: CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
kind: "plugin",
description: "Filesystem tools use workspace-only posture when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
);
},
};
const policyToolsExecSecurityUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsExecSecurityUnapproved,
kind: "plugin",
description: "Exec tool security mode matches policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyToolsExecSecurityUnapproved,
);
},
};
const policyToolsExecAskUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsExecAskUnapproved,
kind: "plugin",
description: "Exec tool ask mode matches policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecAskUnapproved);
},
};
const policyToolsExecHostUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsExecHostUnapproved,
kind: "plugin",
description: "Exec tool host routing matches policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecHostUnapproved);
},
};
const policyToolsElevatedEnabledCheck: HealthCheck = {
id: CHECK_IDS.policyToolsElevatedEnabled,
kind: "plugin",
description: "Elevated tool mode remains disabled when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsElevatedEnabled);
},
};
const policyToolsAlsoAllowMissingCheck: HealthCheck = {
id: CHECK_IDS.policyToolsAlsoAllowMissing,
kind: "plugin",
description: "Configured tools.alsoAllow entries include policy expected lists.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowMissing);
},
};
const policyToolsAlsoAllowUnexpectedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsAlsoAllowUnexpected,
kind: "plugin",
description: "Configured tools.alsoAllow entries match policy expected lists.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowUnexpected);
},
};
const policyToolsRequiredDenyMissingCheck: HealthCheck = {
id: CHECK_IDS.policyToolsRequiredDenyMissing,
kind: "plugin",
description: "Configured tool deny lists include tools required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsRequiredDenyMissing);
},
};
return [
policyAgentsWorkspaceAccessDeniedCheck,
policyAgentsToolNotDeniedCheck,
policyToolsProfileUnapprovedCheck,
policyToolsFsWorkspaceOnlyRequiredCheck,
policyToolsExecSecurityUnapprovedCheck,
policyToolsExecAskUnapprovedCheck,
policyToolsExecHostUnapprovedCheck,
policyToolsElevatedEnabledCheck,
policyToolsAlsoAllowMissingCheck,
policyToolsAlsoAllowUnexpectedCheck,
policyToolsRequiredDenyMissingCheck,
];
}
export function createPolicyToolMetadataChecks(
deps: PolicyDoctorCheckDeps,
): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyToolsMissingRiskCheck: HealthCheck = {
id: CHECK_IDS.policyMissingToolRisk,
kind: "plugin",
description: "TOOLS.md policy entries declare explicit risk levels.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolRisk);
},
};
const policyToolsUnknownRiskCheck: HealthCheck = {
id: CHECK_IDS.policyUnknownToolRisk,
kind: "plugin",
description: "TOOLS.md policy entries use known risk levels.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolRisk);
},
};
const policyToolsMissingSensitivityCheck: HealthCheck = {
id: CHECK_IDS.policyMissingToolSensitivity,
kind: "plugin",
description: "TOOLS.md policy entries declare default artifact sensitivity.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolSensitivity);
},
};
const policyToolsUnknownSensitivityCheck: HealthCheck = {
id: CHECK_IDS.policyUnknownToolSensitivity,
kind: "plugin",
description: "TOOLS.md policy entries use known sensitivity levels.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolSensitivity);
},
};
const policyToolsMissingOwnerCheck: HealthCheck = {
id: CHECK_IDS.policyMissingToolOwner,
kind: "plugin",
description: "TOOLS.md policy entries declare an accountable owner.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolOwner);
},
};
return [
policyToolsMissingRiskCheck,
policyToolsUnknownRiskCheck,
policyToolsMissingSensitivityCheck,
policyToolsMissingOwnerCheck,
policyToolsUnknownSensitivityCheck,
];
}

View File

@@ -0,0 +1,44 @@
// Policy doctor strictness helper tests.
import { describe, expect, it } from "vitest";
import { POLICY_RULE_METADATA } from "./metadata.js";
import { isPolicyValueAtLeastAsStrict } from "./strictness.js";
describe("policy doctor strictness", () => {
it("compares policy values through strictness metadata", () => {
const allowHosts = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.exec.allowHosts",
);
const denyTools = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.denyTools",
);
const fsWorkspaceOnly = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.fs.requireWorkspaceOnly",
);
const denyHostNetwork = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "sandbox.containers.denyHostNetwork",
);
const alsoAllow = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.alsoAllow.expected",
);
expect(allowHosts).toBeDefined();
expect(denyTools).toBeDefined();
expect(fsWorkspaceOnly).toBeDefined();
expect(denyHostNetwork).toBeDefined();
expect(alsoAllow).toBeDefined();
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], ["sandbox", "node"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox", "node"], ["sandbox"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(allowHosts!, [], ["sandbox"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], [])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec", "write"], ["exec"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["write"], ["exec"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["group:runtime"], ["exec"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec"], ["group:runtime"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, true, true)).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, false, true)).toBe(false);
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, true, true)).toBe(true);
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, false, true)).toBe(false);
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, ["read"], ["read"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, [], ["read"])).toBe(false);
});
});

View File

@@ -0,0 +1,270 @@
// Policy doctor strictness comparisons for scoped policy overlays.
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { POLICY_TOOL_GROUPS } from "../tool-policy-conformance.js";
import type { PolicyRuleMetadata } from "./metadata.js";
type ExecApprovalAllowlistRequirement = {
readonly key: string;
readonly pattern: string;
readonly argPattern?: string;
};
export function isPolicyValueAtLeastAsStrict(
metadata: PolicyRuleMetadata,
candidate: unknown,
baseline: unknown,
): boolean {
switch (metadata.strictness) {
case "allowlist-subset":
return isPolicyAllowlistSubset(metadata, candidate, baseline);
case "denylist-superset":
return isPolicyDenylistSuperset(metadata, candidate, baseline);
case "ordered-string":
return isPolicyOrderedStringAtLeastAsStrict(metadata, candidate, baseline);
case "requires-true":
return baseline !== true || candidate === true;
case "requires-false":
return baseline !== false || candidate === false;
case "exact-list":
return samePolicyStringList(candidate, baseline, metadata);
}
return false;
}
function isPolicyOrderedStringAtLeastAsStrict(
metadata: PolicyRuleMetadata,
candidate: unknown,
baseline: unknown,
): boolean {
const candidateValue = policyString(candidate, metadata);
const baselineValue = policyString(baseline, metadata);
if (
candidateValue === undefined ||
baselineValue === undefined ||
metadata.orderedValues === undefined
) {
return false;
}
const orderedValues = metadata.orderedValues.map((entry) =>
metadata.caseSensitive === true ? entry : entry.toLowerCase(),
);
const candidateIndex = orderedValues.indexOf(candidateValue);
const baselineIndex = orderedValues.indexOf(baselineValue);
return candidateIndex >= 0 && baselineIndex >= 0 && candidateIndex >= baselineIndex;
}
function isPolicyAllowlistSubset(
metadata: PolicyRuleMetadata,
candidate: unknown,
baseline: unknown,
): boolean {
const candidateList = policyStringList(candidate, metadata);
const baselineList = policyStringList(baseline, metadata);
if (candidateList === undefined || baselineList === undefined) {
return false;
}
if (metadata.emptyList === "disabled" && baselineList.length === 0) {
return true;
}
if (metadata.emptyList === "disabled" && baselineList.length > 0 && candidateList.length === 0) {
return false;
}
const allowed = new Set(baselineList);
return candidateList.every((entry) => allowed.has(entry));
}
function isPolicyDenylistSuperset(
metadata: PolicyRuleMetadata,
candidate: unknown,
baseline: unknown,
): boolean {
const candidateList = policyStringList(candidate, metadata);
const baselineList = policyStringList(baseline, metadata);
if (candidateList === undefined || baselineList === undefined) {
return false;
}
if (metadata.policyPath.join(".") === "tools.denyTools") {
return baselineList
.flatMap(expandPolicyToolRequirement)
.every((tool) => toolListCoversTool(candidateList, tool));
}
const denied = new Set(candidateList);
return baselineList.every((entry) => denied.has(entry));
}
function samePolicyStringList(
candidate: unknown,
baseline: unknown,
metadata: PolicyRuleMetadata,
): boolean {
const candidateList = policyStringList(candidate, metadata);
const baselineList = policyStringList(baseline, metadata);
if (candidateList === undefined || baselineList === undefined) {
return false;
}
const candidateSorted = candidateList.toSorted();
const baselineSorted = baselineList.toSorted();
return (
candidateSorted.length === baselineSorted.length &&
candidateSorted.every((entry, index) => entry === baselineSorted[index])
);
}
function policyStringList(
value: unknown,
metadata: PolicyRuleMetadata,
): readonly string[] | undefined {
if (metadata.valueType === "channel-provider-deny-rules") {
return channelProviderDenyRuleList(value, metadata);
}
if (!Array.isArray(value)) {
return undefined;
}
if (metadata.policyPath.join(".") === "execApprovals.agents.allowlist.expected") {
const entries = value.map(execApprovalAllowlistRequirement);
if (!entries.every((entry): entry is ExecApprovalAllowlistRequirement => entry !== undefined)) {
return undefined;
}
return entries.map((entry) => entry.key);
}
if (!value.every((entry) => typeof entry === "string")) {
return undefined;
}
return value
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => normalizePolicyStringListEntry(entry, metadata));
}
function normalizePolicyStringListEntry(entry: string, metadata: PolicyRuleMetadata): string {
if (metadata.normalizeValues === "model-provider") {
return normalizeProviderId(entry);
}
return metadata.caseSensitive === true ? entry : entry.toLowerCase();
}
function channelProviderDenyRuleList(
value: unknown,
metadata: PolicyRuleMetadata,
): readonly string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const providers: string[] = [];
for (const entry of value) {
if (!isChannelDenyRule(entry)) {
return undefined;
}
const provider = entry.when?.provider?.trim();
if (provider !== undefined && provider !== "") {
providers.push(metadata.caseSensitive === true ? provider : provider.toLowerCase());
}
}
return providers;
}
function policyString(value: unknown, metadata: PolicyRuleMetadata): string | undefined {
if (typeof value !== "string" || value.trim() === "") {
return undefined;
}
const trimmed = value.trim();
return metadata.caseSensitive === true ? trimmed : trimmed.toLowerCase();
}
function execApprovalAllowlistRequirement(
value: unknown,
): ExecApprovalAllowlistRequirement | undefined {
if (typeof value === "string") {
const pattern = value.trim();
return pattern === "" ? undefined : execApprovalAllowlistRequirementFromParts(pattern);
}
if (!isRecord(value)) {
return undefined;
}
const keys = Object.keys(value);
if (keys.some((key) => key !== "argPattern" && key !== "pattern")) {
return undefined;
}
const pattern = typeof value.pattern === "string" ? value.pattern.trim() : "";
if (pattern === "") {
return undefined;
}
const argPattern = typeof value.argPattern === "string" ? value.argPattern.trim() : undefined;
if (value.argPattern !== undefined && argPattern === undefined) {
return undefined;
}
return execApprovalAllowlistRequirementFromParts(
pattern,
argPattern === "" ? undefined : argPattern,
);
}
function execApprovalAllowlistRequirementFromParts(
pattern: string,
argPattern?: string,
): ExecApprovalAllowlistRequirement {
return {
key: execApprovalAllowlistRequirementKey(pattern, argPattern),
pattern,
...(argPattern === undefined ? {} : { argPattern }),
};
}
function execApprovalAllowlistRequirementKey(
pattern: string,
argPattern: string | undefined,
): string {
return `${pattern}\0${argPattern ?? ""}`;
}
function isChannelDenyRule(value: unknown): value is {
readonly id?: string;
readonly when?: { readonly provider?: string };
readonly reason?: string;
} {
return (
isRecord(value) &&
(value.id === undefined || typeof value.id === "string") &&
(value.reason === undefined || typeof value.reason === "string") &&
isRecord(value.when) &&
typeof value.when.provider === "string"
);
}
function toolListCoversTool(list: readonly string[], tool: string): boolean {
for (const entry of list) {
const normalized = normalizePolicyToolName(entry);
if (normalized === "*" || normalized === tool) {
return true;
}
if (POLICY_TOOL_GROUPS[normalized]?.includes(tool)) {
return true;
}
if (normalized.includes("*") && policyToolGlobMatches(tool, normalized)) {
return true;
}
}
return false;
}
function expandPolicyToolRequirement(value: string): readonly string[] {
const normalized = normalizePolicyToolName(value);
return POLICY_TOOL_GROUPS[normalized] ?? [normalized];
}
function normalizePolicyToolName(value: string): string {
const normalized = value.trim().toLowerCase();
if (normalized === "bash") {
return "exec";
}
if (normalized === "apply-patch") {
return "apply_patch";
}
return normalized;
}
function policyToolGlobMatches(tool: string, pattern: string): boolean {
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`).test(tool);
}

View File

@@ -0,0 +1,35 @@
// Policy doctor shared types.
import type { HealthCheckContext, HealthFinding } from "openclaw/plugin-sdk/health";
import type { PolicyEvidence } from "../policy-state.js";
import type { POLICY_CHECK_IDS } from "./metadata.js";
export type PolicyEvaluation = {
readonly policyPath: string;
readonly policy?: {
readonly value: unknown;
readonly hash: string;
};
readonly evidence: PolicyEvidence;
readonly expectedAttestationHash?: string;
readonly findings: readonly HealthFinding[];
readonly attestedFindings: readonly HealthFinding[];
};
export type PolicyDoctorCheckDeps = {
readonly evaluatePolicy: (ctx: HealthCheckContext) => Promise<PolicyEvaluation>;
readonly findingsForCheck: (
evaluation: PolicyEvaluation,
checkId: (typeof POLICY_CHECK_IDS)[number],
) => readonly HealthFinding[];
readonly workspaceRepairsEnabled: (ctx: HealthCheckContext) => boolean;
readonly workspaceRepairsDisabledResult: (fileName: string) => {
readonly status: "skipped";
readonly reason: string;
readonly changes: readonly string[];
};
readonly channelIdsFromFindings: (findings: readonly HealthFinding[]) => readonly string[];
readonly disableChannels: (
cfg: HealthCheckContext["cfg"],
channelIds: readonly string[],
) => { readonly config: HealthCheckContext["cfg"]; readonly changed: readonly string[] };
};

View File

@@ -0,0 +1,63 @@
// Shared policy doctor value readers.
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export function readPolicyStringArray(
policy: unknown,
path: readonly string[],
options: { readonly lowercase?: boolean } = {},
): readonly string[] | undefined {
let current: unknown = policy;
for (const part of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
if (!Array.isArray(current) || !current.every((entry) => typeof entry === "string")) {
return undefined;
}
const lowercase = options.lowercase ?? true;
return current
.map((entry) => {
const trimmed = entry.trim();
return lowercase ? trimmed.toLowerCase() : trimmed;
})
.filter(Boolean);
}
export function readStringList(
policy: unknown,
path: readonly string[],
options?: { readonly lowercase?: boolean },
): readonly string[] {
return readPolicyStringArray(policy, path, options) ?? [];
}
export function readString(policy: unknown, path: readonly string[]): string | undefined {
let current: unknown = policy;
for (const part of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
return typeof current === "string" ? current.trim().toLowerCase() : undefined;
}
export function ocPathSegment(value: string): string {
if (/^(?:[A-Za-z0-9_-]+|#\d+)$/.test(value)) {
return value;
}
return JSON.stringify(value);
}
export function readPolicyBoolean(policy: unknown, path: readonly string[]): boolean | undefined {
let current: unknown = policy;
for (const part of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
return typeof current === "boolean" ? current : undefined;
}

View File

@@ -0,0 +1,18 @@
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { QaSuiteArtifactError } from "./errors.js";
export async function assertQaSuiteArtifactWritten(
kind: "evidence" | "report" | "summary",
filePath: string,
) {
try {
await fs.access(filePath);
} catch (error) {
throw new QaSuiteArtifactError(
`${kind}_missing`,
`QA suite did not produce ${kind} artifact at ${filePath}: ${formatErrorMessage(error)}`,
{ cause: error },
);
}
}

View File

@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { toQaErrorObject } from "./errors.js";
import { seedQaAgentWorkspace } from "./qa-agent-workspace.js";
import {
createQaChannelGatewayConfig,
@@ -343,7 +344,7 @@ export async function buildQaDockerHarnessImage(
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(command, args, { cwd }, (error, stdout, stderr) => {
if (error) {
reject(toLintErrorObject(error, "Non-Error rejection"));
reject(toQaErrorObject(error, "Non-Error rejection"));
return;
}
resolve({ stdout, stderr });
@@ -368,17 +369,3 @@ export async function buildQaDockerHarnessImage(
return { imageName };
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -34,3 +34,17 @@ export class QaSuiteInfraError extends Error {
this.code = code;
}
}
export function toQaErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -5,7 +5,9 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildQaEvidenceGalleryModel,
resolveQaEvidenceArtifactFileByIndex,
resolveQaEvidenceArtifactFile,
resolveQaEvidenceProducerFile,
resolveQaEvidenceFile,
} from "./evidence-gallery.js";
import {
@@ -23,11 +25,28 @@ async function writeJson(filePath: string, value: unknown) {
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function producerRootLeakSegments(repoRoot: string) {
if (process.platform !== "win32") {
return [`nested${repoRoot}`];
}
return [
"nested",
...repoRoot
.split(/[\\/]+/u)
.filter(Boolean)
.map((part) => part.replace(/[^A-Za-z0-9._-]/gu, "_")),
];
}
function repoRelativePath(repoRoot: string, filePath: string) {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
function vitestArtifactEvidence(params: {
id: string;
title: string;
artifact: { kind: string; path: string };
}) {
}): QaEvidenceSummaryJson {
return {
kind: "openclaw.qa.evidence-summary",
schemaVersion: 2,
@@ -126,7 +145,7 @@ describe("evidence gallery", () => {
expect.objectContaining({
exists: true,
kind: "runner-result",
href: "/api/evidence/artifact?evidencePath=.artifacts%2Fqa-e2e%2Fvitest%2Fqa-evidence.json&artifactPath=runner%2Fresult.json",
href: "/api/evidence/artifact?evidencePath=.artifacts%2Fqa-e2e%2Fvitest%2Fqa-evidence.json&entryIndex=0&artifactIndex=0",
mediaKind: "json",
preview: '{\n "ok": true\n}',
}),
@@ -144,10 +163,152 @@ describe("evidence gallery", () => {
});
});
it("sanitizes local roots from gallery failure reasons", async () => {
const repoRoot = await createTempRepo();
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "vitest");
await fs.mkdir(outputDir, { recursive: true });
const evidence: QaEvidenceSummaryJson = vitestArtifactEvidence({
id: "qa-lab.failure-path",
title: "Failure path evidence",
artifact: { kind: "log", path: "missing.log" },
});
evidence.entries[0] = {
...evidence.entries[0],
result: {
status: "blocked",
failure: {
class: "blocked",
reason: `Command failed at ${repoRoot}/openclaw.mjs and file://${repoRoot}/trace.log`,
},
},
};
await writeJson(path.join(outputDir, QA_EVIDENCE_FILENAME), evidence);
const model = await buildQaEvidenceGalleryModel({
evidencePath: outputDir,
repoRoot,
});
expect(model.entries[0].failureReason).toBe(
"Command failed at <repo-root>/openclaw.mjs and file://<repo-root>/trace.log",
);
expect(JSON.stringify(model)).not.toContain(repoRoot);
});
it("normalizes absolute source and declared artifact paths for gallery links", async () => {
const repoRoot = await createTempRepo();
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "vitest");
const artifactPath = path.join(outputDir, "absolute.log");
await fs.mkdir(outputDir, { recursive: true });
await fs.writeFile(
artifactPath,
`absolute artifact ${repoRoot}\nfile://${repoRoot}/trace.log\n`,
"utf8",
);
const relativeLeakArtifactPath = `nested${repoRoot}/relative.log`;
const relativeLeakFile = path.resolve(outputDir, relativeLeakArtifactPath);
await fs.mkdir(path.dirname(relativeLeakFile), { recursive: true });
await fs.writeFile(relativeLeakFile, "relative artifact\n", "utf8");
const evidence: QaEvidenceSummaryJson = vitestArtifactEvidence({
id: "qa-lab.absolute-artifact-path",
title: "Absolute artifact path",
artifact: { kind: "log", path: artifactPath },
});
evidence.profile = `${repoRoot}/qa-profile`;
evidence.entries[0] = {
...evidence.entries[0],
coverage: [{ id: `${repoRoot}/coverage`, role: `${repoRoot}/role` }],
execution: {
...evidence.entries[0].execution!,
artifacts: [
{
...evidence.entries[0].execution!.artifacts[0],
kind: `${repoRoot}/log`,
source: `${repoRoot}/vitest`,
},
{
kind: "log",
path: relativeLeakArtifactPath,
source: "vitest",
},
],
},
test: {
...evidence.entries[0].test,
id: `${repoRoot}/qa-lab.absolute-artifact-path`,
kind: `${repoRoot}/vitest-test`,
source: { path: path.join(repoRoot, "extensions/qa-lab/src/absolute.test.ts") },
title: `Absolute artifact path at ${repoRoot}`,
},
};
await writeJson(path.join(outputDir, QA_EVIDENCE_FILENAME), evidence);
const model = await buildQaEvidenceGalleryModel({
evidencePath: outputDir,
repoRoot,
});
const artifact = model.entries[0]?.artifacts[0];
expect(artifact).toMatchObject({
exists: true,
kind: "<repo-root>/log",
path: ".artifacts/qa-e2e/vitest/absolute.log",
preview: "absolute artifact <repo-root>\nfile://<repo-root>/trace.log\n",
source: "<repo-root>/vitest",
});
expect(artifact?.href).toContain("entryIndex=0&artifactIndex=0");
const relativeArtifact = model.entries[0]?.artifacts[1];
expect(relativeArtifact).toMatchObject({
exists: true,
path: expect.stringContaining(".artifacts/qa-e2e/vitest/nested"),
preview: "relative artifact\n",
});
expect(decodeURIComponent(relativeArtifact?.href ?? "")).not.toContain(repoRoot);
expect(relativeArtifact?.href).toContain("entryIndex=0&artifactIndex=1");
expect(model.entries[0]?.sourcePath).toBe("extensions/qa-lab/src/absolute.test.ts");
expect(model.entries[0]).toMatchObject({
coverage: [{ id: "<repo-root>/coverage", role: "<repo-root>/role" }],
id: "<repo-root>/qa-lab.absolute-artifact-path",
kind: "<repo-root>/vitest-test",
title: "Absolute artifact path at <repo-root>",
});
expect(model.profile).toBe("<repo-root>/qa-profile");
expect(JSON.stringify(model)).not.toContain(repoRoot);
await expect(
resolveQaEvidenceArtifactFile({
artifactPath: "<repo-root>/.artifacts/qa-e2e/vitest/absolute.log",
evidencePath: outputDir,
repoRoot,
}),
).resolves.toBe(await fs.realpath(artifactPath));
await expect(
resolveQaEvidenceArtifactFileByIndex({
artifactIndex: 1,
entryIndex: 0,
evidencePath: outputDir,
repoRoot,
}),
).resolves.toBe(await fs.realpath(relativeLeakFile));
});
it("detects UX Matrix producer context from suite-level evidence artifacts", async () => {
const repoRoot = await createTempRepo();
const suiteDir = path.join(repoRoot, ".artifacts", "qa-e2e", "suite");
const runDir = path.join(suiteDir, "script", "ux-matrix-evidence-dashboard", "run-1");
const runDir = path.join(
suiteDir,
"script",
...producerRootLeakSegments(repoRoot),
"ux-matrix-evidence-dashboard",
"run-1",
);
const expectedWebScreenshotNeedle =
process.platform === "win32"
? ".artifacts/qa-e2e/suite/script/nested"
: ".artifacts/qa-e2e/suite/script/nested<repo-root>/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png";
const expectedCliLogNeedle =
process.platform === "win32"
? ".artifacts/qa-e2e/suite/script/nested"
: ".artifacts/qa-e2e/suite/script/nested<repo-root>/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt";
await fs.mkdir(path.join(runDir, "surfaces", "web-ui", "stages", "first-run"), {
recursive: true,
});
@@ -176,22 +337,24 @@ describe("evidence gallery", () => {
"proof-gap": 1,
},
stages: [
{ id: `${repoRoot}/diagnostics`, label: "Diagnostics" },
{ id: "first-run", label: "First run" },
{ id: "error-state", label: "Error state" },
],
surfaces: [
{ id: `${repoRoot}/native`, label: "Native" },
{ id: "web-ui", label: "Web UI" },
{ id: "cli", label: "CLI" },
],
cells: [
null,
{
coverageIds: ["ui.control"],
coverageIds: [`${repoRoot}/ui.control`],
runner: {
availability: "local",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
command: `${repoRoot}/openclaw.mjs qa suite --scenario ux-matrix-evidence-dashboard`,
lane: "web-ui-playwright",
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
workflow: `${repoRoot}/.github/workflows/ux-matrix-qa.yml#ux-matrix-local`,
},
stage: "first-run",
status: "pass",
@@ -239,7 +402,7 @@ describe("evidence gallery", () => {
test: {
kind: "ux-matrix-cell",
id: "ux-matrix.web-ui.first-run",
title: "UX Matrix: web-ui / first-run",
title: `UX Matrix: web-ui / first-run at ${repoRoot}`,
source: { path: "scripts/ux-matrix/dashboard.ts" },
},
coverage: [{ id: "ui.control", role: "primary" }],
@@ -260,7 +423,14 @@ describe("evidence gallery", () => {
artifacts: [
{
kind: "screenshot",
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
path: path.join(
runDir,
"surfaces",
"web-ui",
"stages",
"first-run",
"screenshot.png",
),
source: "ux-matrix:web-ui:first-run",
},
],
@@ -292,7 +462,10 @@ describe("evidence gallery", () => {
artifacts: [
{
kind: "log",
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
path: repoRelativePath(
repoRoot,
path.join(runDir, "surfaces", "cli", "stages", "error-state", "logs.txt"),
),
source: "ux-matrix:cli:error-state",
},
],
@@ -326,8 +499,8 @@ describe("evidence gallery", () => {
blocked: 1,
"proof-gap": 1,
},
stages: ["first-run", "error-state"],
surfaces: ["web-ui", "cli"],
stages: ["<repo-root>/diagnostics", "first-run", "error-state"],
surfaces: ["<repo-root>/native", "web-ui", "cli"],
},
releaseLedger: {
counts: {
@@ -340,21 +513,19 @@ describe("evidence gallery", () => {
expect(model.producerContext?.matrix?.cells).toEqual([
{
artifactKinds: ["screenshot"],
artifactPaths: [
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
],
coverageIds: ["ui.control"],
artifactPaths: [expect.stringContaining(expectedWebScreenshotNeedle)],
coverageIds: ["<repo-root>/ui.control"],
runner: {
availability: "local",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
command: "<repo-root>/openclaw.mjs qa suite --scenario ux-matrix-evidence-dashboard",
lane: "web-ui-playwright",
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
workflow: "<repo-root>/.github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "pass",
surface: "web-ui",
testId: "ux-matrix.web-ui.first-run",
title: "UX Matrix: web-ui / first-run",
title: "UX Matrix: web-ui / first-run at <repo-root>",
},
{
artifactKinds: [],
@@ -374,9 +545,7 @@ describe("evidence gallery", () => {
},
{
artifactKinds: ["log"],
artifactPaths: [
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
],
artifactPaths: [expect.stringContaining(expectedCliLogNeedle)],
coverageIds: [],
runner: null,
stage: "error-state",
@@ -388,9 +557,12 @@ describe("evidence gallery", () => {
]);
expect(model.producerContext?.scorecard?.preview).toContain("# UX Matrix");
expect(model.producerContext?.scorecard?.href).toContain("/api/evidence/artifact?");
expect(model.producerContext?.scorecard?.href).not.toContain(repoRoot);
expect(decodeURIComponent(model.producerContext?.scorecard?.href ?? "")).not.toContain(
repoRoot,
);
expect(model.producerContext?.commands?.preview).toBe("node ux matrix\n");
expect(model.producerContext?.commands?.path).toContain("commands.txt");
expect(decodeURIComponent(model.producerContext?.commands?.href ?? "")).not.toContain(repoRoot);
expect(model.producerContext?.manifest?.preview).toContain('"runId": "run-1"');
expect(model.producerContext?.releaseLedger?.preview).toContain('"proof-gap": 1');
expect(model.producerContext?.preflight.memory?.path).toContain("preflight/memory.txt");
@@ -400,6 +572,14 @@ describe("evidence gallery", () => {
);
expect(model.producerContext?.preflight.adbDevices?.preview).toBe("List of devices\n");
expect(model.evidencePath).toBe(".artifacts/qa-e2e/suite/qa-evidence.json");
expect(JSON.stringify(model)).not.toContain(repoRoot);
await expect(
resolveQaEvidenceProducerFile({
evidencePath: suiteDir,
producerFile: "scorecard",
repoRoot,
}),
).resolves.toBe(await fs.realpath(path.join(runDir, "scorecard.md")));
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-evidence-outside-"));
const outsideCommands = path.join(outsideDir, "commands.txt");
await fs.writeFile(outsideCommands, "outside secret\n", "utf8");
@@ -413,8 +593,7 @@ describe("evidence gallery", () => {
expect(JSON.stringify(symlinkModel)).not.toContain("outside secret");
await expect(
resolveQaEvidenceArtifactFile({
artifactPath:
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/scorecard.md",
artifactPath: path.relative(repoRoot, path.join(runDir, "scorecard.md")),
evidencePath: suiteDir,
repoRoot,
}),

View File

@@ -1,6 +1,8 @@
// Qa Lab plugin module implements generic QA evidence gallery data.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
QaEvidenceArtifactView,
@@ -10,7 +12,7 @@ import type {
QaEvidenceProducerContext,
QaEvidenceProducerContextFile,
} from "../shared/evidence-gallery-types.js";
import { toRepoRelativePath } from "./cli-paths.js";
import { toRepoPath, toRepoRelativePath } from "./cli-paths.js";
import {
QA_EVIDENCE_FILENAME,
validateQaEvidenceSummaryJson,
@@ -29,6 +31,7 @@ export type {
const TEXT_PREVIEW_BYTES = 12 * 1024;
const ARTIFACT_VIEW_CONCURRENCY = 8;
const REPO_ROOT_ARTIFACT_PATH_PREFIX = "<repo-root>/";
const UX_MATRIX_PRODUCER_FILES = [
{ key: "commands", path: "commands.txt", previewKind: "text" },
@@ -40,6 +43,7 @@ const UX_MATRIX_PRODUCER_FILES = [
{ key: "adbDevices", path: path.join("preflight", "adb-devices.txt"), previewKind: "text" },
] as const;
type UxMatrixProducerFileKey = (typeof UX_MATRIX_PRODUCER_FILES)[number]["key"];
type QaEvidenceArtifact = NonNullable<QaEvidenceSummaryEntry["execution"]>["artifacts"][number];
export class QaEvidenceGalleryError extends Error {
@@ -61,6 +65,70 @@ function isInside(root: string, candidate: string) {
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function sanitizeGalleryText(
value: string,
params: {
extraRoots?: readonly string[];
repoRoot: string;
},
) {
const localRoots = [...new Set([params.repoRoot, ...(params.extraRoots ?? [])])];
const roots = [
...localRoots.flatMap((root) => [
{ from: path.resolve(root), to: "<repo-root>" },
{ from: pathToFileURL(path.resolve(root)).href, to: "file://<repo-root>" },
]),
{ from: os.homedir(), to: "<home>" },
{ from: pathToFileURL(os.homedir()).href, to: "file://<home>" },
].filter((entry) => entry.from && entry.from !== path.parse(entry.from).root);
return roots
.toSorted((a, b) => b.from.length - a.from.length)
.reduce((text, entry) => text.replaceAll(entry.from, entry.to), value);
}
function displayGalleryPath(
value: string,
params: {
extraRoots?: readonly string[];
repoRoot: string;
},
) {
if (path.isAbsolute(value)) {
const absolute = path.resolve(value);
for (const root of [params.repoRoot, ...(params.extraRoots ?? [])]) {
const resolvedRoot = path.resolve(root);
if (isInside(resolvedRoot, absolute)) {
return sanitizeGalleryText(toRepoPath(path.relative(resolvedRoot, absolute)), params);
}
}
}
return sanitizeGalleryText(value, params);
}
function sanitizeGalleryPreview(
value: string | null,
params: {
extraRoots?: readonly string[];
repoRoot: string;
},
) {
return value === null ? null : sanitizeGalleryText(value, params);
}
function sanitizeGalleryStringArray(
values: Iterable<unknown>,
params: {
extraRoots?: readonly string[];
repoRoot: string;
},
) {
return readOrderedStringArray(
Array.from(values)
.filter((value): value is string => typeof value === "string")
.map((value) => sanitizeGalleryText(value, params)),
);
}
async function realpathIfExists(filePath: string): Promise<string | null> {
return fs.realpath(filePath).catch(() => null);
}
@@ -143,11 +211,86 @@ export async function resolveQaEvidenceArtifactFile(params: {
throw evidenceError("Evidence artifact is not declared by this evidence summary.", 403);
}
export async function resolveQaEvidenceArtifactFileByIndex(params: {
artifactIndex: number;
entryIndex: number;
evidencePath: string;
repoRoot: string;
}): Promise<string> {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidencePath = await resolveQaEvidenceFile({ inputPath: params.evidencePath, repoRoot });
if (
!Number.isSafeInteger(params.entryIndex) ||
params.entryIndex < 0 ||
!Number.isSafeInteger(params.artifactIndex) ||
params.artifactIndex < 0
) {
throw evidenceError("Evidence artifact index is invalid.", 400);
}
const summary = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(evidencePath, "utf8")) as unknown,
);
const artifact = summary.entries[params.entryIndex]?.execution?.artifacts[params.artifactIndex];
if (!artifact) {
throw evidenceError("Evidence artifact not found.", 404);
}
const artifactFile = await resolveArtifactFileWithinRoots({
artifactPath: artifact.path,
evidenceDir: path.dirname(evidencePath),
repoRoot,
});
if (!artifactFile) {
throw evidenceError("Evidence artifact not found.", 404);
}
return artifactFile;
}
export async function resolveQaEvidenceProducerFile(params: {
evidencePath: string;
producerFile: string;
repoRoot: string;
}): Promise<string> {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidencePath = await resolveQaEvidenceFile({ inputPath: params.evidencePath, repoRoot });
const producerFile = UX_MATRIX_PRODUCER_FILES.find((file) => file.key === params.producerFile);
if (!producerFile) {
throw evidenceError("Evidence producer file is unknown.", 400);
}
const summary = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(evidencePath, "utf8")) as unknown,
);
const producerRoot = await findUxMatrixProducerRoot({
evidencePath,
repoRoot,
summaryEntries: summary.entries,
});
if (!producerRoot) {
throw evidenceError("Evidence producer context not found.", 404);
}
const evidenceDir = path.dirname(evidencePath);
const producerPath = path.join(producerRoot, producerFile.path);
const realProducerFile = await resolveContainedFileIfExists(producerPath, [
repoRoot,
evidenceDir,
]);
if (!realProducerFile) {
throw evidenceError("Evidence producer file not found.", 404);
}
return realProducerFile;
}
function isExplicitRepoRootArtifactPath(raw: string): boolean {
const normalized = raw.split(/[\\/]+/u).join("/");
return normalized.startsWith(".artifacts/");
}
function repoRootTokenArtifactPath(raw: string): string | null {
const normalized = raw.split(/[\\/]+/u).join("/");
return normalized.startsWith(REPO_ROOT_ARTIFACT_PATH_PREFIX)
? normalized.slice(REPO_ROOT_ARTIFACT_PATH_PREFIX.length)
: null;
}
// Resolve an artifact path against pre-resolved roots without re-reading the evidence file.
// Returns null when the path is missing or escapes both roots; callers map that to an error.
async function resolveArtifactFileWithinRoots(params: {
@@ -159,8 +302,13 @@ async function resolveArtifactFileWithinRoots(params: {
if (!raw) {
return null;
}
const candidates = path.isAbsolute(raw) ? [raw] : [path.resolve(params.evidenceDir, raw)];
if (!path.isAbsolute(raw) && isExplicitRepoRootArtifactPath(raw)) {
const tokenPath = repoRootTokenArtifactPath(raw);
const candidates = tokenPath
? [path.resolve(params.repoRoot, tokenPath)]
: path.isAbsolute(raw)
? [raw]
: [path.resolve(params.evidenceDir, raw)];
if (!tokenPath && !path.isAbsolute(raw) && isExplicitRepoRootArtifactPath(raw)) {
candidates.push(path.resolve(params.repoRoot, raw));
}
for (const candidate of candidates) {
@@ -290,38 +438,66 @@ async function readJsonIfExists(
}
}
function artifactHref(evidencePath: string, artifactPath: string) {
const params = new URLSearchParams({
evidencePath,
artifactPath,
});
function artifactHref(
evidencePath: string,
artifact:
| {
artifactPath: string;
}
| {
artifactIndex: number;
entryIndex: number;
}
| {
producerFile: UxMatrixProducerFileKey;
},
) {
const params = new URLSearchParams({ evidencePath });
if ("artifactPath" in artifact) {
params.set("artifactPath", artifact.artifactPath);
} else if ("producerFile" in artifact) {
params.set("producerFile", artifact.producerFile);
} else {
params.set("entryIndex", String(artifact.entryIndex));
params.set("artifactIndex", String(artifact.artifactIndex));
}
return `/api/evidence/artifact?${params.toString()}`;
}
async function buildProducerContextFile(params: {
allowedRoots: readonly string[];
artifactPath: string;
extraRoots: readonly string[];
filePath: string;
hrefEvidencePath: string;
previewKind: "json" | "text";
producerFile: UxMatrixProducerFileKey;
repoRoot: string;
}): Promise<QaEvidenceProducerContextFile | null> {
const realFile = await resolveContainedFileIfExists(params.filePath, params.allowedRoots);
if (!realFile) {
return null;
}
const repoPath = toRepoRelativePath(params.repoRoot, params.filePath);
return {
href: artifactHref(params.hrefEvidencePath, params.artifactPath),
path: repoPath,
preview: await readPreview(realFile, params.previewKind).catch(() => null),
href: artifactHref(params.hrefEvidencePath, { producerFile: params.producerFile }),
path: displayGalleryPath(params.filePath, params),
preview: await readPreview(realFile, params.previewKind)
.then((preview) =>
sanitizeGalleryPreview(preview, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
}),
)
.catch(() => null),
};
}
async function buildArtifactView(params: {
allowedArtifactFiles: ReadonlySet<string>;
artifactIndex: number;
artifact: QaEvidenceArtifact;
evidenceDir: string;
entryIndex: number;
extraRoots: readonly string[];
hrefEvidencePath: string;
repoRoot: string;
}): Promise<QaEvidenceArtifactView> {
@@ -331,6 +507,16 @@ async function buildArtifactView(params: {
evidenceDir: params.evidenceDir,
repoRoot: params.repoRoot,
}).catch(() => null);
const realFileRepoPath =
realFile && isInside(params.repoRoot, realFile)
? toRepoRelativePath(params.repoRoot, realFile)
: null;
const displayPath =
(realFileRepoPath ? sanitizeGalleryText(realFileRepoPath, params) : null) ??
sanitizeGalleryText(params.artifact.path, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
});
if (!realFile || !params.allowedArtifactFiles.has(realFile)) {
return {
exists: false,
@@ -338,24 +524,37 @@ async function buildArtifactView(params: {
? "Evidence artifact is not declared by this evidence summary."
: "Evidence artifact not found.",
href: null,
kind: params.artifact.kind,
kind: sanitizeGalleryText(params.artifact.kind, params),
mediaKind,
path: params.artifact.path,
path: displayPath,
preview: null,
source: params.artifact.source,
source: sanitizeGalleryText(params.artifact.source, params),
};
}
return {
exists: true,
error: null,
href: artifactHref(params.hrefEvidencePath, params.artifact.path),
kind: params.artifact.kind,
href: artifactHref(params.hrefEvidencePath, {
artifactIndex: params.artifactIndex,
entryIndex: params.entryIndex,
}),
kind: sanitizeGalleryText(params.artifact.kind, params),
mediaKind,
path: params.artifact.path,
preview: await readPreview(realFile, mediaKind).catch(
(error: unknown) => `Preview unavailable: ${formatErrorMessage(error)}`,
),
source: params.artifact.source,
path: displayPath,
preview: await readPreview(realFile, mediaKind)
.then((preview) =>
sanitizeGalleryPreview(preview, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
}),
)
.catch((error: unknown) =>
sanitizeGalleryText(`Preview unavailable: ${formatErrorMessage(error)}`, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
}),
),
source: sanitizeGalleryText(params.artifact.source, params),
};
}
@@ -391,19 +590,26 @@ function readStringArray(values: Iterable<unknown>) {
return readOrderedStringArray(values).toSorted();
}
function readMatrixDimensionIds(value: unknown, fallback: readonly string[]): string[] {
if (!Array.isArray(value)) {
return readOrderedStringArray(fallback);
function readMatrixDimensionIds(params: {
extraRoots: readonly string[];
fallback: readonly string[];
repoRoot: string;
value: unknown;
}): string[] {
if (!Array.isArray(params.value)) {
return sanitizeGalleryStringArray(params.fallback, params);
}
const ids = readOrderedStringArray(
value.map((entry) => {
const ids = sanitizeGalleryStringArray(
params.value.map((entry) => {
if (typeof entry === "string") {
return entry;
}
return readString(readRecord(entry)?.id);
}),
params,
);
for (const fallbackId of fallback) {
for (const rawFallbackId of params.fallback) {
const fallbackId = sanitizeGalleryText(rawFallbackId, params);
if (!ids.includes(fallbackId)) {
ids.push(fallbackId);
}
@@ -439,7 +645,9 @@ function buildUxMatrixEvidenceEntryIndex(entries: readonly QaEvidenceSummaryEntr
}
function readMatrixCells(params: {
extraRoots: readonly string[];
matrix: Record<string, unknown> | null;
repoRoot: string;
summaryEntries: readonly QaEvidenceSummaryEntry[];
}): QaEvidenceMatrixCellView[] {
const rawCells = Array.isArray(params.matrix?.cells)
@@ -449,34 +657,54 @@ function readMatrixCells(params: {
: [];
const entriesByCell = buildUxMatrixEvidenceEntryIndex(params.summaryEntries);
return rawCells.flatMap((cell): QaEvidenceMatrixCellView[] => {
const surface = readString(cell.surface);
const stage = readString(cell.stage);
const status = readString(cell.status) ?? "proof-gap";
if (!surface || !stage) {
const rawSurface = readString(cell.surface);
const rawStage = readString(cell.stage);
const rawStatus = readString(cell.status) ?? "proof-gap";
if (!rawSurface || !rawStage) {
return [];
}
const entry =
status === "proof-gap" ? null : (entriesByCell.get(`${surface}:${stage}`) ?? null);
rawStatus === "proof-gap" ? null : (entriesByCell.get(`${rawSurface}:${rawStage}`) ?? null);
const artifacts = entry?.execution?.artifacts ?? [];
const runner = readRecord(cell.runner);
const sanitizeCellString = (value: string) =>
sanitizeGalleryText(value, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
});
const readRunnerString = (value: unknown) => {
const text = readString(value);
return text ? sanitizeCellString(text) : null;
};
return [
{
artifactKinds: readStringArray(artifacts.map((artifact) => artifact.kind)),
artifactPaths: artifacts.map((artifact) => artifact.path),
coverageIds: readStringArray(Array.isArray(cell.coverageIds) ? cell.coverageIds : []),
artifactKinds: readStringArray(
artifacts.map((artifact) => sanitizeCellString(artifact.kind)),
),
artifactPaths: artifacts.map((artifact) =>
displayGalleryPath(artifact.path, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
}),
),
coverageIds: readStringArray(
(Array.isArray(cell.coverageIds) ? cell.coverageIds : []).map((coverageId) =>
typeof coverageId === "string" ? sanitizeCellString(coverageId) : coverageId,
),
),
runner: runner
? {
availability: readString(runner.availability),
command: readString(runner.command),
lane: readString(runner.lane),
workflow: readString(runner.workflow),
availability: readRunnerString(runner.availability),
command: readRunnerString(runner.command),
lane: readRunnerString(runner.lane),
workflow: readRunnerString(runner.workflow),
}
: null,
stage,
status,
surface,
testId: entry?.test.id ?? null,
title: entry?.test.title ?? null,
stage: sanitizeCellString(rawStage),
status: sanitizeCellString(rawStatus),
surface: sanitizeCellString(rawSurface),
testId: entry?.test.id ? sanitizeCellString(entry.test.id) : null,
title: entry?.test.title ? sanitizeCellString(entry.test.title) : null,
},
];
});
@@ -533,6 +761,7 @@ async function findUxMatrixProducerRoot(params: {
async function buildProducerContext(params: {
evidencePath: string;
extraRoots: readonly string[];
hrefEvidencePath: string;
repoRoot: string;
summaryEntries: readonly QaEvidenceSummaryEntry[];
@@ -555,16 +784,20 @@ async function buildProducerContext(params: {
const manifest = await readJsonIfExists(manifestPath, allowedRoots);
const matrix = await readJsonIfExists(matrixPath, allowedRoots);
const releaseLedger = await readJsonIfExists(releaseLedgerPath, allowedRoots);
const run = readRecord(manifest?.run);
const runId = readString(run?.runId);
const runStatus = readString(run?.status);
const producerFiles = Object.fromEntries(
await Promise.all(
UX_MATRIX_PRODUCER_FILES.map(async (file) => [
file.key,
await buildProducerContextFile({
allowedRoots,
artifactPath: toRepoRelativePath(repoRoot, producerPaths[file.key]),
extraRoots: params.extraRoots,
filePath: producerPaths[file.key],
hrefEvidencePath: params.hrefEvidencePath,
previewKind: file.previewKind,
producerFile: file.key,
repoRoot,
}),
]),
@@ -574,7 +807,9 @@ async function buildProducerContext(params: {
QaEvidenceProducerContextFile | null
>;
const matrixCells = readMatrixCells({
extraRoots: params.extraRoots,
matrix,
repoRoot,
summaryEntries: params.summaryEntries,
});
return {
@@ -584,23 +819,27 @@ async function buildProducerContext(params: {
manifest && producerFiles.manifest
? {
...producerFiles.manifest,
runId: readString(readRecord(manifest.run)?.runId),
runStatus: readString(readRecord(manifest.run)?.status),
runId: runId ? sanitizeGalleryText(runId, params) : null,
runStatus: runStatus ? sanitizeGalleryText(runStatus, params) : null,
}
: null,
matrix: matrix
? {
cells: matrixCells,
counts: readCountRecord(matrix.counts),
path: toRepoRelativePath(repoRoot, matrixPath),
stages: readMatrixDimensionIds(
matrix.stages,
matrixCells.map((cell) => cell.stage),
),
surfaces: readMatrixDimensionIds(
matrix.surfaces,
matrixCells.map((cell) => cell.surface),
),
path: displayGalleryPath(matrixPath, { extraRoots: params.extraRoots, repoRoot }),
stages: readMatrixDimensionIds({
extraRoots: params.extraRoots,
fallback: matrixCells.map((cell) => cell.stage),
repoRoot,
value: matrix.stages,
}),
surfaces: readMatrixDimensionIds({
extraRoots: params.extraRoots,
fallback: matrixCells.map((cell) => cell.surface),
repoRoot,
value: matrix.surfaces,
}),
}
: null,
preflight: {
@@ -614,7 +853,7 @@ async function buildProducerContext(params: {
counts: readCountRecord(releaseLedger.counts),
}
: null,
rootPath: toRepoRelativePath(repoRoot, rootPath),
rootPath: displayGalleryPath(rootPath, { extraRoots: params.extraRoots, repoRoot }),
scorecard: producerFiles.scorecard,
};
}
@@ -642,7 +881,8 @@ export async function buildQaEvidenceGalleryModel(params: {
evidencePath: string;
repoRoot: string;
}): Promise<QaEvidenceGalleryModel> {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const requestedRepoRoot = path.resolve(params.repoRoot);
const repoRoot = await fs.realpath(requestedRepoRoot);
const evidencePath = await resolveQaEvidenceFile({
inputPath: params.evidencePath,
repoRoot,
@@ -667,29 +907,47 @@ export async function buildQaEvidenceGalleryModel(params: {
});
const limitArtifactView = createConcurrencyLimit(ARTIFACT_VIEW_CONCURRENCY);
const entries = await Promise.all(
summary.entries.map(async (entry): Promise<QaEvidenceGalleryEntryView> => {
summary.entries.map(async (entry, entryIndex): Promise<QaEvidenceGalleryEntryView> => {
counts[entry.result.status] += 1;
const sanitizeEntryText = (value: string) =>
sanitizeGalleryText(value, {
extraRoots: [requestedRepoRoot],
repoRoot,
});
return {
artifacts: await Promise.all(
(entry.execution?.artifacts ?? []).map((artifact) =>
(entry.execution?.artifacts ?? []).map((artifact, artifactIndex) =>
limitArtifactView(() =>
buildArtifactView({
allowedArtifactFiles,
artifact,
artifactIndex,
evidenceDir,
entryIndex,
extraRoots: [requestedRepoRoot],
hrefEvidencePath,
repoRoot,
}),
),
),
),
coverage: entry.coverage,
failureReason: entry.result.failure?.reason ?? null,
id: entry.test.id,
kind: entry.test.kind,
sourcePath: entry.test.source?.path ?? null,
coverage: entry.coverage.map((coverage) => ({
id: sanitizeEntryText(coverage.id),
role: sanitizeEntryText(coverage.role),
})),
failureReason: entry.result.failure?.reason
? sanitizeEntryText(entry.result.failure.reason)
: null,
id: sanitizeEntryText(entry.test.id),
kind: sanitizeEntryText(entry.test.kind),
sourcePath: entry.test.source?.path
? displayGalleryPath(entry.test.source.path, {
extraRoots: [requestedRepoRoot],
repoRoot,
})
: null,
status: entry.result.status,
title: entry.test.title,
title: sanitizeEntryText(entry.test.title),
};
}),
);
@@ -699,9 +957,12 @@ export async function buildQaEvidenceGalleryModel(params: {
evidenceMode: summary.evidenceMode,
evidencePath: hrefEvidencePath,
generatedAt: summary.generatedAt,
profile: summary.profile ?? null,
profile: summary.profile
? sanitizeGalleryText(summary.profile, { extraRoots: [requestedRepoRoot], repoRoot })
: null,
producerContext: await buildProducerContext({
evidencePath,
extraRoots: [requestedRepoRoot],
hrefEvidencePath,
repoRoot,
summaryEntries: summary.entries,

View File

@@ -1087,14 +1087,27 @@ describe("buildQaRuntimeEnv", () => {
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER=convex-maintainer-secret",
"OPENCLAW_LIVE_CODEX_API_KEY=codex-live-secret",
"botToken=12345:AbCdEfGhIjKl",
"--botToken=12345:flag-secret",
'"driverToken":"12345:driver-secr3t"',
"sutToken='12345:sut-secr3t'",
"leaseToken=lease-12345",
'"apiKey":"secret-json-api-key"',
"clientSecret=secret-client-secret&secret-tail",
"url=http://127.0.0.1:18789/#token=abc123",
"callback=https://gateway.example.test/callback?access_token=secret-access-token&ok=1",
].join("\n"),
"utf8",
);
await writeFile(
stderrLogPath,
[
"Authorization: Bearer secret+/token=123456",
"Cookie: qa_session=secret-cookie; theme=dark",
"Set-Cookie: qa_session=secret-cookie; HttpOnly",
"x-api-key: secret-header-api-key",
].join("\n"),
"utf8",
);
await writeFile(stderrLogPath, "Authorization: Bearer secret+/token=123456", "utf8");
await mkdir(path.join(tempRoot, "state"), { recursive: true });
await writeFile(path.join(tempRoot, "state", "secret.txt"), "do-not-copy", "utf8");
@@ -1119,14 +1132,23 @@ describe("buildQaRuntimeEnv", () => {
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER=<redacted>",
"OPENCLAW_LIVE_CODEX_API_KEY=<redacted>",
"botToken=<redacted>",
"--botToken=<redacted>",
'"driverToken":"<redacted>"',
"sutToken=<redacted>",
"leaseToken=<redacted>",
'"apiKey":"<redacted>"',
"clientSecret=<redacted>",
"url=http://127.0.0.1:18789/#token=<redacted>",
"callback=https://gateway.example.test/callback?access_token=<redacted>&ok=1",
].join("\n"),
);
await expect(readFile(path.join(artifactDir, "gateway.stderr.log"), "utf8")).resolves.toBe(
"Authorization: Bearer <redacted>",
[
"Authorization: Bearer <redacted>",
"Cookie: <redacted>",
"Set-Cookie: <redacted>",
"x-api-key: <redacted>",
].join("\n"),
);
await expect(readFile(path.join(artifactDir, "README.txt"), "utf8")).resolves.toContain(
"was not copied because it may contain credentials or auth tokens",

View File

@@ -25,7 +25,7 @@ import {
resolveQaRuntimeHostVersion,
} from "./bundled-plugin-staging.js";
import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js";
import { QaSuiteInfraError } from "./errors.js";
import { QaSuiteInfraError, toQaErrorObject } from "./errors.js";
import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway-log-redaction.js";
import { startQaGatewayRpcClient } from "./gateway-rpc-client.js";
import { splitQaModelRef, type QaProviderMode } from "./model-selection.js";
@@ -810,7 +810,7 @@ export async function startQaGatewayChild(params: {
}
}
if (!rpcReady) {
throw toLintErrorObject(
throw toQaErrorObject(
lastRpcStartupError ?? new Error("qa gateway rpc client failed to start"),
"Non-Error thrown",
);
@@ -913,7 +913,7 @@ export async function startQaGatewayChild(params: {
}
}
if (!rpcReady) {
throw toLintErrorObject(
throw toQaErrorObject(
lastRpcStartupError ?? new Error("qa gateway rpc client failed to start"),
"Non-Error thrown",
);
@@ -1067,17 +1067,3 @@ export async function startQaGatewayChild(params: {
}
}
export { testing as __testing };
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -10,11 +10,35 @@ const QA_GATEWAY_DEBUG_SECRET_ENV_VARS = Object.freeze([
"OPENCLAW_GATEWAY_TOKEN",
]);
const QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS = Object.freeze([
"accessToken",
"access_token",
"apiKey",
"api_key",
"botToken",
"clientSecret",
"client_secret",
"cookie",
"driverToken",
"sutToken",
"leaseToken",
"refreshToken",
"refresh_token",
"set-cookie",
"x-api-key",
]);
const QA_GATEWAY_DEBUG_SECRET_QUERY_KEYS = Object.freeze([
"access_token",
"api_key",
"apiKey",
"auth",
"deviceToken",
"id_token",
"key",
"password",
"refresh_token",
"token",
]);
const QA_GATEWAY_DEBUG_SECRET_HEADER_KEYS = Object.freeze(["cookie", "set-cookie", "x-api-key"]);
function redactSecretEnvKeyPattern(text: string, pattern: RegExp) {
const source = pattern.source.replace(/^\^/u, "").replace(/\$$/u, "");
@@ -26,8 +50,30 @@ function redactSecretEnvKeyPattern(text: string, pattern: RegExp) {
.replace(new RegExp(`"(${source})"\\s*:\\s*"[^"]*"`, "g"), `"$1":"<redacted>"`);
}
function redactSecretValueKey(text: string, key: string) {
const escapedKey = escapeRegExp(key);
return text
.replace(new RegExp(`([?#&]${escapedKey}=)[^&\\s]+`, "gi"), "$1<redacted>")
.replace(
new RegExp(`(^|\\s)(--${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
`$1$2$3<redacted>`,
)
.replace(
new RegExp(`(^|[^\\w?#&-])(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
`$1$2$3<redacted>`,
)
.replace(new RegExp(`("${escapedKey}"\\s*:\\s*)"[^"]*"`, "gi"), `$1"<redacted>"`);
}
export function redactQaGatewayDebugText(text: string) {
let redacted = text;
for (const key of QA_GATEWAY_DEBUG_SECRET_HEADER_KEYS) {
const escapedKey = escapeRegExp(key);
redacted = redacted.replace(
new RegExp(`^(\\s*${escapedKey}\\s*:\\s*).+$`, "gim"),
"$1<redacted>",
);
}
for (const envVar of QA_GATEWAY_DEBUG_SECRET_ENV_VARS) {
const escapedEnvVar = escapeRegExp(envVar);
redacted = redacted.replace(
@@ -43,20 +89,18 @@ export function redactQaGatewayDebugText(text: string) {
redacted = redactSecretEnvKeyPattern(redacted, pattern);
}
for (const key of QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS) {
const escapedKey = escapeRegExp(key);
redacted = redacted.replace(
new RegExp(`\\b(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
`$1$2<redacted>`,
);
redacted = redacted.replace(
new RegExp(`("${escapedKey}"\\s*:\\s*)"[^"]*"`, "gi"),
`$1"<redacted>"`,
);
redacted = redactSecretValueKey(redacted, key);
}
return redacted
.replaceAll(/\bsk-ant-oat01-[A-Za-z0-9_-]+\b/g, "<redacted>")
.replaceAll(/\bBearer\s+[^\s"'<>]{8,}/gi, "Bearer <redacted>")
.replaceAll(/([?#&]token=)[^&\s]+/gi, "$1<redacted>");
.replaceAll(
new RegExp(
`([?#&](?:${QA_GATEWAY_DEBUG_SECRET_QUERY_KEYS.map(escapeRegExp).join("|")})=)[^&\\s]+`,
"gi",
),
"$1<redacted>",
);
}
export function formatQaGatewayLogsForError(logs: string) {

View File

@@ -1,5 +1,5 @@
import { compareToolCallShape, stableHash } from "./parity-shared.js";
// Qa Lab plugin module implements harness parity behavior.
import { createHash } from "node:crypto";
import type {
RuntimeId,
RuntimeParityCell,
@@ -98,20 +98,6 @@ export type HarnessParityResult = {
firstDriftTurn?: number;
};
export type HarnessParityReport = {
generatedAt: string;
providerMode: string;
left: HarnessVariant;
right: HarnessVariant;
results: HarnessParityResult[];
pass: boolean;
failures: string[];
};
function sha256(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function countComparableTranscriptRecords(transcriptBytes: string) {
let count = 0;
for (const line of transcriptBytes.split(/\r?\n/u)) {
@@ -137,25 +123,6 @@ function countComparableTranscriptRecords(transcriptBytes: string) {
return count;
}
function normalizeForStableHash(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => normalizeForStableHash(entry));
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.toSorted((left, right) => left.localeCompare(right))
.map((key) => [key, normalizeForStableHash(record[key])]),
);
}
return value;
}
function stableHash(value: unknown) {
return sha256(JSON.stringify(normalizeForStableHash(value)) ?? "null");
}
function readPositiveNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
@@ -200,23 +167,6 @@ function normalizeTextForParity(text: string) {
return text.replace(/\s+/gu, " ").trim();
}
function compareToolCallShape(left: RuntimeParityToolCall[], right: RuntimeParityToolCall[]) {
if (left.length !== right.length) {
return `tool call count differs (${left.length} vs ${right.length})`;
}
for (let index = 0; index < left.length; index += 1) {
const leftCall = left[index];
const rightCall = right[index];
if (!leftCall || !rightCall) {
return `tool call row ${index + 1} missing`;
}
if (leftCall.tool !== rightCall.tool || leftCall.argsHash !== rightCall.argsHash) {
return `tool call ${index + 1} differs (${leftCall.tool}/${leftCall.argsHash} vs ${rightCall.tool}/${rightCall.argsHash})`;
}
}
return undefined;
}
function compareToolResultShape(left: RuntimeParityToolCall[], right: RuntimeParityToolCall[]) {
const total = Math.min(left.length, right.length);
for (let index = 0; index < total; index += 1) {

View File

@@ -384,7 +384,8 @@ describe("qa-lab server", () => {
port: 0,
outputPath,
repoRoot,
controlUiUrl: "http://127.0.0.1:18789/?token=qa-token&panel=chat#token=fragment-token",
controlUiUrl:
"https://gateway.example.test/?token=qa-token&api_key=qa-api-key&id_token=qa-id-token&panel=chat#token=fragment-token",
embeddedGateway: "disabled",
});
cleanups.push(async () => {
@@ -403,8 +404,8 @@ describe("qa-lab server", () => {
};
expect(bootstrap.defaults.conversationId).toBe("qa-operator");
expect(bootstrap.defaults.senderId).toBe("qa-operator");
expect(bootstrap.controlUiUrl).toBe("http://127.0.0.1:18789/?panel=chat");
expect(bootstrap.controlUiEmbeddedUrl).toBe("http://127.0.0.1:18789/?panel=chat");
expect(bootstrap.controlUiUrl).toBe("https://gateway.example.test/?panel=chat");
expect(bootstrap.controlUiEmbeddedUrl).toBe("https://gateway.example.test/?panel=chat");
expect(bootstrap.kickoffTask).toContain("Lobster Invaders");
expect(bootstrap.scenarios.length).toBeGreaterThanOrEqual(10);
expect(bootstrap.scenarios.map((scenario) => scenario.id)).toContain("dm-chat-baseline");
@@ -422,7 +423,20 @@ describe("qa-lab server", () => {
).json()) as {
status: { gateway: { url: string } };
};
expect(startupStatus.status.gateway.url).toBe("http://127.0.0.1:18789/?panel=chat");
expect(startupStatus.status.gateway.url).toBe("https://gateway.example.test/?panel=chat");
lab.setControlUi({
controlUiUrl:
"/control-ui/?token=late-token&api_key=late-api-key&id_token=late-id-token&panel=chat#token=fragment-token",
});
const relativeBootstrap = (await (
await fetchWithRetry(`${lab.baseUrl}/api/bootstrap`)
).json()) as {
controlUiUrl: string | null;
controlUiEmbeddedUrl: string | null;
};
expect(relativeBootstrap.controlUiUrl).toBe("/control-ui/?panel=chat");
expect(relativeBootstrap.controlUiEmbeddedUrl).toBe("/control-ui/?panel=chat");
const messageResponse = await fetch(`${lab.baseUrl}/api/inbound/message`, {
method: "POST",

View File

@@ -19,7 +19,9 @@ import { createQaBusState, type QaBusState } from "./bus-state.js";
import {
QaEvidenceGalleryError,
buildQaEvidenceGalleryModel,
resolveQaEvidenceArtifactFileByIndex,
resolveQaEvidenceArtifactFile,
resolveQaEvidenceProducerFile,
} from "./evidence-gallery.js";
import { createQaRunnerRuntime } from "./harness-runtime.js";
import {
@@ -137,12 +139,33 @@ function createBootstrapDefaults(autoKickoffTarget?: string): QaLabBootstrapDefa
const CONTROL_UI_CREDENTIAL_QUERY_KEYS = new Set([
"access_token",
"api_key",
"apikey",
"auth",
"devicetoken",
"id_token",
"password",
"refresh_token",
"token",
]);
const CONTROL_UI_CREDENTIAL_QUERY_PATTERN =
/([?&])(?:access_token|api_?key|auth|deviceToken|id_token|password|refresh_token|token)=[^&#\s]*&?/gi;
function stripSensitiveQueryParamsFromText(rawUrl: string): string {
let sanitized = rawUrl;
for (;;) {
const next = sanitized
.replace(CONTROL_UI_CREDENTIAL_QUERY_PATTERN, (match: string, separator: string) =>
match.endsWith("&") ? separator : "",
)
.replace(/[?&]$/, "")
.replace("?&", "?");
if (next === sanitized) {
return next;
}
sanitized = next;
}
}
function stripSensitiveQueryParams(rawUrl: string): string {
try {
@@ -154,13 +177,7 @@ function stripSensitiveQueryParams(rawUrl: string): string {
}
return url.toString();
} catch {
return rawUrl
.replace(
/([?&])(?:access_token|auth|deviceToken|password|refresh_token|token)=[^&#\s]*&?/gi,
(match: string, separator: string) => (match.endsWith("&") ? separator : ""),
)
.replace(/[?&]$/, "")
.replace("?&", "?");
return stripSensitiveQueryParamsFromText(rawUrl);
}
}
@@ -453,15 +470,34 @@ export async function startQaLabServer(
) {
const evidencePath = url.searchParams.get("evidencePath")?.trim();
const artifactPath = url.searchParams.get("artifactPath")?.trim();
if (!evidencePath || !artifactPath) {
writeError(res, 400, "Missing evidencePath or artifactPath");
const producerFile = url.searchParams.get("producerFile")?.trim();
const entryIndexText = url.searchParams.get("entryIndex")?.trim();
const artifactIndexText = url.searchParams.get("artifactIndex")?.trim();
if (
!evidencePath ||
(!artifactPath && !producerFile && (!entryIndexText || !artifactIndexText))
) {
writeError(res, 400, "Missing evidencePath and artifact selector");
return;
}
const artifactFile = await resolveQaEvidenceArtifactFile({
artifactPath,
evidencePath,
repoRoot,
});
const artifactFile = artifactPath
? await resolveQaEvidenceArtifactFile({
artifactPath,
evidencePath,
repoRoot,
})
: producerFile
? await resolveQaEvidenceProducerFile({
evidencePath,
producerFile,
repoRoot,
})
: await resolveQaEvidenceArtifactFileByIndex({
artifactIndex: Number(artifactIndexText),
entryIndex: Number(entryIndexText),
evidencePath,
repoRoot,
});
const artifactStats = await fs.promises.stat(artifactFile);
res.writeHead(200, {
"content-type": detectQaEvidenceArtifactContentType(artifactFile),

View File

@@ -17,6 +17,7 @@ import { chromium } from "playwright-core";
import { z } from "zod";
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
import { startQaGatewayChild } from "../../gateway-child.js";
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
import {
defaultQaModelForMode,
@@ -33,6 +34,7 @@ import {
redactQaLiveLaneIssues,
} from "../shared/live-artifacts.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import { assertLiveScenarioReply as assertDiscordScenarioReply } from "../shared/live-scenario-reply.js";
import {
collectLiveTransportStandardScenarioCoverage,
selectLiveTransportScenarios,
@@ -382,11 +384,6 @@ function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof DISCORD_QA_ENV_KEY
return value;
}
function isTruthyOptIn(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function resolveDiscordQaRuntimeEnv(env: NodeJS.ProcessEnv = process.env): DiscordQaRuntimeEnv {
const voiceChannelId = env.OPENCLAW_QA_DISCORD_VOICE_CHANNEL_ID?.trim();
const runtimeEnv = {
@@ -1482,22 +1479,6 @@ function matchesDiscordScenarioReply(params: {
);
}
function assertDiscordScenarioReply(params: {
expectedTextIncludes?: string[];
message: DiscordObservedMessage;
}) {
if (!params.message.text.trim()) {
throw new Error(`reply message ${params.message.messageId} was empty`);
}
for (const expected of params.expectedTextIncludes ?? []) {
if (!params.message.text.includes(expected)) {
throw new Error(
`reply message ${params.message.messageId} missing expected text: ${expected}`,
);
}
}
}
async function assertDiscordApplicationCommandsRegistered(params: {
applicationId: string;
expectedCommandNames: string[];

View File

@@ -0,0 +1,44 @@
export function formatApprovalResultValue(value: unknown) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (value == null) {
return "<missing>";
}
return JSON.stringify(value) ?? "<unserializable>";
}
export function readAcceptedApprovalRequest(result: unknown) {
const accepted =
typeof result === "object" && result !== null
? (result as { id?: unknown; status?: unknown })
: null;
if (accepted?.status !== "accepted") {
throw new Error(
`approval request status was ${formatApprovalResultValue(
accepted?.status,
)} instead of accepted`,
);
}
return accepted;
}
export function readAcceptedApprovalRequestId(result: unknown) {
const id = readAcceptedApprovalRequest(result).id;
if (typeof id !== "string" || id.trim().length === 0) {
throw new Error(`approval request id was ${formatApprovalResultValue(id)}`);
}
return id;
}
export function assertApprovalDecisionResult(params: { decision: string; result: unknown }) {
const resultDecision =
typeof params.result === "object" && params.result !== null
? (params.result as { decision?: unknown }).decision
: undefined;
if (resultDecision !== params.decision) {
throw new Error(
`approval decision was ${formatApprovalResultValue(resultDecision)} instead of ${params.decision}`,
);
}
}

View File

@@ -0,0 +1,10 @@
export type QaInferredCredentialSource = "convex" | "env";
export function inferQaCredentialSource(
value: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): QaInferredCredentialSource {
const normalized =
value?.trim().toLowerCase() || env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim().toLowerCase();
return normalized === "convex" ? "convex" : "env";
}

View File

@@ -0,0 +1,21 @@
type LiveScenarioReplyMessage = {
messageId: string | number;
text: string;
[key: string]: unknown;
};
export function assertLiveScenarioReply(params: {
expectedTextIncludes?: string[];
message: LiveScenarioReplyMessage;
}) {
if (!params.message.text.trim()) {
throw new Error(`reply message ${params.message.messageId} was empty`);
}
for (const expected of params.expectedTextIncludes ?? []) {
if (!params.message.text.includes(expected)) {
throw new Error(
`reply message ${params.message.messageId} missing expected text: ${expected}`,
);
}
}
}

View File

@@ -11,6 +11,7 @@ import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
import { startQaGatewayChild } from "../../gateway-child.js";
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
import {
defaultQaModelForMode,
@@ -21,10 +22,16 @@ import {
acquireQaCredentialLease,
startQaCredentialLeaseHeartbeat,
} from "../shared/credential-lease.runtime.js";
import {
assertApprovalDecisionResult,
formatApprovalResultValue,
readAcceptedApprovalRequestId,
} from "../shared/live-approval-result.js";
import {
appendQaLiveLaneIssue as appendLiveLaneIssue,
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
} from "../shared/live-artifacts.js";
import { inferQaCredentialSource as inferSlackCredentialSource } from "../shared/live-credential-source.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import {
collectLiveTransportStandardScenarioCoverage,
@@ -504,20 +511,6 @@ function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof SLACK_QA_ENV_KEYS)
return value;
}
function isTruthyOptIn(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function inferSlackCredentialSource(
value: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): "convex" | "env" {
const normalized =
value?.trim().toLowerCase() || env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim().toLowerCase();
return normalized === "convex" ? "convex" : "env";
}
function normalizeSlackId(value: string, label: string) {
const normalized = value.trim();
if (!/^[A-Z][A-Z0-9]+$/.test(normalized)) {
@@ -722,39 +715,6 @@ async function listSlackThreadMessages(params: {
return replies.messages ?? [];
}
function formatApprovalResultValue(value: unknown) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (value == null) {
return "<missing>";
}
return JSON.stringify(value) ?? "<unserializable>";
}
function readAcceptedApprovalRequest(result: unknown) {
const accepted =
typeof result === "object" && result !== null
? (result as { id?: unknown; status?: unknown })
: null;
if (accepted?.status !== "accepted") {
throw new Error(
`approval request status was ${formatApprovalResultValue(
accepted?.status,
)} instead of accepted`,
);
}
return accepted;
}
function readAcceptedApprovalRequestId(result: unknown) {
const id = readAcceptedApprovalRequest(result).id;
if (typeof id !== "string" || id.trim().length === 0) {
throw new Error(`approval request id was ${formatApprovalResultValue(id)}`);
}
return id;
}
function collectSlackBlockStringFields(
value: unknown,
fieldName: string,
@@ -1386,21 +1346,6 @@ async function resolveApprovalDecision(params: {
);
}
function assertApprovalDecisionResult(params: {
decision: SlackQaApprovalDecision;
result: unknown;
}) {
const resultDecision =
typeof params.result === "object" && params.result !== null
? (params.result as { decision?: unknown }).decision
: undefined;
if (resultDecision !== params.decision) {
throw new Error(
`approval decision was ${formatApprovalResultValue(resultDecision)} instead of ${params.decision}`,
);
}
}
async function runSlackApprovalScenario(params: {
channelId: string;
context: Omit<SlackQaScenarioContext, "sentTs">;

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