Compare commits

..

87 Commits

Author SHA1 Message Date
Patrick Erichsen
c4c364cd27 fix: mark approval gateway calls as runtime clients 2026-05-17 21:24:42 -07:00
Peter Steinberger
5980c0d807 fix: wrap Mac menu gateway errors 2026-05-18 05:21:19 +01:00
Ayaan Zaidi
1c778f7afb fix(telegram): repair desktop proof login 2026-05-18 09:49:21 +05:30
Peter Steinberger
84b34519a8 fix: preflight remote skill bin probes 2026-05-18 05:19:02 +01:00
Peter Steinberger
71ed6526b1 ci: reduce aggregate runner jobs 2026-05-18 04:53:40 +01:00
Peter Steinberger
8483d03375 fix(gateway): preserve spawned sessions in configured lists 2026-05-18 04:38:14 +01:00
Peter Steinberger
696b4863c3 chore: quiet autoreview default fallback 2026-05-18 04:37:19 +01:00
Vincent Koc
a642ca9a89 ci(qa-lab): schedule live token efficiency artifacts 2026-05-18 11:33:13 +08:00
Vincent Koc
1300b22630 fix(qa-lab): classify runtime token efficiency 2026-05-18 11:09:08 +08:00
Peter Steinberger
29653e4106 fix: harden Mac gateway transport selection 2026-05-18 04:06:17 +01:00
Peter Steinberger
1ba3368fa6 fix: clean up Mac settings sidebar controls 2026-05-18 04:06:17 +01:00
Vincent Koc
4dec9679e6 fix(qa-lab): gate missing runtime tool coverage 2026-05-18 11:00:20 +08:00
Ayaan Zaidi
1ab84b4327 docs(changelog): note telegram 421 retry (#48908) (thanks @MarsDoge) 2026-05-18 08:28:27 +05:30
Dongyan Qian
63b728de43 fix(telegram): retry 421 misdirected request responses
Treat Telegram HTTP 421 / Misdirected Request responses as retryable transport failures in both the default channel API retry policy and the strict outbound send retry path.

Wire the 421 handling into isSafeToRetrySendError so non-idempotent Telegram send operations can retry this edge-node rejection without enabling broad ambiguous network retries, and add regression coverage for the default retry path plus strict send predicate handling.
2026-05-18 08:28:27 +05:30
Vincent Koc
73ca3cf3c3 test: tolerate optional ACP cron live timeout 2026-05-18 10:55:13 +08:00
Peter Steinberger
11d7499db1 feat: extend autoreview fallback reviewers 2026-05-18 03:49:23 +01:00
Galin Iliev
ad55d486ce fix(github-copilot): sanitize unsafe reasoning replay ids (#83221)
Fixes #83220.
2026-05-17 19:48:27 -07:00
Gio Della-Libera
1b5bc33161 fix(doctor): archive legacy clawd browser profile residue (#83230)
* fix(doctor): archive legacy clawd browser profile residue

* Avoid browser cleanup load without residue

Doctor --fix now skips loading the browser doctor facade unless the legacy browser/clawd profile path exists, preventing broad config repair tests from paying the plugin load cost when there is nothing to archive.

* Use structured health check for browser residue

Register the legacy clawd browser profile residue cleanup through the modern doctor health-check contract so doctor --lint can report it and doctor --fix repairs it through structured effects.
2026-05-17 19:45:03 -07:00
Gio Della-Libera
bcbe8b6299 fix(codex): surface declined native tool replies (#83108) 2026-05-17 19:43:19 -07:00
Galin Iliev
bc4f27c89a ci: skip changelog-only workflow runs (#83215)
Summary
Problem: root CHANGELOG.md updates currently cause broad pull request and push workflow activity, including CI and workflow sanity fanout, even though changelog-only edits do not touch product, runtime, docs site, or workflow logic.
Why it matters: the PR workflow (review, prepare, and land) can add or adjust CHANGELOG.md entries while processing otherwise-ready PRs. Those changelog-only updates retrigger gates, delay landing, and create avoidable contention when several PRs are being landed close together.
What changed: CI now ignores pull requests whose only changed path is CHANGELOG.md; Workflow Sanity ignores changelog-only pull requests and main-branch pushes; Docs keeps its markdown/docs trigger but excludes root CHANGELOG.md from the push path set.
What did NOT change (scope boundary): metadata-only automation such as labelers, auto-response, real behavior proof, or external GitHub apps can still run on PR events because those workflows are event-driven rather than file-scope CI. Other markdown files, docs files, and workflow files still trigger their existing checks.
2026-05-17 19:29:45 -07:00
Ayaan Zaidi
6baa2b38b2 ci(mantis): make telegram proof skips public-safe 2026-05-18 07:54:11 +05:30
Peter Steinberger
48f7db23f0 fix: harden clawpatch-reported edge cases 2026-05-18 03:18:55 +01:00
Tak Hoffman
816fbe0cf0 chore(labels): cool label palette (#83374)
* chore(labels): cool label palette

* chore(labels): soften taxonomy colors

* chore(labels): finalize label palette

* chore(labels): harden final palette
2026-05-17 21:12:10 -05:00
Peter Steinberger
69cea57f69 fix(telegram): fail closed on missing topic threads (#83381)
* fix(telegram): fail closed on missing topic threads

* docs(changelog): reference telegram topic cleanup
2026-05-18 03:07:12 +01:00
Vincent Koc
58e1351863 fix(qa-lab): hard gate runtime tool coverage 2026-05-18 10:05:04 +08:00
Peter Steinberger
73f4657869 docs: require autoreview before PR landing 2026-05-18 03:02:48 +01:00
Gio Della-Libera
1768667374 fix(migrate): count hidden config conflicts in preview (#83314) 2026-05-17 18:50:22 -07:00
Gio Della-Libera
8855a4aa58 fix(update): require integer timeout values (#83310)
* fix(update): require integer timeout values

* fix(update): reject blank timeout values
2026-05-17 18:47:59 -07:00
Peter Steinberger
4b4048fd22 fix: guard xai oauth callback cors (#83322) (thanks @Jaaneek) 2026-05-18 02:43:12 +01:00
Jaaneek
5f1df99a9c xai: OAuth login fixes plus openclaw User-Agent attribution
OAuth login flow
----------------
- Hard-require refresh_token after the authorization-code exchange in
  xai-oauth.ts. Access-only responses persisted credentials that the
  downstream usability check later rejected; the new requireRefreshToken
  option fails the exchange instead. Error wording explains the missing
  refresh_token in OIDC scope terms (offline_access scope rejected),
  not a "grant".
- Derive token expiry from the access-token JWT exp claim when
  expires_in is missing. id_token exp is intentionally not used as a
  fallback because id_token lifetime tracks the OIDC session, not the
  access token, and would defer refresh past actual expiry.
- Handle CORS preflight OPTIONS on the loopback OAuth callback in
  src/plugin-sdk/provider-auth-runtime.ts. The previous handler treated
  any non-callback request as a failed GET, returned "Missing code or
  state", and tore the server down before the real GET arrived. The
  CORS allowlist is now an optional `corsOriginAllowlist` parameter on
  waitForLocalOAuthCallback so the SDK helper stays generic. The xAI
  plugin passes ["auth.x.ai", "accounts.x.ai"] from loginXaiOAuth.

Sidecar surfaces
----------------
- speech-provider.ts (POST /v1/tts) honors the xAI OAuth profile in
  addition to provider config and XAI_API_KEY. isConfigured now also
  reports true when an xAI auth profile is configured (via
  isProviderAuthProfileConfigured), so OAuth-only users are no longer
  silently filtered out by the selection layer. The bearer resolver
  threads req.cfg into resolveApiKeyForProvider so the right xAI auth
  profile is picked when a user has multiple.
- realtime-transcription-provider.ts (WSS /stt) gets the same
  isConfigured fix, and the lazy headers() resolver threads req.cfg
  into the OAuth bearer lookup. createSession stays sync per its
  plugin contract.
- stt.ts: drop the plugin-side OAuth fallback. The media-understanding
  core already resolves auth (cfg/agentDir-aware) via
  resolveProviderExecutionContext before calling transcribeAudio, so
  the wrapper was redundant. transcribeAudio is now the registered
  hook directly.

User-Agent attribution
----------------------
- New buildXaiAttributionPolicy in src/agents/provider-attribution.ts
  injects User-Agent: openclaw/<version>, originator, and version on
  /v1/responses and /v1/chat/completions traffic that goes through
  resolveProviderRequestHeaders. Gated to xai-native and default
  endpoint classes; custom proxy baseUrls remain withheld. reviewNote
  is honest about which headers are spec-verified vs mirrored.
- Shared extensions/xai/src/xai-user-agent.ts helper exports
  xaiUserAgentHeaderFor(baseUrl) which only emits the User-Agent when
  the resolved baseUrl points at the xAI-native API host. Threaded
  through TTS and realtime STT (WS upgrade headers) so user-configured
  proxy baseUrls do not receive the openclaw identity. OAuth discovery
  and token endpoints still send User-Agent unconditionally because
  isTrustedXaiOAuthEndpoint already restricts those URLs to *.x.ai.
- Image gen, batch STT, and video gen rely on the attribution policy
  alone (no manual User-Agent in defaultHeaders), so attribution
  withholding on user-configured proxy baseUrls is preserved
  end-to-end.
- UA is bearer-agnostic: same value whether the bearer comes from an
  xAI API key or the xAI OAuth flow.

Drop dead api.grok.x.ai alias
-----------------------------
- xAI retired the api.grok.x.ai alias; DNS now returns NXDOMAIN from
  xAI's own authoritative nameservers. Drop it from the xai-native
  endpoint host set in extensions/xai/openclaw.plugin.json,
  extensions/xai/api.ts, extensions/xai/tts.ts, and the
  openai-responses payload policy. Update the attribution test to
  classify api.grok.x.ai as "custom" (no live user can reach it; the
  classification keeps documenting the host's status).

Video generation now matches xAI's actual API behavior
------------------------------------------------------
Previously, real video generation requests failed with
"xAI video generation response malformed" because the poll-status
handler validated against a closed enum that did not match what the
xAI service actually returns. Four fixes:
- Loosen the poll-status handler. xAI returns intermediate strings
  outside `["queued", "processing", "done", "failed", "expired"]`
  (commonly `submitted`, `pending`, `in_progress`, ...). Treat `done`
  as terminal-success, `["failed", "error", "expired", "cancelled"]`
  as terminal-failure, and any other string (including empty) as
  continue-polling. Also accept `cancelled` as a terminal failure.
- Send default duration/aspect_ratio/resolution on every generate and
  reference-image submit. xAI rejects bodies that omit these fields.
  Defaults: duration=8s, aspect_ratio="16:9", resolution="720p".
- Accept lowercase resolution input ("480p"/"720p"/"1080p") in
  addition to uppercase, normalize to lowercase on the wire.
- Add an `x-idempotency-key` header (fresh `crypto.randomUUID()`) on
  every submit so a network retry does not double-charge the user.
  Polls intentionally reuse the unmodified `headers` without the key.

Ergonomics
----------
- All "missing xAI credentials" errors (code_execution, lazy
  code_execution fallback in extensions/xai/index.ts, x_search,
  web_search grok in web-search-provider.runtime.ts, TTS, batch STT,
  realtime STT) now mention `openclaw onboard --auth-choice xai-oauth`
  first.
- Dedupe the Grok model-id alias table: model-compat.ts re-exports
  normalizeXaiModelId from model-id.ts as normalizeNativeXaiModelId.

Test coverage
-------------
- src/plugin-sdk/provider-auth-runtime.test.ts: locks the new pure
  buildOAuthCallbackOriginResolver gate (allowlist match,
  case-normalization, https-only, non-allowlisted hosts dropped,
  multi-Origin handling).
- extensions/xai/xai-oauth.test.ts: locks
  XAI_OAUTH_CALLBACK_CORS_ORIGIN_ALLOWLIST so loginXaiOAuth keeps
  threading the right hosts to the SDK helper.
- extensions/xai/speech-provider.test.ts: OAuth-only auth profile
  flips isConfigured to true; cfg threads into the OAuth fallback
  resolver.
- extensions/xai/realtime-transcription-provider.test.ts: same +
  upgrade headers carry the OAuth bearer end-to-end.
- extensions/xai/stt.test.ts: explicit assertion that transcribeAudio
  trusts the core-resolved apiKey (no plugin-side wrapper).

Verification
------------
- pnpm install: clean
- 154/154 vitest tests pass across 13 touched test files
- pnpm check:changed: typecheck core/ext + tests, oxlint core/ext,
  runtime guards, dependency pin guard, package patch guard, runtime
  import cycles, sidecar loader guard - all green
- pnpm build: 0 errors, 0 [INEFFECTIVE_DYNAMIC_IMPORT] warnings
2026-05-18 02:43:12 +01:00
Peter Steinberger
b5046968f6 docs: clarify media completion handoff 2026-05-18 02:36:17 +01:00
Peter Steinberger
645ef817b6 test(channels): preserve thread origin contracts
Add core and hook mapper regression coverage for the thread-origin contract behind #83302.\n\nThe tests prove a flat reply target can coexist with a thread-addressable OriginatingTo, and hook canonical conversation mapping keeps following OriginatingTo.\n\nProof: focused Vitest, autoreview, Testbox check:changed tbx_01krwaztbwm13sx9e4sbyyz4c1, and CI run 26008670388 passed.
2026-05-18 02:30:24 +01:00
Peter Steinberger
9aa46843ec fix(telegram): preserve forum topic origin targets
Fix Telegram forum-topic OriginatingTo routing for inbound, audio-preflight, and skipped-message hook contexts.

Centralize Telegram inbound origin target construction so real forum topics stay encoded in the routing target while DM thread ids remain metadata-only.

Fixes #83302.
2026-05-18 02:19:46 +01:00
Josh Avant
73049d291b Fix transcript-only assistant rows in latest reply lookup (#83362)
* fix: skip transcript-only latest assistant rows

* chore: add changelog for transcript-only assistant fix
2026-05-17 20:13:34 -05:00
Tak Hoffman
7ff8323ed5 chore(labels): add label color sync policy (#83357)
* chore(labels): add label color sync script

* chore(labels): align future label colors
2026-05-17 20:09:47 -05:00
Peter Steinberger
5434769e47 fix(cron): suppress source replies for announce delivery 2026-05-18 01:41:16 +01:00
Peter Steinberger
428fc16ac8 ci: make Tideclaw alpha long gates advisory 2026-05-18 01:40:37 +01:00
compoodment
6ebe91d92b test: cover one-chunk progress final payload 2026-05-18 01:37:59 +01:00
Peter Steinberger
2d2c420ed2 test: speed up prompt snapshot checks 2026-05-18 01:37:31 +01:00
Peter Steinberger
3d85e84df3 test(ci): update prerelease runner expectation 2026-05-18 01:35:04 +01:00
Peter Steinberger
bb691a0d25 fix(ci): recognize gateway run command chunk 2026-05-18 01:35:04 +01:00
Peter Steinberger
9bdc183b7d fix(cli): keep subcommand help lightweight 2026-05-18 01:35:04 +01:00
Peter Steinberger
b0b18d1e4a fix: seed control UI origins for bind aliases 2026-05-18 01:21:33 +01:00
Peter Steinberger
17ab3b11cb ci: reduce main workflow queue time 2026-05-18 01:18:50 +01:00
Peter Steinberger
91266fa928 fix(telegram): bound isolated long-poll timeout 2026-05-18 01:05:27 +01:00
Peter Steinberger
47a2efe483 fix: hide display-hidden chat transcript messages 2026-05-18 01:04:48 +01:00
Peter Steinberger
9da0f80356 fix(openai): allow available Codex OAuth models 2026-05-18 01:04:14 +01:00
Peter Steinberger
77bbffb998 docs: run autoreview with full-access sandbox 2026-05-18 00:58:30 +01:00
Peter Steinberger
bef3356375 fix(macos): keep dashboard failures in window 2026-05-18 00:56:28 +01:00
Peter Steinberger
086d3d012e docs: add maintainer assignment triage workflow 2026-05-18 00:52:37 +01:00
VACInc
72e164a3fe fix: preserve recent Codex context projections 2026-05-18 00:41:36 +01:00
Josh Avant
06f4c97130 Keep legacy Codex OAuth sidecar profiles usable (#83312)
* fix legacy Codex oauth sidecar compatibility

* docs add changelog for legacy Codex oauth compatibility

* annotate legacy oauth hash compatibility
2026-05-17 18:41:07 -05:00
Peter Steinberger
9a936b3063 test: fix CI regressions 2026-05-18 00:37:48 +01:00
Peter Steinberger
691d62630f test: keep slow tests under duration cap 2026-05-18 00:26:44 +01:00
Peter Steinberger
7bcd5acc1a test(codex): type denied tool policy mocks (#82374) (thanks @VACInc) 2026-05-18 00:18:20 +01:00
VACInc
5f1d8a2ee4 fix(codex): fail closed restricted native tools 2026-05-18 00:18:20 +01:00
VACInc
dad3db40d3 fix(codex): honor denied app-server tool policy 2026-05-18 00:18:20 +01:00
wAngByg
d63c581dec fix(gemini-transport): validate thought_signature base64 before forwarding to Gemini (#82995)
Merged via squash.

Prepared head SHA: 8634757622
Co-authored-by: wAngByg <281221101+wAngByg@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-18 02:16:13 +03:00
Peter Steinberger
7afac6015f feat(browser): surface observed dialogs (#83099) 2026-05-18 00:05:29 +01:00
JC
57da466ecb Fix Discord verbose tool progress delivery (#80042)
Summary:
- The PR changes Discord reply delivery, sanitizer, and queued follow-up auto-reply paths so explicit verbose tool-progress payloads are delivered while final assistant replies still use the privacy sanitizer.
- Reproducibility: yes. source-level: current main strips tool-looking Discord payload text at the front-chann ... ds compaction events in queued follow-up runs. I did not run a live Discord repro in this read-only review.

Automerge notes:
- Ran the ClawSweeper repair loop before final review.
- Included post-review commit in the final squash: fix: gate queued follow-up progress when verbose is off
- Included post-review commit in the final squash: fix: preserve queued verbose progress under preview suppression
- Included post-review commit in the final squash: ci: rerun discord verbose progress PR
- Included post-review commit in the final squash: fix: preserve Discord verbose progress after rebase
- Included post-review commit in the final squash: fix: serialize discord queued progress
- Included post-review commit in the final squash: Fix Discord verbose tool progress delivery

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

Prepared head SHA: fd845e773a
Review: https://github.com/openclaw/openclaw/pull/80042#issuecomment-4414121881

Co-authored-by: Clawsistant <clawsistant@users.noreply.github.com>
Co-authored-by: anyech <anyech@gmail.com>
Co-authored-by: OpenClaw Assistant <assistant@openclaw.local>
Co-authored-by: Shadow <hi@shadowing.dev>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: thewilloftheshadow
Co-authored-by: thewilloftheshadow <35580099+thewilloftheshadow@users.noreply.github.com>
2026-05-17 22:59:07 +00:00
Peter Steinberger
127f3f86d7 style(macos): align sessions settings padding 2026-05-17 23:56:52 +01:00
mjamiv
c93d6d8daa fix(gateway): keep unmanaged restarts in-process (#83138)
Summary:
- The PR changes ordinary unmanaged gateway restarts to return the existing in-process fallback instead of detached-spawning a replacement child, with focused tests, docs wording, and a changelog entry.
- Reproducibility: yes. at source level: current main and v2026.5.12 detach-spawn unmanaged ordinary restarts, ... e PR body also supplies after-fix terminal proof that the patched helper returns disabled without spawning.

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

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

Prepared head SHA: 8c82df6c77
Review: https://github.com/openclaw/openclaw/pull/83138#issuecomment-4471071848

Co-authored-by: mjamiv <74088820+mjamiv@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-17 21:19:05 +00:00
VACInc
aa71f7fe15 Fix Telegram stop debounce bypass (#83248)
Summary:
- The PR adds a generic inbound debounce `cancelKey`, uses Telegram stop-like controls to cancel same-chat pen ... buffers and bypass debounce, and adds focused Telegram regression coverage plus updated channel test mocks.
- Reproducibility: yes. by source inspection: current main enqueues Telegram text through inbound debounce bef ... nly has flush semantics for pending keyed work. I did not run a live Telegram repro in this read-only pass.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Fix Telegram stop debounce bypass

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

Prepared head SHA: 19245a341d
Review: https://github.com/openclaw/openclaw/pull/83248#issuecomment-4472300906

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-17 21:10:53 +00:00
Kevin Lin
d85a7c6b67 docs: fix building plugin typebox import 2026-05-17 13:36:09 -07:00
Kevin Lin
d0736919aa docs: clean up building plugins guide
Refactor docs/plugins/building-plugins.md into the scoped plugin-author guide, preserving the legacy registering-agent-tools anchor and restoring the original Next steps section.
2026-05-17 13:32:15 -07:00
clawsweeper[bot]
bacc18a575 Log Telegram outbound delivery success (#83247)
Summary:
- The PR adds info-level Telegram outbound send success logs for text/media sends, tracks accepted threadless  ... s, and loads the OpenAI Codex external auth overlay for Codex plugin-harness runs with regression coverage.
- Reproducibility: yes. there is a source-level reproduction path: the branch adds focused tests for Telegram  ... mission/privacy and Codex auth overlay selection. I did not execute those tests in this read-only checkout.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Use Codex auth overlay for scoped Codex runs
- PR branch already contained follow-up commit before automerge: Add regression tests for Codex auth and Telegram send logs
- PR branch already contained follow-up commit before automerge: Log Telegram outbound delivery success

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

Prepared head SHA: b860487aef
Review: https://github.com/openclaw/openclaw/pull/83247#issuecomment-4472287527

Co-authored-by: jrwrest <jrwrest@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-17 19:46:31 +00:00
Gio Della-Libera
9a5f2f61e7 Doctor: add health-check contract and --lint validation (#80055)
* feat(doctor): add --lint mode + structured HealthFinding shape

Adds the core machinery for `openclaw doctor --lint` per the
doctor-lint-and-oc-rules upstream proposal. PR-1 of the proposal:
no new top-level verb, no public plugin SDK; everything internal.

Files:
- src/flows/checks.ts ? HealthFinding / HealthCheck / HealthCheckContext
   types. Findings carry severity per-finding; checks return
   readonly HealthFinding[]. Mode tag (doctor/lint/fix) lets a check
   distinguish the calling posture.
- src/flows/health-check-registry.ts ? module-level registry with
   duplicate-id rejection + test reset helper.
- src/flows/doctor-lint-flow.ts ? runner over registered checks.
   Catches throws into synthetic error findings (anchored at check id;
   message scrubbed of control chars, capped at 256 bytes). Sorts
   findings by severity desc, check id, path. Exports
   exitCodeFromFindings (1 if any warning/error, 0 otherwise).
- src/flows/doctor-core-checks.ts ? 4 modern HealthChecks rewriting
   logic from existing legacy run*Health functions:
     core/doctor/gateway-config            (warning)
     core/doctor/command-owner             (info)
     core/doctor/workspace-status          (info)
     core/doctor/final-config-validation   (error)
   Each was audited safe per the proposal's adapter constraints
   (no writes, no repair calls, no prompts, no probes incl. local-bind).
   Legacy run*Health contributions in doctor-health-contributions.ts
   are unchanged ? doctor mode (no --lint) still runs the existing 35.
- src/commands/doctor-lint.ts ? CLI dispatch for --lint. Reads config
   snapshot, builds HealthCheckContext (mode: "lint"), runs the registry,
   filters by --severity-min, emits human or JSON output, returns exit
   code from unfiltered set so --severity-min hides info findings
   without changing CI signal.
- src/cli/program/register.maintenance.ts ? adds --lint, --json,
   --severity-min, --skip, --only flags to existing doctor command.
   --lint branches to runDoctorLintCli; without --lint, doctor runs
   unchanged.

LoC: 382 src across 6 files. Tests + doc + oc-path-side rule packs
follow as separate commits on this branch.

* fix: avoid string spread in doctor errors

* chore: refresh plugin SDK API baseline

* docs: clarify doctor lint usage

* feat(doctor): prepare repairs for dry-run reporting
2026-05-17 12:29:57 -07:00
Tak Hoffman
0dc04fb926 ci(mantis): allow ClawSweeper telegram proof agent (#83243) 2026-05-17 14:26:15 -05:00
Gio Della-Libera
fb53c2d610 fix(doctor): detect stale session snapshot paths (#82867)
* fix(doctor): detect stale session snapshot paths

Warn when cached session snapshot metadata still references bundled skill paths from inactive OpenClaw runtime roots, while keeping workspace skill roots and current runtime paths quiet.

* fix(doctor): honor configured session stores

* fix(doctor): scan raw snapshot paths

Expand home-relative cached snapshot paths before stale bundled-skill classification and scan raw session-store JSON so persisted resolvedSkills are inspected before normal session-store normalization strips them.
2026-05-17 12:12:25 -07:00
clawsweeper[bot]
214f718be7 fix(agents): persist subagent registry before returning accepted (#83132) (#83238)
Summary:
- This PR adds a strict initial subagent registry persistence path, rolls back failed registrations, updates affected test seams, adds a regression test, and records the fix in the changelog.
- Reproducibility: yes. Source inspection on current main shows registry save failures are swallowed after the ... s added, and the linked source PR provides an ENOSPC-style after-fix terminal proof for the corrected path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): persist subagent registry before returning accepted (#83…

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

Prepared head SHA: d564ef051d
Review: https://github.com/openclaw/openclaw/pull/83238#issuecomment-4472173642

Co-authored-by: yetval <yetvald@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-17 19:11:01 +00:00
clawsweeper[bot]
f36a1b0c81 fix(codex): preserve streamed command output (#83222)
Summary:
- The PR buffers Codex command-output deltas per command item and uses them as a fallback for transcripts, trajectory output, final tool output, and after-tool-call errors when `aggregatedOutput` is empty.
- Reproducibility: yes. A source-level reproduction is clear: send current-turn command-output delta notificat ... aggregatedOutput: null`; current main has no final transcript or trajectory fallback for the streamed text.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(codex): preserve streamed command output

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

Prepared head SHA: 07393a304f
Review: https://github.com/openclaw/openclaw/pull/83222#issuecomment-4472054629

Co-authored-by: 0x505badc0de <32790662+rozmiarD@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-17 18:41:00 +00:00
clawsweeper[bot]
3e765263dd fix(agents): preserve run-mode keep subagents past session sweep TTL (#83132) (#83226)
Summary:
- The PR exempts run-mode `cleanup: "keep"` subagent registry entries from the session-mode sweep TTL, adds focused regression coverage, and records the fix in the changelog.
- Reproducibility: yes. Current main source shows a run-mode keep entry has no `archiveAtMs` and then matches  ... ; the linked source PR also provides before/after terminal proof against a real persisted `runs.json` path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): preserve run-mode keep subagents past session sweep TTL …

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

Prepared head SHA: 32faf5cf32
Review: https://github.com/openclaw/openclaw/pull/83226#issuecomment-4472073823

Co-authored-by: yetval <yetvald@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-17 18:27:53 +00:00
clawsweeper[bot]
fb028cadc8 fix(codex): deliver Telegram verbose tool progress (#83214)
Summary:
- The branch updates Codex app-server tool-progress projection and auto-reply dispatch so Telegram direct mess ... l-only `/verbose` turns deliver concise tool summaries while filtering message-send and activity-log noise.
- Reproducibility: yes. Current-main source inspection shows `message_tool_only` suppression can drop verbose tool summaries before dispatch, and the linked source PR gives a live Telegram DM before/after path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(codex): deliver Telegram verbose tool progress

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

Prepared head SHA: f6a79cb306
Review: https://github.com/openclaw/openclaw/pull/83214#issuecomment-4471954529

Co-authored-by: Tyler Bea <43728897+kurplunkin@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-17 18:23:58 +00:00
Peter Steinberger
800a0d3166 test: stabilize live subagent steering 2026-05-17 18:45:44 +01:00
sandypockets
a5a5df67da Fix clipped usage chart tooltip (#82846)
Summary:
- The PR replaces per-bar absolute Usage chart tooltips with one viewport-fixed floating tooltip and adds focus/keyboard handling plus focused jsdom coverage.
- Reproducibility: yes. at source level. Current main renders an absolute `.daily-bar-tooltip` inside `.daily- ... ` overflow contexts, and the linked issue plus PR before screenshot demonstrate the tall-bar clipping case.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Merge branch 'main' into fix-usage-tooltip-clipping

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

Prepared head SHA: edbb26a5be
Review: https://github.com/openclaw/openclaw/pull/82846#issuecomment-4468967811

Co-authored-by: sandypockets <41454557+sandypockets@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-17 17:25:23 +00:00
Tak Hoffman
0f1f9525f3 fix(ci): clear Mantis command reactions (#83194)
* fix(ci): clear mantis command reactions

* fix(ci): clear Mantis command reactions

---------

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-05-17 12:22:01 -05:00
Peter Steinberger
c3308b9195 test: keep matrix subagent spawn opt-in 2026-05-17 18:20:54 +01:00
100menotu001
7c416950c6 fix(feishu): return subagent thread delivery origin (#83190)
Summary:
- The PR returns a Feishu/Lark deliveryOrigin from subagent_spawning after successful thread-bound session binding, adds DM/topic/sender-scoped topic hook assertions, and adds a changelog entry.
- Reproducibility: yes. by source inspection. Current main's Feishu subagent_spawning hook binds the child con ... eneric session-spawn path only directly routes the initial child run when result.deliveryOrigin is present.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(feishu): return subagent thread delivery origin

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

Prepared head SHA: 44a6200a91
Review: https://github.com/openclaw/openclaw/pull/83190#issuecomment-4471452247

Co-authored-by: OpenClaw Contributor <100menotu001@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-17 17:12:06 +00:00
Peter Steinberger
59b85d4eb9 test: stabilize release validation flakes 2026-05-17 18:04:35 +01:00
Gio Della-Libera
44c3d8ea2e fix(memory): preserve qmd lexical search for hyphenated queries (#81423) 2026-05-17 09:52:04 -07:00
clawsweeper[bot]
893f580072 fix(update): tailor gateway recovery hints by platform (#83191)
Summary:
- The PR updates the CLI post-update gateway recovery formatter and tests to show Linux, macOS, Windows, or generic service-manager guidance, plus a changelog entry.
- Reproducibility: yes. Source inspection gives a high-confidence reproduction path: current main reaches a fo ... hAgent recovery text, while the platform contract says Linux uses systemd and Windows uses Scheduled Tasks.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(update): tailor gateway recovery hints by platform

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

Prepared head SHA: 0cf2a0c5a7
Review: https://github.com/openclaw/openclaw/pull/83191#issuecomment-4471471293

Co-authored-by: Rubén Cuevas <hi@rubencu.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-05-17 16:48:08 +00:00
Peter Steinberger
af62fd45cd test: stabilize release qa gates 2026-05-17 17:45:58 +01:00
Peter Steinberger
6ebc5e4719 test: harden release qa edge scenarios 2026-05-17 17:26:37 +01:00
Tak Hoffman
f349fb82aa fix(mantis): remove ambiguous github trigger mention (#83179) 2026-05-17 11:24:23 -05:00
Vincent Koc
79212f9869 feat(qa-lab): select runtime parity tiers 2026-05-18 00:21:13 +08:00
Gavin Zeng
ea72414e1c fix(build): bundle zod inline to fix pnpm global install resolution (#78515)
Merged via squash.

Prepared head SHA: c925d1afab
Co-authored-by: ggzeng <20488795+ggzeng@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-17 19:20:42 +03:00
Chris Zhang
ac848d318d fix(agents): exclude tool result details from guard budget (#75525)
Merged via squash.

Prepared head SHA: 4efe094507
Co-authored-by: zqchris <4436110+zqchris@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-17 19:14:59 +03:00
487 changed files with 18558 additions and 4995 deletions

View File

@@ -7,6 +7,8 @@ description: "Autoreview closeout: local dirty changes, PR branch vs main, paral
Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing.
Codex native review mode performs best and is recommended. Non-Codex reviewers are fallback/second-opinion paths that receive a generated diff prompt, not the full Codex review-mode runtime.
Use when:
- user asks for Codex review / autoreview / second-model review
- after non-trivial code edits, before final/commit/ship
@@ -21,7 +23,7 @@ Use when:
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
- Keep going until the selected review path returns no accepted/actionable findings.
- If a review-triggered fix changes code, rerun focused tests and rerun the review helper.
- Default to Codex review. If Codex is unavailable or exits with an error, the helper may fall back to `claude -p`; `pi -p` and `opencode run` are explicit reviewer/fallback options. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
- Default to Codex review. If Codex is unavailable or exits with an error, the helper falls back to the first configured CLI from `claude -p`, `pi -p`, `opencode run`, `droid exec`, or `copilot`. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
@@ -107,12 +109,12 @@ The helper:
- otherwise uses `origin/main` for non-main branches
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- supports `--reviewer codex|claude|pi|opencode|auto`; `auto` runs Codex first
- supports `--fallback-reviewer claude|pi|opencode|none`; default is `claude`
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is configured CLI fallback
- falls back only when Codex is unavailable or exits nonzero, not when Codex reports findings
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
- supports `--dry-run`, `--parallel-tests`, and commit refs
- runs nested review with `--dangerously-bypass-approvals-and-sandbox` by default
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0

View File

@@ -10,14 +10,16 @@ Options:
Target selection. Default: auto.
--base REF Base ref for branch review. Default: PR base or origin/main.
--commit REF Commit ref for commit review. Default: HEAD.
--reviewer codex|claude|pi|opencode|auto
Review engine. Default: auto (Codex, fallback reviewer on error).
--fallback-reviewer claude|pi|opencode|none
Fallback when Codex is unavailable or exits nonzero. Default: claude.
--reviewer codex|claude|pi|opencode|droid|copilot|auto
Review engine. Default: Codex with configured fallback on error.
--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none
Fallback when Codex is unavailable or exits nonzero. Default: auto.
--codex-bin PATH Codex binary. Default: codex.
--claude-bin PATH Claude binary. Default: claude.
--pi-bin PATH Pi binary. Default: pi.
--opencode-bin PATH OpenCode binary. Default: opencode.
--droid-bin PATH Droid binary. Default: droid.
--copilot-bin PATH GitHub Copilot binary. Default: copilot.
--full-access Keep yolo/full-access mode enabled. Default.
--no-yolo Run nested Codex review with normal sandbox/approval prompts.
--output FILE Also save output to file.
@@ -37,11 +39,13 @@ mode=auto
base_ref=
commit_ref=HEAD
reviewer=${AUTOREVIEW_REVIEWER:-${CODEX_REVIEW_REVIEWER:-auto}}
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-claude}}
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-auto}}
codex_bin=${CODEX_BIN:-codex}
claude_bin=${CLAUDE_BIN:-claude}
pi_bin=${PI_BIN:-pi}
opencode_bin=${OPENCODE_BIN:-opencode}
droid_bin=${DROID_BIN:-droid}
copilot_bin=${COPILOT_BIN:-copilot}
codex_args=()
yolo=${AUTOREVIEW_YOLO:-${CODEX_REVIEW_YOLO:-1}}
output=${AUTOREVIEW_OUTPUT:-${CODEX_REVIEW_OUTPUT:-}}
@@ -86,6 +90,14 @@ while [[ $# -gt 0 ]]; do
opencode_bin=${2:-}
shift 2
;;
--droid-bin)
droid_bin=${2:-}
shift 2
;;
--copilot-bin)
copilot_bin=${2:-}
shift 2
;;
--full-access)
yolo=1
shift
@@ -119,7 +131,7 @@ done
case "$yolo" in
0|false|False|FALSE|no|No|NO|off|Off|OFF) ;;
*) codex_args+=(--dangerously-bypass-approvals-and-sandbox) ;;
*) codex_args+=(--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access) ;;
esac
case "$mode" in
@@ -131,7 +143,7 @@ case "$mode" in
esac
case "$reviewer" in
auto|codex|claude|pi|opencode) ;;
auto|codex|claude|pi|opencode|droid|copilot) ;;
*)
echo "invalid --reviewer: $reviewer" >&2
exit 2
@@ -139,7 +151,7 @@ case "$reviewer" in
esac
case "$fallback_reviewer" in
claude|pi|opencode|none) ;;
auto|claude|pi|opencode|droid|copilot|none) ;;
*)
echo "invalid --fallback-reviewer: $fallback_reviewer" >&2
exit 2
@@ -194,10 +206,17 @@ printf 'branch: %s\n' "${current_branch:-detached}"
if [[ -n "$pr_url" ]]; then
printf 'pr: %s\n' "$pr_url"
fi
printf 'reviewer: %s\n' "$reviewer"
if [[ "$reviewer" == auto ]]; then
printf 'fallback-reviewer: %s\n' "$fallback_reviewer"
printf 'reviewer: codex\n'
else
printf 'reviewer: %s\n' "$reviewer"
fi
case "$reviewer" in
codex|auto) ;;
*)
printf 'note: Codex native review mode is the recommended and best-supported review path; %s uses a generated diff prompt.\n' "$reviewer"
;;
esac
if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
printf 'review:'
printf ' %q' "${review_cmd[@]}"
@@ -284,10 +303,14 @@ Base: ${base_ref:-}
Commit: ${commit_ref:-}
Rules:
- Review only the diff below.
- Review the proposed code change as a closeout reviewer.
- Focus on the diff below. If your CLI exposes read-only repository tools, inspect surrounding code and tests to verify findings; never modify files.
- Do not modify files.
- Prioritize correctness bugs, regressions, security issues, and missing tests.
- Ignore speculative edge cases and broad rewrites.
- Report only discrete, actionable issues introduced by this change.
- Prioritize correctness, regressions, security, data loss, performance cliffs, and missing tests that would catch a real bug.
- Do not report pre-existing issues, speculative risks, broad rewrites, style nits, changelog gaps, or findings that depend on unstated assumptions.
- Identify the concrete scenario where the issue appears, and keep the line reference as small as possible.
- A finding should overlap changed code or clearly cite changed code as the cause.
- For each accepted/actionable finding, use exactly this format:
[P<0-3>] Short title
File: path:line
@@ -302,8 +325,15 @@ EOF
} > "$prompt_file" || return
}
reviewer_output_has_clean_marker() {
local path=$1
grep -Eq '^[^[:alnum:]]*autoreview clean: no accepted/actionable findings reported[[:space:]]*$' "$path"
}
run_prompt_reviewer() {
local selected=$1
local copilot_prompt=
local prompt_bytes=0
local reviewer_output
local status=0
@@ -343,13 +373,46 @@ run_prompt_reviewer() {
echo "fallback reviewer unavailable: $opencode_bin" >&2
status=127
elif printf 'fallback: opencode run\n' | tee -a "$review_output"; then
"$opencode_bin" run --pure --dir "$(dirname "$prompt_file")" --file "$prompt_file" \
"Review the attached prompt file. Do not modify files." 2>&1 | tee -a "$review_output" "$reviewer_output"
"$opencode_bin" run --pure --dir "$repo_root" \
"Review the attached prompt file. Do not modify files." \
--file "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
droid)
if ! command -v "$droid_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $droid_bin" >&2
status=127
elif printf 'fallback: droid exec\n' | tee -a "$review_output"; then
"$droid_bin" exec --cwd "$repo_root" -f "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
copilot)
if ! command -v "$copilot_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $copilot_bin" >&2
status=127
elif printf 'fallback: copilot\n' | tee -a "$review_output"; then
prompt_bytes=$(wc -c < "$prompt_file" | tr -d '[:space:]')
if (( prompt_bytes > 120000 )); then
echo "copilot reviewer unavailable: generated prompt is too large for copilot -p; use codex, droid, or another file/stdin-capable reviewer" \
2>&1 | tee -a "$review_output" "$reviewer_output"
status=1
else
copilot_prompt=$(< "$prompt_file")
"$copilot_bin" -C "$repo_root" --available-tools=none --stream off --output-format text --silent \
-p "$copilot_prompt" \
2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
fi
else
status=$?
fi
;;
*)
echo "unsupported prompt reviewer: $selected" >&2
status=2
@@ -360,7 +423,7 @@ run_prompt_reviewer() {
status=1
elif ! grep -q '[^[:space:]]' "$reviewer_output"; then
status=1
elif ! grep -Fxq 'autoreview clean: no accepted/actionable findings reported' "$reviewer_output"; then
elif ! reviewer_output_has_clean_marker "$reviewer_output"; then
status=1
fi
fi
@@ -380,7 +443,7 @@ run_selected_review() {
fi
run_review
;;
claude|pi|opencode)
claude|pi|opencode|droid|copilot)
run_prompt_reviewer "$selected"
;;
*)
@@ -390,6 +453,36 @@ run_selected_review() {
esac
}
fallback_reviewer_is_available() {
local selected=$1
case "$selected" in
claude) command -v "$claude_bin" >/dev/null 2>&1 ;;
pi) command -v "$pi_bin" >/dev/null 2>&1 ;;
opencode) command -v "$opencode_bin" >/dev/null 2>&1 ;;
droid) command -v "$droid_bin" >/dev/null 2>&1 ;;
copilot) command -v "$copilot_bin" >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
run_auto_fallback_review() {
local selected
if [[ "$fallback_reviewer" != auto ]]; then
run_selected_review "$fallback_reviewer"
return $?
fi
for selected in claude pi opencode droid copilot; do
if fallback_reviewer_is_available "$selected"; then
run_selected_review "$selected"
return $?
fi
done
echo "fallback reviewer unavailable: no configured fallback CLI found" >&2
return 127
}
run_auto_review() {
run_selected_review codex
local status=$?
@@ -405,8 +498,12 @@ run_auto_review() {
if [[ "$fallback_reviewer" == none ]]; then
return "$status"
fi
printf 'autoreview warning: codex exited %s; falling back to %s\n' "$status" "$fallback_reviewer" >&2
run_selected_review "$fallback_reviewer"
if [[ "$fallback_reviewer" == auto ]]; then
printf 'autoreview warning: codex exited %s; trying configured fallback reviewers\n' "$status" >&2
else
printf 'autoreview warning: codex exited %s; falling back to %s\n' "$status" "$fallback_reviewer" >&2
fi
run_auto_fallback_review
}
elapsed_since() {

View File

@@ -24,6 +24,36 @@ gitcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hyb
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
```
## Claim specific review targets
When a maintainer asks Codex to review, triage, fix, or land a specific OpenClaw issue/PR, check assignment before deep work.
- Identify the requesting maintainer's GitHub login. In this environment, default Peter to `steipete`; if another maintainer is clearly the requester, use that maintainer's bare login.
- Read current assignees with live `gh issue view` / `gh pr view`; `gitcrawl` is not enough for assignment state.
- If unassigned, assign the requester before deep review. This is allowed for specific requested targets; do not auto-assign broad discovery candidates or shortlists.
- If assigned to someone else, say so clearly before analysis and include assignment age:
- fresh: assigned within 6h; treat as actively owned unless user explicitly asks to continue or reassign
- stale: assigned 6h+ ago; treat as ownership hint, not a hard block; continue only with that caveat
- If assigned to requester plus others, mention co-assignees and continue.
- If assignment event time is unavailable, say `assigned, time unknown`; treat as assigned, not stale.
- Never remove or replace assignees unless explicitly asked.
Assignment time proof:
```bash
gh api "repos/openclaw/openclaw/issues/<number>/timeline" --paginate \
-H "Accept: application/vnd.github+json" \
--jq '[.[] | select(.event=="assigned") | {assignee:.assignee.login, assigner:.assigner.login, actor:.actor.login, created_at}]'
```
Use the newest `assigned` event for each current assignee. Issue timeline events expose `created_at`; GitHub GraphQL `AssignedEvent.createdAt` is also valid when REST pagination is awkward.
Claim command for issues or PRs:
```bash
gh api -X POST "repos/openclaw/openclaw/issues/<number>/assignees" -f 'assignees[]=<login>' >/dev/null
```
## Surface opener identity
- For every reviewed, triaged, closed, or landed issue/PR, show the opener's human name when available, GitHub login, and account age.
@@ -217,6 +247,7 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
not correctness findings.
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
- When landing or merging any PR, follow the global `/landpr` process.
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
- Keep commit messages concise and action-oriented.

View File

@@ -16,8 +16,11 @@ Hard limits:
- Do not finish with tiny, cropped-wrong, off-bottom, or sidebar-heavy GIFs.
- Do not invent a generic proof. The proof must match the PR behavior.
- Do not force GIFs for internal-only, workflow-only, test-only, docs-only, or
otherwise non-visual PRs. A no-visual-proof manifest is a successful outcome
when GIFs would be misleading.
otherwise non-visual PRs. A no-visual-proof manifest is a successful workflow
outcome when GIFs would be misleading, but it is not proof that the PR passed.
- Keep public-facing manifest summaries short and user-domain. Do not mention
harness internals, mock-provider limits, secret/trust boundaries, local paths,
transcript seeding, or workflow implementation details in the summary.
Inputs are provided as environment variables:
@@ -42,9 +45,10 @@ Required workflow:
before/after. If it does not, write
`${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with `comparison.pass: true`, no
artifacts, and a summary that starts with
`Mantis did not generate before/after GIFs because`. Include the concrete
reason in the summary. Use this manifest shape and do not create worktrees
or start Crabbox for this case:
`Mantis did not generate before/after GIFs because`. Include a short
public reason, such as `the PR changes internal session bookkeeping rather
than Telegram-visible behavior`. Use this manifest shape and do not create
worktrees or start Crabbox for this case:
```json
{
@@ -73,6 +77,14 @@ Required workflow:
}
```
If the PR appears visual but proof is blocked by Telegram Desktop session
state, authorization, credentials, Crabbox, or another capture-infrastructure
issue, do not describe it as a no-visual PR. Write a manifest with
`comparison.pass: false`, skipped lanes, no artifacts, and a summary that
starts with `Mantis could not capture Telegram Desktop proof because`. The
publisher will keep that out of PR comments so the failure stays in the
workflow logs and artifacts.
4. Decide what Telegram message, mock model response, command, callback, button,
media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra
maintainer guidance, not as a replacement for reading the PR.
@@ -134,4 +146,6 @@ Expected final state:
`Main` and `This PR`.
- No-visual-proof manifests contain no artifacts and have `comparison.pass:
true`.
- Capture-infrastructure failure manifests contain no artifacts and have
`comparison.pass: false`.
- The worktree can be dirty only under `.artifacts/`.

View File

@@ -20,6 +20,8 @@ on:
- "docs/**"
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
paths-ignore:
- "CHANGELOG.md"
permissions:
contents: read
@@ -38,7 +40,7 @@ jobs:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
outputs:
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
@@ -301,7 +303,7 @@ jobs:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
env:
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
@@ -394,7 +396,7 @@ jobs:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 10
steps:
- name: Checkout
@@ -419,7 +421,7 @@ jobs:
permissions: {}
needs: [security-scm-fast, security-dependency-audit]
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 5
steps:
- name: Verify fast security jobs
@@ -641,6 +643,15 @@ jobs:
echo "${name}-result=${results[$name]}" >> "$GITHUB_OUTPUT"
done
failures=0
for name in channels core-support-boundary gateway-watch; do
if [ "${results[$name]}" = "failure" ]; then
echo "::error title=${name} failed::${name} failed"
failures=1
fi
done
exit "$failures"
- name: Upload gateway watch regression artifacts
if: always() && needs.preflight.outputs.run_check_additional == 'true'
uses: actions/upload-artifact@v7
@@ -828,28 +839,6 @@ jobs:
EOF
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:plugins
checks-fast-plugin-contracts:
permissions:
contents: read
name: checks-fast-contracts-plugins
needs: [preflight, checks-fast-plugin-contracts-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_contracts_shards == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify plugin contract shards
env:
SHARD_RESULT: ${{ needs.checks-fast-plugin-contracts-shard.result }}
run: |
if [ "$SHARD_RESULT" = "cancelled" ]; then
echo "Plugin contract shards were cancelled, usually because a newer commit superseded this run." >&2
exit 1
fi
if [ "$SHARD_RESULT" != "success" ]; then
echo "Plugin contract shards failed: $SHARD_RESULT" >&2
exit 1
fi
checks-fast-channel-contracts-shard:
permissions:
contents: read
@@ -934,35 +923,13 @@ jobs:
EOF
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
checks-fast-channel-contracts:
permissions:
contents: read
name: checks-fast-contracts-channels
needs: [preflight, checks-fast-channel-contracts-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify channel contract shards
env:
SHARD_RESULT: ${{ needs.checks-fast-channel-contracts-shard.result }}
run: |
if [ "$SHARD_RESULT" = "cancelled" ]; then
echo "Channel contract shards were cancelled, usually because a newer commit superseded this run." >&2
exit 1
fi
if [ "$SHARD_RESULT" != "success" ]; then
echo "Channel contract shards failed: $SHARD_RESULT" >&2
exit 1
fi
checks-fast-protocol:
permissions:
contents: read
name: "checks-fast-protocol"
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -1021,38 +988,6 @@ jobs:
- name: Run protocol check
run: pnpm protocol:check
checks:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
steps:
- name: Verify ${{ matrix.task }} (${{ matrix.runtime }})
env:
TASK: ${{ matrix.task }}
CHANNELS_RESULT: ${{ needs.build-artifacts.outputs['channels-result'] }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
channels)
if [ "$CHANNELS_RESULT" != "success" ]; then
echo "Channel tests failed in build-artifacts: $CHANNELS_RESULT" >&2
exit 1
fi
;;
*)
echo "Unsupported checks task: $TASK" >&2
exit 1
;;
esac
checks-node-compat:
permissions:
contents: read
@@ -1240,63 +1175,6 @@ jobs:
}
EOF
checks-node-core-test-dist-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_dist_matrix) }}
steps:
- name: Verify Node test shard
env:
CORE_SUPPORT_BOUNDARY_RESULT: ${{ needs.build-artifacts.outputs['core-support-boundary-result'] }}
SHARD_NAME: ${{ matrix.shard_name }}
shell: bash
run: |
set -euo pipefail
case "$SHARD_NAME" in
core-support-boundary)
if [ "$CORE_SUPPORT_BOUNDARY_RESULT" != "success" ]; then
echo "Core support boundary shard failed in build-artifacts: $CORE_SUPPORT_BOUNDARY_RESULT" >&2
exit 1
fi
;;
*)
echo "Unsupported built-artifact shard: $SHARD_NAME" >&2
exit 1
;;
esac
checks-node-core-test:
permissions:
contents: read
name: checks-node-core
needs: [preflight, checks-node-core-test-nondist-shard, checks-node-core-test-dist-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify node test shards
env:
DIST_SHARD_RESULT: ${{ needs.checks-node-core-test-dist-shard.result }}
NONDIST_SHARD_RESULT: ${{ needs.checks-node-core-test-nondist-shard.result }}
RUN_DIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
RUN_NONDIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_nondist }}
run: |
if [ "$RUN_NONDIST_SHARDS" = "true" ] && [ "$NONDIST_SHARD_RESULT" != "success" ]; then
echo "Node non-dist test shards failed: $NONDIST_SHARD_RESULT" >&2
exit 1
fi
if [ "$RUN_DIST_SHARDS" = "true" ] && [ "$DIST_SHARD_RESULT" != "success" ]; then
echo "Node dist test shards failed: $DIST_SHARD_RESULT" >&2
exit 1
fi
# Types, lint, and format check shards.
check-shard:
permissions:
@@ -1312,7 +1190,7 @@ jobs:
include:
- check_name: check-preflight-guards
task: preflight-guards
runner: ubuntu-24.04
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-prod-types
task: prod-types
runner: blacksmith-4vcpu-ubuntu-2404
@@ -1321,16 +1199,16 @@ jobs:
runner: blacksmith-16vcpu-ubuntu-2404
- check_name: check-dependencies
task: dependencies
runner: ubuntu-24.04
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-policy-guards
task: policy-guards
runner: ubuntu-24.04
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-test-types
task: test-types
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-strict-smoke
task: strict-smoke
runner: ubuntu-24.04
runner: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
shell: bash
@@ -1442,24 +1320,6 @@ jobs:
path: .artifacts/deadcode
if-no-files-found: ignore
check:
permissions:
contents: read
name: "check"
needs: [preflight, check-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify check shards
env:
SHARD_RESULT: ${{ needs.check-shard.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Check shards failed: $SHARD_RESULT" >&2
exit 1
fi
check-additional-shard:
permissions:
contents: read
@@ -1637,59 +1497,13 @@ jobs:
exit "$failures"
check-additional:
permissions:
contents: read
name: "check-additional"
needs: [preflight, check-additional-shard, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify additional check shards
env:
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
GATEWAY_RESULT: ${{ needs.build-artifacts.outputs.gateway-watch-result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Additional check shards failed: $SHARD_RESULT" >&2
exit 1
fi
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build artifact job failed: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
if [ "$GATEWAY_RESULT" != "success" ]; then
echo "Gateway topology check failed: $GATEWAY_RESULT" >&2
exit 1
fi
build-smoke:
permissions:
contents: read
name: "build-smoke"
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify build smoke
env:
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
run: |
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build smoke checks failed in build-artifacts: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
permissions:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_check_docs == 'true'
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
steps:
- name: Checkout
@@ -1763,7 +1577,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_skills_python_job == 'true'
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
steps:
- name: Checkout

View File

@@ -6,6 +6,7 @@ on:
paths:
- "**/*.md"
- "docs/**"
- "!CHANGELOG.md"
permissions:
contents: read

View File

@@ -638,6 +638,7 @@ jobs:
name: Run package Telegram E2E
needs: [resolve_target, prepare_release_package]
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 120 || 60 }}
outputs:
@@ -955,6 +956,8 @@ jobs:
if [[ "$NPM_TELEGRAM_RESULT" == "skipped" && -z "${NPM_TELEGRAM_RUN_ID// }" ]]; then
check_child "npm_telegram" "" 0 || failed=1
elif [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 0 || echo "::warning::npm_telegram is advisory for Tideclaw alpha validation."
else
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 1 || failed=1
fi

View File

@@ -46,9 +46,8 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@Mantis') ||
contains(github.event.comment.body, '@mantis') ||
contains(github.event.comment.body, '/mantis')
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
)
)
}}
@@ -128,7 +127,7 @@ jobs:
const normalized = body.toLowerCase();
const requested =
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
normalized.includes("discord") &&
normalized.includes("status") &&
normalized.includes("reaction");
@@ -574,3 +573,44 @@ jobs:
--artifact-url "$ARTIFACT_URL" \
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--request-source "$REQUEST_SOURCE"
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_refs, run_status_reactions]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -46,9 +46,8 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@Mantis') ||
contains(github.event.comment.body, '@mantis') ||
contains(github.event.comment.body, '/mantis')
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
)
)
}}
@@ -128,7 +127,7 @@ jobs:
const normalized = body.toLowerCase();
const requested =
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
normalized.includes("discord") &&
normalized.includes("thread") &&
(normalized.includes("attachment") ||
@@ -596,3 +595,44 @@ jobs:
run: |
echo "Mantis comparison failed." >&2
exit 1
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_candidate, run_thread_attachment]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -3,7 +3,7 @@ name: Mantis Telegram Desktop Proof
on:
issue_comment:
types: [created]
pull_request_target:
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned Mantis label trigger; trusted base workflow validates refs before checkout/use
types: [labeled]
workflow_dispatch:
inputs:
@@ -120,6 +120,7 @@ jobs:
publish_run_id: ${{ steps.resolve.outputs.publish_run_id }}
pr_number: ${{ steps.resolve.outputs.pr_number }}
request_source: ${{ steps.resolve.outputs.request_source }}
should_run: ${{ steps.resolve.outputs.should_run }}
steps:
- name: Resolve refs and target PR
id: resolve
@@ -145,24 +146,52 @@ jobs:
return;
}
const { owner, repo } = context.repo;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: Number(prNumber),
});
const body =
eventName === "workflow_dispatch"
? inputs.instructions || ""
: eventName === "issue_comment"
? context.payload.comment?.body || ""
: "";
if (eventName === "issue_comment") {
const normalized = body.toLowerCase();
const requestedDesktopProof =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
(normalized.includes("desktop proof") ||
normalized.includes("desktop-proof") ||
normalized.includes("telegram desktop") ||
normalized.includes("native telegram") ||
normalized.includes("visible proof") ||
normalized.includes("visible-proof") ||
normalized.includes("telegram-visible-proof"));
if (!requestedDesktopProof) {
core.notice("Comment mentioned Mantis but did not request Telegram desktop proof.");
setOutput("should_run", "false");
setOutput("baseline_ref", "");
setOutput("candidate_ref", "");
setOutput("pr_number", "");
setOutput("instructions", "");
setOutput("crabbox_provider", "");
setOutput("lease_id", "");
setOutput("publish_artifact_name", "");
setOutput("publish_run_id", "");
setOutput("request_source", "unsupported_issue_comment");
return;
}
}
const { owner, repo } = context.repo;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: Number(prNumber),
});
const provider = inputs.crabbox_provider || "aws";
if (!["aws", "hetzner"].includes(provider)) {
core.setFailed(`Unsupported Crabbox provider for Mantis Telegram desktop proof: ${provider}`);
return;
}
setOutput("should_run", "true");
setOutput("baseline_ref", pr.base.sha);
setOutput("candidate_ref", pr.head.sha);
setOutput("pr_number", String(pr.number));
@@ -185,7 +214,7 @@ jobs:
validate_refs:
name: Validate selected refs
needs: resolve_request
if: needs.resolve_request.outputs.publish_artifact_name == ''
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
runs-on: ubuntu-24.04
outputs:
baseline_revision: ${{ steps.validate.outputs.baseline_revision }}
@@ -264,7 +293,7 @@ jobs:
run_telegram_desktop_proof:
name: Run agentic native Telegram proof
needs: [resolve_request, validate_refs]
if: needs.resolve_request.outputs.publish_artifact_name == ''
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 360
environment: qa-live-shared
@@ -429,6 +458,7 @@ jobs:
codex-home: /tmp/mantis-codex-home-${{ github.run_id }}
safety-strategy: unprivileged-user
codex-user: codex
allow-bot-users: clawsweeper[bot]
- name: Inspect Mantis evidence manifest
id: inspect
@@ -513,7 +543,7 @@ jobs:
publish_existing_telegram_desktop_proof:
name: Publish existing native Telegram proof
needs: resolve_request
if: needs.resolve_request.outputs.publish_artifact_name != ''
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name != ''
runs-on: ubuntu-24.04
environment: qa-live-shared
steps:
@@ -598,3 +628,44 @@ jobs:
--artifact-url "$PUBLISH_ARTIFACT_URL" \
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${PUBLISH_RUN_ID}" \
--request-source "$REQUEST_SOURCE"
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_refs, run_telegram_desktop_proof]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -56,9 +56,8 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@Mantis') ||
contains(github.event.comment.body, '@mantis') ||
contains(github.event.comment.body, '/mantis')
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
)
)
}}
@@ -140,9 +139,18 @@ jobs:
}
const normalized = body.toLowerCase();
const requestedDesktopProof =
normalized.includes("desktop proof") ||
normalized.includes("desktop-proof") ||
normalized.includes("telegram desktop") ||
normalized.includes("native telegram") ||
normalized.includes("visible proof") ||
normalized.includes("visible-proof") ||
normalized.includes("telegram-visible-proof");
const requested =
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
normalized.includes("telegram");
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
normalized.includes("telegram") &&
!requestedDesktopProof;
if (!requested) {
core.notice("Comment mentioned Mantis but did not request Telegram live QA.");
setOutput("should_run", "false");
@@ -532,3 +540,44 @@ jobs:
run: |
echo "Mantis Telegram live failed: comparison=${COMPARISON_STATUS:-unset} telegram_exit=${TELEGRAM_EXIT:-unset}." >&2
exit 1
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_ref, run_telegram_live]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -40,8 +40,18 @@ on:
description: Optional comma-separated Telegram scenario ids
required: false
type: string
advisory:
description: Treat package Telegram failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat package Telegram failures as advisory for the caller
required: false
default: false
type: boolean
package_spec:
description: Published OpenClaw package spec to test when no artifact is supplied
required: true
@@ -100,6 +110,7 @@ jobs:
run_package_telegram_e2e:
name: Run package Telegram E2E
runs-on: blacksmith-32vcpu-ubuntu-2404
continue-on-error: ${{ inputs.advisory }}
timeout-minutes: 60
environment: qa-live-shared
permissions:

View File

@@ -86,8 +86,18 @@ on:
required: false
default: ""
type: string
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
ref:
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
required: true
@@ -191,6 +201,7 @@ env:
jobs:
prepare:
runs-on: ubuntu-24.04
continue-on-error: ${{ inputs.advisory }}
outputs:
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
baseline_spec: ${{ steps.baseline.outputs.value }}
@@ -513,6 +524,7 @@ jobs:
cross_os_release_checks:
name: "${{ matrix.display_name }} / ${{ matrix.suite_label }}"
needs: prepare
continue-on-error: ${{ inputs.advisory }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}

View File

@@ -97,8 +97,18 @@ on:
- beta
- stable
- full
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
ref:
description: Ref, tag, or SHA to validate
required: true
@@ -455,6 +465,7 @@ jobs:
validate_release_live_cache:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: 20
env:
@@ -505,6 +516,7 @@ jobs:
validate_repo_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e && inputs.live_suite_filter == ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
env:
@@ -534,6 +546,7 @@ jobs:
validate_special_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'openshell-e2e')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
@@ -608,6 +621,7 @@ jobs:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
name: Docker E2E (${{ matrix.label }})
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
@@ -876,6 +890,7 @@ jobs:
plan_docker_lane_groups:
needs: validate_selected_ref
if: inputs.docker_lanes != ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
outputs:
@@ -903,6 +918,7 @@ jobs:
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_docker_lane_groups]
if: inputs.docker_lanes != ''
name: Docker E2E targeted lanes (${{ matrix.group.label }})
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: 60
strategy:
@@ -1112,6 +1128,7 @@ jobs:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
name: Docker E2E (openwebui)
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: 60
env:
@@ -1239,6 +1256,7 @@ jobs:
prepare_docker_e2e_image:
needs: validate_selected_ref
if: inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
permissions:
@@ -1483,6 +1501,7 @@ jobs:
prepare_live_test_image:
needs: validate_selected_ref
if: inputs.include_live_suites && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-') || startsWith(inputs.live_suite_filter, 'docker-live-models'))
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: 60
permissions:
@@ -1556,6 +1575,7 @@ jobs:
name: Docker live models (${{ matrix.provider_label }})
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: 45
strategy:
@@ -1708,6 +1728,7 @@ jobs:
name: Docker live models (selected providers)
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && inputs.live_model_providers != '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: 45
env:
@@ -1883,6 +1904,7 @@ jobs:
validate_live_provider_suites:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || (startsWith(inputs.live_suite_filter, 'native-live-') && !startsWith(inputs.live_suite_filter, 'native-live-extensions-media') && inputs.live_suite_filter != 'native-live-extensions-a-k'))
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
@@ -2204,6 +2226,7 @@ jobs:
name: Docker live suites (${{ matrix.label }})
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-'))
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
@@ -2423,6 +2446,7 @@ jobs:
name: Live media suites (${{ matrix.label }})
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'native-live-extensions-media') || inputs.live_suite_filter == 'native-live-extensions-a-k')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
container:
image: ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04

View File

@@ -534,6 +534,7 @@ jobs:
permissions: read-all
uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
ref: ${{ needs.resolve_target.outputs.revision }}
provider: ${{ needs.resolve_target.outputs.provider }}
mode: ${{ needs.resolve_target.outputs.mode }}
@@ -565,6 +566,7 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
ref: ${{ needs.resolve_target.outputs.revision }}
include_repo_e2e: true
include_release_path_suites: false
@@ -630,6 +632,7 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
ref: ${{ needs.resolve_target.outputs.revision }}
include_repo_e2e: false
include_release_path_suites: true
@@ -650,6 +653,7 @@ jobs:
pull-requests: read
uses: ./.github/workflows/package-acceptance.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
workflow_ref: ${{ github.ref_name }}
source: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec != '' || needs.resolve_target.outputs.release_package_spec != '') && 'npm' || 'artifact' }}
package_spec: ${{ needs.resolve_target.outputs.package_acceptance_package_spec || needs.resolve_target.outputs.release_package_spec || 'openclaw@beta' }}
@@ -660,7 +664,7 @@ jobs:
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
telegram_mode: mock-openai
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-long-final-reuses-preview,telegram-mention-gating
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -897,6 +901,7 @@ jobs:
run: pnpm build
- name: Run runtime parity lane
id: runtime_parity_lane
run: |
set -euo pipefail
pnpm openclaw qa suite \
@@ -908,6 +913,19 @@ jobs:
--runtime-pair pi,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity"
- name: Run standard runtime parity tier
if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
pnpm openclaw qa suite \
--provider-mode mock-openai \
--runtime-parity-tier standard \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
- name: Generate runtime parity report
if: always()
run: |
@@ -918,6 +936,16 @@ jobs:
--summary .artifacts/qa-e2e/runtime-parity/qa-suite-summary.json \
--output-dir .artifacts/qa-e2e/runtime-parity-report
- name: Generate standard runtime parity report
if: always()
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
- name: Upload runtime parity artifacts
if: always()
uses: actions/upload-artifact@v4
@@ -927,6 +955,57 @@ jobs:
retention-days: 14
if-no-files-found: warn
runtime_tool_coverage_release_checks:
name: Enforce QA Lab runtime tool coverage
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
actions: read
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Download runtime parity artifacts
uses: actions/download-artifact@v4
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
- name: Enforce standard runtime tool coverage
run: |
set -euo pipefail
pnpm openclaw qa coverage \
--repo-root . \
--tools \
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
- name: Upload runtime tool coverage artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/runtime-parity-standard-report/
retention-days: 14
if-no-files-found: warn
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
needs: [resolve_target]
@@ -1406,6 +1485,7 @@ jobs:
- qa_lab_parity_lane_release_checks
- qa_lab_parity_report_release_checks
- qa_lab_runtime_parity_release_checks
- runtime_tool_coverage_release_checks
- qa_live_matrix_release_checks
- qa_live_telegram_release_checks
- qa_live_discord_release_checks
@@ -1418,9 +1498,15 @@ jobs:
steps:
- name: Verify release check results
shell: bash
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
failed=0
tideclaw_alpha=false
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha=true
fi
for item in \
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
@@ -1431,6 +1517,7 @@ jobs:
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \
"qa_live_matrix_release_checks=${{ needs.qa_live_matrix_release_checks.result }}" \
"qa_live_telegram_release_checks=${{ needs.qa_live_telegram_release_checks.result }}" \
"qa_live_discord_release_checks=${{ needs.qa_live_discord_release_checks.result }}" \
@@ -1440,6 +1527,15 @@ jobs:
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
if [[ "$tideclaw_alpha" == "true" ]]; then
case "$name" in
prepare_release_package|install_smoke_release_checks) ;;
*)
echo "::warning::${name} ended with ${result}; Tideclaw alpha treats non-package-safety release-check lanes as advisory."
continue
;;
esac
fi
if [[ "$name" == qa_* ]]; then
echo "::warning::${name} ended with ${result}; QA release-check lanes are advisory and do not block release validation."
continue

View File

@@ -93,8 +93,18 @@ on:
required: false
default: ""
type: string
advisory:
description: Treat acceptance failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat acceptance failures as advisory for the caller
required: false
default: false
type: boolean
workflow_ref:
description: Trusted repo ref for workflow scripts and Docker E2E harness
required: false
@@ -509,6 +519,7 @@ jobs:
needs: resolve_package
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
advisory: ${{ inputs.advisory }}
ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}
include_repo_e2e: false
include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }}
@@ -573,6 +584,7 @@ jobs:
if: needs.resolve_package.outputs.telegram_enabled == 'true'
uses: ./.github/workflows/npm-telegram-beta-e2e.yml
with:
advisory: ${{ inputs.advisory }}
package_spec: ${{ inputs.package_spec }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
@@ -599,6 +611,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
advisory="${{ inputs.advisory }}"
failed=0
for item in \
"resolve_package=${RESOLVE_RESULT}" \
@@ -608,6 +621,10 @@ jobs:
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
if [[ "$advisory" == "true" && "$name" != "resolve_package" ]]; then
echo "::warning::${name} ended with ${result}; package acceptance is advisory for this caller."
continue
fi
echo "::error::${name} ended with ${result}"
failed=1
fi

View File

@@ -229,6 +229,96 @@ jobs:
retention-days: 14
if-no-files-found: warn
run_live_runtime_token_efficiency:
name: Run live runtime token-efficiency lane
needs: [authorize_actor, validate_selected_ref]
if: github.event_name == 'schedule'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
environment: qa-live-shared
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing required OPENAI_API_KEY." >&2
exit 1
fi
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run live runtime parity lane
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_LIVE_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/runtime-token-efficiency-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
pnpm openclaw qa suite \
--repo-root . \
--provider-mode live-frontier \
--runtime-parity-tier standard \
--runtime-parity-tier live-only \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--runtime-pair pi,codex \
--fast \
--allow-failures \
--output-dir "${output_dir}/runtime-suite"
- name: Generate live runtime token-efficiency report
if: always() && steps.run_lane.outcome != 'skipped' && steps.run_lane.outcome != 'cancelled'
shell: bash
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--token-efficiency \
--summary "${{ steps.run_lane.outputs.output_dir }}/runtime-suite/qa-suite-summary.json" \
--output-dir "${{ steps.run_lane.outputs.output_dir }}/runtime-report"
- name: Upload live runtime token-efficiency artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
run_live_matrix:
name: Run Matrix live QA lane
needs: [authorize_actor, validate_selected_ref]

View File

@@ -2,8 +2,12 @@ name: Workflow Sanity
on:
pull_request:
paths-ignore:
- "CHANGELOG.md"
push:
branches: [main]
paths-ignore:
- "CHANGELOG.md"
workflow_dispatch:
permissions:

View File

@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
- Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.
- Skills: rename the repo-local Codex closeout review skill and helper to `autoreview` while preserving the Codex-first fallback behavior.
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
- Browser: surface pending and recently handled modal dialogs in snapshots, return `blockedByDialog` when an action opens a modal, and allow `browser dialog --dialog-id` to answer pending dialogs.
- Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.
- Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.
- CLI/plugins: add `defineToolPlugin` plus `openclaw plugins build`, `validate`, and `init` for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.
@@ -18,30 +19,64 @@ Docs: https://docs.openclaw.ai
- Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated.
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
- QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.
- QA-Lab: add `openclaw qa suite --runtime-parity-tier` and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.
- QA-Lab: add a live-only Codex Pi-shaped Read vocabulary canary so runtime parity catches native workspace-read prompt compatibility drift. (#80323) Thanks @100yenadmin.
- QA-Lab: add live-only harness self-health scenarios for plugin hook crashes, manifest contract errors, and WebChat direct-reply self-message routing. (#80323) Thanks @100yenadmin.
- QA-Lab: add runtime tool fixture scenarios and coverage reporting for Codex-native workspace tools, OpenClaw dynamic tools, and optional plugin-backed tools. Fixes #80173. Thanks @100yenadmin.
- QA-Lab: expose runtime tool fixture coverage through `openclaw qa coverage --tools`, with optional suite-summary evaluation for parity gate artifacts. Thanks @100yenadmin.
- QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.
- QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.
- QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.
### Fixes
- Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow `system.which` timeout warnings.
- GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with `invalid_request_body`. Fixes #83220. Thanks @galiniliev.
- QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.
- Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.
- Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.
- Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected, and keep the Settings sidebar toggle in the leading titlebar area.
- Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.
- Gateway/webchat: hide internal runtime-context and other `display: false` transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.
- CLI/help: keep `gateway`, `doctor`, `status`, and `health` help registration out of action/runtime imports so subcommand `--help` stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.
- Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.
- Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.
- Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.
- Telegram: fail topic sends closed when Telegram reports `message thread not found` instead of retrying without `message_thread_id` into the base chat. Refs #83302.
- Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.
- OpenAI/Codex: stop rejecting available `openai-codex` GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.
- Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.
- Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.
- Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.
- Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.
- Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.
- CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.
- Plugins: apply a default 15-second timeout to legacy `before_agent_start` hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.
- Feishu: refresh inbound session delivery context for DM, group, and broadcast turns so later replies do not inherit stale WebChat routing. Fixes #78274.
- Agents/subagents: require the initial subagent registry save before reporting spawn accepted, returning a spawn error instead of losing an untracked run when the registry write fails. (#83146) Thanks @yetval.
- QA-Lab/qa-channel: attach redacted agent tool-start traces to outbound `QaBusMessage` records so scenarios can assert actual tool use instead of relying only on reply text. Fixes #67637. Thanks @100yenadmin.
- QA-Lab: fail live runtime parity reports when assistant-message usage is missing, preventing `0 vs 0` live token rows from being reported as passing proof. Fixes #80411. Thanks @100yenadmin.
- QA-Lab: add a runtime token-efficiency sidecar report that classifies Codex savings separately from regressions and fails only positive Codex-over-Pi live token deltas above threshold. Fixes #81093. Thanks @100yenadmin.
- QA-Lab: fail Codex-backed OpenAI live runtime-pair runs before launching isolated workers when no portable Codex auth is available, while staging API-key fallbacks and configured Codex keys for isolated QA agents. Fixes #80412. Thanks @100yenadmin.
- QA-Lab: refresh parity gates, mock frontier fixtures, model scenarios, and workflow artifact lanes to compare GPT-5.5 against Claude Opus 4.7. Fixes #74262. Thanks @100yenadmin.
- QA-Lab: make mock parity dispatch provider-aware for source discovery and subagent scenarios so OpenAI and Anthropic lanes no longer share identical canned plans. Fixes #64879. Thanks @100yenadmin.
- QA-Lab: stop returning Control UI bearer tokens from unauthenticated bootstrap payloads and bind Docker harness ports to loopback-only host addresses. (#66355) Thanks @pgondhi987.
- Mac app: avoid a SwiftUI metadata crash when rendering the Cron Jobs settings pane.
- Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.
- Agents/OpenAI streams: yield via `setTimeout(0)` instead of `setImmediate` between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.
- Agents/Codex: keep legacy `oauthRef`-backed OAuth profiles usable while `openclaw doctor --fix` migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.
- CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.
- CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like `0.0.0.0`. Fixes #83286. Thanks @giodl73-repo.
- CLI/plugins: ship the bundled memory CLI as a package entry so package-installed `openclaw memory` commands register correctly.
- CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.
- Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.
- Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.
- CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.
- Telegram: keep isolated long polling below the hard `getUpdates` request guard so idle bot accounts with high `timeoutSeconds` do not false-disconnect and restart-loop. Fixes #83264. Thanks @riccodecarvalho.
- Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing `thought_signature` 400s. Fixes #72879. (#80358) Thanks @abnershang.
- Telegram: skip transcript-only delivery mirrors and gateway-injected rows when resolving latest assistant text, preventing retained previews from replacing final replies with stale fragments. Fixes #83159. (#83362) Thanks @joshavant.
- Memory/QMD: keep lexical search on raw hyphenated queries while normalizing semantic QMD sub-searches, avoiding fallback to the builtin index for dashed identifiers and dates. Fixes #81328.
- Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded `memory index` warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.
- Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.
- Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.
@@ -77,6 +112,7 @@ Docs: https://docs.openclaw.ai
- Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.
- Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving `default` instead of silently dropping it.
- Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram `/verbose` stays visible when command events arrive only at completion.
- Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.
- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.
- Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.
- Gateway/protocol: restore Gateway WS protocol v4 and keep `message.action` room-event metadata on the existing `inboundTurnKind` wire field while preserving internal inbound-event classification.
@@ -114,6 +150,8 @@ Docs: https://docs.openclaw.ai
- Android: prompt before replacing a changed Gateway TLS thumbprint, showing the old and new SHA-256 fingerprints so users can accept expected certificate rotations instead of hard failing on pin mismatch. (#83077) Thanks @sliekens.
- CLI/status: render extra gateway-like service diagnostics as warning/info output instead of error output. Fixes #46930. (#82922) thanks @giodl73-repo.
- Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.
- Plugin SDK: bundle `openclaw/plugin-sdk/zod` into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local `zod` symlink. Fixes #78398. (#78515) Thanks @ggzeng.
- Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.
## 2026.5.17
@@ -1414,6 +1452,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: cap summarization output reserve tokens to the selected model's `maxTokens` so 1M-context Anthropic compactions do not request more output than the API permits. Fixes #54383.
- Control UI/login: replace raw connection failures with structured, actionable login guidance for auth, pairing, insecure HTTP, origin, protocol, and transport failures. Thanks @BunsDev.
- Agents/tools: fail `exec host=node` before `system.run` when the selected node is known to be disconnected, with an actionable reconnect message instead of a raw node invoke failure. Thanks @BunsDev.
- Agents/tool-result guard: ignore internal tool-result `details` when estimating model-visible context, so large diagnostic metadata no longer triggers unnecessary truncation or compaction even though the provider boundary already strips `details` before model conversion. (#75525) Thanks @zqchris.
- Agents/models: accept legacy `anthropic-cli/*` model refs as Claude CLI runtime refs instead of failing model resolution with `Unknown model`. Thanks @BunsDev.
- Agents/tools: keep restrictive-profile tool-section warnings scoped to the configured sections whose tools are still missing from `alsoAllow`, so already re-allowed filesystem tools do not make exec-only fixes look broader than they are. Thanks @BunsDev.
- Agents/tools: avoid warning messaging-only agents about inherited global `tools.exec` or `tools.fs` sections when the agent profile did not configure those tool sections itself. Thanks @BunsDev.

View File

@@ -363,9 +363,11 @@ final class AppState {
}
let configRoot = OpenClawConfigFile.loadDict()
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
let configRemoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
let configRemoteTransport = configRemoteResolution.transport
let configRemoteUrl = configRemoteResolution.directURL?.absoluteString
?? GatewayRemoteConfig.resolveUrlString(root: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
self.connectionMode = resolvedConnectionMode
@@ -532,7 +534,10 @@ final class AppState {
}
case .ssh:
changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.ssh.rawValue) || changed
let sanitizedTarget = Self.sanitizeSSHTarget(draft.remoteTarget)
let expectedRemoteHost = CommandResolver.parseSSHTarget(sanitizedTarget)?.host ?? draft.remoteHost
@@ -576,7 +581,8 @@ final class AppState {
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
let remoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
let remoteTransport = remoteResolution.transport
let desiredMode: ConnectionMode? = switch modeRaw {
case "local":
@@ -600,7 +606,7 @@ final class AppState {
if remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
let remoteUrlText = remoteUrl ?? ""
let remoteUrlText = remoteResolution.directURL?.absoluteString ?? remoteUrl ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}

View File

@@ -23,7 +23,7 @@ struct ContextRootMenuLabelView: View {
if self.usesStackedLayout {
self.subtitleText
.lineLimit(3)
.lineLimit(5)
.fixedSize(horizontal: false, vertical: true)
}
}

View File

@@ -265,9 +265,10 @@ final class ControlChannel {
private static func isLikelyLocalNetworkPermissionBlock() -> Bool {
let root = OpenClawConfigFile.loadDict()
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
guard ConnectionModeResolver.resolve(root: root).mode == .remote,
GatewayRemoteConfig.resolveTransport(root: root) == .direct,
let url = GatewayRemoteConfig.resolveGatewayUrl(root: root),
resolution.transport == .direct,
let url = resolution.directURL,
url.scheme?.lowercased() == "ws",
let host = url.host,
GatewayRemoteConfig.isTrustedPlaintextRemoteHost(host),

View File

@@ -10,6 +10,7 @@ final class DashboardManager {
static let shared = DashboardManager()
private var controller: DashboardWindowController?
private static let failureURL = URL(string: "about:blank")!
private init() {}
@@ -69,6 +70,19 @@ final class DashboardManager {
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
}
func showFailure(_ error: Error) {
let message = (error as NSError).localizedDescription
dashboardManagerLogger.error("dashboard setup failed error=\(message, privacy: .public)")
let controller = self.controller ?? DashboardWindowController(
url: Self.failureURL,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
self.controller = controller
controller.showFailure(
title: "Dashboard unavailable",
message: message,
detail: "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.")
}
func close() {
self.controller?.closeDashboard()
}
@@ -101,9 +115,10 @@ final class DashboardManager {
private func immediateDashboardConfig(mode: AppState.ConnectionMode) -> GatewayConnection.Config? {
let root = OpenClawConfigFile.loadDict()
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
if mode == .remote,
GatewayRemoteConfig.resolveTransport(root: root) == .direct,
let url = GatewayRemoteConfig.resolveGatewayUrl(root: root)
resolution.transport == .direct,
let url = resolution.directURL
{
return (
url,

View File

@@ -80,6 +80,17 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
self.window?.performClose(nil)
}
func showFailure(title: String, message: String, detail: String? = nil) {
self.currentURL = URL(string: "about:blank")!
self.auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)
self.refreshNativeAuthScript(url: self.currentURL, auth: self.auth)
self.webView.stopLoading()
self.webView.loadHTMLString(
Self.failureHTML(title: title, message: message, detail: detail, url: nil),
baseURL: nil)
self.show()
}
private func load(_ url: URL) {
dashboardWindowLogger.debug("dashboard load \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
@@ -282,54 +293,107 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return }
dashboardWindowLogger.error(
"dashboard load failed url=\(self.currentURL.absoluteString, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
let html = Self.failureHTML(url: self.currentURL, message: error.localizedDescription)
let html = Self.failureHTML(
title: "Dashboard unavailable",
message: error.localizedDescription,
detail: "The dashboard window is open, but the web UI could not load from this endpoint.",
url: self.currentURL)
self.webView.loadHTMLString(html, baseURL: nil)
}
private static func failureHTML(url: URL, message: String) -> String {
"""
private static func failureHTML(title: String, message: String, detail: String?, url: URL?) -> String {
let detailHTML = detail.map { "<p class=\"detail\">\(self.htmlEscape($0))</p>" } ?? ""
let urlHTML = url.map { "<code>\(self.htmlEscape($0.absoluteString))</code>" } ?? ""
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: Canvas;
color: CanvasText;
font: -apple-system-body;
background: #101114;
color: rgba(255,255,255,.92);
font: 15px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
}
main {
width: min(520px, calc(100vw - 64px));
line-height: 1.4;
width: min(540px, calc(100vw - 72px));
padding: 34px;
border: 1px solid rgba(255,255,255,.12);
border-radius: 22px;
background: rgba(255,255,255,.035);
box-shadow: 0 28px 90px rgba(0,0,0,.36);
line-height: 1.45;
}
.badge {
width: 44px;
height: 44px;
display: grid;
place-items: center;
margin-bottom: 20px;
border-radius: 14px;
background: rgba(255,255,255,.07);
color: #ff746b;
font-size: 24px;
}
h1 {
margin: 0 0 10px;
font: -apple-system-title2;
font-weight: 650;
margin: 0 0 12px;
font-size: 24px;
line-height: 1.16;
font-weight: 700;
letter-spacing: 0;
}
p {
margin: 0;
color: rgba(255,255,255,.76);
font-size: 16px;
}
.detail {
margin-top: 14px;
color: rgba(255,255,255,.56);
font-size: 13px;
}
p { margin: 8px 0; color: color-mix(in srgb, CanvasText 72%, transparent); }
code {
display: block;
margin-top: 14px;
margin-top: 18px;
padding: 12px;
border-radius: 8px;
background: color-mix(in srgb, CanvasText 8%, transparent);
color: CanvasText;
border: 1px solid rgba(255,255,255,.08);
border-radius: 10px;
background: rgba(0,0,0,.26);
color: rgba(255,255,255,.76);
overflow-wrap: anywhere;
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (prefers-color-scheme: light) {
body { background: #f5f6f8; color: rgba(0,0,0,.86); }
main {
background: rgba(255,255,255,.84);
border-color: rgba(0,0,0,.1);
box-shadow: 0 28px 90px rgba(0,0,0,.12);
}
.badge { background: rgba(0,0,0,.06); }
p { color: rgba(0,0,0,.68); }
.detail { color: rgba(0,0,0,.54); }
code {
background: rgba(0,0,0,.05);
border-color: rgba(0,0,0,.08);
color: rgba(0,0,0,.68);
}
}
</style>
</head>
<body>
<main>
<h1>Dashboard unavailable</h1>
<div class="badge">!</div>
<h1>\(self.htmlEscape(title))</h1>
<p>\(self.htmlEscape(message))</p>
<code>\(self.htmlEscape(url.absoluteString))</code>
\(detailHTML)
\(urlHTML)
</main>
</body>
</html>

View File

@@ -184,7 +184,7 @@ final class DeepLinkHandler {
do {
try await DashboardManager.shared.show()
} catch {
self.presentAlert(title: "Dashboard unavailable", message: error.localizedDescription)
DashboardManager.shared.showFailure(error)
}
}

View File

@@ -41,21 +41,31 @@ enum GatewayDiscoveryHelpers {
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
serviceHost: gateway.serviceHost,
servicePort: gateway.servicePort)
servicePort: gateway.servicePort,
gatewayTls: gateway.gatewayTls)
}
static func directGatewayUrl(
serviceHost: String?,
servicePort: Int?) -> String?
servicePort: Int?,
gatewayTls: Bool = false) -> String?
{
// Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort).
// Prefer the resolved service endpoint (SRV + A/AAAA).
guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else {
return nil
}
// Security: for non-loopback hosts, force TLS to avoid plaintext credential/session leakage.
let scheme = self.isLoopbackHost(endpoint.host) ? "ws" : "wss"
let portSuffix = endpoint.port == 443 ? "" : ":\(endpoint.port)"
let scheme: String
if gatewayTls {
scheme = "wss"
} else if self.isLoopbackHost(endpoint.host)
|| GatewayRemoteConfig.isTrustedPlaintextRemoteHost(endpoint.host)
{
scheme = "ws"
} else {
return nil
}
let portSuffix = scheme == "wss" && endpoint.port == 443 ? "" : ":\(endpoint.port)"
return "\(scheme)://\(endpoint.host)\(portSuffix)"
}

View File

@@ -25,14 +25,14 @@ enum GatewayDiscoverySelectionSupport {
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
if preferredTransport == .direct {
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
port: endpoint.port)
OpenClawConfigFile.setRemoteGatewayTransport(AppState.RemoteTransport.direct.rawValue)
if !state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
OpenClawConfigFile.setRemoteGatewayUrlString(state.remoteUrl)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
} else {
OpenClawConfigFile.setRemoteGatewayTransport(AppState.RemoteTransport.ssh.rawValue)
OpenClawConfigFile.setRemoteGatewayUrlString(state.remoteUrl)
}
}
@@ -65,9 +65,10 @@ enum GatewayDiscoverySelectionSupport {
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool
{
guard GatewayDiscoveryHelpers.directUrl(for: gateway) != nil else { return false }
if gateway.stableID.hasPrefix("tailscale-serve|") {
if gateway.gatewayTls || gateway.gatewayDirectReachable {
return true
}
guard let host = GatewayDiscoveryHelpers.resolvedServiceHost(for: gateway)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()

View File

@@ -306,8 +306,9 @@ actor GatewayEndpointStore {
password: password))
case .remote:
let root = OpenClawConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
if resolution.transport == .direct {
guard let url = resolution.directURL else {
self.cancelRemoteEnsure()
self.setState(.unavailable(
mode: .remote,
@@ -470,8 +471,9 @@ actor GatewayEndpointStore {
private func resolveDirectRemoteURL() throws -> URL? {
let root = OpenClawConfigFile.loadDict()
guard GatewayRemoteConfig.resolveTransport(root: root) == .direct else { return nil }
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
guard resolution.transport == .direct else { return nil }
guard let url = resolution.directURL else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,

View File

@@ -5,6 +5,18 @@ import Darwin
#endif
enum GatewayRemoteConfig {
enum TransportSource: Equatable {
case explicit
case inferredRemoteURL
case legacySSH
}
struct TransportResolution: Equatable {
let transport: AppState.RemoteTransport
let source: TransportSource
let directURL: URL?
}
enum TokenValue: Equatable {
case missing
case plaintext(String)
@@ -28,14 +40,49 @@ enum GatewayRemoteConfig {
}
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
self.resolveTransportResolution(root: root).transport
}
static func resolveTransportResolution(root: [String: Any]) -> TransportResolution {
let explicit = self.resolveExplicitTransport(root: root)
switch explicit {
case .direct:
return TransportResolution(
transport: .direct,
source: .explicit,
directURL: self.resolveGatewayUrl(root: root))
case .ssh:
return TransportResolution(transport: .ssh, source: .explicit, directURL: nil)
case nil:
break
}
if let url = self.resolveGatewayUrl(root: root),
let host = url.host,
!LoopbackHost.isLoopbackHost(host)
{
return TransportResolution(transport: .direct, source: .inferredRemoteURL, directURL: url)
}
return TransportResolution(transport: .ssh, source: .legacySSH, directURL: nil)
}
private static func resolveExplicitTransport(root: [String: Any]) -> AppState.RemoteTransport? {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let raw = remote["transport"] as? String
else {
return .ssh
return nil
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
switch trimmed {
case AppState.RemoteTransport.direct.rawValue:
return .direct
case AppState.RemoteTransport.ssh.rawValue:
return .ssh
default:
return .ssh
}
}
static func resolveUrlString(root: [String: Any]) -> String? {

View File

@@ -91,8 +91,11 @@ struct OpenClawApp: App {
}
}
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
self.statusItem?.button?.appearsDisabled = paused || sleeping
private func applyStatusItemAppearance(paused _: Bool, sleeping _: Bool) {
// Keep the status item actionable even when the Gateway is paused or disconnected.
// The SwiftUI label already renders those states; AppKit's disabled appearance can
// leak into menu item validation and grey out app-level commands like Settings.
self.statusItem?.button?.appearsDisabled = false
}
private static func applyAttachOnlyOverrideIfNeeded() {
@@ -180,10 +183,7 @@ struct OpenClawApp: App {
do {
try await DashboardManager.shared.show()
} catch {
let alert = NSAlert()
alert.messageText = "Dashboard unavailable"
alert.informativeText = error.localizedDescription
alert.runModal()
DashboardManager.shared.showFailure(error)
}
}
}
@@ -302,10 +302,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
do {
try await DashboardManager.shared.show()
} catch {
let alert = NSAlert()
alert.messageText = "Dashboard unavailable"
alert.informativeText = error.localizedDescription
alert.runModal()
DashboardManager.shared.showFailure(error)
}
}
}

View File

@@ -38,7 +38,7 @@ struct MenuHeaderCard<Content: View>: View {
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(3)
.lineLimit(5)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
}

View File

@@ -301,6 +301,16 @@ enum OpenClawConfigFile {
}
}
static func setRemoteGatewayTransport(_ value: String) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.updateGatewayDict { gateway in
var remote = gateway["remote"] as? [String: Any] ?? [:]
remote["transport"] = trimmed
gateway["remote"] = remote
}
}
static func clearRemoteGatewayUrl() {
self.updateGatewayDict { gateway in
guard var remote = gateway["remote"] as? [String: Any] else { return }

View File

@@ -16,6 +16,32 @@ final class RemotePortTunnel: @unchecked Sendable {
let localPort: UInt16?
private let stderrHandle: FileHandle?
private final class StderrCapture: @unchecked Sendable {
private let lock = NSLock()
private var text = ""
private let limit = 4096
func append(_ chunk: String) {
let trimmed = chunk.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.lock.lock()
defer { self.lock.unlock() }
if !self.text.isEmpty {
self.text += "\n"
}
self.text += trimmed
if self.text.count > self.limit {
self.text = String(self.text.suffix(self.limit))
}
}
func snapshot() -> String {
self.lock.lock()
defer { self.lock.unlock() }
return self.text.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
private init(process: Process, localPort: UInt16?, stderrHandle: FileHandle?) {
self.process = process
self.localPort = localPort
@@ -93,6 +119,7 @@ final class RemotePortTunnel: @unchecked Sendable {
let pipe = Pipe()
process.standardError = pipe
let stderrHandle = pipe.fileHandleForReading
let stderrCapture = StderrCapture()
// Consume stderr so ssh cannot block if it logs.
stderrHandle.readabilityHandler = { handle in
@@ -106,6 +133,7 @@ final class RemotePortTunnel: @unchecked Sendable {
.trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty
else { return }
stderrCapture.append(line)
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
}
process.terminationHandler = { _ in
@@ -114,7 +142,11 @@ final class RemotePortTunnel: @unchecked Sendable {
try process.run()
try await Self.waitForListener(process: process, localPort: localPort, stderrHandle: stderrHandle)
try await Self.waitForListener(
process: process,
localPort: localPort,
stderrHandle: stderrHandle,
stderrCapture: stderrCapture)
// Track tunnel so we can clean up stale listeners on restart.
Task {
@@ -131,12 +163,13 @@ final class RemotePortTunnel: @unchecked Sendable {
private static func waitForListener(
process: Process,
localPort: UInt16,
stderrHandle: FileHandle) async throws
stderrHandle: FileHandle,
stderrCapture: StderrCapture) async throws
{
let deadline = Date().addingTimeInterval(6)
repeat {
if !process.isRunning {
let stderr = Self.drainStderr(stderrHandle)
let stderr = Self.drainStderr(stderrHandle, captured: stderrCapture.snapshot())
let msg = stderr.isEmpty ? "ssh tunnel exited before listening" : "ssh tunnel failed: \(stderr)"
throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg])
}
@@ -152,7 +185,7 @@ final class RemotePortTunnel: @unchecked Sendable {
} while Date() < deadline
process.terminate()
let stderr = Self.drainStderr(stderrHandle)
let stderr = Self.drainStderr(stderrHandle, captured: stderrCapture.snapshot())
let msg = stderr.isEmpty ? "ssh tunnel did not open local port \(localPort)" : "ssh tunnel failed: \(stderr)"
throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg])
}
@@ -311,16 +344,27 @@ final class RemotePortTunnel: @unchecked Sendable {
}
private static func drainStderr(_ handle: FileHandle) -> String {
self.drainStderr(handle, captured: "")
}
private static func drainStderr(_ handle: FileHandle, captured: String) -> String {
handle.readabilityHandler = nil
defer { try? handle.close() }
do {
let data = try handle.readToEnd() ?? Data()
return String(data: data, encoding: .utf8)?
let remaining = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if captured.isEmpty {
return remaining
}
if remaining.isEmpty {
return captured
}
return captured + "\n" + remaining
} catch {
self.logger.debug("Failed to drain ssh stderr: \(error, privacy: .public)")
return ""
return captured
}
}

View File

@@ -23,8 +23,9 @@ struct SessionsSettings: View {
self.content
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.leading, 18)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
@@ -34,16 +35,16 @@ struct SessionsSettings: View {
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.headline)
.font(.title3.weight(.semibold))
Text("Peek at the stored conversation buckets the CLI reuses for context and rate limits.")
.font(.footnote)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Spacer(minLength: 16)
SettingsRefreshButton(isLoading: self.loading) {
Task { await self.refresh() }
}
@@ -58,21 +59,30 @@ struct SessionsSettings: View {
.foregroundStyle(.secondary)
.padding(.top, 6)
} else {
List(self.rows) { row in
self.sessionRow(row)
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(self.rows.enumerated()), id: \.element.id) { index, row in
self.sessionRow(row)
.padding(.horizontal, 8)
.padding(.vertical, 8)
if index != self.rows.count - 1 {
Divider()
.padding(.leading, 8)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.listStyle(.inset)
.overlay(alignment: .topLeading) {
if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
.padding(.leading, 4)
.padding(.leading, 8)
.padding(.top, 4)
}
}
// The view already applies horizontal padding; keep the list aligned with the text above.
.padding(.horizontal, -12)
}
}
}
@@ -136,7 +146,7 @@ struct SessionsSettings: View {
}
}
}
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func label(icon: String, text: String) -> some View {

View File

@@ -8,6 +8,7 @@ struct SettingsRootView: View {
@State private var monitoringPermissions = false
@State private var selectedTab: SettingsTab = .general
@State private var cachedTabs: Set<SettingsTab>
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var snapshotPaths: (configPath: String?, stateDir: String?) = (nil, nil)
let updater: UpdaterProviding?
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -22,7 +23,7 @@ struct SettingsRootView: View {
}
var body: some View {
NavigationSplitView {
NavigationSplitView(columnVisibility: self.$columnVisibility) {
List(selection: self.$selectedTab) {
ForEach(self.visibleGroups) { group in
Section(group.title) {
@@ -46,6 +47,7 @@ struct SettingsRootView: View {
.padding(.horizontal, 22)
.padding(.vertical, 18)
}
.navigationSplitViewStyle(.balanced)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(SettingsWindowChromeConfigurator())

View File

@@ -10,5 +10,6 @@ struct SettingsSidebarScroll<Content: View>: View {
.padding(.horizontal, 10)
}
.settingsSidebarCardLayout()
.padding(.leading, 16)
}
}

View File

@@ -30,6 +30,8 @@ public final class GatewayDiscoveryModel {
public var tailnetDns: String?
public var sshPort: Int
public var gatewayPort: Int?
public var gatewayTls: Bool
public var gatewayDirectReachable: Bool
public var cliPath: String?
public var stableID: String
public var debugID: String
@@ -43,6 +45,8 @@ public final class GatewayDiscoveryModel {
tailnetDns: String? = nil,
sshPort: Int,
gatewayPort: Int? = nil,
gatewayTls: Bool = false,
gatewayDirectReachable: Bool = false,
cliPath: String? = nil,
stableID: String,
debugID: String,
@@ -55,6 +59,8 @@ public final class GatewayDiscoveryModel {
self.tailnetDns = tailnetDns
self.sshPort = sshPort
self.gatewayPort = gatewayPort
self.gatewayTls = gatewayTls
self.gatewayDirectReachable = gatewayDirectReachable
self.cliPath = cliPath
self.stableID = stableID
self.debugID = debugID
@@ -184,6 +190,8 @@ public final class GatewayDiscoveryModel {
tailnetDns: beacon.tailnetDns,
sshPort: beacon.sshPort ?? 22,
gatewayPort: beacon.gatewayPort,
gatewayTls: beacon.gatewayTls,
gatewayDirectReachable: beacon.gatewayDirectReachable,
cliPath: beacon.cliPath,
stableID: stableID,
debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)",
@@ -210,6 +218,8 @@ public final class GatewayDiscoveryModel {
tailnetDns: beacon.tailnetDns,
sshPort: 22,
gatewayPort: beacon.port,
gatewayTls: true,
gatewayDirectReachable: true,
cliPath: nil,
stableID: stableID,
debugID: "\(beacon.host):\(beacon.port)",
@@ -282,6 +292,8 @@ public final class GatewayDiscoveryModel {
tailnetDns: parsedTXT.tailnetDns,
sshPort: parsedTXT.sshPort,
gatewayPort: parsedTXT.gatewayPort,
gatewayTls: parsedTXT.gatewayTls,
gatewayDirectReachable: parsedTXT.gatewayDirectReachable,
cliPath: parsedTXT.cliPath,
stableID: stableID,
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
@@ -445,6 +457,8 @@ public final class GatewayDiscoveryModel {
public var tailnetDns: String?
public var sshPort: Int
public var gatewayPort: Int?
public var gatewayTls: Bool
public var gatewayDirectReachable: Bool
public var cliPath: String?
}
@@ -453,6 +467,8 @@ public final class GatewayDiscoveryModel {
var tailnetDns: String?
var sshPort = 22
var gatewayPort: Int?
var gatewayTls = false
var gatewayDirectReachable = false
var cliPath: String?
if let value = txt["lanHost"] {
@@ -475,6 +491,14 @@ public final class GatewayDiscoveryModel {
{
gatewayPort = parsed
}
if let value = txt["gatewayTls"] {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
gatewayTls = normalized == "1" || normalized == "true" || normalized == "yes"
}
if let value = txt["gatewayDirectReachable"] {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
gatewayDirectReachable = normalized == "1" || normalized == "true" || normalized == "yes"
}
if let value = txt["cliPath"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
cliPath = trimmed.isEmpty ? nil : trimmed
@@ -485,6 +509,8 @@ public final class GatewayDiscoveryModel {
tailnetDns: tailnetDns,
sshPort: sshPort,
gatewayPort: gatewayPort,
gatewayTls: gatewayTls,
gatewayDirectReachable: gatewayDirectReachable,
cliPath: cliPath)
}

View File

@@ -9,6 +9,8 @@ struct WideAreaGatewayBeacon: Equatable {
var lanHost: String?
var tailnetDns: String?
var gatewayPort: Int?
var gatewayTls: Bool
var gatewayDirectReachable: Bool
var sshPort: Int?
var cliPath: String?
}
@@ -83,6 +85,8 @@ enum WideAreaGatewayDiscovery {
lanHost: txt["lanHost"],
tailnetDns: txt["tailnetDns"],
gatewayPort: parseInt(txt["gatewayPort"]),
gatewayTls: parseBool(txt["gatewayTls"]),
gatewayDirectReachable: parseBool(txt["gatewayDirectReachable"]),
sshPort: parseInt(txt["sshPort"]),
cliPath: txt["cliPath"])
beacons.append(beacon)
@@ -246,6 +250,12 @@ enum WideAreaGatewayDiscovery {
return Int(trimmed)
}
private static func parseBool(_ value: String?) -> Bool {
guard let value else { return false }
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized == "1" || normalized == "true" || normalized == "yes"
}
private static func isTailnetIPv4(_ value: String) -> Bool {
let parts = value.split(separator: ".")
if parts.count != 4 { return false }

View File

@@ -41,6 +41,8 @@ struct DiscoveryOutput: Encodable {
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var gatewayTls: Bool
var gatewayDirectReachable: Bool
var cliPath: String?
var stableID: String
var debugID: String
@@ -106,6 +108,8 @@ func runDiscover(_ args: [String]) async {
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
gatewayTls: $0.gatewayTls,
gatewayDirectReachable: $0.gatewayDirectReachable,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
@@ -139,6 +143,8 @@ func runDiscover(_ args: [String]) async {
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
print(" gatewayTls: \(gateway.gatewayTls)")
print(" gatewayDirectReachable: \(gateway.gatewayDirectReachable)")
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}

View File

@@ -51,7 +51,7 @@ struct AppStateRemoteConfigTests {
remoteTokenDirty: false))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
#expect((remote["transport"] as? String) == nil)
#expect(remote["transport"] as? String == "ssh")
#expect(remote["sshTarget"] as? String == "alice@gateway.example")
}
@@ -161,6 +161,29 @@ struct AppStateRemoteConfigTests {
}
}
@Test
func `app state init preserves legacy SSH tunnel config until transport is explicit`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [remoteTargetKey: nil])
{
OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"url": "ws://127.0.0.1:18789",
"sshTarget": "steipete@192.168.0.202",
],
],
])
let state = AppState(preview: true)
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "ws://127.0.0.1:18789")
}
}
@Test
func `synced gateway root preserves object token across mode and transport changes when untouched`() {
let initialRoot: [String: Any] = [

View File

@@ -37,4 +37,18 @@ struct DashboardWindowSmokeTests {
let url = try #require(URL(string: "http://[fd12:3456:789a::1]:18789/control/"))
#expect(DashboardWindowController.originString(for: url) == "http://[fd12:3456:789a::1]:18789")
}
@Test func `dashboard failure state opens in dashboard window`() throws {
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
controller.showFailure(
title: "Dashboard unavailable",
message: "Remote control tunnel failed",
detail: "Reset the remote tunnel and try again.")
#expect(controller.window?.isVisible == true)
#expect(controller.window?.styleMask.contains(.closable) == true)
controller.closeDashboard()
}
}

View File

@@ -10,7 +10,8 @@ struct GatewayDiscoveryHelpersTests {
lanHost: String? = "txt-host.local",
tailnetDns: String? = "txt-host.ts.net",
sshPort: Int = 22,
gatewayPort: Int? = 18789) -> GatewayDiscoveryModel.DiscoveredGateway
gatewayPort: Int? = 18789,
gatewayTls: Bool = false) -> GatewayDiscoveryModel.DiscoveredGateway
{
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Gateway",
@@ -20,6 +21,7 @@ struct GatewayDiscoveryHelpersTests {
tailnetDns: tailnetDns,
sshPort: sshPort,
gatewayPort: gatewayPort,
gatewayTls: gatewayTls,
cliPath: "/tmp/openclaw",
stableID: UUID().uuidString,
debugID: UUID().uuidString,
@@ -70,13 +72,14 @@ struct GatewayDiscoveryHelpersTests {
@Test func `direct url uses resolved service endpoint only`() {
let tlsGateway = self.makeGateway(
serviceHost: "resolved.example.ts.net",
servicePort: 443)
servicePort: 443,
gatewayTls: true)
#expect(GatewayDiscoveryHelpers.directUrl(for: tlsGateway) == "wss://resolved.example.ts.net")
let wsGateway = self.makeGateway(
serviceHost: "resolved.example.ts.net",
servicePort: 18789)
#expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "wss://resolved.example.ts.net:18789")
#expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "ws://resolved.example.ts.net:18789")
let localGateway = self.makeGateway(
serviceHost: "127.0.0.1",
@@ -84,6 +87,15 @@ struct GatewayDiscoveryHelpersTests {
#expect(GatewayDiscoveryHelpers.directUrl(for: localGateway) == "ws://127.0.0.1:18789")
}
@Test func `direct url rejects public plaintext service endpoint`() {
let gateway = self.makeGateway(
serviceHost: "gateway.example",
servicePort: 18789,
gatewayTls: false)
#expect(GatewayDiscoveryHelpers.directUrl(for: gateway) == nil)
}
@Test func `direct url rejects txt only fallback`() {
let gateway = self.makeGateway(
serviceHost: nil,

View File

@@ -87,12 +87,16 @@ struct GatewayDiscoveryModelTests {
"tailnetDns": " peters-mac-studio-1.ts.net ",
"sshPort": " 2222 ",
"gatewayPort": " 18799 ",
"gatewayTls": " yes ",
"gatewayDirectReachable": " true ",
"cliPath": " /opt/openclaw ",
])
#expect(parsed.lanHost == "studio.local")
#expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net")
#expect(parsed.sshPort == 2222)
#expect(parsed.gatewayPort == 18799)
#expect(parsed.gatewayTls)
#expect(parsed.gatewayDirectReachable)
#expect(parsed.cliPath == "/opt/openclaw")
}
@@ -107,6 +111,8 @@ struct GatewayDiscoveryModelTests {
#expect(parsed.tailnetDns == nil)
#expect(parsed.sshPort == 22)
#expect(parsed.gatewayPort == nil)
#expect(!parsed.gatewayTls)
#expect(!parsed.gatewayDirectReachable)
#expect(parsed.cliPath == nil)
}

View File

@@ -11,6 +11,8 @@ struct GatewayDiscoverySelectionSupportTests {
servicePort: Int?,
tailnetDns: String? = nil,
sshPort: Int = 22,
gatewayTls: Bool = false,
gatewayDirectReachable: Bool = false,
stableID: String) -> GatewayDiscoveryModel.DiscoveredGateway
{
GatewayDiscoveryModel.DiscoveredGateway(
@@ -21,6 +23,8 @@ struct GatewayDiscoverySelectionSupportTests {
tailnetDns: tailnetDns,
sshPort: sshPort,
gatewayPort: servicePort,
gatewayTls: gatewayTls,
gatewayDirectReachable: gatewayDirectReachable,
cliPath: nil,
stableID: stableID,
debugID: UUID().uuidString,
@@ -40,6 +44,7 @@ struct GatewayDiscoverySelectionSupportTests {
serviceHost: tailnetHost,
servicePort: 443,
tailnetDns: tailnetHost,
gatewayTls: true,
stableID: "tailscale-serve|\(tailnetHost)"),
state: state)
@@ -61,6 +66,7 @@ struct GatewayDiscoverySelectionSupportTests {
serviceHost: tailnetHost,
servicePort: 443,
tailnetDns: tailnetHost,
gatewayTls: true,
stableID: "wide-area|openclaw.internal.|gateway-host"),
state: state)
@@ -69,12 +75,33 @@ struct GatewayDiscoverySelectionSupportTests {
}
}
@Test func `selecting nearby lan gateway keeps ssh transport`() async {
@Test func `legacy tailnet discovery without reachability flags still switches to direct transport`() async {
let tailnetHost = "gateway-host.tailnet-example.ts.net"
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
GatewayDiscoverySelectionSupport.applyRemoteSelection(
gateway: self.makeGateway(
serviceHost: tailnetHost,
servicePort: 18789,
tailnetDns: tailnetHost,
stableID: "wide-area|openclaw.internal.|gateway-host"),
state: state)
#expect(state.remoteTransport == .direct)
#expect(state.remoteUrl == "ws://\(tailnetHost):18789")
}
}
@Test func `selecting nearby lan gateway keeps ssh without direct reachability signal`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteTarget = "user@old-host"
state.remoteUrl = "ws://localhost:29876"
GatewayDiscoverySelectionSupport.applyRemoteSelection(
gateway: self.makeGateway(
@@ -84,16 +111,17 @@ struct GatewayDiscoverySelectionSupportTests {
state: state)
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "ws://127.0.0.1:18789")
#expect(state.remoteUrl == "ws://127.0.0.1:29876")
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local")
let configRoot = OpenClawConfigFile.loadDict()
let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
#expect(remote["transport"] as? String == "ssh")
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
}
}
@Test func `selecting nearby lan gateway preserves existing ssh tunnel port`() async {
@Test func `selecting direct reachable lan gateway ignores stale local tunnel port`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
@@ -104,15 +132,17 @@ struct GatewayDiscoverySelectionSupportTests {
gateway: self.makeGateway(
serviceHost: "nearby-gateway.local",
servicePort: 19999,
gatewayDirectReachable: true,
stableID: "bonjour|nearby-gateway-custom"),
state: state)
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "ws://127.0.0.1:29876")
#expect(state.remoteTransport == .direct)
#expect(state.remoteUrl == "ws://nearby-gateway.local:19999")
let configRoot = OpenClawConfigFile.loadDict()
let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
#expect(remote["transport"] as? String == "direct")
#expect(remote["url"] as? String == "ws://nearby-gateway.local:19999")
}
}
}

View File

@@ -315,6 +315,54 @@ struct GatewayEndpointStoreTests {
#expect(url?.absoluteString == "ws://100.123.224.76:18789")
}
@Test func `missing transport infers direct from private remote URL`() {
let root: [String: Any] = [
"gateway": [
"remote": [
"url": "ws://192.168.0.202:18789",
],
],
]
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
#expect(resolution.transport == .direct)
#expect(resolution.source == .inferredRemoteURL)
#expect(resolution.directURL?.absoluteString == "ws://192.168.0.202:18789")
}
@Test func `legacy loopback URL keeps SSH even with trusted SSH target`() {
let root: [String: Any] = [
"gateway": [
"remote": [
"url": "ws://127.0.0.1:18789",
"sshTarget": "steipete@192.168.0.202",
],
],
]
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
#expect(resolution.transport == .ssh)
#expect(resolution.source == .legacySSH)
#expect(resolution.directURL == nil)
}
@Test func `explicit ssh keeps legacy tunnel even when target is direct capable`() {
let root: [String: Any] = [
"gateway": [
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": "steipete@192.168.0.202",
],
],
]
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
#expect(resolution.transport == .ssh)
#expect(resolution.source == .explicit)
#expect(resolution.directURL == nil)
}
@Test func `normalize gateway url rejects public host ws`() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789")
#expect(url == nil)

View File

@@ -1,2 +1,2 @@
d979b8c2721eeb83380a38853309e9ba0f2c28e040a9ad2ee1e7b2ab10c547db plugin-sdk-api-baseline.json
4815f711fe2481483159137cfb97ce3d1c173e0b50a364a2353f49888f4d53df plugin-sdk-api-baseline.jsonl
048d8ff5e4455d16f75f6762a916f67c982e1211fb7085456647234255567466 plugin-sdk-api-baseline.json
2d46a9660c9143f823a47df3c7ecfd315a4999e96af5eddb4ba4e71d9bb377a6 plugin-sdk-api-baseline.jsonl

View File

@@ -102,7 +102,7 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat
<Accordion title="Notify defaults for cron and media">
Main-session cron tasks use `silent` notify policy by default - they create records for tracking but do not generate notifications. Isolated cron tasks also default to `silent` but are more visible because they run in their own session.
Session-backed `image_generate`, `music_generate`, and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished media itself. Group/channel completions follow the normal visible-reply policy, so the agent uses the message tool when source delivery requires it. If the completion agent fails to produce message-tool delivery evidence in a tool-only route, OpenClaw sends the completion fallback directly to the original channel instead of leaving the media private.
Session-backed `image_generate`, `music_generate`, and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished media itself. Generated-media completion events require message-tool delivery: the agent must send the finished media with the `message` tool, then reply `NO_REPLY`. If the completion agent only writes a private final reply or misses the media attachment, OpenClaw marks the completion handoff as failed; it does not auto-post the generated media as a fallback.
</Accordion>
<Accordion title="Concurrent media-generation guardrail">

View File

@@ -1631,7 +1631,7 @@ openclaw logs --follow
// Molty listens to all bot-authored Discord messages.
allowBots: true,
mentionAliases: {
// Lets Molty write "@Mantis" and send a real Discord mention.
// Lets Molty write a Mantis Discord mention with the configured user id.
Mantis: "MANTIS_DISCORD_USER_ID",
},
botLoopProtection: {

View File

@@ -12,39 +12,39 @@ OpenClaw CI runs on every push to `main` and every pull request. The `preflight`
## Pipeline overview
| Job | Purpose | When it runs |
| -------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-scm-fast` | Private key detection and workflow audit via `zizmor` | Always on non-draft pushes and PRs |
| `security-dependency-audit` | Dependency-free production lockfile audit against npm advisories | Always on non-draft pushes and PRs |
| `security-fast` | Required aggregate for the fast security jobs | Always on non-draft pushes and PRs |
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
| `build-artifacts` | Build `dist/`, Control UI, built-artifact checks, and reusable downstream artifacts | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled/plugin-contract/protocol checks | Node-relevant changes |
| `checks-fast-contracts-channels` | Sharded channel contract checks with a stable aggregate check result | Node-relevant changes |
| `checks-node-core-test` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes |
| `check` | Sharded main local gate equivalent: prod types, lint, guards, test types, and strict smoke | Node-relevant changes |
| `check-additional` | Architecture, sharded boundary/prompt drift, extension guards, package boundary, and gateway watch | Node-relevant changes |
| `build-smoke` | Built-CLI smoke tests and startup-memory smoke | Node-relevant changes |
| `checks` | Verifier for built-artifact channel tests | Node-relevant changes |
| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Manual CI dispatch for releases |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
| `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
| `openclaw-performance` | Daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.5 live lanes | Scheduled and manual dispatch |
| Job | Purpose | When it runs |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-scm-fast` | Private key detection and workflow audit via `zizmor` | Always on non-draft pushes and PRs |
| `security-dependency-audit` | Dependency-free production lockfile audit against npm advisories | Always on non-draft pushes and PRs |
| `security-fast` | Required aggregate for the fast security jobs | Always on non-draft pushes and PRs |
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled and CI-routing checks | Node-relevant changes |
| `checks-fast-protocol` | Gateway protocol compatibility check | Node-relevant changes |
| `checks-fast-contracts-plugins-*` | Two sharded plugin contract checks | Node-relevant changes |
| `checks-fast-contracts-channels-*` | Two sharded channel contract checks | Node-relevant changes |
| `checks-node-core-*` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes |
| `check-*` | Sharded main local gate equivalent: prod types, lint, guards, test types, and strict smoke | Node-relevant changes |
| `check-additional-*` | Architecture, sharded boundary/prompt drift, extension guards, package boundary, and runtime topology | Node-relevant changes |
| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Manual CI dispatch for releases |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
| `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
| `openclaw-performance` | Daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.5 live lanes | Scheduled and manual dispatch |
## Fail-fast order
1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
2. `security-scm-fast`, `security-dependency-audit`, `security-fast`, `check`, `check-additional`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
2. `security-scm-fast`, `security-dependency-audit`, `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
3. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-channels`, `checks-node-core-test`, `checks`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Aggregate shard checks use `!cancelled() && always()` so they still report normal shard failures but do not queue after the whole workflow has already been superseded. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs.
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Matrix jobs use `fail-fast: false`, and `build-artifacts` reports embedded channel, core-support-boundary, and gateway-watch failures directly instead of queuing tiny verifier jobs. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs.
The `ci-timings-summary` job uploads a compact `ci-timings-summary` artifact for each non-draft CI run. It records wall time, queue time, slowest jobs, and failed jobs for the current run, so CI health checks do not need to scrape the full Actions payload repeatedly.
@@ -56,7 +56,7 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
- **Windows Node checks** are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes.
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: channel contracts run as three weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, cron, and shared shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped across four matrix shards, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: plugin contracts and channel contracts each run as two weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, cron, and shared shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional-*` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped across four matrix shards, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest` and then builds the Play debug APK. The third-party flavor has no separate source set or manifest; its unit-test lane still compiles the flavor with the SMS/call-log BuildConfig flags, while avoiding a duplicate debug APK packaging job on every Android-relevant push.
@@ -81,7 +81,7 @@ Treat GitHub titles, comments, bodies, review text, branch names, and commit mes
## Manual dispatches
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, channel contracts, Node 22 compatibility, `check`, `check-additional`, build smoke, docs checks, Python skills, Windows, macOS, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref.
@@ -93,15 +93,15 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
## Runners
| Runner | Jobs |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ubuntu-24.04` | `preflight`, fast security jobs and aggregates (`security-scm-fast`, `security-dependency-audit`, `security-fast`), fast protocol/contract/bundled checks, sharded channel contract checks, `check` shards except lint, `check-additional` aggregates, Node test aggregate verifiers, docs checks, Python skills, workflow-sanity, labeler, auto-response; install-smoke preflight also uses GitHub-hosted Ubuntu so the Blacksmith matrix can queue earlier |
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, lower-weight extension shards, `checks-fast-core`, `checks-node-compat-node22`, `check-prod-types`, and `check-test-types` |
| `blacksmith-8vcpu-ubuntu-2404` | build-smoke, Linux Node test shards, bundled plugin test shards, `check-additional` shards, `android` |
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
| `blacksmith-6vcpu-macos-latest` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-latest` |
| `blacksmith-12vcpu-macos-latest` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-latest` |
| Runner | Jobs |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ubuntu-24.04` | `preflight`, fast security jobs and aggregates (`security-scm-fast`, `security-dependency-audit`, `security-fast`), fast protocol/contract/bundled checks, docs checks, Python skills, workflow-sanity, labeler, auto-response; install-smoke preflight also uses GitHub-hosted Ubuntu so the Blacksmith matrix can queue earlier |
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, lower-weight extension shards, `checks-fast-core`, `checks-fast-protocol`, plugin/channel contract shards, `checks-node-compat-node22`, `check-prod-types`, and `check-test-types` |
| `blacksmith-8vcpu-ubuntu-2404` | Linux Node test shards, bundled plugin test shards, `check-additional-*` shards, `android` |
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
| `blacksmith-6vcpu-macos-latest` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-latest` |
| `blacksmith-12vcpu-macos-latest` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-latest` |
Canonical-repo CI keeps Blacksmith as the default runner path. During `preflight`, `scripts/ci-runner-labels.mjs` checks recent queued and in-progress Actions runs for queued Blacksmith jobs. If a specific Blacksmith label already has queued jobs, downstream jobs that would use that exact label fall back to the matching GitHub-hosted runner (`ubuntu-24.04`, `windows-2025`, or `macos-latest`) for that run only. Other Blacksmith sizes in the same OS family stay on their primary labels. If the API probe fails, no fallback is applied.
@@ -121,7 +121,7 @@ pnpm test:changed # cheap smart changed Vitest targe
pnpm test:channels
pnpm test:contracts:channels
pnpm check:docs # docs format + lint + broken links
pnpm build # build dist when CI artifact/build-smoke lanes matter
pnpm build # build dist when CI artifact/smoke checks matter
pnpm ci:timings # summarize the latest origin/main push CI run
pnpm ci:timings:recent # compare recent successful main CI runs
node scripts/ci-run-timings.mjs <run-id> # summarize wall time, queue time, and slowest jobs
@@ -203,7 +203,7 @@ Docker release-path soak; `full` forces soak on.
The umbrella records the dispatched child run ids, and the final `Verify full validation` job re-checks current child run conclusions and appends slowest-job tables for each child run. If a child workflow is rerun and turns green, rerun only the parent verifier job to refresh the umbrella result and timing summary.
For recovery, both `Full Release Validation` and `OpenClaw Release Checks` accept `rerun_group`. Use `all` for a release candidate, `ci` for only the normal full CI child, `plugin-prerelease` for only the plugin prerelease child, `release-checks` for every release child, or a narrower group: `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, or `npm-telegram` on the umbrella. This keeps a failed release box rerun bounded after a focused fix. For one failed cross-OS lane, combine `rerun_group=cross-os` with `cross_os_suite_filter`, for example `windows/packaged-upgrade`; long cross-OS commands emit heartbeat lines and packaged-upgrade summaries include per-phase timings. QA release-check lanes are advisory, so QA-only failures warn but do not block the release-check verifier.
For recovery, both `Full Release Validation` and `OpenClaw Release Checks` accept `rerun_group`. Use `all` for a release candidate, `ci` for only the normal full CI child, `plugin-prerelease` for only the plugin prerelease child, `release-checks` for every release child, or a narrower group: `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, or `npm-telegram` on the umbrella. This keeps a failed release box rerun bounded after a focused fix. For one failed cross-OS lane, combine `rerun_group=cross-os` with `cross_os_suite_filter`, for example `windows/packaged-upgrade`; long cross-OS commands emit heartbeat lines and packaged-upgrade summaries include per-phase timings. QA release-check lanes are advisory except the standard runtime tool coverage gate, which blocks when required OpenClaw dynamic tools drift or disappear from the standard tier summary.
`OpenClaw Release Checks` uses the trusted workflow ref to resolve the selected ref once into a `release-package-under-test` tarball, then passes that artifact to cross-OS checks and Package Acceptance, plus the live/E2E release-path Docker workflow when soak coverage runs. That keeps the package bytes consistent across release boxes and avoids repacking the same candidate in multiple child jobs.

View File

@@ -204,12 +204,17 @@ openclaw browser upload /tmp/openclaw/uploads/file.pdf --ref <ref>
openclaw browser waitfordownload
openclaw browser download <ref> report.pdf
openclaw browser dialog --accept
openclaw browser dialog --dismiss --dialog-id d1
```
Managed Chrome profiles save ordinary click-triggered downloads into the OpenClaw
downloads directory (`/tmp/openclaw/downloads` by default, or the configured temp
root). Use `waitfordownload` or `download` when the agent needs to wait for a
specific file and return its path; those explicit waiters own the next download.
When an action opens a modal dialog, the action response returns
`blockedByDialog` with `browserState.dialogs.pending`; pass `--dialog-id` to
answer it directly. Dialogs handled outside OpenClaw appear under
`browserState.dialogs.recent`.
## State and storage

View File

@@ -35,7 +35,7 @@ openclaw daemon uninstall
## Common options
- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
- `install`: `--port`, `--runtime <node|bun>`, `--runtime-path <path>`, `--token`, `--force`, `--json`
- `install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
- `restart`: `--safe`, `--skip-deferral`, `--force`, `--wait <duration>`, `--json`
- lifecycle (`uninstall|start|stop`): `--json`
@@ -52,7 +52,6 @@ Notes:
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly.
- `install --runtime-path <path>` pins the managed service to an absolute Node or Bun executable for the selected `--runtime` and persists `OPENCLAW_DAEMON_RUNTIME_PATH` for later forced reinstalls, updates, and doctor repairs.
- On macOS, `install` keeps LaunchAgent plists owner-only and loads managed service environment values through an owner-only file and wrapper instead of serializing API keys or auth-profile env refs into `EnvironmentVariables`.
- If you intentionally run multiple gateways on one host, isolate ports, config/state, and workspaces; see [/gateway#multiple-gateways-same-host](/gateway#multiple-gateways-same-host).
- `restart --safe` asks the running Gateway to preflight active work and schedule one coalesced restart after active work drains. Plain `restart` keeps the existing service-manager behavior; `--force` remains the immediate override path.

View File

@@ -15,13 +15,34 @@ Related:
- Troubleshooting: [Troubleshooting](/gateway/troubleshooting)
- Security audit: [Security](/gateway/security)
## Why Use It
`openclaw doctor` is the OpenClaw health surface. Use it when the gateway,
channels, plugins, skills, model routing, local state, or config migrations are
not behaving as expected and you want one command that can explain what is
wrong.
Doctor has three postures:
| Posture | Command | Behavior |
| ------- | ------------------------ | ------------------------------------------------------------------------------- |
| Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. |
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
| Lint | `openclaw doctor --lint` | Read-only structured findings for CI, preflight, and review gates. |
Prefer `--lint` when automation needs a stable result. Prefer `--fix` when a
human operator intentionally wants doctor to edit config or state.
## Examples
```bash
openclaw doctor
openclaw doctor --repair
openclaw doctor --lint
openclaw doctor --lint --json
openclaw doctor --lint --severity-min warning
openclaw doctor --deep
openclaw doctor --repair --non-interactive
openclaw doctor --fix
openclaw doctor --fix --non-interactive
openclaw doctor --generate-gateway-token
```
@@ -44,13 +65,134 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
- `--non-interactive`: run without prompts; safe migrations and non-service repairs only
- `--generate-gateway-token`: generate and configure a gateway token
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
- `--json`: with `--lint`, emit JSON findings instead of human output
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
## Lint mode
`openclaw doctor --lint` is the read-only automation posture for doctor checks.
It uses the structured health-check path, does not prompt, and does not repair
or rewrite config/state. Use it in CI, preflight scripts, and review workflows
when you want machine-readable findings instead of guided repair prompts.
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
are only accepted with `--lint`.
```bash
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --only core/doctor/gateway-config --json
```
Human output is compact:
```text
doctor --lint: ran 6 check(s), 1 finding(s)
[warning] core/doctor/gateway-config gateway.mode - gateway.mode is unset; gateway start will be blocked.
fix: Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`.
```
JSON output is the scripting surface for lint runs:
```json
{
"ok": false,
"checksRun": 5,
"checksSkipped": 0,
"findings": [
{
"checkId": "core/doctor/gateway-config",
"severity": "warning",
"message": "gateway.mode is unset; gateway start will be blocked.",
"path": "gateway.mode",
"fixHint": "Run `openclaw configure` and set Gateway mode (local/remote), or `openclaw config set gateway.mode local`."
}
]
}
```
Exit behavior:
- `0`: no findings at or above the selected severity threshold
- `1`: at least one finding meets the selected threshold
- `2`: command/runtime failure before lint findings can be produced
`--severity-min` controls both visible findings and the exit threshold. For
example, `openclaw doctor --lint --severity-min error` can print no findings and
exit `0` even when lower-severity `info` or `warning` findings exist.
## Structured Health Checks
Modern doctor checks use a small structured contract:
```ts
detect(ctx, scope?) -> HealthFinding[]
repair?(ctx, findings) -> HealthRepairResult
```
`detect()` powers `doctor --lint`. `repair()` is optional and is only considered
by `doctor --fix` / `doctor --repair`. Checks that have not migrated to this
shape continue to use the legacy doctor contribution flow.
The split is intentional: `detect()` owns diagnosis, while `repair()` owns
reporting what it changed or would change. Repair contexts can carry
`dryRun`/`diff` requests, and repair results can return structured `diffs` for
config/file edits plus `effects` for service, process, package, state, or other
side effects. That lets converted checks grow toward `doctor --fix --dry-run`
and diff reporting without moving mutation planning into `detect()`.
`repair()` reports whether it attempted the requested repair with `status:
"repaired" | "skipped" | "failed"`. Omitted status means `repaired`, so simple
repair checks only need to return changes. When repair returns `skipped` or
`failed`, doctor reports the reason and does not run validation for that check.
After a successful structured repair, doctor re-runs `detect()` with the
repaired findings as scope. Checks can use selected findings, paths, or `ocPath`
values for focused validation. If the finding is still present, doctor reports a
repair warning instead of treating the change as silently complete.
A finding includes:
| Field | Purpose |
| ----------------- | ------------------------------------------------------ |
| `checkId` | Stable id for skip/only filters and CI allowlists. |
| `severity` | `info`, `warning`, or `error`. |
| `message` | Human-readable problem statement. |
| `path` | Config, file, or logical path when available. |
| `line` / `column` | Source location when available. |
| `ocPath` | Precise `oc://` address when a check can point to one. |
| `fixHint` | Suggested operator action or repair summary. |
This release registers the modernized core doctor checks on the structured
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
contract for bundled follow-up consumers, but plugin-backed checks only run
after their owning package registers them in the active command path.
## Check Selection
Use `--only` and `--skip` when a workflow wants a focused gate:
```bash
openclaw doctor --lint --only core/doctor/gateway-config --json
openclaw doctor --lint --skip core/doctor/skills-readiness
```
`--only` and `--skip` accept full check ids and may be repeated. If an `--only`
id is not registered, no check runs for that id; use the command's `checksRun`
and `checksSkipped` fields to verify a focused gate is selecting the checks you
expect.
Notes:
- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive sessions still fully load plugins when a check needs their contribution.
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive doctor sessions still load the plugin surfaces needed by the legacy health and repair flow.
- `--lint` is stricter than `--non-interactive`: it is always read-only, never prompts, and never applies safe migrations. Run `doctor --fix` or `doctor --repair` when you want doctor to make changes.
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
- Modernized health checks can expose a `repair()` path for `doctor --fix`; checks that do not expose one continue through the existing doctor repair flow.
- `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` when you intentionally want to replace the launcher.
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.

View File

@@ -448,22 +448,6 @@ openclaw gateway restart
openclaw gateway uninstall
```
### Install with a pinned runtime path
Use `--runtime-path` when the managed service must run with a specific Node or Bun executable.
This is useful for launchd/systemd/schtasks services because they do not load your interactive
shell startup files.
```bash
openclaw gateway install --runtime node --runtime-path "$(mise which node)" --force
openclaw gateway restart
```
`--runtime` selects the runtime family (`node` or `bun`). `--runtime-path` must point to an
absolute executable path for that runtime. The installer validates the executable, writes it into
the service command, and persists `OPENCLAW_DAEMON_RUNTIME_PATH` so forced reinstalls, updates, and
doctor repairs keep the same operator-selected runtime path.
### Install with a wrapper
Use `--wrapper` when the managed service must start through another executable, for example a
@@ -502,7 +486,7 @@ openclaw gateway restart
<AccordionGroup>
<Accordion title="Command options">
- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
- `gateway install`: `--port`, `--runtime <node|bun>`, `--runtime-path <path>`, `--token`, `--wrapper <path>`, `--force`, `--json`
- `gateway install`: `--port`, `--runtime <node|bun>`, `--token`, `--wrapper <path>`, `--force`, `--json`
- `gateway restart`: `--safe`, `--skip-deferral`, `--force`, `--wait <duration>`, `--json`
- `gateway uninstall|start`: `--json`
- `gateway stop`: `--disable`, `--json`

View File

@@ -100,18 +100,8 @@ Options:
- `--node-id <id>`: Override node id (clears pairing token)
- `--display-name <name>`: Override the node display name
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
- `--runtime-path <path>`: Absolute executable path for the selected service runtime
- `--force`: Reinstall/overwrite if already installed
Use `--runtime-path` when a managed node host should run with a specific Node or Bun executable:
```bash
openclaw node install --runtime node --runtime-path "$(mise which node)" --force
```
The installer validates that the path matches the selected runtime and persists it in the service
environment as `OPENCLAW_DAEMON_RUNTIME_PATH`.
Manage the service:
```bash

View File

@@ -252,7 +252,7 @@ Telegram Web login state is not required for normal Mantis automation.
`Mantis Telegram Desktop Proof` is the agentic native Telegram Desktop
before/after wrapper. A maintainer can trigger it from a PR comment with
`@Mantis telegram desktop proof`, from the Actions UI with freeform
`@openclaw-mantis telegram desktop proof`, from the Actions UI with freeform
instructions, or through the generic `Mantis Scenario` dispatcher. The workflow
hands the PR, baseline ref, candidate ref, and maintainer instructions to Codex.
The agent reads the PR, decides what Telegram-visible behavior proves the
@@ -351,7 +351,7 @@ region, and public URL values directly. The reusable publisher requires:
You can also trigger the status-reactions run directly from a PR comment:
```text
@Mantis discord status reactions
@openclaw-mantis discord status reactions
```
The comment trigger is intentionally narrow. It only runs on pull request
@@ -361,15 +361,15 @@ and the current PR head SHA as the candidate. Maintainers can override either
ref:
```text
@Mantis discord status reactions baseline=origin/main candidate=HEAD
@openclaw-mantis discord status reactions baseline=origin/main candidate=HEAD
```
Telegram live QA can also be triggered from a PR comment:
```text
@Mantis telegram
@Mantis telegram scenario=telegram-status-command
@Mantis telegram scenarios=telegram-status-command,telegram-mentioned-message-reply
@openclaw-mantis telegram
@openclaw-mantis telegram scenario=telegram-status-command
@openclaw-mantis telegram scenarios=telegram-status-command,telegram-mentioned-message-reply
```
By default it uses the current PR head SHA as the candidate and runs

View File

@@ -149,7 +149,7 @@ Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so Ope
- Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw.
- For the common subscription plus native Codex runtime route, sign in with `openai-codex` auth but configure `openai/gpt-5.5`; OpenAI agent turns select Codex by default.
- Use provider/model `agentRuntime.id: "pi"` only when you want a compatibility route through PI; otherwise keep `openai/gpt-5.5` on the default Codex harness.
- Older `openai-codex/gpt-5.1*`, `openai-codex/gpt-5.2*`, and `openai-codex/gpt-5.3*` refs are suppressed because ChatGPT/Codex OAuth accounts reject them; use `openai-codex/gpt-5.5` or the native Codex runtime route instead.
- `openai-codex/gpt-*` refs remain a legacy PI route. Prefer `openai/gpt-5.5` on the native Codex runtime for new agent config, and run `openclaw doctor --fix` when you want to migrate old `openai-codex/*` refs to canonical `openai/*` refs.
```json5
{

View File

@@ -34,7 +34,7 @@ script aliases; both forms are supported.
| `qa run` | Bundled QA self-check; writes a Markdown report. |
| `qa suite` | Run repo-backed scenarios against the QA gateway lane. Aliases: `pnpm openclaw qa suite --runner multipass` for a disposable Linux VM. |
| `qa coverage` | Print the markdown scenario-coverage inventory (`--json` for machine output). |
| `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report. |
| `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report, or use `--runtime-axis --token-efficiency` to write Codex-vs-Pi runtime parity and token-efficiency reports from one runtime-pair summary. |
| `qa character-eval` | Run the character QA scenario across multiple live models with a judged report. See [Reporting](#reporting). |
| `qa manual` | Run a one-off prompt against the selected provider/model lane. |
| `qa ui` | Start the QA debugger UI and local QA bus (alias: `pnpm qa:lab:ui`). |

View File

@@ -26,17 +26,28 @@ openclaw doctor
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
</Tab>
<Tab title="--repair">
<Tab title="--fix">
```bash
openclaw doctor --repair
openclaw doctor --fix
```
Apply recommended repairs without prompting (repairs + restarts where safe).
</Tab>
<Tab title="--repair --force">
<Tab title="--lint">
```bash
openclaw doctor --repair --force
openclaw doctor --lint
openclaw doctor --lint --json
```
Run structured health checks for CI or preflight automation. This mode is
read-only: it does not prompt, repair, migrate config, restart services, or
touch state.
</Tab>
<Tab title="--fix --force">
```bash
openclaw doctor --fix --force
```
Apply aggressive repairs too (overwrites custom supervisor configs).
@@ -66,6 +77,57 @@ If you want to review changes before writing, open the config file first:
cat ~/.openclaw/openclaw.json
```
## Read-only lint mode
`openclaw doctor --lint` is the automation-friendly sibling of
`openclaw doctor --fix`. Both use doctor health checks, but their posture is
different:
| Mode | Prompts | Writes config/state | Output | Use it for |
| ------------------------ | --------- | ----------------------- | ---------------------- | ------------------------------- |
| `openclaw doctor` | yes | no | friendly health report | a human checking status |
| `openclaw doctor --fix` | sometimes | yes, with repair policy | friendly repair log | applying approved repairs |
| `openclaw doctor --lint` | no | no | structured findings | CI, preflight, and review gates |
Modernized health checks may provide an optional `repair()` implementation.
`doctor --fix` applies those repairs when they exist and continues to use the
existing doctor repair flow for checks that have not migrated yet.
The structured repair contract also separates repair reporting from detection:
`detect()` reports current findings, while `repair()` can report changes,
config/file diffs, and non-file side effects. That keeps the migration path open
for future `doctor --fix --dry-run` and diff output without making lint checks
plan mutations.
Examples:
```bash
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --only core/doctor/gateway-config --json
```
JSON output includes:
- `ok`: whether any visible finding met the selected severity threshold
- `checksRun`: number of health checks executed
- `checksSkipped`: checks skipped by `--only` or `--skip`
- `findings`: structured diagnostics with `checkId`, `severity`, `message`, and
optional `path`, `line`, `column`, `ocPath`, and `fixHint`
Exit codes:
- `0`: no findings at or above the selected threshold
- `1`: one or more findings met the selected threshold
- `2`: command/runtime failure before lint findings could be emitted
Use `--severity-min info|warning|error` to control both what is printed and what
causes a non-zero lint exit. Use `--only <id>` for narrow preflight gates and
`--skip <id>` to temporarily exclude a noisy check while keeping the rest of the
lint run active.
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
must be paired with `--lint`; regular doctor and repair runs reject them.
## What it does (summary)
<AccordionGroup>
@@ -112,7 +174,7 @@ cat ~/.openclaw/openclaw.json
- Codex route repair for legacy `openai-codex/*` model refs in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` rewrites them to `openai/*`, removes stale session/whole-agent runtime pins, and leaves canonical OpenAI agent refs on the default Codex harness.
- Supervisor config audit (launchd/systemd/schtasks) with optional repair.
- Embedded proxy environment cleanup for gateway services that captured shell `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` values during install or update.
- Gateway runtime best-practice checks (Node vs Bun, version-manager paths). Services installed with an explicit `OPENCLAW_DAEMON_RUNTIME_PATH` keep that operator-selected executable during doctor repairs.
- Gateway runtime best-practice checks (Node vs Bun, version-manager paths).
- Gateway port collision diagnostics (default `18789`).
</Accordion>
@@ -471,8 +533,8 @@ That stages grounded durable candidates into the short-term dreaming store while
- `openclaw doctor` prompts before rewriting supervisor config.
- `openclaw doctor --yes` accepts the default repair prompts.
- `openclaw doctor --repair` applies recommended fixes without prompts.
- `openclaw doctor --repair --force` overwrites custom supervisor configs.
- `openclaw doctor --fix` applies recommended fixes without prompts (`--repair` is an alias).
- `openclaw doctor --fix --force` overwrites custom supervisor configs.
- `OPENCLAW_SERVICE_REPAIR_POLICY=external` keeps doctor read-only for gateway service lifecycle. It still reports service health and runs non-service repairs, but skips service install/start/restart/bootstrap, supervisor config rewrites, and legacy service cleanup because an external supervisor owns that lifecycle.
- On Linux, doctor does not rewrite command/entrypoint metadata while the matching systemd gateway unit is active. It also ignores inactive non-legacy extra gateway-like units during the duplicate-service scan so companion service files do not create cleanup noise.
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.

View File

@@ -335,9 +335,9 @@ start it from the Actions UI through `Mantis Scenario` (`scenario_id:
telegram-live`) or directly from a pull request comment:
```text
@Mantis telegram
@Mantis telegram scenario=telegram-status-command
@Mantis telegram scenarios=telegram-status-command,telegram-mentioned-message-reply
@openclaw-mantis telegram
@openclaw-mantis telegram scenario=telegram-status-command
@openclaw-mantis telegram scenarios=telegram-status-command,telegram-mentioned-message-reply
```
`Mantis Telegram Desktop Proof` is the agentic native Telegram Desktop
@@ -346,7 +346,7 @@ freeform `instructions`, through `Mantis Scenario` (`scenario_id:
telegram-desktop-proof`), or from a PR comment:
```text
@Mantis telegram desktop proof
@openclaw-mantis telegram desktop proof
```
The Mantis agent reads the PR, decides what Telegram-visible behavior proves the

View File

@@ -145,6 +145,8 @@ EOF
source ~/.bashrc
```
`OPENCLAW_NO_RESPAWN=1` keeps routine Gateway restarts in-process, which avoids extra process handoffs and keeps PID tracking simple on small hosts.
**Reduce memory usage** -- For headless setups, free GPU memory and disable unused services:
```bash

View File

@@ -2,276 +2,196 @@
summary: "Create your first OpenClaw plugin in minutes"
title: "Building plugins"
sidebarTitle: "Getting Started"
doc-schema-version: 1
read_when:
- You want to create a new OpenClaw plugin
- You need a quick-start for plugin development
- You are adding a new channel, provider, tool, or other capability to OpenClaw
- You are choosing between channel, provider, CLI backend, tool, or hook docs
---
Plugins extend OpenClaw with new capabilities: channels, model providers,
speech, realtime transcription, realtime voice, media understanding, image
generation, video generation, web fetch, web search, agent tools, or any
combination.
Plugins extend OpenClaw without changing core. A plugin can add a messaging
channel, model provider, local CLI backend, agent tool, hook, media provider,
or another plugin-owned capability.
You do not need to add your plugin to the OpenClaw repository. Publish to
[ClawHub](/clawhub) and users install with
`openclaw plugins install clawhub:<package-name>`. Bare package specs still
install from npm during the launch cutover.
You do not need to add an external plugin to the OpenClaw repository. Publish
the package to [ClawHub](/clawhub) and users install it with:
## Prerequisites
```bash
openclaw plugins install clawhub:<package-name>
```
- Node >= 22 and a package manager (npm or pnpm)
- Familiarity with TypeScript (ESM)
- For in-repo plugins: repository cloned and `pnpm install` done. Source
checkout plugin development is pnpm-only because OpenClaw loads bundled
plugins from the `extensions/*` workspace packages.
Bare package specs still install from npm during the launch cutover. Use the
`clawhub:` prefix when you want ClawHub resolution.
## What kind of plugin?
## Requirements
<CardGroup cols={3}>
- Use Node 22 or newer and a package manager such as `npm` or `pnpm`.
- Be familiar with TypeScript ESM modules.
- For in-repo bundled plugin work, clone the repository and run `pnpm install`.
Source-checkout plugin development is pnpm-only because OpenClaw loads bundled
plugins from `extensions/*` workspace packages.
## Choose the plugin shape
<CardGroup cols={2}>
<Card title="Channel plugin" icon="messages-square" href="/plugins/sdk-channel-plugins">
Connect OpenClaw to a messaging platform (Discord, IRC, etc.)
Connect OpenClaw to a messaging platform.
</Card>
<Card title="Provider plugin" icon="cpu" href="/plugins/sdk-provider-plugins">
Add a model provider (LLM, proxy, or custom endpoint)
Add a model, media, search, fetch, speech, or realtime provider.
</Card>
<Card title="CLI backend plugin" icon="terminal" href="/plugins/cli-backend-plugins">
Map a local AI CLI into OpenClaw's text fallback runner
Run a local AI CLI through OpenClaw model fallback.
</Card>
<Card title="Tool plugin" icon="wrench" href="/plugins/tool-plugins">
Add simple typed agent tools with generated manifest metadata
</Card>
<Card title="Hook plugin" icon="plug" href="/plugins/hooks">
Register event hooks, services, or advanced runtime integrations
Register agent tools.
</Card>
</CardGroup>
For a channel plugin that isn't guaranteed to be installed when onboarding/setup
runs, use `createOptionalChannelSetupSurface(...)` from
`openclaw/plugin-sdk/channel-setup`. It produces a setup adapter + wizard pair
that advertises the install requirement and fails closed on real config writes
until the plugin is installed.
## Quickstart
## Quick start: tool plugin
This walkthrough creates a minimal plugin that registers an agent tool. Channel
and provider plugins have dedicated guides linked above.
For the detailed tool-only workflow, see [Tool Plugins](/plugins/tool-plugins).
Build a minimal tool plugin by registering one required agent tool. This is the
shortest useful plugin shape and shows the package, manifest, entry point, and
local proof.
<Steps>
<Step title="Create the package and manifest">
<Step title="Create package metadata">
<CodeGroup>
```json package.json
{
"name": "@myorg/openclaw-my-plugin",
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./index.ts"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
}
}
```
```json openclaw.plugin.json
{
"id": "my-plugin",
"name": "My Plugin",
"description": "Adds a custom tool to OpenClaw",
"contracts": {
"tools": ["my_tool"]
},
"activation": {
"onStartup": true
},
"configSchema": {
"type": "object",
"additionalProperties": false
}
```json package.json
{
"name": "@myorg/openclaw-my-plugin",
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./index.ts"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
```
}
}
```
```json openclaw.plugin.json
{
"id": "my-plugin",
"name": "My Plugin",
"description": "Adds a custom tool to OpenClaw",
"contracts": {
"tools": ["my_tool"]
},
"activation": {
"onStartup": true
},
"configSchema": {
"type": "object",
"additionalProperties": false
}
}
```
</CodeGroup>
Every plugin needs a manifest, even with no config. Runtime-registered tools
must be listed in `contracts.tools` so OpenClaw can discover the owning
plugin without loading every plugin runtime. For simple tool-only plugins,
prefer `defineToolPlugin` plus `openclaw plugins build` so tool names and
the empty config schema are generated from one source of truth. Plugins
should also declare `activation.onStartup` intentionally. This example sets
it to `true`. See [Manifest](/plugins/manifest) for the full schema. The
canonical ClawHub publish snippets live in `docs/snippets/plugin-publish/`.
Published external plugins should point runtime entries at built JavaScript
files. See [SDK entry points](/plugins/sdk-entrypoints) for the full entry
point contract.
Every plugin needs a manifest, even when it has no config. Runtime tools
must appear in `contracts.tools` so OpenClaw can discover ownership without
eagerly loading every plugin runtime. Set `activation.onStartup`
intentionally. This example starts on Gateway startup.
For every manifest field, see [Plugin manifest](/plugins/manifest).
</Step>
<Step title="Write the entry point">
```typescript
// index.ts
<Step title="Register the tool">
```typescript index.ts
import { Type } from "typebox";
import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export default defineToolPlugin({
export default definePluginEntry({
id: "my-plugin",
name: "My Plugin",
description: "Adds a custom tool to OpenClaw",
tools: (tool) => [
tool({
register(api) {
api.registerTool({
name: "my_tool",
description: "Do a thing",
description: "Echo one input value",
parameters: Type.Object({ input: Type.String() }),
async execute({ input }) {
return { message: `Got: ${input}` };
async execute(_id, params) {
return {
content: [{ type: "text", text: `Got: ${params.input}` }],
};
},
}),
],
});
},
});
```
`defineToolPlugin` is for simple agent-tool plugins. For providers, hooks,
services, and other advanced non-channel plugins, use `definePluginEntry`.
For channels, use `defineChannelPluginEntry` - see
[Channel Plugins](/plugins/sdk-channel-plugins). For the full
`defineToolPlugin` workflow, see [Tool Plugins](/plugins/tool-plugins). For
full entry point options, see [Entry Points](/plugins/sdk-entrypoints).
Use `definePluginEntry` for non-channel plugins. Channel plugins use
`defineChannelPluginEntry`.
</Step>
<Step title="Generate and validate metadata">
<Step title="Test the runtime">
For an installed or external plugin, inspect the loaded runtime:
```bash
npm run build
openclaw plugins build --entry ./dist/index.js
openclaw plugins validate --entry ./dist/index.js
openclaw plugins inspect my-plugin --runtime --json
```
`openclaw plugins build` writes `openclaw.plugin.json` and keeps
`package.json` `openclaw.extensions` pointed at the entry module. For
published packages, point it at built JavaScript such as `./dist/index.js`.
The generated manifest is the cold-load contract that OpenClaw reads before
runtime import. `openclaw plugins validate` imports the entry only during
author validation and checks that the manifest and package metadata match
the static `defineToolPlugin` metadata.
If the plugin registers a CLI command, run that command too. For example,
a demo command should have an execution proof such as
`openclaw demo-plugin ping`.
For a bundled plugin in this repository, OpenClaw discovers source-checkout
plugin packages from the `extensions/*` workspace. Run the closest targeted
test:
```bash
pnpm test -- extensions/my-plugin/
pnpm check
```
</Step>
<Step title="Test and publish">
**External plugins:** validate and publish with ClawHub, then install:
<Step title="Publish">
Validate the package before publishing:
```bash
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
openclaw plugins install clawhub:@myorg/openclaw-my-plugin
```
Bare package specs like `@myorg/openclaw-my-plugin` install from npm during
the launch cutover. Use `clawhub:` when you want ClawHub resolution.
The canonical ClawHub snippets live in `docs/snippets/plugin-publish/`.
**In-repo plugins:** place under the bundled plugin workspace tree - automatically discovered.
</Step>
<Step title="Install">
Install the published package through ClawHub:
```bash
pnpm test -- <bundled-plugin-root>/my-plugin/
openclaw plugins install clawhub:your-org/your-plugin
```
</Step>
</Steps>
## Plugin capabilities
<a id="registering-agent-tools"></a>
A single plugin can register any number of capabilities via the `api` object:
## Registering tools
| Capability | Registration method | Detailed guide |
| ---------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------- |
| Text inference (LLM) | `api.registerProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins) |
| CLI inference backend | `api.registerCliBackend(...)` | [CLI Backend Plugins](/plugins/cli-backend-plugins) |
| Channel / messaging | `api.registerChannel(...)` | [Channel Plugins](/plugins/sdk-channel-plugins) |
| Speech (TTS/STT) | `api.registerSpeechProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Realtime transcription | `api.registerRealtimeTranscriptionProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Realtime voice | `api.registerRealtimeVoiceProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Media understanding | `api.registerMediaUnderstandingProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Image generation | `api.registerImageGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Music generation | `api.registerMusicGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Video generation | `api.registerVideoGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Web fetch | `api.registerWebFetchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Web search | `api.registerWebSearchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Tool-result middleware | `api.registerAgentToolResultMiddleware(...)` | [SDK Overview](/plugins/sdk-overview#registration-api) |
| Agent tools | `api.registerTool(...)` | Below |
| Custom commands | `api.registerCommand(...)` | [Entry Points](/plugins/sdk-entrypoints) |
| Plugin hooks | `api.on(...)` | [Plugin hooks](/plugins/hooks) |
| Internal event hooks | `api.registerHook(...)` | [Entry Points](/plugins/sdk-entrypoints) |
| HTTP routes | `api.registerHttpRoute(...)` | [Internals](/plugins/architecture-internals#gateway-http-routes) |
| CLI subcommands | `api.registerCli(...)` | [Entry Points](/plugins/sdk-entrypoints) |
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
Bundled plugins can use `api.registerAgentToolResultMiddleware(...)` when they
need async tool-result rewriting before the model sees the output. Declare the
targeted runtimes in `contracts.agentToolResultMiddleware`, for example
`["pi", "codex"]`. This is a trusted bundled-plugin seam; external
plugins should prefer regular OpenClaw plugin hooks unless OpenClaw grows an
explicit trust policy for this capability.
If your plugin registers custom gateway RPC methods, keep them on a
plugin-specific prefix. Core admin namespaces (`config.*`,
`exec.approvals.*`, `wizard.*`, `update.*`) stay reserved and always resolve to
`operator.admin`, even if a plugin asks for a narrower scope.
`openclaw/plugin-sdk/gateway-method-runtime` is a reserved control-plane bridge
for plugin HTTP routes that declare
`contracts.gatewayMethodDispatch: ["authenticated-request"]`. It is an
intentional-use guard for reviewed native plugins, not a sandbox boundary.
Hook guard semantics to keep in mind:
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_tool_call`: `{ block: false }` is treated as no decision.
- `before_tool_call`: `{ requireApproval: true }` pauses agent execution and prompts the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel.
- `before_install`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_install`: `{ block: false }` is treated as no decision.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is treated as no decision.
- `message_received`: prefer the typed `threadId` field when you need inbound thread/topic routing. Keep `metadata` for channel-specific extras.
- `message_sending`: prefer typed `replyToId` / `threadId` routing fields over channel-specific metadata keys.
The `/approve` command handles both exec and plugin approvals with bounded fallback: when an exec approval id is not found, OpenClaw retries the same id through plugin approvals. Plugin approval forwarding can be configured independently via `approvals.plugin` in config.
If custom approval plumbing needs to detect that same bounded fallback case,
prefer `isApprovalNotFoundError` from `openclaw/plugin-sdk/error-runtime`
instead of matching approval-expiry strings manually.
See [Plugin hooks](/plugins/hooks) for examples and the hook reference.
## Registering agent tools
Tools are typed functions the LLM can call. They can be required (always
available) or optional (user opt-in):
For simple plugins that only own a fixed set of tools, prefer
[`defineToolPlugin`](/plugins/tool-plugins). It generates manifest metadata and
keeps `contracts.tools` aligned. Use the lower-level `api.registerTool(...)`
surface when the plugin also owns channels, providers, hooks, services,
commands, or fully dynamic tool registration.
Tools can be required or optional. Required tools are always available when the
plugin is enabled. Optional tools require user opt-in.
```typescript
register(api) {
// Required tool - always available
api.registerTool({
name: "my_tool",
description: "Do a thing",
parameters: Type.Object({ input: Type.String() }),
async execute(_id, params) {
return { content: [{ type: "text", text: params.input }] };
},
});
// Optional tool - user must add to allowlist
api.registerTool(
{
name: "workflow_tool",
@@ -286,21 +206,13 @@ register(api) {
}
```
Tool factories receive a runtime-supplied context object. Use
`ctx.activeModel` when a tool needs to log, display, or adapt to the active
model for the current turn. The object can include `provider`, `modelId`, and
`modelRef`. Treat it as informational runtime metadata, not as a security
boundary against the local operator, installed plugin code, or a modified
OpenClaw runtime. For sensitive local tools, keep an explicit plugin or operator
opt-in and fail closed when the active model metadata is missing or unsuitable.
Every tool registered with `api.registerTool(...)` must also be declared in the
plugin manifest:
```json
{
"contracts": {
"tools": ["my_tool", "workflow_tool"]
"tools": ["workflow_tool"]
},
"toolMetadata": {
"workflow_tool": {
@@ -310,110 +222,74 @@ plugin manifest:
}
```
OpenClaw captures and caches the validated descriptor from the registered tool,
so plugins do not duplicate `description` or schema data in the manifest. The
manifest contract only declares ownership and discovery; execution still calls
the live registered tool implementation.
Set `toolMetadata.<tool>.optional: true` for tools registered with
`api.registerTool(..., { optional: true })` so OpenClaw can avoid loading that
plugin runtime until the tool is explicitly allowlisted.
Users enable optional tools in config:
Users opt in with `tools.allow`:
```json5
{
tools: { allow: ["workflow_tool"] },
tools: { allow: ["workflow_tool"] }, // or ["my-plugin"] for all tools from one plugin
}
```
- Tool names must not clash with core tools (conflicts are skipped)
- Tools with malformed registration objects, including missing `parameters`, are skipped and reported in plugin diagnostics instead of breaking agent runs
- Use `optional: true` for tools with side effects or extra binary requirements
- Users can enable all tools from a plugin by adding the plugin id to `tools.allow`
Use optional tools for side effects, unusual binaries, or capabilities that
should not be exposed by default. Tool names must not conflict with core tools;
conflicts are skipped and reported in plugin diagnostics. Malformed
registrations, including tool descriptors without `parameters`, are skipped and
reported the same way. Registered tools are typed functions the model can call
after policy and allowlist checks pass.
## Registering CLI commands
Tool factories receive a runtime-supplied context object. Use `ctx.activeModel`
when a tool needs to log, display, or adapt to the active model for the current
turn. The object can include `provider`, `modelId`, and `modelRef`. Treat it as
informational runtime metadata, not as a security boundary against the local
operator, installed plugin code, or a modified OpenClaw runtime. Sensitive local
tools should still require an explicit plugin or operator opt-in and fail closed
when active-model metadata is missing or unsuitable.
Plugins can add root `openclaw` command groups with `api.registerCli`. Provide
`descriptors` for every top-level command root so OpenClaw can show and route
the command without eagerly loading every plugin runtime.
```typescript
register(api) {
api.registerCli(
({ program }) => {
const demo = program
.command("demo-plugin")
.description("Run demo plugin commands");
demo
.command("ping")
.description("Check that the plugin CLI is executable")
.action(() => {
console.log("demo-plugin:pong");
});
},
{
descriptors: [
{
name: "demo-plugin",
description: "Run demo plugin commands",
hasSubcommands: true,
},
],
},
);
}
```
After install, verify the runtime registration and execute the command:
```bash
openclaw plugins inspect demo-plugin --runtime --json
openclaw demo-plugin ping
```
The manifest declares ownership and discovery; execution still calls the live
registered tool implementation. Keep `toolMetadata.<tool>.optional: true`
aligned with `api.registerTool(..., { optional: true })` so OpenClaw can avoid
loading that plugin runtime until the tool is explicitly allowlisted.
## Import conventions
Always import from focused `openclaw/plugin-sdk/<subpath>` paths:
Import from focused SDK subpaths:
```typescript
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
// Wrong: monolithic root (deprecated, will be removed)
import { ... } from "openclaw/plugin-sdk";
```
For the full subpath reference, see [SDK Overview](/plugins/sdk-overview).
Do not import from the deprecated root barrel:
Within your plugin, use local barrel files (`api.ts`, `runtime-api.ts`) for
internal imports - never import your own plugin through its SDK path.
```typescript
import { definePluginEntry } from "openclaw/plugin-sdk";
```
For provider plugins, keep provider-specific helpers in those package-root
barrels unless the seam is truly generic. Current bundled examples:
Within your plugin package, use local barrel files such as `api.ts` and
`runtime-api.ts` for internal imports. Do not import your own plugin through an
SDK path. Provider-specific helpers should stay in the provider package unless
the seam is truly generic.
- Anthropic: Claude stream wrappers and `service_tier` / beta helpers
- OpenAI: provider builders, default-model helpers, realtime providers
- OpenRouter: provider builder plus onboarding/config helpers
Custom Gateway RPC methods are an advanced entry point. Keep them on a
plugin-specific prefix; core admin namespaces such as `config.*`,
`exec.approvals.*`, `operator.admin.*`, `wizard.*`, and `update.*` stay reserved
and resolve to `operator.admin`. The
`openclaw/plugin-sdk/gateway-method-runtime` bridge is reserved for plugin HTTP
routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]`.
If a helper is only useful inside one bundled provider package, keep it on that
package-root seam instead of promoting it into `openclaw/plugin-sdk/*`.
Some generated `openclaw/plugin-sdk/<bundled-id>` helper seams still exist for
bundled-plugin maintenance when they have tracked owner usage. Treat those as
reserved surfaces, not as the default pattern for new third-party plugins.
For the full import map, see [Plugin SDK overview](/plugins/sdk-overview).
## Pre-submission checklist
<Check>**package.json** has correct `openclaw` metadata</Check>
<Check>**openclaw.plugin.json** manifest is present and valid</Check>
<Check>Entry point uses `defineToolPlugin`, `defineChannelPluginEntry`, or `definePluginEntry`</Check>
<Check>Entry point uses `defineChannelPluginEntry` or `definePluginEntry`</Check>
<Check>All imports use focused `plugin-sdk/<subpath>` paths</Check>
<Check>Internal imports use local modules, not SDK self-imports</Check>
<Check>Tests pass (`pnpm test -- <bundled-plugin-root>/my-plugin/`)</Check>
<Check>`pnpm check` passes (in-repo plugins)</Check>
## Beta release testing
## Test against beta releases
1. Watch for GitHub release tags on [openclaw/openclaw](https://github.com/openclaw/openclaw/releases) and subscribe via `Watch` > `Releases`. Beta tags look like `v2026.3.N-beta.1`. You can also turn on notifications for the official OpenClaw X account [@openclaw](https://x.com/openclaw) for release announcements.
2. Test your plugin against the beta tag as soon as it appears. The window before stable is typically only a few hours.
@@ -450,8 +326,5 @@ reserved surfaces, not as the default pattern for new third-party plugins.
## Related
- [Plugin Architecture](/plugins/architecture) - internal architecture deep dive
- [SDK Overview](/plugins/sdk-overview) - Plugin SDK reference
- [Manifest](/plugins/manifest) - plugin manifest format
- [Channel Plugins](/plugins/sdk-channel-plugins) - building channel plugins
- [Provider Plugins](/plugins/sdk-provider-plugins) - building provider plugins
- [Plugin hooks](/plugins/hooks)
- [Plugin architecture](/plugins/architecture)

View File

@@ -248,10 +248,10 @@ Choose your preferred auth method and follow the setup steps.
| `codex-cli/gpt-5.5` | repaired by doctor | Legacy CLI route rewritten to `openai/gpt-5.5` | Codex app-server auth |
<Warning>
Do not configure older `openai-codex/gpt-5.1*`, `openai-codex/gpt-5.2*`, or
`openai-codex/gpt-5.3*` model refs. ChatGPT/Codex OAuth accounts now reject
those models. Use `openai/gpt-5.5`; OpenAI agent turns now select the Codex
runtime by default.
Prefer `openai/gpt-5.5` for new subscription-backed agent config. Older
`openai-codex/gpt-*` refs are legacy PI routes, not the native Codex runtime
path; run `openclaw doctor --fix` when you want to migrate them to canonical
`openai/*` refs.
</Warning>
<Note>

View File

@@ -185,10 +185,10 @@ vYYYY.M.D-beta.N` from the matching `release/YYYY.M.D` branch. The helper runs
- `custom`: exact `docker_lanes` selection for a focused rerun
- Run the manual `CI` workflow directly when you only need full normal CI
coverage for the release candidate. Manual CI dispatches bypass changed
scoping and force the Linux Node shards, bundled-plugin shards, channel
contracts, Node 22 compatibility, `check`, `check-additional`, build smoke,
docs checks, Python skills, Windows, macOS, Android, and Control UI i18n
lanes.
scoping and force the Linux Node shards, bundled-plugin shards, plugin and
channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`,
built-artifact smoke checks, docs checks, Python skills, Windows, macOS,
Android, and Control UI i18n lanes.
Example: `gh workflow run ci.yml --ref release/YYYY.M.D`
- Run `pnpm qa:otel:smoke` when validating release telemetry. It exercises
QA-lab through a local OTLP/HTTP receiver and verifies the exported trace
@@ -442,16 +442,19 @@ Focused `npm-telegram` reruns require `release_package_spec` or
`npm_telegram_package_spec`; full/all runs with `release_profile=full` use the
release-checks package artifact. Focused
cross-OS reruns can add `cross_os_suite_filter=windows/packaged-upgrade` or
another OS/suite filter. QA release-check failures are advisory; a QA-only
failure does not block release validation.
another OS/suite filter. QA release-check failures are advisory except the
standard runtime tool coverage gate, which blocks release validation when
required OpenClaw dynamic tools drift or disappear from the standard tier
summary.
### Vitest
The Vitest box is the manual `CI` child workflow. Manual CI intentionally
bypasses changed scoping and forces the normal test graph for the release
candidate: Linux Node shards, bundled-plugin shards, channel contracts, Node 22
compatibility, `check`, `check-additional`, build smoke, docs checks, Python
skills, Windows, macOS, Android, and Control UI i18n.
candidate: Linux Node shards, bundled-plugin shards, plugin and channel contract
shards, Node 22 compatibility, `check-*`, `check-additional-*`,
built-artifact smoke checks, docs checks, Python skills, Windows, macOS,
Android, and Control UI i18n.
Use this box to answer "did the source tree pass the full normal test suite?"
It is not the same as release-path product validation. Evidence to keep:

View File

@@ -44,7 +44,7 @@ only when Package Acceptance should intentionally prove a different package.
| Stage | Details |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Target resolution | **Job:** `Resolve target ref`<br />**Child workflow:** none<br />**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.<br />**Rerun:** rerun the umbrella if this fails. |
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, channel contracts, Node 22 compatibility, `check`, `check-additional`, build smoke, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.<br />**Rerun:** `rerun_group=ci`. |
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.<br />**Rerun:** `rerun_group=ci`. |
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, plugin prerelease Docker lanes, and a non-blocking `plugin-inspector-advisory` artifact for compatibility triage.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram. With `run_release_soak=true` or `release_profile=full`, also runs exhaustive live/E2E suites and Docker release-path chunks.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
| Package artifact | **Job:** `Prepare release package artifact`<br />**Child workflow:** none<br />**Proves:** creates the parent `release-package-under-test` tarball early enough for package-facing checks that do not need to wait for `OpenClaw Release Checks`.<br />**Rerun:** rerun the umbrella or provide `release_package_spec` for published-package reruns. |
@@ -166,9 +166,10 @@ summaries include per-phase timings for packaged upgrade lanes, and long-running
commands print heartbeat lines so a stuck Windows update is visible before the
job timeout.
QA release-check lanes are advisory. A QA-only failure is reported as a warning
and does not block the release-check verifier; rerun `rerun_group=qa`,
`qa-parity`, or `qa-live` when you need fresh QA evidence.
QA release-check lanes are advisory except the standard runtime tool coverage
gate. Required OpenClaw dynamic tool drift in the standard tier blocks the
release-check verifier; other QA-only failures are reported as warnings. Rerun
`rerun_group=qa`, `qa-parity`, or `qa-live` when you need fresh QA evidence.
## Evidence to keep

View File

@@ -193,6 +193,7 @@ openclaw browser waitfordownload report.pdf
openclaw browser upload /tmp/openclaw/uploads/file.pdf
openclaw browser fill --fields '[{"ref":"1","type":"text","value":"Ada"}]'
openclaw browser dialog --accept
openclaw browser dialog --dismiss --dialog-id d1
openclaw browser wait --text "Done"
openclaw browser wait "#main" --url "**/dash" --load networkidle --fn "window.ready===true"
openclaw browser evaluate --fn '(el) => el.textContent' --ref 7
@@ -228,7 +229,7 @@ openclaw browser set device "iPhone 14"
Notes:
- `upload` and `dialog` are **arming** calls; run them before the click/press that triggers the chooser/dialog.
- `upload` and `dialog` are **arming** calls; run them before the click/press that triggers the chooser/dialog. If an action opens a modal, the action response includes `blockedByDialog` and `browserState.dialogs.pending`; pass that `dialogId` to respond directly. Dialogs handled outside OpenClaw appear under `browserState.dialogs.recent`.
- `click`/`type`/etc require a `ref` from `snapshot` (numeric `12`, role ref `e12`, or actionable ARIA ref `ax12`). CSS selectors are intentionally not supported for actions. Use `click-coords` when the visible viewport position is the only reliable target.
- Download, trace, and upload paths are constrained to OpenClaw temp roots: `/tmp/openclaw{,/downloads,/uploads}` (fallback: `${os.tmpdir()}/openclaw/...`).
- `upload` can also set file inputs directly via `--input-ref` or `--element`.

View File

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

View File

@@ -90,7 +90,7 @@ source ~/.bashrc
```
- `NODE_COMPILE_CACHE` improves repeated command startup times.
- `OPENCLAW_NO_RESPAWN=1` avoids extra startup overhead from a self-respawn path.
- `OPENCLAW_NO_RESPAWN=1` keeps routine Gateway restarts in-process, which avoids extra process handoffs and keeps PID tracking simple on small hosts.
- First command run warms the cache; subsequent runs are faster.
- For Raspberry Pi specifics, see [Raspberry Pi](/install/raspberry-pi).

View File

@@ -72,6 +72,7 @@ describe("bonjour plugin entry", () => {
gatewayPort: 3210,
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
gatewayDirectReachable: true,
canvasPort: 9876,
sshPort: 22,
tailnetDns: "dev.tailnet.ts.net",
@@ -88,6 +89,7 @@ describe("bonjour plugin entry", () => {
gatewayPort: 3210,
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
gatewayDirectReachable: true,
canvasPort: 9876,
sshPort: 22,
tailnetDns: "dev.tailnet.ts.net",

View File

@@ -32,6 +32,7 @@ export default definePluginEntry({
gatewayPort: ctx.gatewayPort,
gatewayTlsEnabled: ctx.gatewayTlsEnabled,
gatewayTlsFingerprintSha256: ctx.gatewayTlsFingerprintSha256,
gatewayDirectReachable: ctx.gatewayDirectReachable,
canvasPort: ctx.canvasPort,
sshPort: ctx.sshPort,
tailnetDns: ctx.tailnetDns,

View File

@@ -180,6 +180,7 @@ describe("gateway bonjour advertiser", () => {
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
gatewayDirectReachable: true,
tailnetDns: "host.tailnet.ts.net",
cliPath: "/opt/homebrew/bin/openclaw",
minimal: false,
@@ -195,6 +196,7 @@ describe("gateway bonjour advertiser", () => {
expect(gatewayCall?.[0]?.hostname).toBe("test-host");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayDirectReachable).toBe("1");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.tailnetDns).toBe(
"host.tailnet.ts.net",

View File

@@ -22,6 +22,7 @@ export type GatewayBonjourAdvertiseOpts = {
sshPort?: number;
gatewayTlsEnabled?: boolean;
gatewayTlsFingerprintSha256?: string;
gatewayDirectReachable?: boolean;
canvasPort?: number;
tailnetDns?: string;
cliPath?: string;
@@ -451,6 +452,9 @@ export async function startGatewayBonjourAdvertiser(
txtBase.gatewayTlsSha256 = opts.gatewayTlsFingerprintSha256;
}
}
if (opts.gatewayDirectReachable) {
txtBase.gatewayDirectReachable = "1";
}
if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) {
txtBase.canvasPort = String(opts.canvasPort);
}

View File

@@ -1 +1,6 @@
export { noteChromeMcpBrowserReadiness } from "./src/doctor-browser.js";
export {
detectLegacyClawdBrowserProfileResidue,
maybeArchiveLegacyClawdBrowserProfileResidue,
noteChromeMcpBrowserReadiness,
} from "./src/doctor-browser.js";
export type { LegacyClawdBrowserProfileResidue } from "./src/doctor-browser.js";

View File

@@ -404,6 +404,31 @@ export async function executeSnapshotAction(params: {
}
params.onTabActivity?.(readStringValue(snapshot.targetId) ?? targetId);
if (snapshot.format === "ai") {
const dialogStateFields = {
...(snapshot.blockedByDialog ? { blockedByDialog: true } : {}),
...(snapshot.browserState !== undefined ? { browserState: snapshot.browserState } : {}),
};
if (snapshot.blockedByDialog) {
const wrapped = wrapBrowserExternalJson({
kind: "snapshot",
payload: {
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url,
...dialogStateFields,
},
});
return {
content: [{ type: "text" as const, text: wrapped.wrappedText }],
details: {
...wrapped.safeDetails,
format: snapshot.format,
targetId: snapshot.targetId,
url: snapshot.url,
...dialogStateFields,
},
};
}
const extractedText = snapshot.snapshot ?? "";
const wrappedSnapshot = wrapExternalContent(extractedText, {
source: "browser",
@@ -423,6 +448,7 @@ export async function executeSnapshotAction(params: {
imagePath: snapshot.imagePath,
imageType: snapshot.imageType,
refsFallback,
...dialogStateFields,
externalContent: {
untrusted: true,
source: "browser",
@@ -457,6 +483,8 @@ export async function executeSnapshotAction(params: {
targetId: snapshot.targetId,
url: snapshot.url,
nodeCount: snapshot.nodes.length,
...(snapshot.blockedByDialog ? { blockedByDialog: true } : {}),
...(snapshot.browserState !== undefined ? { browserState: snapshot.browserState } : {}),
externalContent: {
untrusted: true,
source: "browser",

View File

@@ -118,6 +118,7 @@ export const BrowserToolSchema = Type.Object({
paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
dialogId: Type.Optional(Type.String()),
accept: Type.Optional(Type.Boolean()),
promptText: Type.Optional(Type.String()),
// Legacy flattened act params (preferred: request={...})

View File

@@ -1230,6 +1230,35 @@ describe("browser tool external content wrapping", () => {
expect(details.nodeCount).toBe(1);
});
it("preserves pending dialog state in ai snapshot results", async () => {
browserClientMocks.browserSnapshot.mockResolvedValueOnce({
ok: true,
format: "ai",
targetId: "t1",
url: "https://example.com",
snapshot: "",
blockedByDialog: true,
browserState: {
dialogs: {
pending: [{ id: "d1", type: "confirm", message: "Continue?" }],
recent: [],
},
},
});
const tool = createBrowserTool();
const result = await tool.execute?.("call-1", { action: "snapshot", snapshotFormat: "ai" });
const text = firstResultText(result);
expect(text).toContain('"blockedByDialog": true');
expect(text).toContain('"id": "d1"');
const details = externalContentDetails(result, "snapshot") as {
blockedByDialog?: unknown;
browserState?: { dialogs?: { pending?: Array<{ id?: string }> } };
};
expect(details.blockedByDialog).toBe(true);
expect(details.browserState?.dialogs?.pending?.[0]?.id).toBe("d1");
});
it("wraps tabs output as external content", async () => {
browserClientMocks.browserTabs.mockResolvedValueOnce([
{

View File

@@ -867,6 +867,7 @@ export function createBrowserTool(opts?: {
case "dialog": {
const accept = Boolean(params.accept);
const promptText = readStringValue(params.promptText);
const dialogId = readStringValue(params.dialogId);
const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params);
if (proxyRequest) {
const result = await proxyRequest({
@@ -876,6 +877,7 @@ export function createBrowserTool(opts?: {
body: {
accept,
promptText,
dialogId,
targetId,
timeoutMs,
},
@@ -885,6 +887,7 @@ export function createBrowserTool(opts?: {
const result = await browserToolDeps.browserArmDialog(baseUrl, {
accept,
promptText,
dialogId,
targetId,
timeoutMs,
profile,

View File

@@ -19,6 +19,8 @@ type BrowserActResponse = {
url?: string;
result?: unknown;
results?: Array<{ ok: boolean; error?: string }>;
blockedByDialog?: boolean;
browserState?: unknown;
};
const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000;
@@ -66,6 +68,7 @@ export async function browserArmDialog(
opts: {
accept: boolean;
promptText?: string;
dialogId?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
@@ -78,6 +81,7 @@ export async function browserArmDialog(
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
dialogId: opts.dialogId,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),

View File

@@ -44,6 +44,8 @@ export type SnapshotResult =
targetId: string;
url: string;
nodes: SnapshotAriaNode[];
blockedByDialog?: boolean;
browserState?: unknown;
}
| {
ok: true;
@@ -64,6 +66,8 @@ export type SnapshotResult =
labelsSkipped?: number;
imagePath?: string;
imageType?: "png" | "jpeg";
blockedByDialog?: boolean;
browserState?: unknown;
};
export async function browserStatus(

View File

@@ -10,9 +10,16 @@ export {
ensurePageState,
forceDisconnectPlaywrightForTarget,
focusPageByTargetIdViaPlaywright,
createObservedDialogAbortSignalForPage,
getObservedBrowserStateForPage,
getObservedBrowserStateViaPlaywright,
getPageForTargetId,
isBrowserObservedDialogBlockedError,
listPagesViaPlaywright,
markObservedDialogsHandledRemotelyForPage,
refLocator,
respondToObservedDialogOnPage,
respondToObservedDialogViaPlaywright,
} from "./pw-session.js";
export {

View File

@@ -0,0 +1,139 @@
import type { Dialog, Page } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
armObservedDialogResponseOnPage,
createObservedDialogAbortSignalForPage,
ensurePageState,
getObservedBrowserStateForPage,
isBrowserObservedDialogBlockedError,
markObservedDialogsHandledRemotelyForPage,
respondToObservedDialogOnPage,
} from "./pw-session.js";
type Handler = (arg: unknown) => void;
function createPageHarness() {
const handlers = new Map<string, Handler[]>();
const page = {
on: (event: string, handler: Handler) => {
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
return page;
},
};
return {
page: page as unknown as Page,
emit: (event: string, arg: unknown) => {
for (const handler of handlers.get(event) ?? []) {
handler(arg);
}
},
};
}
function createDialog(
overrides: Partial<{
type: string;
message: string;
defaultValue: string;
}> = {},
) {
return {
type: vi.fn(() => overrides.type ?? "confirm"),
message: vi.fn(() => overrides.message ?? "Continue?"),
defaultValue: vi.fn(() => overrides.defaultValue ?? ""),
accept: vi.fn(async (_promptText?: string) => {}),
dismiss: vi.fn(async () => {}),
} as unknown as Dialog & {
accept: ReturnType<typeof vi.fn>;
dismiss: ReturnType<typeof vi.fn>;
};
}
describe("observed browser dialogs", () => {
afterEach(() => {
vi.useRealTimers();
});
it("surfaces pending dialogs and lets callers respond by id", async () => {
const { page, emit } = createPageHarness();
ensurePageState(page);
const dialog = createDialog({ message: "Ship it?" });
emit("dialog", dialog);
expect(getObservedBrowserStateForPage(page).dialogs.pending).toMatchObject([
{ id: "d1", type: "confirm", message: "Ship it?" },
]);
const closed = await respondToObservedDialogOnPage({
page,
dialogId: "d1",
accept: true,
promptText: "yes",
});
expect(dialog.accept).toHaveBeenCalledWith("yes");
expect(closed.closedBy).toBe("agent");
expect(getObservedBrowserStateForPage(page).dialogs.pending).toEqual([]);
expect(getObservedBrowserStateForPage(page).dialogs.recent).toMatchObject([
{ id: "d1", closedBy: "agent" },
]);
});
it("keeps arm-next-dialog behavior through the observed dialog path", async () => {
const { page, emit } = createPageHarness();
ensurePageState(page);
const dialog = createDialog({ type: "alert", message: "Heads up" });
const observed = createObservedDialogAbortSignalForPage({ page });
armObservedDialogResponseOnPage({ page, accept: false, timeoutMs: 1000 });
emit("dialog", dialog);
await Promise.resolve();
expect(observed.signal.aborted).toBe(false);
expect(dialog.dismiss).toHaveBeenCalledOnce();
expect(getObservedBrowserStateForPage(page).dialogs.pending).toEqual([]);
expect(getObservedBrowserStateForPage(page).dialogs.recent).toMatchObject([
{ id: "d1", type: "alert", closedBy: "armed" },
]);
observed.cleanup();
});
it("aborts in-flight actions while keeping unarmed dialogs pending", async () => {
const { page, emit } = createPageHarness();
ensurePageState(page);
const dialog = createDialog({ type: "alert", message: "Heads up" });
const observed = createObservedDialogAbortSignalForPage({ page });
emit("dialog", dialog);
expect(observed.signal.aborted).toBe(true);
expect(isBrowserObservedDialogBlockedError(observed.signal.reason)).toBe(true);
expect(getObservedBrowserStateForPage(page).dialogs.pending).toMatchObject([
{ id: "d1", type: "alert", message: "Heads up" },
]);
expect(dialog.dismiss).not.toHaveBeenCalled();
await respondToObservedDialogOnPage({ page, dialogId: "d1", accept: false });
observed.cleanup();
expect(dialog.dismiss).toHaveBeenCalledOnce();
expect(getObservedBrowserStateForPage(page).dialogs.pending).toEqual([]);
expect(getObservedBrowserStateForPage(page).dialogs.recent).toMatchObject([
{ id: "d1", type: "alert", closedBy: "agent" },
]);
});
it("moves remotely handled pending dialogs into recent state", () => {
const { page, emit } = createPageHarness();
ensurePageState(page);
emit("dialog", createDialog({ type: "confirm", message: "Continue?" }));
const state = markObservedDialogsHandledRemotelyForPage(page);
expect(state.dialogs.pending).toEqual([]);
expect(state.dialogs.recent).toMatchObject([
{ id: "d1", type: "confirm", message: "Continue?", closedBy: "remote" },
]);
});
});

View File

@@ -5,6 +5,7 @@ import type {
Browser,
BrowserContext,
ConsoleMessage,
Dialog,
Page,
Request,
Response,
@@ -67,6 +68,52 @@ export type BrowserNetworkRequest = {
failureText?: string;
};
export type BrowserObservedDialogRecord = {
id: string;
type: string;
message: string;
defaultValue?: string;
openedAt: string;
closedAt?: string;
closedBy?: "agent" | "armed" | "auto" | "timeout" | "remote";
};
export type BrowserObservedDialogState = {
pending: BrowserObservedDialogRecord[];
recent: BrowserObservedDialogRecord[];
};
export type BrowserObservedState = {
dialogs: BrowserObservedDialogState;
};
export class BrowserObservedDialogBlockedError extends Error {
readonly browserState: BrowserObservedState;
constructor(browserState: BrowserObservedState) {
super("Browser action blocked by a modal dialog.");
this.name = "BrowserObservedDialogBlockedError";
this.browserState = browserState;
}
}
export function isBrowserObservedDialogBlockedError(
err: unknown,
): err is BrowserObservedDialogBlockedError {
return err instanceof BrowserObservedDialogBlockedError;
}
type PendingObservedDialog = BrowserObservedDialogRecord & {
dialog: Dialog;
};
type ArmedDialogResponse = {
accept: boolean;
promptText?: string;
expiresAt: number;
timer?: ReturnType<typeof setTimeout>;
};
type TargetInfoResponse = {
targetInfo?: {
targetId?: string;
@@ -86,9 +133,13 @@ type PageState = {
requestIds: WeakMap<Request, string>;
nextRequestId: number;
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
downloadWaiterDepth: number;
nextObservedDialogId: number;
pendingDialogs: PendingObservedDialog[];
recentDialogs: BrowserObservedDialogRecord[];
armedDialogResponse?: ArmedDialogResponse;
dialogAbortControllers: Set<AbortController>;
/**
* Role-based refs from the last role snapshot (e.g. e1/e2).
* Mode "role" refs are generated from ariaSnapshot and resolved via getByRole.
@@ -123,6 +174,8 @@ const MAX_ROLE_REFS_CACHE = 50;
const MAX_CONSOLE_MESSAGES = 500;
const MAX_PAGE_ERRORS = 200;
const MAX_NETWORK_REQUESTS = 500;
const MAX_RECENT_DIALOGS = 20;
const OBSERVED_DIALOG_TIMEOUT_MS = 120_000;
const cachedByCdpUrl = new Map<string, ConnectedBrowser>();
const connectingByCdpUrl = new Map<string, Promise<ConnectedBrowser>>();
@@ -183,6 +236,135 @@ function findNetworkRequestById(state: PageState, id: string): BrowserNetworkReq
return undefined;
}
function appendRecentDialog(state: PageState, record: BrowserObservedDialogRecord): void {
state.recentDialogs.push(record);
while (state.recentDialogs.length > MAX_RECENT_DIALOGS) {
state.recentDialogs.shift();
}
}
function serializeDialogRecord(dialog: BrowserObservedDialogRecord): BrowserObservedDialogRecord {
return {
id: dialog.id,
type: dialog.type,
message: dialog.message,
...(dialog.defaultValue !== undefined ? { defaultValue: dialog.defaultValue } : {}),
openedAt: dialog.openedAt,
...(dialog.closedAt !== undefined ? { closedAt: dialog.closedAt } : {}),
...(dialog.closedBy !== undefined ? { closedBy: dialog.closedBy } : {}),
};
}
function serializePendingDialog(dialog: PendingObservedDialog): BrowserObservedDialogRecord {
return serializeDialogRecord(dialog);
}
function serializeObservedBrowserState(state: PageState): BrowserObservedState {
return {
dialogs: {
pending: state.pendingDialogs.map(serializePendingDialog),
recent: state.recentDialogs.map(serializeDialogRecord),
},
};
}
function clearArmedDialogResponse(state: PageState): void {
if (state.armedDialogResponse?.timer) {
clearTimeout(state.armedDialogResponse.timer);
}
state.armedDialogResponse = undefined;
}
function abortActionsBlockedByDialog(state: PageState): void {
if (state.dialogAbortControllers.size === 0) {
return;
}
const err = new BrowserObservedDialogBlockedError(serializeObservedBrowserState(state));
for (const controller of state.dialogAbortControllers) {
if (!controller.signal.aborted) {
controller.abort(err);
}
}
state.dialogAbortControllers.clear();
}
function isNoDialogShowingError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return message.toLowerCase().includes("no dialog is showing");
}
async function settleObservedDialog(params: {
state: PageState;
pending: PendingObservedDialog;
accept: boolean;
promptText?: string;
closedBy: NonNullable<BrowserObservedDialogRecord["closedBy"]>;
}): Promise<BrowserObservedDialogRecord> {
const { state, pending } = params;
state.pendingDialogs = state.pendingDialogs.filter((dialog) => dialog.id !== pending.id);
let closedBy = params.closedBy;
try {
if (params.accept) {
await pending.dialog.accept(params.promptText);
} else {
await pending.dialog.dismiss();
}
} catch (err) {
if (!isNoDialogShowingError(err)) {
if (params.closedBy === "agent") {
state.pendingDialogs.push(pending);
}
throw err;
}
closedBy = "remote";
}
const record: BrowserObservedDialogRecord = {
id: pending.id,
type: pending.type,
message: pending.message,
...(pending.defaultValue !== undefined ? { defaultValue: pending.defaultValue } : {}),
openedAt: pending.openedAt,
closedAt: new Date().toISOString(),
closedBy,
};
appendRecentDialog(state, record);
return record;
}
function observeDialog(pageState: PageState, dialog: Dialog): void {
pageState.nextObservedDialogId += 1;
const type = dialog.type();
const defaultValue = dialog.defaultValue();
const pending: PendingObservedDialog = {
id: `d${pageState.nextObservedDialogId}`,
type,
message: dialog.message(),
openedAt: new Date().toISOString(),
dialog,
...(type === "prompt" ? { defaultValue } : {}),
};
pageState.pendingDialogs.push(pending);
const armed = pageState.armedDialogResponse;
if (armed && armed.expiresAt >= Date.now()) {
clearArmedDialogResponse(pageState);
void settleObservedDialog({
state: pageState,
pending,
accept: armed.accept,
...(armed.promptText !== undefined ? { promptText: armed.promptText } : {}),
closedBy: "armed",
}).catch(() => {});
return;
}
if (armed) {
clearArmedDialogResponse(pageState);
}
abortActionsBlockedByDialog(pageState);
}
function targetKey(cdpUrl: string, targetId: string) {
return `${normalizeCdpUrl(cdpUrl)}::${targetId}`;
}
@@ -380,9 +562,12 @@ export function ensurePageState(page: Page): PageState {
requestIds: new WeakMap(),
nextRequestId: 0,
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
downloadWaiterDepth: 0,
nextObservedDialogId: 0,
pendingDialogs: [],
recentDialogs: [],
dialogAbortControllers: new Set(),
};
pageStates.set(page, state);
@@ -451,6 +636,9 @@ export function ensurePageState(page: Page): PageState {
rec.failureText = req.failure()?.errorText;
rec.ok = false;
});
page.on("dialog", (dialog: Dialog) => {
observeDialog(state, dialog);
});
page.on(
"download",
(download: {
@@ -481,6 +669,14 @@ export function ensurePageState(page: Page): PageState {
},
);
page.on("close", () => {
clearArmedDialogResponse(state);
for (const controller of state.dialogAbortControllers) {
if (!controller.signal.aborted) {
controller.abort(new Error("Page closed before browser action completed."));
}
}
state.dialogAbortControllers.clear();
state.pendingDialogs = [];
pageStates.delete(page);
observedPages.delete(page);
});
@@ -489,6 +685,158 @@ export function ensurePageState(page: Page): PageState {
return state;
}
export function getObservedBrowserStateForPage(page: Page): BrowserObservedState {
const state = ensurePageState(page);
return serializeObservedBrowserState(state);
}
export async function getObservedBrowserStateViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<BrowserObservedState> {
const page = await getPageForTargetId(opts);
return getObservedBrowserStateForPage(page);
}
function resolvePendingDialogForResponse(params: {
state: PageState;
dialogId?: string;
}): PendingObservedDialog {
const dialogId = normalizeOptionalString(params.dialogId);
if (dialogId) {
const found = params.state.pendingDialogs.find((dialog) => dialog.id === dialogId);
if (found) {
return found;
}
throw new Error(`Dialog "${dialogId}" is not pending.`);
}
if (params.state.pendingDialogs.length === 1) {
return params.state.pendingDialogs[0];
}
if (params.state.pendingDialogs.length > 1) {
throw new Error("Multiple dialogs are pending; pass dialogId.");
}
throw new Error("No dialog is pending.");
}
export async function respondToObservedDialogOnPage(opts: {
page: Page;
dialogId?: string;
accept: boolean;
promptText?: string;
closedBy?: "agent" | "armed";
}): Promise<BrowserObservedDialogRecord> {
const state = ensurePageState(opts.page);
const pending = resolvePendingDialogForResponse({
state,
...(opts.dialogId !== undefined ? { dialogId: opts.dialogId } : {}),
});
return await settleObservedDialog({
state,
pending,
accept: opts.accept,
...(opts.promptText !== undefined ? { promptText: opts.promptText } : {}),
closedBy: opts.closedBy ?? "agent",
});
}
export async function respondToObservedDialogViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
dialogId?: string;
accept: boolean;
promptText?: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<BrowserObservedDialogRecord> {
const page = await getPageForTargetId(opts);
return await respondToObservedDialogOnPage({
page,
accept: opts.accept,
...(opts.dialogId !== undefined ? { dialogId: opts.dialogId } : {}),
...(opts.promptText !== undefined ? { promptText: opts.promptText } : {}),
});
}
export function markObservedDialogsHandledRemotelyForPage(page: Page): BrowserObservedState {
const state = ensurePageState(page);
const pending = state.pendingDialogs.splice(0);
const closedAt = new Date().toISOString();
for (const dialog of pending) {
appendRecentDialog(state, {
id: dialog.id,
type: dialog.type,
message: dialog.message,
...(dialog.defaultValue !== undefined ? { defaultValue: dialog.defaultValue } : {}),
openedAt: dialog.openedAt,
closedAt,
closedBy: "remote",
});
}
return serializeObservedBrowserState(state);
}
export function armObservedDialogResponseOnPage(opts: {
page: Page;
accept: boolean;
promptText?: string;
timeoutMs?: number;
}): void {
const state = ensurePageState(opts.page);
clearArmedDialogResponse(state);
const timeoutMs = Math.max(1, Math.floor(opts.timeoutMs ?? OBSERVED_DIALOG_TIMEOUT_MS));
const response: ArmedDialogResponse = {
accept: opts.accept,
expiresAt: Date.now() + timeoutMs,
...(opts.promptText !== undefined ? { promptText: opts.promptText } : {}),
};
response.timer = setTimeout(() => {
if (state.armedDialogResponse === response) {
state.armedDialogResponse = undefined;
}
}, timeoutMs);
state.armedDialogResponse = response;
}
export function createObservedDialogAbortSignalForPage(opts: {
page: Page;
parentSignal?: AbortSignal;
}): { signal: AbortSignal; cleanup: () => void } {
const state = ensurePageState(opts.page);
const controller = new AbortController();
const abortForCurrentDialog = () => {
if (!controller.signal.aborted) {
controller.abort(new BrowserObservedDialogBlockedError(serializeObservedBrowserState(state)));
}
};
const abortForParent = () => {
if (!controller.signal.aborted) {
controller.abort(opts.parentSignal?.reason ?? new Error("aborted"));
}
};
if (state.pendingDialogs.length > 0) {
abortForCurrentDialog();
} else {
state.dialogAbortControllers.add(controller);
}
if (opts.parentSignal) {
if (opts.parentSignal.aborted) {
abortForParent();
} else {
opts.parentSignal.addEventListener("abort", abortForParent, { once: true });
}
}
return {
signal: controller.signal,
cleanup: () => {
state.dialogAbortControllers.delete(controller);
opts.parentSignal?.removeEventListener("abort", abortForParent);
},
};
}
function observeContext(context: BrowserContext) {
if (observedContexts.has(context)) {
return;

View File

@@ -17,7 +17,9 @@ const sessionMocks = vi.hoisted(() => ({
return pageState.page;
}),
gotoPageWithNavigationGuard: vi.fn(async () => null),
isBrowserObservedDialogBlockedError: vi.fn(() => false),
isPolicyDenyNavigationError: vi.fn(() => false),
markObservedDialogsHandledRemotelyForPage: vi.fn(() => ({})),
refLocator: vi.fn(() => {
if (!pageState.locator) {
throw new Error("missing locator");

View File

@@ -5,13 +5,14 @@ import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { writeExternalFileWithinOutputRoot } from "./output-files.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
armObservedDialogResponseOnPage,
ensurePageState,
getPageForTargetId,
refLocator,
respondToObservedDialogOnPage,
restoreRoleRefsForTarget,
} from "./pw-session.js";
import {
bumpDialogArmId,
bumpDownloadArmId,
bumpUploadArmId,
normalizeTimeoutMs,
@@ -191,32 +192,34 @@ export async function armFileUploadViaPlaywright(opts: {
export async function armDialogViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
dialogId?: string;
accept: boolean;
promptText?: string;
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
state.armIdDialog = bumpDialogArmId();
const armId = state.armIdDialog;
void page
.waitForEvent("dialog", { timeout })
.then(async (dialog) => {
if (state.armIdDialog !== armId) {
return;
}
if (opts.accept) {
await dialog.accept(opts.promptText);
} else {
await dialog.dismiss();
}
})
.catch(() => {
// Ignore timeouts; the dialog may never appear.
try {
await respondToObservedDialogOnPage({
page,
accept: opts.accept,
closedBy: "agent",
...(opts.dialogId !== undefined ? { dialogId: opts.dialogId } : {}),
...(opts.promptText !== undefined ? { promptText: opts.promptText } : {}),
});
return;
} catch (err) {
if (opts.dialogId || (err instanceof Error && !err.message.includes("No dialog is pending"))) {
throw err;
}
}
armObservedDialogResponseOnPage({
page,
accept: opts.accept,
timeoutMs: timeout,
...(opts.promptText !== undefined ? { promptText: opts.promptText } : {}),
});
}
export async function waitForDownloadViaPlaywright(opts: {

View File

@@ -14,6 +14,8 @@ const getPageForTargetId = vi.fn(async () => {
const ensurePageState = vi.fn(() => {});
const assertPageNavigationCompletedSafely = vi.fn(async () => {});
const forceDisconnectPlaywrightForTarget = vi.fn(async () => {});
const isBrowserObservedDialogBlockedError = vi.fn(() => false);
const markObservedDialogsHandledRemotelyForPage = vi.fn(() => ({}));
const refLocator = vi.fn(() => {
throw new Error("test: refLocator should not be called");
});
@@ -27,6 +29,8 @@ vi.mock("./pw-session.js", () => ({
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
isBrowserObservedDialogBlockedError,
markObservedDialogsHandledRemotelyForPage,
refLocator,
restoreRoleRefsForTarget,
}));

View File

@@ -13,6 +13,10 @@ const getPageForTargetId = vi.fn(async () => {
const ensurePageState = vi.fn(() => {});
const assertPageNavigationCompletedSafely = vi.fn(async () => {});
const restoreRoleRefsForTarget = vi.fn(() => {});
const isBrowserObservedDialogBlockedError = vi.fn(
(err: unknown) => err instanceof Error && err.name === "BrowserObservedDialogBlockedError",
);
const markObservedDialogsHandledRemotelyForPage = vi.fn(() => ({}));
const refLocator = vi.fn(() => {
if (!locator) {
throw new Error("test: locator not set");
@@ -26,6 +30,8 @@ vi.mock("./pw-session.js", () => {
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
isBrowserObservedDialogBlockedError,
markObservedDialogsHandledRemotelyForPage,
refLocator,
restoreRoleRefsForTarget,
};
@@ -93,4 +99,37 @@ describe("evaluateViaPlaywright (abort)", () => {
await expect(p).rejects.toThrow("aborted by test");
expect(forceDisconnectPlaywrightForTarget).toHaveBeenCalled();
});
it("does not disconnect when evaluate is blocked by an observed dialog", async () => {
const ctrl = new AbortController();
const pending = createPendingEval();
let resolveEval: (value: unknown) => void = () => {};
const pendingPromise = new Promise((resolve) => {
resolveEval = resolve;
});
page = {
evaluate: vi.fn(() => {
pending.resolveEvalCalled();
return pendingPromise;
}),
url: vi.fn(() => "https://example.com/current"),
};
const p = evaluateViaPlaywright({
cdpUrl: "http://127.0.0.1:9222",
fn: "() => alert('x')",
signal: ctrl.signal,
});
await pending.evalCalledPromise;
const err = new Error("blocked by dialog");
err.name = "BrowserObservedDialogBlockedError";
ctrl.abort(err);
await expect(p).rejects.toThrow("blocked by dialog");
expect(forceDisconnectPlaywrightForTarget).not.toHaveBeenCalled();
resolveEval(true);
await Promise.resolve();
expect(markObservedDialogsHandledRemotelyForPage).toHaveBeenCalled();
});
});

View File

@@ -11,6 +11,8 @@ const getPageForTargetId = vi.fn(async () => {
});
const ensurePageState = vi.fn(() => ({}));
const restoreRoleRefsForTarget = vi.fn(() => {});
const isBrowserObservedDialogBlockedError = vi.fn(() => false);
const markObservedDialogsHandledRemotelyForPage = vi.fn(() => ({}));
const refLocator = vi.fn(() => {
if (!locator) {
throw new Error("test: locator not set");
@@ -27,6 +29,8 @@ vi.mock("./pw-session.js", () => {
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
isBrowserObservedDialogBlockedError,
markObservedDialogsHandledRemotelyForPage,
refLocator,
restoreRoleRefsForTarget,
};

View File

@@ -19,9 +19,12 @@ import {
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
assertPageNavigationCompletedSafely,
createObservedDialogAbortSignalForPage,
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
isBrowserObservedDialogBlockedError,
markObservedDialogsHandledRemotelyForPage,
refLocator,
restoreRoleRefsForTarget,
} from "./pw-session.js";
@@ -66,6 +69,16 @@ async function getRestoredPageForTarget(opts: TargetOpts) {
return page;
}
function toFriendlyInteractionError(err: unknown, label: string): Error {
return isBrowserObservedDialogBlockedError(err) ? err : toAIFriendlyError(err, label);
}
function reconcileRemoteDialogAfterActionSettled(page: Page, signal?: AbortSignal): void {
if (isBrowserObservedDialogBlockedError(signal?.reason)) {
markObservedDialogsHandledRemotelyForPage(page);
}
}
const resolveInteractionTimeoutMs = resolveActInteractionTimeoutMs;
// Returns true only when the URL change indicates a cross-document navigation
@@ -426,6 +439,7 @@ async function assertInteractionNavigationCompletedSafely<T>(opts: {
async function awaitActionWithAbort<T>(
actionPromise: Promise<T>,
abortPromise?: Promise<never>,
onActionResolvedAfterAbort?: () => void,
): Promise<T> {
if (!abortPromise) {
return await actionPromise;
@@ -434,7 +448,10 @@ async function awaitActionWithAbort<T>(
return await Promise.race([actionPromise, abortPromise]);
} catch (err) {
// If abort wins the race, the action may reject later; avoid unhandled rejections.
void actionPromise.catch(() => {});
void actionPromise.then(
() => onActionResolvedAfterAbort?.(),
() => {},
);
throw err;
}
}
@@ -448,7 +465,7 @@ function createAbortPromise(signal?: AbortSignal): {
function createAbortPromiseWithListener(
signal?: AbortSignal,
onAbort?: () => void,
onAbort?: (reason: unknown) => void,
): {
abortPromise?: Promise<never>;
cleanup: () => void;
@@ -459,12 +476,12 @@ function createAbortPromiseWithListener(
let abortListener: (() => void) | undefined;
const abortPromise: Promise<never> = signal.aborted
? (() => {
onAbort?.();
onAbort?.(signal.reason);
return Promise.reject(signal.reason ?? new Error("aborted"));
})()
: new Promise((_, reject) => {
abortListener = () => {
onAbort?.();
onAbort?.(signal.reason);
reject(signal.reason ?? new Error("aborted"));
};
signal.addEventListener("abort", abortListener, { once: true });
@@ -490,7 +507,7 @@ export async function highlightViaPlaywright(opts: {
try {
await refLocator(page, ref).highlight();
} catch (err) {
throw toAIFriendlyError(err, ref);
throw toFriendlyInteractionError(err, ref);
}
}
@@ -525,6 +542,9 @@ export async function clickViaPlaywright(opts: {
});
void abortPromise.catch(() => {});
const disconnect = () => {
if (isBrowserObservedDialogBlockedError(signal.reason)) {
return;
}
void forceDisconnectPlaywrightForTarget({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
@@ -545,6 +565,7 @@ export async function clickViaPlaywright(opts: {
throw signal.reason ?? new Error("aborted");
}
}
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, signal);
try {
await assertInteractionNavigationCompletedSafely({
action: async () => {
@@ -554,7 +575,11 @@ export async function clickViaPlaywright(opts: {
ACT_MAX_CLICK_DELAY_MS,
);
if (delayMs > 0) {
await awaitActionWithAbort(locator.hover({ timeout }), abortPromise);
await awaitActionWithAbort(
locator.hover({ timeout }),
abortPromise,
reconcileRemoteDialog,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
if (opts.doubleClick) {
@@ -565,6 +590,7 @@ export async function clickViaPlaywright(opts: {
modifiers: opts.modifiers,
}),
abortPromise,
reconcileRemoteDialog,
);
return;
}
@@ -575,6 +601,7 @@ export async function clickViaPlaywright(opts: {
modifiers: opts.modifiers,
}),
abortPromise,
reconcileRemoteDialog,
);
},
cdpUrl: opts.cdpUrl,
@@ -584,7 +611,7 @@ export async function clickViaPlaywright(opts: {
targetId: opts.targetId,
});
} catch (err) {
throw toAIFriendlyError(err, label);
throw toFriendlyInteractionError(err, label);
} finally {
if (signal && abortListener) {
signal.removeEventListener("abort", abortListener);
@@ -602,23 +629,30 @@ export async function clickCoordsViaPlaywright(opts: {
delayMs?: number;
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
signal?: AbortSignal;
}): Promise<void> {
const page = await getRestoredPageForTarget(opts);
const previousUrl = page.url();
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, opts.signal);
await assertInteractionNavigationCompletedSafely({
action: async () => {
await page.mouse.click(opts.x, opts.y, {
button: opts.button,
clickCount: opts.doubleClick ? 2 : 1,
delay: resolveBoundedDelayMs(opts.delayMs, "clickCoords delayMs", ACT_MAX_CLICK_DELAY_MS),
});
await awaitActionWithAbort(
page.mouse.click(opts.x, opts.y, {
button: opts.button,
clickCount: opts.doubleClick ? 2 : 1,
delay: resolveBoundedDelayMs(opts.delayMs, "clickCoords delayMs", ACT_MAX_CLICK_DELAY_MS),
}),
abortPromise,
reconcileRemoteDialog,
);
},
cdpUrl: opts.cdpUrl,
page,
previousUrl,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}).finally(cleanup);
}
export async function hoverViaPlaywright(opts: {
@@ -627,6 +661,7 @@ export async function hoverViaPlaywright(opts: {
ref?: string;
selector?: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
@@ -634,12 +669,20 @@ export async function hoverViaPlaywright(opts: {
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, opts.signal);
try {
await locator.hover({
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
});
await awaitActionWithAbort(
locator.hover({
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
}),
abortPromise,
reconcileRemoteDialog,
);
} catch (err) {
throw toAIFriendlyError(err, label);
throw toFriendlyInteractionError(err, label);
} finally {
cleanup();
}
}
@@ -651,6 +694,7 @@ export async function dragViaPlaywright(opts: {
endRef?: string;
endSelector?: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<void> {
const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
@@ -663,12 +707,20 @@ export async function dragViaPlaywright(opts: {
: page.locator(resolvedEnd.selector!);
const startLabel = resolvedStart.ref ?? resolvedStart.selector!;
const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!;
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, opts.signal);
try {
await startLocator.dragTo(endLocator, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
});
await awaitActionWithAbort(
startLocator.dragTo(endLocator, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
}),
abortPromise,
reconcileRemoteDialog,
);
} catch (err) {
throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`);
throw toFriendlyInteractionError(err, `${startLabel} -> ${endLabel}`);
} finally {
cleanup();
}
}
@@ -680,6 +732,7 @@ export async function selectOptionViaPlaywright(opts: {
values: string[];
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
signal?: AbortSignal;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
if (!opts.values?.length) {
@@ -691,12 +744,18 @@ export async function selectOptionViaPlaywright(opts: {
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const previousUrl = page.url();
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, opts.signal);
try {
await assertInteractionNavigationCompletedSafely({
action: async () => {
await locator.selectOption(opts.values, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
});
await awaitActionWithAbort(
locator.selectOption(opts.values, {
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
}),
abortPromise,
reconcileRemoteDialog,
);
},
cdpUrl: opts.cdpUrl,
page,
@@ -705,7 +764,9 @@ export async function selectOptionViaPlaywright(opts: {
targetId: opts.targetId,
});
} catch (err) {
throw toAIFriendlyError(err, label);
throw toFriendlyInteractionError(err, label);
} finally {
cleanup();
}
}
@@ -715,6 +776,7 @@ export async function pressKeyViaPlaywright(opts: {
key: string;
delayMs?: number;
ssrfPolicy?: SsrFPolicy;
signal?: AbortSignal;
}): Promise<void> {
const key = normalizeOptionalString(opts.key) ?? "";
if (!key) {
@@ -723,18 +785,28 @@ export async function pressKeyViaPlaywright(opts: {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const previousUrl = page.url();
await assertInteractionNavigationCompletedSafely({
action: async () => {
await page.keyboard.press(key, {
delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
});
},
cdpUrl: opts.cdpUrl,
page,
previousUrl,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, opts.signal);
try {
await assertInteractionNavigationCompletedSafely({
action: async () => {
await awaitActionWithAbort(
page.keyboard.press(key, {
delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
}),
abortPromise,
reconcileRemoteDialog,
);
},
cdpUrl: opts.cdpUrl,
page,
previousUrl,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
} finally {
cleanup();
}
}
export async function typeViaPlaywright(opts: {
@@ -747,6 +819,7 @@ export async function typeViaPlaywright(opts: {
slowly?: boolean;
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
signal?: AbortSignal;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const text = opts.text ?? "";
@@ -756,15 +829,29 @@ export async function typeViaPlaywright(opts: {
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, opts.signal);
try {
const previousUrl = page.url();
if (opts.slowly) {
await assertInteractionNavigationCompletedSafely({
action: async () => {
await locator.click({ timeout });
await locator.type(text, { timeout, delay: 75 });
await awaitActionWithAbort(
locator.click({ timeout }),
abortPromise,
reconcileRemoteDialog,
);
await awaitActionWithAbort(
locator.type(text, { timeout, delay: 75 }),
abortPromise,
reconcileRemoteDialog,
);
if (opts.submit) {
await locator.press("Enter", { timeout });
await awaitActionWithAbort(
locator.press("Enter", { timeout }),
abortPromise,
reconcileRemoteDialog,
);
}
},
cdpUrl: opts.cdpUrl,
@@ -776,9 +863,17 @@ export async function typeViaPlaywright(opts: {
} else {
await assertInteractionNavigationCompletedSafely({
action: async () => {
await locator.fill(text, { timeout });
await awaitActionWithAbort(
locator.fill(text, { timeout }),
abortPromise,
reconcileRemoteDialog,
);
if (opts.submit) {
await locator.press("Enter", { timeout });
await awaitActionWithAbort(
locator.press("Enter", { timeout }),
abortPromise,
reconcileRemoteDialog,
);
}
},
cdpUrl: opts.cdpUrl,
@@ -789,7 +884,9 @@ export async function typeViaPlaywright(opts: {
});
}
} catch (err) {
throw toAIFriendlyError(err, label);
throw toFriendlyInteractionError(err, label);
} finally {
cleanup();
}
}
@@ -799,31 +896,60 @@ export async function fillFormViaPlaywright(opts: {
fields: BrowserFormField[];
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
signal?: AbortSignal;
}): Promise<void> {
const page = await getRestoredPageForTarget(opts);
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
for (const field of opts.fields) {
const ref = field.ref.trim();
const type = (field.type || DEFAULT_FILL_FIELD_TYPE).trim() || DEFAULT_FILL_FIELD_TYPE;
const rawValue = field.value;
const value =
typeof rawValue === "string"
? rawValue
: typeof rawValue === "number" || typeof rawValue === "boolean"
? String(rawValue)
: "";
if (!ref) {
continue;
}
const locator = refLocator(page, ref);
if (type === "checkbox" || type === "radio") {
const checked =
rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true";
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, opts.signal);
try {
for (const field of opts.fields) {
const ref = field.ref.trim();
const type = (field.type || DEFAULT_FILL_FIELD_TYPE).trim() || DEFAULT_FILL_FIELD_TYPE;
const rawValue = field.value;
const value =
typeof rawValue === "string"
? rawValue
: typeof rawValue === "number" || typeof rawValue === "boolean"
? String(rawValue)
: "";
if (!ref) {
continue;
}
const locator = refLocator(page, ref);
if (type === "checkbox" || type === "radio") {
const checked =
rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true";
try {
const previousUrl = page.url();
await assertInteractionNavigationCompletedSafely({
action: async () => {
await awaitActionWithAbort(
locator.setChecked(checked, { timeout }),
abortPromise,
reconcileRemoteDialog,
);
},
cdpUrl: opts.cdpUrl,
page,
previousUrl,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
} catch (err) {
throw toFriendlyInteractionError(err, ref);
}
continue;
}
try {
const previousUrl = page.url();
await assertInteractionNavigationCompletedSafely({
action: async () => {
await locator.setChecked(checked, { timeout });
await awaitActionWithAbort(
locator.fill(value, { timeout }),
abortPromise,
reconcileRemoteDialog,
);
},
cdpUrl: opts.cdpUrl,
page,
@@ -832,25 +958,11 @@ export async function fillFormViaPlaywright(opts: {
targetId: opts.targetId,
});
} catch (err) {
throw toAIFriendlyError(err, ref);
throw toFriendlyInteractionError(err, ref);
}
continue;
}
try {
const previousUrl = page.url();
await assertInteractionNavigationCompletedSafely({
action: async () => {
await locator.fill(value, { timeout });
},
cdpUrl: opts.cdpUrl,
page,
previousUrl,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
} catch (err) {
throw toAIFriendlyError(err, ref);
}
} finally {
cleanup();
}
}
@@ -882,7 +994,10 @@ export async function evaluateViaPlaywright(opts: {
evaluateTimeout = Math.min(evaluateTimeout, outerTimeout);
const signal = opts.signal;
const { abortPromise, cleanup } = createAbortPromiseWithListener(signal, () => {
const { abortPromise, cleanup } = createAbortPromiseWithListener(signal, (reason) => {
if (isBrowserObservedDialogBlockedError(reason)) {
return;
}
void forceDisconnectPlaywrightForTarget({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
@@ -935,8 +1050,9 @@ export async function evaluateViaPlaywright(opts: {
fnBody: fnText,
timeoutMs: evaluateTimeout,
});
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, signal);
const result = await assertInteractionNavigationCompletedSafely({
action: () => awaitActionWithAbort(evalPromise, abortPromise),
action: () => awaitActionWithAbort(evalPromise, abortPromise, reconcileRemoteDialog),
cdpUrl: opts.cdpUrl,
page,
previousUrl,
@@ -973,8 +1089,9 @@ export async function evaluateViaPlaywright(opts: {
fnBody: fnText,
timeoutMs: evaluateTimeout,
});
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, signal);
const result = await assertInteractionNavigationCompletedSafely({
action: () => awaitActionWithAbort(evalPromise, abortPromise),
action: () => awaitActionWithAbort(evalPromise, abortPromise, reconcileRemoteDialog),
cdpUrl: opts.cdpUrl,
page,
previousUrl,
@@ -993,6 +1110,7 @@ export async function scrollIntoViewViaPlaywright(opts: {
ref?: string;
selector?: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
@@ -1002,10 +1120,18 @@ export async function scrollIntoViewViaPlaywright(opts: {
const locator = resolved.ref
? refLocator(page, requireRef(resolved.ref))
: page.locator(resolved.selector!);
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, opts.signal);
try {
await locator.scrollIntoViewIfNeeded({ timeout });
await awaitActionWithAbort(
locator.scrollIntoViewIfNeeded({ timeout }),
abortPromise,
reconcileRemoteDialog,
);
} catch (err) {
throw toAIFriendlyError(err, label);
throw toFriendlyInteractionError(err, label);
} finally {
cleanup();
}
}
@@ -1026,8 +1152,9 @@ export async function waitForViaPlaywright(opts: {
ensurePageState(page);
const timeout = resolveActWaitTimeoutMs(opts.timeoutMs);
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
const reconcileRemoteDialog = () => reconcileRemoteDialogAfterActionSettled(page, opts.signal);
const waitForStep = async <T>(stepPromise: Promise<T>) => {
await awaitActionWithAbort(stepPromise, abortPromise);
await awaitActionWithAbort(stepPromise, abortPromise, reconcileRemoteDialog);
};
try {
@@ -1281,7 +1408,7 @@ export async function setInputFilesViaPlaywright(opts: {
try {
await locator.setInputFiles(resolvedPaths);
} catch (err) {
throw toAIFriendlyError(err, inputRef || element);
throw toFriendlyInteractionError(err, inputRef || element);
}
try {
const handle = await locator.elementHandle();
@@ -1324,6 +1451,7 @@ async function executeSingleAction(
delayMs: action.delayMs,
timeoutMs: action.timeoutMs,
ssrfPolicy,
signal,
});
break;
case "clickCoords":
@@ -1337,6 +1465,7 @@ async function executeSingleAction(
delayMs: action.delayMs,
timeoutMs: action.timeoutMs,
ssrfPolicy,
signal,
});
break;
case "type":
@@ -1350,6 +1479,7 @@ async function executeSingleAction(
slowly: action.slowly,
timeoutMs: action.timeoutMs,
ssrfPolicy,
signal,
});
break;
case "press":
@@ -1359,6 +1489,7 @@ async function executeSingleAction(
key: action.key,
delayMs: action.delayMs,
ssrfPolicy,
signal,
});
break;
case "hover":
@@ -1368,6 +1499,7 @@ async function executeSingleAction(
ref: action.ref,
selector: action.selector,
timeoutMs: action.timeoutMs,
signal,
});
break;
case "scrollIntoView":
@@ -1377,6 +1509,7 @@ async function executeSingleAction(
ref: action.ref,
selector: action.selector,
timeoutMs: action.timeoutMs,
signal,
});
break;
case "drag":
@@ -1388,6 +1521,7 @@ async function executeSingleAction(
endRef: action.endRef,
endSelector: action.endSelector,
timeoutMs: action.timeoutMs,
signal,
});
break;
case "select":
@@ -1399,6 +1533,7 @@ async function executeSingleAction(
values: action.values,
timeoutMs: action.timeoutMs,
ssrfPolicy,
signal,
});
break;
case "fill":
@@ -1408,6 +1543,7 @@ async function executeSingleAction(
fields: action.fields,
timeoutMs: action.timeoutMs,
ssrfPolicy,
signal,
});
break;
case "resize":
@@ -1483,32 +1619,52 @@ export async function executeActViaPlaywright(opts: {
}): Promise<{
result?: unknown;
results?: Array<{ ok: boolean; error?: string }>;
blockedByDialog?: boolean;
browserState?: unknown;
}> {
if (opts.action.kind === "batch") {
const batch = await batchViaPlaywright({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
ssrfPolicy: opts.ssrfPolicy,
actions: opts.action.actions,
stopOnError: opts.action.stopOnError,
evaluateEnabled: opts.evaluateEnabled,
signal: opts.signal,
});
return { results: batch.results };
const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
ssrfPolicy: opts.ssrfPolicy,
});
const dialogAbort = createObservedDialogAbortSignalForPage({
page,
parentSignal: opts.signal,
});
try {
if (opts.action.kind === "batch") {
const batch = await batchViaPlaywright({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
ssrfPolicy: opts.ssrfPolicy,
actions: opts.action.actions,
stopOnError: opts.action.stopOnError,
evaluateEnabled: opts.evaluateEnabled,
signal: dialogAbort.signal,
});
return { results: batch.results };
}
const result = await executeSingleAction(
opts.action,
opts.cdpUrl,
opts.targetId,
opts.evaluateEnabled,
opts.ssrfPolicy,
0,
dialogAbort.signal,
);
if (opts.action.kind === "evaluate") {
return { result };
}
return {};
} catch (err) {
if (isBrowserObservedDialogBlockedError(err)) {
return { blockedByDialog: true, browserState: err.browserState };
}
throw err;
} finally {
dialogAbort.cleanup();
}
const result = await executeSingleAction(
opts.action,
opts.cdpUrl,
opts.targetId,
opts.evaluateEnabled,
opts.ssrfPolicy,
0,
opts.signal,
);
if (opts.action.kind === "evaluate") {
return { result };
}
return {};
}
export async function batchViaPlaywright(opts: {
@@ -1545,6 +1701,9 @@ export async function batchViaPlaywright(opts: {
);
results.push({ ok: true });
} catch (err) {
if (isBrowserObservedDialogBlockedError(err)) {
throw err;
}
const message = formatErrorMessage(err);
results.push({ ok: false, error: message });
if (opts.stopOnError !== false) {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
getPwToolsCoreSessionMocks,
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
} from "./pw-tools-core.test-harness.js";
@@ -74,38 +75,42 @@ describe("pw-tools-core", () => {
}
});
it("arms the next dialog and accepts/dismisses (default timeout)", async () => {
const accept = vi.fn(async () => {});
const dismiss = vi.fn(async () => {});
const dialog = { accept, dismiss };
const waitForEvent = vi.fn(async () => dialog);
setPwToolsCoreCurrentPage({
waitForEvent,
});
const sessionMocks = getPwToolsCoreSessionMocks();
const page = {};
setPwToolsCoreCurrentPage(page);
await mod.armDialogViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
accept: true,
promptText: "x",
});
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
expect(accept).toHaveBeenCalledWith("x");
expect(dismiss).not.toHaveBeenCalled();
expect(sessionMocks.respondToObservedDialogOnPage).toHaveBeenCalledWith({
page,
accept: true,
promptText: "x",
closedBy: "agent",
});
expect(sessionMocks.armObservedDialogResponseOnPage).toHaveBeenCalledWith({
page,
accept: true,
promptText: "x",
timeoutMs: 120_000,
});
accept.mockClear();
dismiss.mockClear();
waitForEvent.mockClear();
sessionMocks.respondToObservedDialogOnPage.mockClear();
sessionMocks.armObservedDialogResponseOnPage.mockClear();
await mod.armDialogViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
accept: false,
});
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
expect(dismiss).toHaveBeenCalled();
expect(accept).not.toHaveBeenCalled();
expect(sessionMocks.armObservedDialogResponseOnPage).toHaveBeenCalledWith({
page,
accept: false,
timeoutMs: 120_000,
});
});
it("waits for selector, url, load state, and function", async () => {
const waitForSelector = vi.fn(async () => {});

View File

@@ -3,7 +3,6 @@ import { formatErrorMessage } from "../infra/errors.js";
import { parseRoleRef } from "./pw-role-snapshot.js";
let nextUploadArmId = 0;
let nextDialogArmId = 0;
let nextDownloadArmId = 0;
export function bumpUploadArmId(): number {
@@ -11,11 +10,6 @@ export function bumpUploadArmId(): number {
return nextUploadArmId;
}
export function bumpDialogArmId(): number {
nextDialogArmId += 1;
return nextDialogArmId;
}
export function bumpDownloadArmId(): number {
nextDownloadArmId += 1;
return nextDownloadArmId;

View File

@@ -5,13 +5,11 @@ let currentRefLocator: Record<string, unknown> | null = null;
let pageState: {
console: unknown[];
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
downloadWaiterDepth: number;
} = {
console: [],
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
downloadWaiterDepth: 0,
};
@@ -42,6 +40,15 @@ const sessionMocks = vi.hoisted(() => ({
return err.name === "SsrFBlockedError" || err.name === "InvalidBrowserNavigationUrlError";
}),
restoreRoleRefsForTarget: vi.fn(() => {}),
respondToObservedDialogOnPage: vi.fn(async () => {
throw new Error("No dialog is pending.");
}),
armObservedDialogResponseOnPage: vi.fn(() => {}),
createObservedDialogAbortSignalForPage: vi.fn((opts?: { parentSignal?: AbortSignal }) => ({
signal: opts?.parentSignal ?? new AbortController().signal,
cleanup: vi.fn(() => {}),
})),
isBrowserObservedDialogBlockedError: vi.fn(() => false),
storeRoleRefsForTarget: vi.fn(() => {}),
refLocator: vi.fn(() => {
if (!currentRefLocator) {
@@ -89,7 +96,6 @@ export function installPwToolsCoreTestHooks() {
pageState = {
console: [],
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
downloadWaiterDepth: 0,
};

View File

@@ -119,6 +119,7 @@ export function registerBrowserAgentActHookRoutes(
const accept = toBoolean(body.accept);
const promptText = toStringOrEmpty(body.promptText) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
const dialogId = toStringOrEmpty(body.dialogId) || undefined;
if (accept === undefined) {
return jsonError(res, 400, "accept is required");
}
@@ -130,6 +131,9 @@ export function registerBrowserAgentActHookRoutes(
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (dialogId) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogId);
}
if (timeoutMs) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogTimeout);
}
@@ -186,6 +190,7 @@ export function registerBrowserAgentActHookRoutes(
await pw.armDialogViaPlaywright({
cdpUrl,
targetId: tab.targetId,
dialogId,
accept,
promptText,
timeoutMs: timeoutMs ?? undefined,

View File

@@ -654,6 +654,12 @@ export function registerBrowserAgentActRoutes(
ssrfPolicy,
signal: req.signal,
});
if (result.blockedByDialog) {
return await jsonOk({
blockedByDialog: true,
browserState: result.browserState,
});
}
switch (action.kind) {
case "batch":
return await jsonOk(

View File

@@ -97,6 +97,31 @@ export function registerBrowserAgentDebugRoutes(
}),
);
app.get(
"/dialogs",
asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "dialog state",
enforceCurrentUrlAllowed: true,
run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => {
const browserState = await pw.getObservedBrowserStateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
const url = await resolveTabUrl(tab.url);
res.json({ ok: true, targetId: tab.targetId, ...(url ? { url } : {}), browserState });
},
});
}),
);
app.post(
"/trace/start",
asyncBrowserRoute(async (req, res) => {

View File

@@ -74,6 +74,7 @@ vi.mock("../../media/store.js", () => ({
vi.mock("./agent.shared.js", () => createExistingSessionAgentSharedModule());
const { registerBrowserAgentActRoutes } = await import("./agent.act.js");
const { registerBrowserAgentActHookRoutes } = await import("./agent.act.hooks.js");
const { registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js");
function getSnapshotGetHandler(ssrfPolicy?: unknown) {
@@ -106,6 +107,16 @@ function getActPostHandler() {
return handler;
}
function getDialogHookPostHandler() {
const { app, postHandlers } = createBrowserRouteApp();
registerBrowserAgentActHookRoutes(app, {
state: () => ({ resolved: {} }),
} as never);
const handler = postHandlers.get("/hooks/dialog");
expect(handler).toBeTypeOf("function");
return handler;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`expected ${label}`);
@@ -286,6 +297,24 @@ describe("existing-session browser routes", () => {
expect(chromeMcpMocks.fillChromeMcpElement).not.toHaveBeenCalled();
});
it("fails closed for existing-session dialogId responses", async () => {
const handler = getDialogHookPostHandler();
const response = createBrowserRouteResponse();
await handler?.(
{
params: {},
query: {},
body: { accept: true, dialogId: "d1" },
},
response.res,
);
expect(response.statusCode).toBe(501);
const body = requireRecord(response.body, "response body");
expect(String(body.error)).toContain("dialogId");
expect(chromeMcpMocks.evaluateChromeMcpScript).not.toHaveBeenCalled();
});
it("supports glob URL waits for existing-session profiles", async () => {
chromeMcpMocks.evaluateChromeMcpScript.mockReset();
chromeMcpMocks.evaluateChromeMcpScript.mockImplementation(

View File

@@ -248,6 +248,26 @@ async function saveBrowserMediaResponse(params: {
});
}
function hasObservableBrowserState(state: unknown): boolean {
if (!state || typeof state !== "object") {
return false;
}
const dialogs = (state as { dialogs?: { pending?: unknown[]; recent?: unknown[] } }).dialogs;
return Boolean(dialogs?.pending?.length || dialogs?.recent?.length);
}
function hasPendingDialogs(state: unknown): boolean {
if (!state || typeof state !== "object") {
return false;
}
const dialogs = (state as { dialogs?: { pending?: unknown[] } }).dialogs;
return Boolean(dialogs?.pending?.length);
}
function browserStateResponseFields(state: unknown): { browserState?: unknown } {
return hasObservableBrowserState(state) ? { browserState: state } : {};
}
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
@@ -524,11 +544,26 @@ export function registerBrowserAgentSnapshotRoutes(
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const usesChromeMcp = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
let observedBrowserState: unknown;
if (!usesChromeMcp && pwModule) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
observedBrowserState = await pwModule
.getObservedBrowserStateViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
})
.catch(() => undefined);
}
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
if (usesChromeMcp) {
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
}
@@ -628,6 +663,17 @@ export function registerBrowserAgentSnapshotRoutes(
...builtWithUrls,
});
}
if (hasPendingDialogs(observedBrowserState)) {
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
blockedByDialog: true,
...browserStateResponseFields(observedBrowserState),
...(plan.format === "aria" ? { nodes: [] } : { snapshot: "", refs: {} }),
});
}
if (plan.format === "ai") {
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
@@ -715,6 +761,7 @@ export function registerBrowserAgentSnapshotRoutes(
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...browserStateResponseFields(observedBrowserState),
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
@@ -729,6 +776,7 @@ export function registerBrowserAgentSnapshotRoutes(
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...browserStateResponseFields(observedBrowserState),
...snap,
});
}
@@ -771,6 +819,7 @@ export function registerBrowserAgentSnapshotRoutes(
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...browserStateResponseFields(observedBrowserState),
...resolved,
});
} catch (err) {

View File

@@ -28,6 +28,7 @@ export const EXISTING_SESSION_LIMITS = {
"existing-session file uploads do not support element selectors; use ref/inputRef.",
uploadSingleFile: "existing-session file uploads currently support one file at a time.",
uploadRefRequired: "existing-session file uploads require ref or inputRef.",
dialogId: "existing-session dialog handling does not support dialogId.",
dialogTimeout: "existing-session dialog handling does not support timeoutMs.",
},
download: {

View File

@@ -1,6 +1,9 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
import { BROWSER_NAVIGATION_BLOCKED_MESSAGE } from "./errors.js";
import { ACT_ERROR_CODES } from "./routes/agent.act.errors.js";
import { isActKind } from "./routes/agent.act.shared.js";
import {
installAgentContractHooks,
postJson,
@@ -17,6 +20,8 @@ import {
setBrowserControlServerEvaluateEnabled,
setBrowserControlServerProfiles,
setBrowserControlServerReachable,
setBrowserControlServerSsrFPolicy,
setBrowserControlServerTabUrl,
startBrowserControlServerFromConfig,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-support/fetch.js";
@@ -83,15 +88,18 @@ describe("browser control server", () => {
const slowTimeoutMs = 60_000;
beforeAll(async () => {
await resetBrowserControlServerTestContext();
await startBrowserControlServerFromConfig();
await cleanupBrowserControlServerTestContext();
}, slowTimeoutMs);
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
() => {
expect(isActKind(undefined)).toBe(false);
expect(isActKind("")).toBe(false);
expect(ACT_ERROR_CODES.kindRequired).toBe("ACT_KIND_REQUIRED");
},
slowTimeoutMs,
);
@@ -223,6 +231,46 @@ describe("browser control server", () => {
slowTimeoutMs,
);
it(
"returns blocked dialog state for action-triggered modals",
async () => {
const base = await startServerAndBase();
pwMocks.executeActViaPlaywright.mockResolvedValueOnce({
blockedByDialog: true,
browserState: {
dialogs: {
pending: [
{
id: "d1",
type: "confirm",
message: "Continue?",
openedAt: "2026-05-17T12:00:00.000Z",
},
],
recent: [],
},
},
});
const response = await postJson<{
ok: boolean;
blockedByDialog?: boolean;
browserState?: { dialogs?: { pending?: Array<{ id?: string; message?: string }> } };
}>(`${base}/act`, {
kind: "click",
ref: "5",
});
expect(response.ok).toBe(true);
expect(response.blockedByDialog).toBe(true);
expect(response.browserState?.dialogs?.pending?.[0]).toMatchObject({
id: "d1",
message: "Continue?",
});
},
slowTimeoutMs,
);
it(
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
async () => {
@@ -338,6 +386,67 @@ describe("browser control server", () => {
});
});
it("agent contract: snapshot surfaces pending dialog state without reading the blocked page", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
pwMocks.getObservedBrowserStateViaPlaywright.mockResolvedValueOnce({
dialogs: {
pending: [
{
id: "d1",
type: "confirm",
message: "Continue?",
openedAt: "2026-05-17T12:00:00.000Z",
},
],
recent: [],
},
});
const snap = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json())) as {
ok: boolean;
blockedByDialog?: boolean;
browserState?: { dialogs?: { pending?: Array<{ id?: string; message?: string }> } };
snapshot?: string;
};
expect(snap.ok).toBe(true);
expect(snap.blockedByDialog).toBe(true);
expect(snap.snapshot).toBe("");
expect(snap.browserState?.dialogs?.pending?.[0]).toMatchObject({
id: "d1",
message: "Continue?",
});
expect(pwMocks.snapshotAiViaPlaywright).not.toHaveBeenCalled();
});
it("agent contract: snapshot blocks pending dialog state on disallowed current tab URLs", async () => {
setBrowserControlServerSsrFPolicy({ allowPrivateNetwork: false });
setBrowserControlServerTabUrl("http://127.0.0.1:8080/admin");
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
pwMocks.getObservedBrowserStateViaPlaywright.mockResolvedValueOnce({
dialogs: {
pending: [
{
id: "d1",
type: "alert",
message: "blocked secret",
openedAt: "2026-05-17T12:00:00.000Z",
},
],
recent: [],
},
});
const res = await realFetch(`${base}/snapshot?format=ai`);
expect(res.status).toBe(400);
const body = (await res.json()) as { error?: unknown };
expect(body.error).toBe(BROWSER_NAVIGATION_BLOCKED_MESSAGE);
expect(pwMocks.getObservedBrowserStateViaPlaywright).not.toHaveBeenCalled();
expect(pwMocks.snapshotAiViaPlaywright).not.toHaveBeenCalled();
});
it("agent contract: doctor deep runs a live snapshot probe", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();

View File

@@ -437,9 +437,16 @@ describe("browser control server", () => {
const dialog = await postJson(`${base}/hooks/dialog`, {
accept: true,
dialogId: "d1",
timeoutMs: 5678,
});
expectOkResult(dialog);
expectBrowserCallFields(pwMocks.armDialogViaPlaywright, {
targetId: "abcd1234",
accept: true,
dialogId: "d1",
timeoutMs: 5678,
});
const waitDownload = await postJson(`${base}/wait/download`, {
path: "report.pdf",

View File

@@ -176,6 +176,9 @@ const pwMocks = vi.hoisted(() => ({
fillFormViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
getNetworkRequestsViaPlaywright: vi.fn(async () => ({ requests: [] })),
getObservedBrowserStateViaPlaywright: vi.fn(async () => ({
dialogs: { pending: [], recent: [] },
})),
getPageErrorsViaPlaywright: vi.fn(async () => ({ errors: [] })),
hoverViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
scrollIntoViewViaPlaywright: vi.fn(async (_opts?: unknown) => {}),

View File

@@ -175,6 +175,7 @@ export function registerBrowserFilesAndDownloadsCommands(
.option("--accept", "Accept the dialog", false)
.option("--dismiss", "Dismiss the dialog", false)
.option("--prompt <text>", "Prompt response text")
.option("--dialog-id <id>", "Pending dialog id from snapshot/browser state")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option(
"--timeout-ms <ms>",
@@ -202,6 +203,7 @@ export function registerBrowserFilesAndDownloadsCommands(
body: {
accept,
promptText: normalizeOptionalString(opts.prompt),
dialogId: normalizeOptionalString(opts.dialogId),
targetId,
timeoutMs,
},

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