Compare commits

..

131 Commits

Author SHA1 Message Date
Vincent Koc
b78260515d fix(state): harden sqlite path caching 2026-06-01 05:55:31 +01:00
Vincent Koc
abe2145153 refactor: share cron delivery test fixture 2026-06-01 06:52:52 +02:00
Vincent Koc
0ae0051ae7 feat(ui): improve Workboard task details
Make Workboard cards compact by moving expanded task/run metadata, proof, diagnostics, worker logs, automation, protocol state, events, and operator notes into a detail drawer.

Keep execution state simple and safe: active, linked, and archived cards avoid duplicate start paths; stale task cache is ignored when session lifecycle is authoritative; recent proof/events stay visible; dispatcher capacity distinguishes unclaimed review cards from claimed cards.
2026-06-01 05:52:40 +01:00
Vincent Koc
5957bfdc54 fix(e2e): fail bundled smoke on missing channels 2026-06-01 06:45:58 +02:00
Vincent Koc
e843a3612b refactor: inline secrets error response guard 2026-06-01 06:40:17 +02:00
Vincent Koc
8cab0f23f8 fix(e2e): clean bundled runtime smoke state 2026-06-01 06:35:28 +02:00
Vincent Koc
296cd8c912 fix(plugin-sdk): isolate provider catalog projection failures (#88767)
* fix(plugin-sdk): isolate provider catalog projection failures

* fix(plugin-sdk): share safe provider catalog projection

* fix(cron): preserve raw null clear schema

* fix(plugin-sdk): copy provider catalog model rows safely

* fix(plugin-sdk): keep id-only catalog models

* fix(plugin-sdk): require readable provider catalog base url

* fix(ci): satisfy cron and matrix lint checks

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-01 00:35:19 -04:00
Vincent Koc
3386bf989f refactor: share secrets resolve test helper 2026-06-01 06:31:03 +02:00
Peter Steinberger
5b79e81569 fix: harden CLI and plugin edge cases (#88896)
* fix: harden CLI and plugin edge cases

* fix: preserve explicit TTS provider credentials

* fix: preserve direct TTS credentials

* fix: type TTS credential hydration config

* fix: preserve scoped TTS channel credentials

* fix: pin hydrated TTS runtime config

* fix: satisfy TTS hydration lint

* fix: preserve inherited TTS provider keys

* fix: read resolved TTS provider keys
2026-06-01 00:30:12 -04:00
Vincent Koc
ec6ad888a4 fix(e2e): bound telegram proof commands 2026-06-01 06:26:44 +02:00
Kip
c213827aa5 fix(cron): include job name when reading single-job run history (#88294)
* fix(cron): include job name in single-job run history

The cron.runs gateway handler enriches log entries with jobName in the all-jobs scope, but the single-job scope did not pass any job-name lookup into the SQLite run-log reader. Entries returned for one job could therefore reach Control UI without jobName, making the run-history title fall back to the raw job id.

Build a one-entry jobNameById map for the current job and pass it through the same reader enrichment path used by all-jobs history. If the job no longer exists, the map stays undefined and existing fallback behavior is unchanged.

* test(cron): cover single-job run history job name enrichment

Asserts that readCronRunLogEntriesPage stamps a supplied jobNameById map onto single-job page entries, matching the gateway data shape used for both all-jobs and single-job cron.runs responses.

Addresses review feedback on #88294.

* test(cron): preserve nullable tool schema validation

* test(cron): assert runtime nullable tool schema

* test(cron): refresh prompt snapshots

---------

Co-authored-by: Kip Claw <kip@kipclaw.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-01 00:25:39 -04:00
Vincent Koc
dc9e67d2d4 refactor: share tools catalog test helpers 2026-06-01 06:13:33 +02:00
Peter Steinberger
b2a1c5caa8 test(matrix): keep async monitor callbacks lint-clean 2026-06-01 05:11:28 +01:00
Vincent Koc
51bad9b319 refactor: share config open file test helpers 2026-06-01 06:08:13 +02:00
Vincent Koc
fb17986af5 fix(ci): preserve hydrated Windows test deps 2026-06-01 06:08:10 +02:00
Vincent Koc
17245a0890 fix(test): bound qa otel smoke runs 2026-06-01 06:04:30 +02:00
Peter Steinberger
3b802a7fbc docs(plugin-sdk): refresh API baseline hash 2026-06-01 04:59:39 +01:00
Vincent Koc
e9c7a64c5e refactor: share update test helpers 2026-06-01 05:58:33 +02:00
Peter Steinberger
817c4ce4fc test(release): stabilize installer and matrix async checks 2026-06-01 04:55:21 +01:00
Vincent Koc
d4240cde5b refactor: share native hook relay test helpers 2026-06-01 05:48:14 +02:00
Ted Li
6cb06f5fbc fix(reply): preserve sessions_send external routes (#88803)
* fix(reply): preserve sessions_send external routes

* fix(reply): preserve inherited route thread ids

* fix(reply): keep sessions_send delivery single-owner

* fix(reply): satisfy dispatch route lint

* fix(reply): preserve inherited ACP route metadata

* test(reply): type inherited route event assertions

* test(ci): satisfy current lint rules

* fix(reply): avoid stale inherited route threads

* fix(reply): trust explicit inherited route threads

* fix(reply): require trusted route thread sources

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 23:43:05 -04:00
Firas Alswihry
70c59f59b2 feat(dreaming): score candidates with shadow trial results
Add report-only memory-core dreaming shadow trial scoring and ranking helpers. Keep rank lookup keyed by durable candidate keys and document the advisory behavior. Thanks @iFiras-Max1.
2026-05-31 23:40:20 -04:00
Vincent Koc
a30c12e711 refactor: share restart test helpers 2026-06-01 05:38:35 +02:00
Vincent Koc
56a7000b3e fix(mattermost): route send attachments through upload
Routes Mattermost send attachments through the upload path so local file paths and structured attachment media are uploaded instead of being posted as plain text. Preserves scoped media access for local uploads, rejects unsupported or ambiguous attachment payloads, and keeps HTTP media fallback behavior.

Fixes #87930.

Proof:
- autoreview clean
- node scripts/run-vitest.mjs extensions/mattermost/src/channel.test.ts extensions/mattermost/src/channel.message-adapter.test.ts extensions/mattermost/src/mattermost/send.test.ts src/infra/outbound/message-action-params.test.ts src/infra/outbound/outbound-send-service.test.ts src/infra/outbound/message-action-runner.media.test.ts src/media/load-options.test.ts
- pnpm prompt:snapshots:check
- GitHub Actions completed with no pending/failing checks for head 2a65cbb1ee
2026-05-31 23:38:17 -04:00
Vincent Koc
5054b20832 fix(test): harden secret provider proof cleanup 2026-06-01 05:37:45 +02:00
Vincent Koc
a5ee3569d3 test(ci): refresh cron prompt snapshots 2026-06-01 04:33:39 +01:00
Peter Steinberger
33349269fd fix: wake legacy cron jobs without enabled 2026-05-31 23:31:44 -04:00
Vincent Koc
2dcee8ac2b refactor: share webchat media audio fixture 2026-06-01 05:28:03 +02:00
Peter Steinberger
e2c9c06de1 fix: advance exact-boundary every schedules 2026-05-31 23:27:24 -04:00
Peter Steinberger
ebcdb637bb perf(memory-core): defer embedding engine startup imports 2026-06-01 04:22:22 +01:00
Peter Steinberger
592b6e2916 docs(config): refresh config baseline hash 2026-06-01 04:20:57 +01:00
Peter Steinberger
45b5f876dd fix: reject blank cron payloads 2026-05-31 23:20:04 -04:00
Vincent Koc
76fa1b99c3 fix(test): bound test group report runs 2026-06-01 05:17:27 +02:00
Vincent Koc
aab1e727c6 refactor: share chat abort authorization helpers 2026-06-01 05:16:37 +02:00
Vincent Koc
a46d331723 fix(ci): reword durable final alias comment 2026-06-01 04:12:46 +01:00
Vincent Koc
916ee82814 test(installer): isolate install shell snippets 2026-06-01 04:11:22 +01:00
Vincent Koc
fcc279e233 fix(test): avoid Vite runtime import in UI config helpers 2026-06-01 04:08:53 +01:00
Vincent Koc
9dd7f04b71 fix(ci): repair phone control and cron schema gates 2026-06-01 04:06:25 +01:00
Vincent Koc
6e985931de refactor: share models list test helper 2026-06-01 05:05:20 +02:00
Vincent Koc
dc1cfcc28d refactor: share tasks handler test helpers 2026-06-01 05:05:20 +02:00
Peter Steinberger
ee6373aa5f fix: preserve cron failure destination clears 2026-05-31 23:04:31 -04:00
Peter Steinberger
6deded6698 fix: raise bootstrap file default limit 2026-06-01 04:02:51 +01:00
Peter Steinberger
f879e3d6a0 docs(plugin-sdk): refresh API baseline hash 2026-06-01 04:01:25 +01:00
Vincent Koc
f42cf9059e fix(ci): repair phone control and cron schema gates 2026-06-01 04:00:18 +01:00
Andy Ye
c317fd2bd7 docs(imessage): document SSH wrapper TCC send failure (#88758) 2026-05-31 23:00:08 -04:00
Vincent Koc
be967545c5 fix(plugins): fail closed on trusted policy errors
Fail closed when bundled trusted tool policy registry, registration, owner id, evaluation, or decision reads fail, so malformed trusted-policy state cannot crash diagnostics or accidentally allow a tool call.

Route before-tool-call diagnostics through guarded trusted-policy readers and keep healthy no-op policy behavior unchanged.

Add focused host-hook contract and before-tool-call e2e coverage for the new fail-closed paths.

PR: #88394
2026-05-31 22:57:38 -04:00
Nayrosk
388ba3218b fix(ui): bypass service worker for top-level navigations
HTTP auth challenges (basic, digest, negotiate) only fire the browser's
native credentials dialog when the response comes straight from the
network. Service worker responses bypass the WWW-Authenticate flow, so
reverse-proxy deployments with HTTP auth in front of the gateway show
a bare 401 after the browser's HTTP-auth memory cache expires (e.g. on
full browser restart) — forcing users to clear site data to recover.

Skip event.request.mode === "navigate" so the browser handles those
requests natively. Offline navigation of the app shell is lost, but
the SPA cannot function without network (all API calls go to the
network), so the trade-off is acceptable.

Refs: #85939, #71669, #53274
2026-05-31 22:57:27 -04:00
Peter Steinberger
7722ade22e test(install): clear node lookup cache in floor check 2026-06-01 03:56:37 +01:00
Vincent Koc
b2b9fbe033 fix(test): bound mock OpenAI request bodies 2026-06-01 04:48:32 +02:00
Peter Steinberger
551c9637d8 fix(ios): polish iPad gateway setup 2026-06-01 03:47:09 +01:00
Vincent Koc
c5eddadd9d refactor: share channel start test helpers 2026-06-01 04:40:21 +02:00
Vincent Koc
98b8e85beb refactor: share agent wait dedupe test helpers 2026-06-01 04:35:37 +02:00
Vincent Koc
a9938907dc fix(test): harden MCP E2E proof checks 2026-06-01 04:34:25 +02:00
Peter Steinberger
4c824aa809 perf(phone-control): use startup config for expiry guard 2026-06-01 03:32:38 +01:00
Peter Steinberger
1e7510ae10 docs: continue inline comment pass (#88849)
Adds broad inline comments and JSDoc for CLI, cron, outbound/channel, plugin SDK, ACP, shared helpers, net policy, and related utility contracts. Proof: git diff --check on latest exact head plus focused cron tests passed; CI had no failing checks observed before merge attempt.
2026-05-31 22:32:28 -04:00
Peter Steinberger
4932391e8a fix(ui): scope global agent model controls 2026-05-31 22:25:43 -04:00
Vincent Koc
822864c539 refactor: share channel status test helpers 2026-06-01 04:24:57 +02:00
Vincent Koc
a7ae3f6707 refactor: share usage session test state setup 2026-06-01 04:24:57 +02:00
Dallin Romney
78165cc387 docs: clarify diffs language pack additions (#88865) 2026-05-31 19:24:45 -07:00
Peter Steinberger
44765cfabe fix(acpx): seed Codex ACP auth from API key 2026-05-31 22:24:29 -04:00
Vincent Koc
0c3644cb24 perf(ui): stream stable markdown blocks 2026-06-01 03:23:47 +01:00
Peter Steinberger
53a7545ae3 perf(phone-control): avoid disarmed startup state lookup 2026-06-01 03:19:08 +01:00
Andy Ye
921598442a fix(hooks): expose inbound reply metadata before dispatch
Fixes #88521.

Expose finalized inbound reply metadata on plugin-visible hook payloads so before_dispatch and message hooks can implement reply-aware behavior without channel-specific workarounds.
2026-05-31 22:15:17 -04:00
Peter Steinberger
e72def6983 Persist Discord thread bindings in SQLite (#88866)
* refactor: persist discord thread bindings in sqlite

* test: read discord thread bindings from sqlite smoke
2026-05-31 22:10:30 -04:00
ksj3421
45bdaa2f7b fix(agents): return schema lookup misses in-band
Return unknown config.schema.lookup paths as an in-band agent gateway tool result instead of throwing into channel warning surfaces.

The direct gateway RPC still reports INVALID_REQUEST, preserving the existing protocol contract, while the agent-facing gateway tool returns schema_path_not_found for exploratory misses.

Fixes #88813.
Thanks @ksj3421.
Reported by @cjalden.
2026-05-31 22:10:02 -04:00
Vincent Koc
91ca036717 test(agents): use neutral tool schema fixtures (#88848) 2026-05-31 22:09:48 -04:00
Ted Li
c002887223 fix(memory): rehydrate daily list promotions
* fix(memory): rehydrate daily list promotions

* fix(memory): preserve multi-line daily list promotions

* fix(memory): preserve daily list promotion context

* fix(memory): rehydrate capped daily list promotions

* test(memory): cover capped daily list promotion

* test(agents): update model selection mocks

* ci: ignore lazy three dependency

* fix(memory): skip heading-only rehydration

* fix(memory): preserve list rehydration mode

* fix(memory): match capped renamed heading bodies

* fix(memory): avoid duplicate tail heading matches

* fix(microsoft-foundry): satisfy provider lint

* perf(memory): precompute promotion heading context

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 22:08:35 -04:00
Peter Steinberger
912ea4897f fix: scope global in-flight history to default agent 2026-06-01 03:08:29 +01:00
Peter Steinberger
6ad73e173b fix: keep hidden runs out of visible chat state 2026-06-01 03:08:29 +01:00
Vincent Koc
6c73ffc51a fix(test): bound MCP code mode client responses 2026-06-01 04:05:23 +02:00
Dallin Romney
632447d66d test(ui): remove stylesheet grep tests (#88847) 2026-05-31 19:05:02 -07:00
Dallin Romney
4b56c44c02 test: consolidate plugin registration contracts (#88824) 2026-05-31 19:04:53 -07:00
Peter Steinberger
d86b6da012 fix: allow cron delivery clears 2026-05-31 22:04:25 -04:00
Vincent Koc
d2c5ad2b36 refactor: share commands test helpers 2026-06-01 04:01:02 +02:00
Dallin Romney
b097cec219 fix(microsoft-foundry): satisfy extension lint (#88855) 2026-05-31 18:58:56 -07:00
Vincent Koc
207359a056 fix(ci): repair current main checks
Summary:
- Guard child-session candidate lookup when the session store is absent.
- Refresh Talk UI and compaction rotation tests for current main.
- Clean up Microsoft Foundry provider lint that blocked the refreshed CI lane.

Verification:
- node scripts/run-vitest.mjs src/gateway/session-utils.test.ts ui/src/ui/views/chat.test.ts src/agents/agent-command.compaction-rotation.test.ts --reporter=dot
- node scripts/run-vitest.mjs extensions/microsoft-foundry/index.test.ts --reporter=dot
- node_modules/.bin/oxfmt --check --threads=1 extensions/microsoft-foundry/provider.ts src/gateway/session-utils.ts ui/src/ui/views/chat.test.ts src/agents/agent-command.compaction-rotation.test.ts
- node scripts/run-oxlint.mjs extensions/microsoft-foundry/provider.ts src/gateway/session-utils.ts ui/src/ui/views/chat.test.ts src/agents/agent-command.compaction-rotation.test.ts
- pnpm lint --threads=8
- autoreview clean
- GitHub checks on f96270ed7e: 135 success, 29 skipped, 1 neutral, 0 pending/failing

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-31 21:57:07 -04:00
Rohit
3fc485ca92 fix(browser): isolate Chrome MCP pending attach aborts (#88305)
* fix(browser): isolate Chrome MCP pending attach aborts

* fix(browser): evict closing Chrome MCP sessions

* fix(browser): clean chrome mcp pending session lifecycle

* fix(browser): handle stale chrome mcp pending sessions

* fix(browser): serialize stale chrome mcp replacement

* fix(browser): skip cancelled chrome mcp attach

* fix(browser): retire timed-out chrome mcp pending sessions

* fix(browser): retire stale chrome mcp after readiness

* fix(browser): keep shared chrome mcp timeouts isolated

* fix(browser): bound stale chrome mcp ready retries

* fix(browser): narrow pending session lease release

* fix(browser): keep ephemeral probes out of pending attaches

* fix(foundry): satisfy provider lint

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 21:55:38 -04:00
Peter Steinberger
2b184ac3a0 docs(changelog): refresh 2026.5.31 notes 2026-06-01 02:52:49 +01:00
Vincent Koc
be1273182e refactor: share models auth status test helpers 2026-06-01 03:49:46 +02:00
Vincent Koc
c764eb96c4 fix(test): tolerate vanished RPC gateway teardown 2026-06-01 03:48:59 +02:00
Peter Steinberger
0369672691 feat(minimax): add m3 model support (#88860) 2026-05-31 21:47:47 -04:00
Vincent Koc
9919e4601f refactor: share skills clawhub test helpers 2026-06-01 03:38:39 +02:00
Vincent Koc
b6bac3cc2b test(agents): include Ollama in small live model matrix (#87838)
* test(agents): include Ollama in small live model matrix

* test: avoid Ollama cloud key in local live runs

* test: recognize Ollama env secret refs

* test: type Ollama live key fixtures

* test: prevent Ollama cloud auth in local live probes

* test: preserve equivalent Ollama live credentials

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 21:38:31 -04:00
Peter Steinberger
72bc9ae952 fix: keep cron update delivery validation scoped 2026-05-31 21:32:23 -04:00
Peter Steinberger
d2f1c0eac8 fix: harden cron validation and restart state 2026-05-31 21:32:23 -04:00
kiranmagic7
cc97eca9b1 test(installer): keep Node floor tied to package engine
Adds a focused installer regression test tying install.sh's accepted Node 22 floor to the package engine floor. Thanks @kiranmagic7.
2026-05-31 21:32:00 -04:00
Vincent Koc
dbc83b4213 refactor: share chat reply media test helpers 2026-06-01 03:29:30 +02:00
Vincent Koc
2d0c755013 fix(test): order unit-fast fake-timer project 2026-06-01 02:24:48 +01:00
Peter Steinberger
fb64546d9e fix: preserve no-policy native hook fallback
Keep selected no-policy Codex PreToolUse relay hooks installed with an explicit unavailable no-op marker, while unknown unavailable PreToolUse and PermissionRequest still fail closed.

Refs #87543.
Replaces #88620.

Verification:
- pnpm test extensions/codex/src/app-server/native-hook-relay.test.ts src/agents/harness/native-hook-relay.test.ts src/cli/native-hook-relay-cli.test.ts
- pnpm lint --threads=8
- autoreview --mode branch --base origin/main
- GitHub CI run 26729700996, Real behavior proof 26729874455, OpenGrep 26729701010, CodeQL high 26729701003

Co-authored-by: woodym-dotcom <266261448+woodym-dotcom@users.noreply.github.com>
2026-05-31 21:24:09 -04:00
EmpX2025
83f290005a feat(ios): support native iPad display
Make the iOS app a universal iPhone+iPad app by targeting device family 1,2 in the XcodeGen source of truth.

Update iOS docs and App Store metadata so user-facing copy no longer describes the app as iPhone-only.

Verification:
- git diff --check
- cd apps/ios && xcodegen generate
- xcodebuild -project apps/ios/OpenClaw.xcodeproj -scheme OpenClaw -configuration Debug -destination 'platform=iOS Simulator,id=410B81D3-784E-4A01-B69C-490B79EAFCEA' CODE_SIGNING_ALLOWED=NO build
- GitHub CI: Real behavior proof, macos-swift, macos-node, check-docs, preflight, security-fast, actionlint, no-tabs, dependency-guard, OpenGrep

Thanks @EmpX2025.
2026-05-31 21:23:33 -04:00
William Liu AI
8eeb9300df fix: restore in-flight TUI run switch-back
Restore TUI switch-back adoption for backgrounded visible chat-send runs by surfacing a bounded `chat.history.inFlightRun` snapshot.

The snapshot keeps the run id even when buffered text is empty or over budget, filters live text through the same projection path as streaming chat, scopes bare global history to the default agent, and excludes hidden internal agent runs.

Proof:
- node scripts/run-vitest.mjs run src/gateway/chat-abort.test.ts src/tui/tui-session-actions.test.ts
- node scripts/run-tsgo.mjs -p tsconfig.core.json
- pnpm --silent exec oxfmt --check src/gateway/chat-abort.ts src/gateway/chat-abort.test.ts src/gateway/server-methods/chat.ts src/tui/tui-session-actions.ts src/tui/tui-session-actions.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- CI: Real behavior proof, TUI PTY, dependency guard, OpenGrep precise diff, workflow sanity passed on PR head 2b8bf5f214.

Co-authored-by: William Liu <william@williamliu.ai>
2026-05-31 21:22:52 -04:00
Vincent Koc
52c809a759 fix(infra): bridge WSL clipboard through shell
* fix(infra): bridge WSL2 clipboard through shell

* test(infra): assert wsl clipboard argv stays token-free

* fix(infra): keep wsl clipboard timeout ownership
2026-05-31 21:22:08 -04:00
elfka toruviel
f22e39823d fix(doctor): respect explicit PI runtime policy
Respect explicit PI/OpenClaw runtime policy when deciding whether Codex plugin diagnostics are actionable.

Diagnostics now use the resolved OpenAI route: intentional PI and custom OpenAI-compatible routes suppress only the missing `plugins.entries.codex` noise, while enabled/stale Codex policy still warns.

Proof: focused doctor/config/agent routing Vitest coverage, full lint, test types, dependency checks, isolated live doctor configs, autoreview clean, and GitHub CI green at c5a84de4ca.

Fixes #88706.

Co-authored-by: Elfka Toruviel <aeb31988340aa87b@toruviel.online>
2026-05-31 21:21:11 -04:00
Vincent Koc
30bde29893 refactor: share config auth test helpers 2026-06-01 03:20:04 +02:00
Peter Steinberger
6b940ed3ca perf: streamline chat startup metadata (#88825)
* perf: streamline chat startup metadata

* fix: defer global queued agent selection

* style: format gateway startup refresh
2026-05-31 21:18:41 -04:00
Andy Ye
1b10739d60 fix(agents): guard vanished workspaces
Fixes #88333

Preserves contributor workspace contents when an attested workspace disappears or is partially regenerated, and clears OpenClaw-owned attestation state on delete/reset/uninstall.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-31 21:14:54 -04:00
Vincent Koc
efd5d07734 fix(ci): preserve lint cleanup behavior 2026-06-01 03:12:53 +02:00
Peter Steinberger
1d4277b071 refactor: persist openrouter model cache in sqlite (#88851) 2026-05-31 21:12:43 -04:00
Vincent Koc
b029634bd6 refactor: share cron validation test helpers 2026-06-01 03:08:54 +02:00
Vincent Koc
af927038cc test(gateway): fail strict codex subagent timeouts 2026-06-01 03:08:02 +02:00
Peter Steinberger
5b0c4c0491 fix: align Foundry chat reasoning metadata 2026-06-01 02:05:38 +01:00
Vincent Koc
570e2db252 fix(plugins): isolate cached tool runtime siblings 2026-05-31 21:05:23 -04:00
Vincent Koc
53990d5bbf fix(plugins): isolate web provider factory failures (#88807) 2026-05-31 21:04:18 -04:00
NVIDIAN
37169697d7 fix(status): resolve gateway auth secrets for deep audit
Resolve gateway auth SecretRef targets in status deep audit.

The static secret target coverage now includes gateway auth and remote token/password keys for both status and security audit scans. Focused status/secret-target tests passed, Auto Review reported no actionable findings, and CI is running on rebased head 41b052a181.

Fixes #87815
2026-05-31 21:02:11 -04:00
Alix-007
909c24e3b7 fix(config): skip state-dir dotenv values that are unresolved shell references (#88288)
* fix(config): skip state-dir dotenv values that are unresolved shell references

readStateDirDotEnvVarsFromStateDir accepted any non-empty value from the
state-dir .env file and passed it into the managed service env. When a value
contains an unresolved shell variable reference such as "${SUPERMEMORY_KEY}"
or "$MY_VAR", dotenv preserves the literal string. The value then reaches
the LaunchAgent/systemd wrapper as a single-quoted literal, so the credential
is never resolved.

Add containsUnresolvedShellReference() and skip any value matching
$IDENTIFIER, ${...}, or $(...) in parseStateDirDotEnvContent(). Real credential
values (e.g. "sm_abc123") are unaffected.

Fixes #88274

* fix(config): narrow shell-reference detector to whole-value patterns only

The previous /$[\w{(]/ regex matched any value containing $ followed by
a word character, which would incorrectly drop real credentials that merely
contain a dollar sign (e.g. a password like abc$2!xyz).

Replace with isUnresolvedShellReference() that only matches values whose
ENTIRE content is a recognised reference form:
  - $VAR_NAME (simple reference)
  - ${VAR_NAME} (brace-form reference)
  - $(command) (command substitution)

Add a regression test that verifies dollar-bearing real secrets are kept.

* fix(config): use letter/underscore-anchored pattern to avoid matching dollar-numbers

$100, $2, etc. are NOT shell variable references — shell variable names must
begin with a letter or underscore. The previous /^$[\w_]/ would match them.

Change to /^$[A-Za-z_]\w*$/ so only genuine named-variable references like
$MY_VAR are rejected. Dollar-number sequences are now preserved.

* fix(daemon): drop stale systemd env-file refs for skipped state-dir dotenv keys

When a state-dir .env value is an unresolved shell reference ($VAR/${VAR}/$(cmd))
the parser skips it from the managed environment. A prior install could have
written that literal reference into gateway.systemd.env; because the skipped key
no longer appeared in the incoming env or the managed-key removal sets, the stale
literal survived re-stage and could override fresh inline Environment= values.

Surface the skipped shell-reference keys from the state-dir dotenv parser and add
them to the systemd env-file managed-key removal set so re-staging strips the
obsolete literal while preserving operator-only secrets that were never managed
via state-dir .env. launchd regenerates its env file wholesale, so it is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(config): skip quoted shell parameter dotenv refs

* fix(config): preserve lowercase dollar-prefixed dotenv literals

* fix(daemon): clear stale unresolved systemd env refs

* fix(daemon): avoid re-staging unresolved file env refs

* fix(daemon): drop unresolved file env refs inline

* fix(daemon): drop inline-and-file unresolved env refs

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 21:01:33 -04:00
Vincent Koc
732748c8c5 perf(ui): skip markdown parsing while chat streams 2026-06-01 02:00:06 +01:00
Brian
fda5254e99 fix: preserve npm plugin root on blocked install (#77237)
Preserve the active per-plugin managed npm project when npm-backed install validation blocks a candidate after npm has already mutated local state.

This snapshots package.json, package-lock.json, and node_modules before managed npm installs, restores that exact project state on failed validation, and rolls back staged npm-pack archives so blocked pack installs do not leave candidate debris.

Validation:
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs src/plugins/install.npm-spec.test.ts
- pnpm tsgo:core && pnpm tsgo:core:test
- node scripts/run-oxlint.mjs src/plugins/install.ts src/plugins/install.npm-spec.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub CI 26729255950
- Crabbox run_26e9f9f7591c

Thanks @zhuisDEV.

Co-authored-by: Brian <95547369+zhuisDEV@users.noreply.github.com>
2026-05-31 20:59:32 -04:00
Vincent Koc
9da4835cdf refactor: share artifacts test helpers 2026-06-01 02:57:01 +02:00
Vincent Koc
43ced7bc49 fix(ui): preserve startup chat sends during history load 2026-06-01 01:52:58 +01:00
Vincent Koc
49b62079f7 fix(ui): unblock initial control chat send 2026-06-01 01:52:58 +01:00
Andy Ye
432312a17c test: cover Vertex API key model config
Adds regression coverage for Google Vertex API-key model config planning when the credential comes from an env-backed auth profile. This keeps the planner-level guard around the Vertex static catalog rows that fixed #88816 on main.

Verification:
- `node scripts/run-vitest.mjs src/agents/models-config.applies-config-env-vars.test.ts extensions/google/provider-catalog.test.ts extensions/google/provider-models.test.ts`
- `./node_modules/.bin/oxfmt --check --threads=1 src/agents/models-config.applies-config-env-vars.test.ts extensions/ollama/src/stream.ts extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts extensions/qa-lab/src/mantis/visual-task.runtime.ts`
- `git diff --check`
- `pnpm deadcode:dependencies`

CI note: PR CI had an unrelated `check-dependencies` failure for `ui/package.json: three`; the PR diff is one `src/agents` test file.

Refs #88816
2026-05-31 20:51:50 -04:00
Peter Steinberger
5443baa852 Persist plugin install index in SQLite (#88794)
* refactor: persist plugin install index in sqlite

* fix: merge legacy plugin index records into sqlite

* test: update plugin index sqlite fixtures

* fix: migrate custom plugin install indexes

* test: update plugin index sentinel

* fix: exclude migrated plugin index archives

* fix: read post-upgrade plugin index from sqlite

* fix: migrate legacy plugin index before agent runs

* fix: respect disabled persisted plugin registry reads

* test: type plugin install record fixtures

* fix: simplify plugin index record reader type

* test: fix sqlite plugin index CI fallout

* test: mock provider normalization in agent command tests

# Conflicts:
#	src/commands/agent-command.test-mocks.ts

* build: remove unused ui three dependency
2026-05-31 20:51:33 -04:00
Vincent Koc
b475de834a refactor: share plugin approval test helpers 2026-06-01 02:45:37 +02:00
Matthew Schleder
6a96058f50 fix(minimax): use account oauth endpoints
Routes MiniMax OAuth device-code and token polling directly to account-hosted OAuth2 endpoints for global and CN regions, avoiding guarded-fetch cross-origin redirect body stripping. Keeps provider API base URLs unchanged and adds regression coverage for both endpoint pairs.

Proof: local minimax OAuth tests, oxfmt check, lint, autoreview clean, official MiniMax CLI/source check, live MiniMax endpoint probes, and CI run 26729242892 on 6bfe20eb06.

Co-authored-by: Matt Schleder <schledermatthew@gmail.com>
2026-05-31 20:44:41 -04:00
Vincent Koc
82d24b26ea fix(workboard): wire task-backed board runs
Summary:
- remove the leftover Workboard mini-game/prototype surface
- wire autonomous Workboard card starts through Gateway task-backed agent runs
- reconcile card task/session lifecycle for starts, stops, stale tasks, reassignment, and default-agent sessions
- clarify dispatch summary copy and admin-only model override behavior

Verification:
- autoreview clean: no accepted/actionable findings
- targeted Workboard/UI Vitest: 72 tests passed
- Workboard extension Vitest: 9 tests passed
- UI build, docs list, docs format, diff check, and focused oxlint passed
- PR CI checks: 50 ok, 0 attention
- Testbox tbx_01kt07mk5sjyj2whjq2sc967hg: pnpm verify check phase passed; broad test phase exposed unrelated latest-main failures/stalls in memory, Codex app-server, provider timeout, command daemon env, Telegram worker OOM, and gateway-client timeout suites
2026-06-01 01:41:21 +01:00
Vincent Koc
015c6b40ae fix(ci): clear extension lint regressions 2026-06-01 01:36:16 +01:00
Vincent Koc
915c156115 refactor: share tools effective test helpers 2026-06-01 02:33:47 +02:00
Vincent Koc
b3742b9edb fix(ui): stream chat deltas incrementally 2026-06-01 01:32:48 +01:00
Vincent Koc
bcaf326c3a refactor: share sessions abort scope test helpers 2026-06-01 02:21:44 +02:00
Vincent Koc
3c7c03f236 test(ci): update agent command model-selection mocks 2026-06-01 01:18:09 +01:00
Peter Steinberger
7562afdca3 fix(ollama): suppress disabled reasoning output 2026-06-01 01:16:47 +01:00
Peter Steinberger
27dde7a4d6 chore(lint): enable stricter error rules 2026-06-01 01:12:21 +01:00
Vincent Koc
0bfba7e26d fix(ui): detect system chromium for e2e 2026-06-01 01:09:46 +01:00
Vincent Koc
d95471afef test: type manifest catalog mock 2026-06-01 02:06:26 +02:00
Vincent Koc
69c948a752 refactor: share web start test snapshot 2026-06-01 02:06:26 +02:00
Andy Ye
002c1d2d5a test(agents): cover nonfatal trajectory flush timeout
Fixes #88520.

Adds focused regression coverage for the embedded attempt trajectory recorder cleanup boundary so a stalled trajectory flush resolves after the cleanup timeout and logs pending write details instead of rejecting attempt cleanup.

Verification:
- node scripts/run-vitest.mjs src/agents/run-cleanup-timeout.test.ts
- git diff --check origin/main...origin/pr/88802
- PR CI green: https://github.com/openclaw/openclaw/actions/runs/26727232564

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-31 20:01:12 -04:00
Vincent Koc
2fc5072021 fix(ci): repair Copilot thinking compat types 2026-06-01 00:53:35 +01:00
Vincent Koc
160aad6fb3 fix(agents): preserve exact custom provider models 2026-06-01 01:50:30 +02:00
Vincent Koc
dd8d52c7d9 refactor: share optional model catalog loading 2026-06-01 01:49:51 +02:00
Peter Steinberger
219d854178 fix: keep tool detail redaction canonical 2026-06-01 00:49:43 +01:00
Vincent Koc
37d79a4303 test(ui): make chat sessions e2e deterministic 2026-06-01 00:45:29 +01:00
1211 changed files with 31065 additions and 10274 deletions

View File

@@ -358,8 +358,8 @@ jobs:
$env:COREPACK_HOME = Join-Path $env:XDG_CACHE_HOME "corepack"
$env:PNPM_HOME = Join-Path $cacheRoot "pnpm-home"
$env:PNPM_CONFIG_STORE_DIR = Join-Path $cacheRoot "openclaw-pnpm-store"
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $workspace "node_modules"
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $workspace "node_modules\.pnpm"
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $cacheRoot "openclaw-pnpm-node-modules"
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $env:PNPM_CONFIG_MODULES_DIR ".pnpm"
$env:PNPM_CONFIG_CHILD_CONCURRENCY = "4"
$env:PNPM_CONFIG_NETWORK_CONCURRENCY = "8"
$env:PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN = "false"

View File

@@ -1953,7 +1953,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-profiles-minimax
label: Native live gateway profiles MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 60
profile_env_only: false
profiles: stable full
@@ -2252,7 +2252,7 @@ jobs:
profiles: stable full
- suite_id: live-gateway-minimax-docker
label: Docker live gateway MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full

View File

@@ -82,7 +82,10 @@
"typescript/no-meaningless-void-operator": "error",
"typescript/no-misused-promises": "error",
"typescript/no-inferrable-types": "error",
"typescript/only-throw-error": "error",
"typescript/no-non-null-asserted-nullish-coalescing": "error",
"typescript/prefer-promise-reject-errors": "error",
"typescript/restrict-plus-operands": "error",
"typescript/no-unnecessary-qualifier": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-arguments": "error",
@@ -109,6 +112,8 @@
"typescript/require-array-sort-compare": "error",
"typescript/restrict-template-expressions": "error",
"typescript/triple-slash-reference": "error",
"typescript/unbound-method": "error",
"typescript/use-unknown-in-catch-callback-variable": "error",
"unicorn/consistent-date-clone": "error",
"unicorn/consistent-empty-array-spread": "error",
"unicorn/consistent-function-scoping": "off",
@@ -128,6 +133,7 @@
"unicorn/no-unnecessary-slice-end": "error",
"unicorn/no-useless-error-capture-stack-trace": "error",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/no-useless-switch-case": "error",
"unicorn/no-zero-fractions": "error",
"unicorn/prefer-date-now": "error",
"unicorn/prefer-dom-node-text-content": "error",

View File

@@ -12,6 +12,10 @@ Docs: https://docs.openclaw.ai
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
- Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, and expose calmer composer controls. (#88772, #88825)
- Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)
- iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
### Changes
@@ -21,17 +25,28 @@ Docs: https://docs.openclaw.ai
- Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
- Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
- Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the `skill_workshop` agent tool. Thanks @shakkernerd.
- Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
- iOS: support native iPad display layouts.
- Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)
- Workboard: wire task-backed board runs and show task comments in the edit modal.
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
- Code mode: add MCP API files and docs for code-mode integrations.
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
- Control UI: add calmer chat composer controls for active chat entry. (#88772)
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
- Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)
- Providers: add MiniMax M3 model support. (#88860)
- Doctor: add disk space health checks and stabilize post-upgrade JSON probes.
- Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)
- Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.
### Fixes
- Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
@@ -40,10 +55,14 @@ Docs: https://docs.openclaw.ai
- Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill `apiKey` SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.
- CLI: avoid live catalog validation during `openclaw agents add`, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- CLI/desktop: bridge WSL clipboard operations through the shell and recognize manual-update launchd jobs. (#88764)
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
- Plugins: preserve npm plugin roots after blocked installs, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
- Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512)
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
@@ -57,6 +76,11 @@ Docs: https://docs.openclaw.ai
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, honor Chromium executable overrides, and detect system Chromium for E2E.
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.
- OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.

View File

@@ -1,6 +1,6 @@
# OpenClaw iOS (Super Alpha)
This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`.
This iOS app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node` on iPhone and iPad.
## Distribution Status
@@ -34,7 +34,7 @@ open OpenClaw.xcodeproj
3. In Xcode:
- Scheme: `OpenClaw`
- Destination: connected iPhone (recommended for real behavior)
- Destination: connected iPhone or iPad (recommended for real behavior)
- Build configuration: `Debug`
- Run (`Product` -> `Run`)
4. If signing fails on a personal team:
@@ -245,13 +245,13 @@ gateway can only send pushes for iOS devices that paired with that gateway.
- Pairing via QR or setup code flow (`/pair qr` or `/pair`, then `/pair approve` in Telegram).
- Gateway connection via discovery or manual host/port with TLS fingerprint trust prompt.
- Chat + Talk surfaces through the operator gateway session.
- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications.
- iOS node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications.
- Authenticated background `node.presence.alive` beacons that update gateway last-seen metadata when the app moves between foreground and background, without treating suspended sockets as connected.
- Share extension deep-link forwarding into the connected gateway session.
## Computer Use Relationship
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone or iPad canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
## Location Automation Use Case (Testing)

View File

@@ -50,6 +50,11 @@ struct ChatProTab: View {
.onChange(of: self.appModel.chatSessionKey) { _, _ in
self.syncChatViewModel()
}
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
guard connected else { return }
self.syncChatViewModel()
self.viewModel?.refresh()
}
}
private var header: some View {
@@ -151,7 +156,8 @@ struct ChatProTab: View {
}
private var gatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
GatewayStatusBuilder.build(appModel: self.appModel) == .connected &&
self.appModel.isOperatorGatewayConnected
}
private var chatUserAccent: Color {

View File

@@ -45,6 +45,7 @@ struct SettingsProTab: View {
@State var gatewayPassword = ""
@State var manualGatewayPortText = ""
@State var setupStatusText: String?
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State var defaultShareInstruction = ""
@State var showGatewayProblemDetails = false
@@ -82,6 +83,7 @@ struct SettingsProTab: View {
self.previousLocationModeRaw = self.locationModeRaw
self.syncSettingsState()
self.refreshNotificationSettings()
self.applyPendingGatewaySetupLinkIfNeeded()
}
.onChange(of: self.scenePhase) { _, phase in
if phase == .active {
@@ -107,9 +109,17 @@ struct SettingsProTab: View {
.onChange(of: self.gatewayPassword) { _, newValue in
self.persistGatewayPassword(newValue)
}
.onChange(of: self.setupCode) { _, newValue in
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.stagedGatewaySetupLink = nil
}
}
.onChange(of: self.defaultShareInstruction) { _, newValue in
ShareToAgentSettings.saveDefaultInstruction(newValue)
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {

View File

@@ -202,17 +202,29 @@ extension SettingsProTab {
await self.connectManual()
}
func applyPendingGatewaySetupLinkIfNeeded() {
guard let link = self.appModel.consumePendingGatewaySetupLink() else { return }
self.setupCode = ""
self.setupStatusText = nil
self.stagedGatewaySetupLink = link
let security = link.tls ? "TLS" : "plain"
self.setupStatusText = "Setup link loaded for \(link.host):\(link.port) (\(security)). Tap Connect to apply."
}
@discardableResult
func applySetupCode() -> Bool {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
let stagedLink = self.stagedGatewaySetupLink
guard !raw.isEmpty || stagedLink != nil else {
self.setupStatusText = "Paste a setup code to continue."
return false
}
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return false
}
self.stagedGatewaySetupLink = nil
self.applyGatewayLink(link)
return true
}
@@ -299,7 +311,7 @@ extension SettingsProTab {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
self.setupStatusText = "Tailscale is off on this iPhone. Turn it on, then try again."
self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again."
return false
}
self.setupStatusText = "Checking gateway reachability..."
@@ -510,10 +522,15 @@ extension SettingsProTab {
return gatewayStatus
}
var canApplyGatewaySetup: Bool {
!self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| self.stagedGatewaySetupLink != nil
}
var tailnetWarningText: String? {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil }
return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect."
return "This gateway is on your tailnet. Turn on Tailscale on this device, then tap Connect."
}
func friendlyGatewayMessage(from raw: String) -> String? {

View File

@@ -542,7 +542,7 @@ extension SettingsProTab {
{
Task { await self.applySetupCodeAndConnect() }
}
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.disabled(!self.canApplyGatewaySetup)
}
if let status = self.setupStatusLine {
Text(status)

View File

@@ -111,7 +111,7 @@ struct GatewayProblemBanner: View {
case .gateway:
"Fix on gateway"
case .iphone:
"Fix on iPhone"
"Fix on this device"
case .both:
"Check both"
case .network:
@@ -227,9 +227,9 @@ struct GatewayProblemDetailsSheet: View {
case .gateway:
"Primary fix: gateway"
case .iphone:
"Primary fix: this iPhone"
"Primary fix: this device"
case .both:
"Primary fix: check both this iPhone and the gateway"
"Primary fix: check both this device and the gateway"
case .network:
"Primary fix: network or remote access"
case .unknown:

View File

@@ -138,7 +138,9 @@ final class NodeAppModel {
var homeCanvasRevision: Int = 0
var lastShareEventText: String = "No share events yet."
var openChatRequestID: Int = 0
var gatewaySetupRequestID: Int = 0
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
private var pendingGatewaySetupLink: GatewayConnectDeepLink?
private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt?
private(set) var pendingExecApprovalPromptResolving: Bool = false
private(set) var pendingExecApprovalPromptErrorText: String?
@@ -4134,11 +4136,23 @@ extension NodeAppModel {
switch route {
case let .agent(link):
await self.handleAgentDeepLink(link, originalURL: url)
case .gateway, .dashboard:
case let .gateway(link):
self.stageGatewaySetupLink(link)
case .dashboard:
break
}
}
func stageGatewaySetupLink(_ link: GatewayConnectDeepLink) {
self.pendingGatewaySetupLink = link
self.gatewaySetupRequestID &+= 1
}
func consumePendingGatewaySetupLink() -> GatewayConnectDeepLink? {
defer { self.pendingGatewaySetupLink = nil }
return self.pendingGatewaySetupLink
}
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return }

View File

@@ -7,7 +7,7 @@ struct OnboardingIntroStep: View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "iphone.gen3")
Image(systemName: UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone.gen3")
.font(.system(size: 60, weight: .semibold))
.foregroundStyle(.tint)
.padding(.bottom, 18)
@@ -17,7 +17,7 @@ struct OnboardingIntroStep: View {
.multilineTextAlignment(.center)
.padding(.bottom, 10)
Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.")
Text("Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -114,7 +114,7 @@ struct OnboardingWelcomeStep: View {
.foregroundStyle(.secondary)
Text("/pair qr")
.font(.system(.footnote, design: .monospaced).weight(.semibold))
Text("Then scan the QR code here to connect this iPhone.")
Text("Then scan the QR code here to connect this device.")
.font(.footnote)
.foregroundStyle(.secondary)
}

View File

@@ -669,8 +669,8 @@ extension OpenClawApp {
switch route {
case .agent, .dashboard:
await self.appModel.handleDeepLink(url: url)
case .gateway:
break
case let .gateway(link):
self.appModel.stageGatewaySetupLink(link)
}
}

View File

@@ -32,6 +32,7 @@ struct RootTabs: View {
@State private var didAutoOpenSettings: Bool = false
@State private var didApplyInitialAppearance: Bool = false
@State private var didApplyInitialChatSession: Bool = false
@State private var handledGatewaySetupRequestID: Int = 0
private enum AppTab: Hashable {
case control
@@ -237,6 +238,7 @@ struct RootTabs: View {
.onAppear { self.updateCanvasState() }
.onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeAutoOpenSettings() }
.onAppear { self.maybeOpenSettingsForGatewaySetup() }
.onAppear { self.maybeShowQuickSetup() }
.onAppear { self.applyInitialAppearanceIfNeeded() }
.onAppear { self.applyInitialChatSessionIfNeeded() }
@@ -296,6 +298,9 @@ struct RootTabs: View {
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.selectedTab = .chat
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.maybeOpenSettingsForGatewaySetup()
}
}
private func rootPresentation(_ content: some View) -> some View {
@@ -560,6 +565,16 @@ struct RootTabs: View {
self.selectedTab = .settings
}
private func maybeOpenSettingsForGatewaySetup() {
let requestID = self.appModel.gatewaySetupRequestID
guard requestID != 0, requestID != self.handledGatewaySetupRequestID else { return }
self.handledGatewaySetupRequestID = requestID
self.showOnboarding = false
self.presentedSheet = nil
self.didAutoOpenSettings = true
self.selectedTab = .settings
}
private func applyInitialChatSessionIfNeeded() {
guard !self.didApplyInitialChatSession else { return }
self.didApplyInitialChatSession = true

View File

@@ -147,8 +147,8 @@ struct TalkPermissionPromptView: View {
case .upgradeRequested:
"Approve this request on your gateway. Talk will start automatically when approval lands."
default:
"This iPhone needs gateway approval before Talk can use realtime voice. Audio will go directly from " +
"this phone to the voice provider."
"This device needs gateway approval before Talk can use realtime voice. Audio will go directly from " +
"this device to the voice provider."
}
}

View File

@@ -1,12 +1,12 @@
OpenClaw is a personal AI assistant you run on your own devices.
Pair this iPhone app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, sharing, and device-aware automation.
Pair this iOS app with your OpenClaw Gateway to use your iPhone or iPad as a secure node for chat, voice, approvals, sharing, and device-aware automation.
What you can do:
- Pair with your private OpenClaw Gateway by QR code or setup code
- Chat with your assistant from iPhone
- Chat with your assistant from iPhone or iPad
- Use realtime Talk mode and push-to-talk
- Review Gateway action approvals from your phone
- Review Gateway action approvals from your iPhone or iPad
- Share text, links, and media directly from iOS into OpenClaw
- Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose
- Receive push wakes and node status updates for connected workflows
@@ -16,4 +16,4 @@ OpenClaw is local-first: you control your gateway, keys, configuration, and perm
Getting started:
1) Set up your OpenClaw Gateway
2) Open the iOS app and pair with your gateway
3) Start using chat, Talk mode, approvals, and automations from your phone
3) Start using chat, Talk mode, approvals, and automations from your iPhone or iPad

View File

@@ -1 +1 @@
Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.
Pair your iPhone or iPad with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.

View File

@@ -97,7 +97,7 @@ targets:
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)"
TARGETED_DEVICE_FAMILY: "1"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
SUPPORTS_LIVE_ACTIVITIES: YES

View File

@@ -213,7 +213,7 @@ public enum GatewayConnectionProblemMapper {
owner: .both,
title: authError.titleOverride ?? "Gateway token required",
message: authError.userMessageOverride
?? "This gateway requires an auth token, but this iPhone did not send one.",
?? "This gateway requires an auth token, but this device did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(
@@ -229,7 +229,7 @@ public enum GatewayConnectionProblemMapper {
owner: .both,
title: authError.titleOverride ?? "Gateway token is out of date",
message: authError.userMessageOverride
?? "The token on this iPhone does not match the gateway token.",
?? "The token on this device does not match the gateway token.",
actionLabel: authError
.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
actionCommand: authError.actionCommand,
@@ -262,7 +262,7 @@ public enum GatewayConnectionProblemMapper {
owner: .both,
title: authError.titleOverride ?? "Gateway password required",
message: authError.userMessageOverride
?? "This gateway requires a password, but this iPhone did not send one.",
?? "This gateway requires a password, but this device did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(
@@ -278,7 +278,7 @@ public enum GatewayConnectionProblemMapper {
owner: .both,
title: authError.titleOverride ?? "Gateway password is out of date",
message: authError.userMessageOverride
?? "The saved password on this iPhone does not match the gateway password.",
?? "The saved password on this device does not match the gateway password.",
actionLabel: authError.actionLabel ?? "Update password",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(
@@ -322,7 +322,7 @@ public enum GatewayConnectionProblemMapper {
return self.problem(
kind: .deviceTokenMismatch,
owner: .both,
title: authError.titleOverride ?? "This iPhone's saved device token is no longer valid",
title: authError.titleOverride ?? "This device's saved device token is no longer valid",
message: authError.userMessageOverride
?? "The gateway rejected the stored device token for this role.",
actionLabel: authError.actionLabel ?? "Repair pairing",
@@ -355,7 +355,7 @@ public enum GatewayConnectionProblemMapper {
title: authError.titleOverride ?? "Secure device identity is required",
message: authError.userMessageOverride
??
"This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
"This connection must include a signed device identity before the gateway can bind permissions to this device.",
actionLabel: authError.actionLabel ?? "Retry from the app",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
@@ -369,7 +369,7 @@ public enum GatewayConnectionProblemMapper {
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake expired",
message: authError.userMessageOverride ?? "The device signature is too old to use.",
actionLabel: authError.actionLabel ?? "Check iPhone time",
actionLabel: authError.actionLabel ?? "Check device time",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(
authError.docsURLString,
@@ -415,8 +415,8 @@ public enum GatewayConnectionProblemMapper {
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the identity this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
?? "The gateway could not verify the identity this device presented.",
actionLabel: authError.actionLabel ?? "Re-pair this device",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
@@ -429,8 +429,8 @@ public enum GatewayConnectionProblemMapper {
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the public key this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
?? "The gateway could not verify the public key this device presented.",
actionLabel: authError.actionLabel ?? "Re-pair this device",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
@@ -444,7 +444,7 @@ public enum GatewayConnectionProblemMapper {
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway rejected the device identity because the device ID did not match.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionLabel: authError.actionLabel ?? "Re-pair this device",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
@@ -745,7 +745,7 @@ public enum GatewayConnectionProblemMapper {
title: authError.titleOverride ?? "Additional approval required",
message: authError.userMessageOverride
??
"This iPhone is already paired, but it is requesting a new role that was not previously approved.",
"This device is already paired, but it is requesting a new role that was not previously approved.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
@@ -759,7 +759,7 @@ public enum GatewayConnectionProblemMapper {
owner: .gateway,
title: authError.titleOverride ?? "Additional permissions required",
message: authError.userMessageOverride
?? "This iPhone is already paired, but it is requesting new permissions that require approval.",
?? "This device is already paired, but it is requesting new permissions that require approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
@@ -786,7 +786,7 @@ public enum GatewayConnectionProblemMapper {
return self.problem(
kind: .pairingRequired,
owner: .gateway,
title: authError.titleOverride ?? "This iPhone is not approved yet",
title: authError.titleOverride ?? "This device is not approved yet",
message: authError.userMessageOverride
?? "The gateway received the connection request, but this device must be approved first.",
actionLabel: authError.actionLabel ?? "Approve on gateway",

View File

@@ -165,6 +165,8 @@ const config = {
"vite.config.ts!",
"vitest*.ts!",
],
// Workboard lazy-loads Three.js at runtime; Knip's dependency pass misses it.
ignoreDependencies: ["three"],
project: ["src/**/*.{ts,tsx}!"],
},
"packages/sdk": {

View File

@@ -1,4 +1,4 @@
f4a00ada9d154a4d3a54e109aa6e9f73f22b09d7df9ab6745e87f88724eec06b config-baseline.json
5ee177382cf32c2816dca0a4e67cd6c01df1045d600b21a6e9c11639ddb10ce8 config-baseline.core.json
0e654bad3f1ef9100f76e512c4453c1f26b6bc1f5ee121ce505d0624a1dad4cd config-baseline.channel.json
e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json
cc0fb4e3f1a7e8f233626adb80d686608ddac8c177fe6a55b33970c2baf4ace4 config-baseline.json
042ca98e6200a365accda00e5a6f3e72bdae5853f39ff0cdc3b2cb9c0d6f8f3e config-baseline.core.json
cbf81829dcc8cfd0a16435912da709f8c1d508707385b6493f94cafe211ec67c config-baseline.channel.json
4012b1f8de6f9527c47320a6c7120f30dc30ac1b5524ed63dadef890aad44b20 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
19bdf1196ec771a00777a16fd1e9c3662b8fd788a81034e705c41a74ee79c7ec plugin-sdk-api-baseline.json
43feff80c90adad0f821d1f1e184a9bff1e93d81e6d53a26a26fd9e2972be759 plugin-sdk-api-baseline.jsonl
47d4365c4133f57769758907b7cf1a43d17e040db0570a1433e2f03e4fb0bd02 plugin-sdk-api-baseline.json
161027bba89497f5e30127dd0f57b4da623270cfc771b989e29262da5760e723 plugin-sdk-api-baseline.jsonl

View File

@@ -65,7 +65,7 @@ Use this checklist when you already know your old BlueBubbles config and want th
imsg rpc --help
```
Replace `42` with a real chat id from `imsg chats`. Sending requires Automation permission for Messages.app. If OpenClaw will run through SSH, run these commands through the same SSH wrapper or user context that OpenClaw will use.
Replace `42` with a real chat id from `imsg chats`. Sending requires Automation permission for Messages.app. If OpenClaw will run through SSH, run these commands through the same SSH wrapper or user context that OpenClaw will use. If reads/probes work but sends fail with AppleEvents `-1743`, check whether Automation landed on `/usr/libexec/sshd-keygen-wrapper`; see [SSH wrapper sends fail with AppleEvents -1743](/channels/imessage#ssh-wrapper-sends-fail-with-appleevents-1743).
3. Enable the private API bridge when you need advanced actions:

View File

@@ -151,6 +151,29 @@ imsg send <handle> "test"
</Tip>
<Accordion title="SSH wrapper sends fail with AppleEvents -1743">
A remote-SSH setup can read chats, pass `channels status --probe`, and process inbound messages while outbound sends still fail with an AppleEvents authorization error:
```text
Not authorized to send Apple events to Messages. (-1743)
```
Check the signed-in Mac user's TCC database or System Settings > Privacy & Security > Automation. If the Automation entry is recorded for `/usr/libexec/sshd-keygen-wrapper` instead of the `imsg` or local shell process, macOS may not expose a usable Messages toggle for that SSH server-side client:
```text
kTCCServiceAppleEvents | /usr/libexec/sshd-keygen-wrapper | auth_value=0 | com.apple.MobileSMS
```
In that state, repeating `tccutil reset AppleEvents` or rerunning `imsg send` through the same SSH wrapper may keep failing because the process context that needs Messages Automation is the SSH wrapper, not an app the UI can grant.
Use one of the supported `imsg` process contexts instead:
- Run the Gateway, or at least the `imsg` bridge, in the logged-in Messages user's local session.
- Start the Gateway with a LaunchAgent for that user after granting Full Disk Access and Automation from the same session.
- If you keep the two-user SSH topology, verify that a real outbound `imsg send` succeeds through the exact wrapper before enabling the channel. If it cannot be granted Automation, reconfigure to a single-user `imsg` setup instead of relying on the SSH wrapper for sends.
</Accordion>
## Enabling the imsg private API
`imsg` ships in two operational modes:

View File

@@ -336,7 +336,7 @@ Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
### Plugin index
Plugin install metadata is machine-managed state, not user config. Installs and updates write it to `plugins/installs.json` under the active OpenClaw state directory. Its top-level `installRecords` map is the durable source of install metadata, including records for broken or missing plugin manifests. The `plugins` array is the manifest-derived cold registry cache. The file includes a do-not-edit warning and is used by `openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
Plugin install metadata is machine-managed state, not user config. Installs and updates write it to the shared SQLite state database under the active OpenClaw state directory. The `installed_plugin_index` row stores durable `installRecords` metadata, including records for broken or missing plugin manifests, plus a manifest-derived cold registry cache used by `openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
When OpenClaw sees shipped legacy `plugins.installs` records in config, runtime reads treat them as compatibility input without rewriting `openclaw.json`. Explicit plugin writes and `openclaw doctor --fix` move those records into the plugin index and remove the config key when config writes are allowed; if either write fails, the config records are kept so the install metadata is not lost.

View File

@@ -22,6 +22,7 @@ Initialize the baseline config and agent workspace. With any onboarding flag pre
| `--workspace <dir>` | Agent workspace directory (default `~/.openclaw/workspace`; stored as `agents.defaults.workspace`). |
| `--wizard` | Run interactive onboarding. |
| `--non-interactive` | Run onboarding without prompts. |
| `--accept-risk` | Acknowledge full-system agent access risk; required with `--non-interactive`. |
| `--mode <mode>` | Onboarding mode: `local` or `remote`. |
| `--import-from <provider>` | Migration provider to run during onboarding. |
| `--import-source <path>` | Source agent home for `--import-from`. |
@@ -33,7 +34,7 @@ Initialize the baseline config and agent workspace. With any onboarding flag pre
`openclaw setup` runs the wizard when any of these flags are explicitly present, even without `--wizard`:
`--wizard`, `--non-interactive`, `--mode`, `--import-from`, `--import-source`, `--import-secrets`, `--remote-url`, `--remote-token`.
`--wizard`, `--non-interactive`, `--accept-risk`, `--mode`, `--import-from`, `--import-source`, `--import-secrets`, `--remote-url`, `--remote-token`.
## Examples
@@ -42,7 +43,7 @@ openclaw setup
openclaw setup --workspace ~/.openclaw/workspace
openclaw setup --wizard
openclaw setup --wizard --import-from hermes --import-source ~/.hermes
openclaw setup --non-interactive --mode remote --remote-url wss://gateway-host:18789 --remote-token <token>
openclaw setup --non-interactive --accept-risk --mode remote --remote-url wss://gateway-host:18789 --remote-token <token>
```
## Notes

View File

@@ -99,7 +99,10 @@ openclaw workboard dispatch --url http://127.0.0.1:18789 --token "$OPENCLAW_GATE
`dispatch` first calls the running Gateway RPC method
`workboard.cards.dispatch`. That path uses the same subagent runtime as the
dashboard dispatch action, so ready cards can become real worker sessions.
dashboard dispatch action, so ready cards become task-tracked worker runs with
linked session keys. Cards with an assigned agent use agent-scoped subagent
session keys; unassigned cards keep an unscoped subagent key so the Gateway's
configured default agent is preserved.
The dispatch loop:
@@ -110,8 +113,8 @@ The dispatch loop:
5. Claims each selected card for the dispatcher or assigned agent.
6. Starts a subagent worker run with bounded card context and the card claim
token.
7. Stores the worker run id, session key, execution status, and worker log on
the card.
7. Stores the worker run id, session key, task linkage when the Gateway task
ledger reports it, execution status, and worker log on the card.
Selection is intentionally conservative. One dispatch starts at most three
workers by default, skips archived or already-claimed cards, and starts only one
@@ -146,6 +149,10 @@ JSON output includes the dispatch result. Gateway-backed dispatch can include
`started` and `startFailures`; data-only fallback includes
`gatewayUnavailable: true`. Claim tokens are redacted from card JSON output.
In the dashboard, the same dispatch result is shown as a short summary so an
operator can see how many cards started, promoted, blocked, reclaimed, or
failed without opening card details.
## Slash Command Parity
Command-capable channels can use the matching slash command:

View File

@@ -99,7 +99,7 @@ These are the standard files OpenClaw expects inside the workspace:
</AccordionGroup>
<Note>
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into the session and continues. Large bootstrap files are truncated when injected; adjust limits with `agents.defaults.bootstrapMaxChars` (default: 12000) and `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `openclaw setup` can recreate missing defaults without overwriting existing files.
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into the session and continues. Large bootstrap files are truncated when injected; adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `openclaw setup` can recreate missing defaults without overwriting existing files.
</Note>
## What is NOT in the workspace

View File

@@ -41,6 +41,8 @@ If a file is missing, OpenClaw injects a single "missing file" marker line (and
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). While it is pending, OpenClaw keeps it in Project Context and adds system-prompt bootstrap guidance for the initial ritual instead of copying it into the user message. If you delete it after completing the ritual, it should not be recreated on later restarts.
After a workspace has been observed, OpenClaw also keeps a state-dir attestation marker for the workspace path. If a recently attested workspace disappears or is wiped, startup refuses to silently re-seed `BOOTSTRAP.md`; restore the workspace or use a full onboard reset so the workspace and marker are cleared together.
To disable bootstrap file creation entirely (for pre-seeded workspaces), set:
```json5

View File

@@ -122,7 +122,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present):
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (first-run only)
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `12000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `always`).

View File

@@ -107,6 +107,13 @@ Deep ranking uses six weighted base signals plus phase reinforcement:
Light and REM phase hits add a small recency-decayed boost from `memory/.dreams/phase-signals.json`.
Shadow-trial results can be layered on top of that base score as a review
signal before any durable write. A helpful trial gives the candidate a small
bounded boost, a neutral trial keeps it deferred, and a harmful trial marks it
as rejected for that scoring pass. This signal is still report-only: it can
change candidate ordering or review metadata, but it does not write to
`MEMORY.md` or promote the candidate by itself.
## QA shadow trial report coverage
QA Lab includes a report-only scenario for exploring how a future dreaming

View File

@@ -303,7 +303,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
| Hugging Face Inference | `huggingface` | `HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN` | `huggingface/deepseek-ai/DeepSeek-R1` |
| Kilo Gateway | `kilocode` | `KILOCODE_API_KEY` | `kilocode/kilo/auto` |
| Kimi Coding | `kimi` | `KIMI_API_KEY` or `KIMICODE_API_KEY` | `kimi/kimi-for-coding` |
| MiniMax | `minimax` / `minimax-portal` | `MINIMAX_API_KEY` / `MINIMAX_OAUTH_TOKEN` | `minimax/MiniMax-M2.7` |
| MiniMax | `minimax` / `minimax-portal` | `MINIMAX_API_KEY` / `MINIMAX_OAUTH_TOKEN` | `minimax/MiniMax-M3` |
| Mistral | `mistral` | `MISTRAL_API_KEY` | `mistral/mistral-large-latest` |
| Moonshot | `moonshot` | `MOONSHOT_API_KEY` | `moonshot/kimi-k2.6` |
| NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-super-120b-a12b` |
@@ -331,7 +331,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
Gemini-backed refs follow the same proxy-Gemini sanitation path; `kilocode/kilo/auto` and other proxy-reasoning-unsupported refs skip proxy reasoning injection.
</Accordion>
<Accordion title="MiniMax">
API-key onboarding writes explicit text-only M2.7 chat model definitions; image understanding stays on the plugin-owned `MiniMax-VL-01` media provider.
API-key onboarding writes explicit M3 and M2.7 chat model definitions; image understanding stays on the plugin-owned `MiniMax-VL-01` media provider.
</Accordion>
<Accordion title="NVIDIA">
Model ids use a `nvidia/<vendor>/<model>` namespace (for example `nvidia/nvidia/nemotron-...` alongside `nvidia/moonshotai/kimi-k2.5`); pickers preserve the literal `<provider>/<model-id>` composition while the canonical key sent to the API stays single-prefixed.
@@ -537,7 +537,7 @@ On MiniMax's Anthropic-compatible streaming path, OpenClaw disables thinking by
Plugin-owned capability split:
- Text/chat defaults stay on `minimax/MiniMax-M2.7`
- Text/chat defaults stay on `minimax/MiniMax-M3`
- Image generation is `minimax/image-01` or `minimax-portal/image-01`
- Image understanding is plugin-owned `MiniMax-VL-01` on both MiniMax auth paths
- Web search stays on provider id `minimax`

View File

@@ -208,7 +208,7 @@ because of the bootstrap file limits below.
</Note>
Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 12000). Total injected bootstrap
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
(default: 60000). Missing files inject a short missing-file marker. When truncation
occurs, OpenClaw can inject a concise system-prompt warning notice; control this with

View File

@@ -103,11 +103,11 @@ Per-agent override: `agents.list[].contextInjection`. Omitted values inherit
### `agents.defaults.bootstrapMaxChars`
Max characters per workspace bootstrap file before truncation. Default: `12000`.
Max characters per workspace bootstrap file before truncation. Default: `20000`.
```json5
{
agents: { defaults: { bootstrapMaxChars: 12000 } },
agents: { defaults: { bootstrapMaxChars: 20000 } },
}
```
@@ -138,7 +138,7 @@ injection behavior from the shared defaults. Omitted fields inherit from
agents: {
defaults: {
contextInjection: "continuation-skip",
bootstrapMaxChars: 12000,
bootstrapMaxChars: 20000,
bootstrapTotalMaxChars: 60000,
},
list: [

View File

@@ -597,6 +597,8 @@ BlueBubbles support was removed. `channels.bluebubbles` is not a supported runti
If the Gateway is not running on the signed-in Messages Mac, keep `channels.imessage.enabled=true` and set `channels.imessage.cliPath` to an SSH wrapper that runs `imsg "$@"` on that Mac. The default local `imsg` path is macOS-only.
Before relying on an SSH wrapper for production sends, verify an outbound `imsg send` through that exact wrapper. Some macOS TCC states assign Messages Automation to `/usr/libexec/sshd-keygen-wrapper`, which can make reads and probes work while sends fail with AppleEvents `-1743`; see [SSH wrapper sends fail with AppleEvents -1743](/channels/imessage#ssh-wrapper-sends-fail-with-appleevents-1743).
```json5
{
channels: {

View File

@@ -645,14 +645,14 @@ Interactive custom-provider onboarding infers image input for common vision mode
<Accordion title="Local models (LM Studio)">
See [Local Models](/gateway/local-models). TL;DR: run a large local model via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
</Accordion>
<Accordion title="MiniMax M2.7 (direct)">
<Accordion title="MiniMax M3 (direct)">
```json5
{
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M2.7" },
model: { primary: "minimax/MiniMax-M3" },
models: {
"minimax/MiniMax-M2.7": { alias: "Minimax" },
"minimax/MiniMax-M3": { alias: "Minimax" },
},
},
},
@@ -665,12 +665,12 @@ Interactive custom-provider onboarding infers image input for common vision mode
api: "anthropic-messages",
models: [
{
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
id: "MiniMax-M3",
name: "MiniMax M3",
reasoning: true,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
contextWindow: 204800,
input: ["text", "image"],
cost: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 131072,
},
],
@@ -680,7 +680,7 @@ Interactive custom-provider onboarding infers image input for common vision mode
}
```
Set `MINIMAX_API_KEY`. Shortcuts: `openclaw onboard --auth-choice minimax-global-api` or `openclaw onboard --auth-choice minimax-cn-api`. The model catalog defaults to M2.7 only. On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking by default unless you explicitly set `thinking` yourself. `/fast on` or `params.fastMode: true` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
Set `MINIMAX_API_KEY`. Shortcuts: `openclaw onboard --auth-choice minimax-global-api` or `openclaw onboard --auth-choice minimax-cn-api`. The model catalog defaults to M3 and also includes the M2.7 variants. On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking by default unless you explicitly set `thinking` yourself. `/fast on` or `params.fastMode: true` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
</Accordion>
<Accordion title="Moonshot AI (Kimi)">

View File

@@ -215,7 +215,7 @@ troubleshooting, see the main [FAQ](/help/faq).
</Accordion>
<Accordion title='Why do I see "Unknown model: minimax/MiniMax-M2.7"?'>
<Accordion title='Why do I see "Unknown model: minimax/MiniMax-M3"?'>
This means the **provider isn't configured** (no MiniMax provider config or auth
profile was found), so the model can't be resolved.
@@ -227,8 +227,9 @@ troubleshooting, see the main [FAQ](/help/faq).
(`MINIMAX_API_KEY` for `minimax`, `MINIMAX_OAUTH_TOKEN` or stored MiniMax
OAuth for `minimax-portal`).
3. Use the exact model id (case-sensitive) for your auth path:
`minimax/MiniMax-M2.7` or `minimax/MiniMax-M2.7-highspeed` for API-key
setup, or `minimax-portal/MiniMax-M2.7` /
`minimax/MiniMax-M3`, `minimax/MiniMax-M2.7`, or
`minimax/MiniMax-M2.7-highspeed` for API-key setup, or
`minimax-portal/MiniMax-M3`, `minimax-portal/MiniMax-M2.7`, or
`minimax-portal/MiniMax-M2.7-highspeed` for OAuth setup.
4. Run:
@@ -253,9 +254,9 @@ troubleshooting, see the main [FAQ](/help/faq).
env: { MINIMAX_API_KEY: "sk-...", OPENAI_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M2.7" },
model: { primary: "minimax/MiniMax-M3" },
models: {
"minimax/MiniMax-M2.7": { alias: "minimax" },
"minimax/MiniMax-M3": { alias: "minimax" },
"openai/gpt-5.5": { alias: "gpt" },
},
},

View File

@@ -631,6 +631,48 @@ lives on the [First-run FAQ](/help/faq-first-run).
</Accordion>
<Accordion title="Can I make SOUL.md bigger?">
Yes. `SOUL.md` is one of the workspace bootstrap files injected into the
agent context. The default per-file injection limit is `20000` characters,
and the total bootstrap budget across files is `60000` characters.
Change the shared defaults in your OpenClaw config:
```json5
{
agents: {
defaults: {
bootstrapMaxChars: 50000,
bootstrapTotalMaxChars: 300000,
},
},
}
```
Or override one agent:
```json5
{
agents: {
list: [
{
id: "main",
bootstrapMaxChars: 50000,
bootstrapTotalMaxChars: 300000,
},
],
},
}
```
Use `/context` to check raw vs injected sizes and whether truncation happened.
Keep `SOUL.md` focused on voice, stance, and personality; put operating rules
in `AGENTS.md` and durable facts in memory.
See [Context](/concepts/context) and [Agent config](/gateway/config-agents).
</Accordion>
<Accordion title="Recommended backup strategy">
Put your **agent workspace** in a **private** git repo and back it up somewhere
private (for example GitHub private). This captures memory + AGENTS/SOUL/USER

View File

@@ -73,10 +73,11 @@ Live tests are split into two layers so we can isolate failures:
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
- Set `OPENCLAW_LIVE_MODELS=modern`, `small`, or `all` (alias for modern) to actually run this suite; otherwise it skips to keep `pnpm test:live` focused on gateway smoke
- How to select models:
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M2.7, Grok 4.3)
- `OPENCLAW_LIVE_MODELS=small` to run the constrained small-model allowlist (Qwen 8B/9B local-compatible routes, OpenRouter Qwen/GLM, and Z.AI GLM)
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M3, Grok 4.3)
- `OPENCLAW_LIVE_MODELS=small` to run the constrained small-model allowlist (Qwen 8B/9B local-compatible routes, Ollama Gemma, OpenRouter Qwen/GLM, and Z.AI GLM)
- `OPENCLAW_LIVE_MODELS=all` is an alias for the modern allowlist
- or `OPENCLAW_LIVE_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,..."` (comma allowlist)
- Local Ollama small-model runs default to `http://127.0.0.1:11434`; set `OPENCLAW_LIVE_OLLAMA_BASE_URL` only for LAN, custom, or Ollama Cloud endpoints.
- Modern/all and small sweeps default to their curated caps; set `OPENCLAW_LIVE_MAX_MODELS=0` for an exhaustive selected-profile sweep or a positive number for a smaller cap.
- Exhaustive sweeps use `OPENCLAW_LIVE_TEST_TIMEOUT_MS` for the whole direct-model test timeout. Default: 60 minutes.
- Direct-model probes run with 20-way parallelism by default; set `OPENCLAW_LIVE_MODEL_CONCURRENCY` to override.
@@ -108,7 +109,7 @@ Live tests are split into two layers so we can isolate failures:
- How to enable:
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
- How to select models:
- Default: modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M2.7, Grok 4.3)
- Default: modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M3, Grok 4.3)
- `OPENCLAW_LIVE_GATEWAY_MODELS=all` is an alias for the modern allowlist
- Or set `OPENCLAW_LIVE_GATEWAY_MODELS="provider/model"` (or comma list) to narrow
- Modern/all gateway sweeps default to a curated high-signal cap; set `OPENCLAW_LIVE_GATEWAY_MAX_MODELS=0` for an exhaustive modern sweep or a positive number for a smaller cap.
@@ -350,7 +351,7 @@ Narrow, explicit allowlists are fastest and least flaky:
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- Tool calling across several providers:
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,deepseek/deepseek-v4-flash,zai/glm-5.1,minimax/MiniMax-M2.7" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,deepseek/deepseek-v4-flash,zai/glm-5.1,minimax/MiniMax-M3" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- Google focus (Gemini API key + Antigravity):
- Gemini (API key): `OPENCLAW_LIVE_GATEWAY_MODELS="google/gemini-3-flash-preview" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
@@ -384,10 +385,10 @@ This is the "common models" run we expect to keep working:
- Google (Antigravity): `google-antigravity/claude-opus-4-6-thinking` and `google-antigravity/gemini-3-flash`
- DeepSeek: `deepseek/deepseek-v4-flash` and `deepseek/deepseek-v4-pro`
- Z.AI (GLM): `zai/glm-5.1`
- MiniMax: `minimax/MiniMax-M2.7`
- MiniMax: `minimax/MiniMax-M3`
Run gateway smoke with tools + image:
`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,google/gemini-3.1-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,deepseek/deepseek-v4-flash,zai/glm-5.1,minimax/MiniMax-M2.7" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,google/gemini-3.1-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,deepseek/deepseek-v4-flash,zai/glm-5.1,minimax/MiniMax-M3" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
### Baseline: tool calling (Read + optional Exec)
@@ -398,7 +399,7 @@ Pick at least one per provider family:
- Google: `google/gemini-3-flash-preview` (or `google/gemini-3.1-pro-preview`)
- DeepSeek: `deepseek/deepseek-v4-flash`
- Z.AI (GLM): `zai/glm-5.1`
- MiniMax: `minimax/MiniMax-M2.7`
- MiniMax: `minimax/MiniMax-M3`
Optional additional coverage (nice to have):

View File

@@ -245,7 +245,7 @@ Notes:
The iOS app is a mobile node surface, not a Codex Computer Use backend. Codex
Computer Use and `cua-driver mcp` control a local macOS desktop through MCP
tools; the iOS app exposes iPhone capabilities through OpenClaw node commands
tools; the iOS app exposes iPhone and iPad capabilities through OpenClaw node commands
such as `canvas.*`, `camera.*`, `screen.*`, `location.*`, and `talk.*`.
Agents can still operate the iOS app through OpenClaw by invoking node

View File

@@ -1021,10 +1021,10 @@ plugin index entry with `source: "path"` and a workspace-relative
`plugins.load.paths`; the install record avoids duplicating local workstation
paths into long-lived config. This keeps local development installs visible to
source-plane diagnostics without adding a second raw filesystem-path disclosure
surface. The persisted `plugins/installs.json` plugin index is the install
surface. The persisted `installed_plugin_index` SQLite row is the install
source of truth and can be refreshed without loading plugin runtime modules.
Its `installRecords` map is durable even when a plugin manifest is missing or
invalid; its `plugins` array is a rebuildable manifest view.
invalid; its `plugins` payload is a rebuildable manifest view.
## Context engine plugins

View File

@@ -399,8 +399,10 @@ media caption.
Message hook contexts expose stable correlation fields when available:
`ctx.sessionKey`, `ctx.runId`, `ctx.messageId`, `ctx.senderId`, `ctx.trace`,
`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Prefer
these first-class fields before reading legacy metadata.
`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Inbound
and `before_dispatch` contexts also expose reply metadata when the channel has
visibility-filtered quoted message data: `replyToId`, `replyToBody`, and
`replyToSender`. Prefer these first-class fields before reading legacy metadata.
Prefer typed `threadId` and `replyToId` fields before using channel-specific
metadata.

View File

@@ -17,3 +17,15 @@ Adds syntax highlighting for languages outside the default diffs viewer set.
## Surface
plugin
<!-- openclaw-plugin-reference:manual-start -->
## Added languages
The base `diffs` plugin already highlights the common languages documented in [Diffs](/tools/diffs). Install this language pack when you want syntax highlighting for a broader set of Shiki-supported languages. If the pack is not installed, those files still render as readable plain text.
Examples include Astro, Vue, Svelte, MDX, GraphQL, Terraform/HCL, Nix, Clojure, Elixir, Haskell, OCaml, Scala, Zig, Solidity, Verilog/VHDL, Fortran, MATLAB, LaTeX, Mermaid, Sass/Less/SCSS, Nginx, Apache, CSV, dotenv, INI, and diff files.
See [Shiki languages](https://shiki.style/languages) for Shiki's upstream language and alias catalog.
<!-- openclaw-plugin-reference:manual-end -->

View File

@@ -652,6 +652,7 @@ releases.
| `plugin-sdk/zod` | Deprecated Zod compatibility re-export | Import `zod` from `zod` directly |
| `plugin-sdk/memory-core` | Bundled memory-core helpers | Memory manager/config/file/CLI helper surface |
| `plugin-sdk/memory-core-engine-runtime` | Memory engine runtime facade | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-embedding-registry` | Memory embedding registry | Lightweight memory embedding provider registry helpers |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory embedding contracts, registry access, local provider, and generic batch/remote helpers; concrete remote providers live in their owning plugins |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports |

View File

@@ -355,6 +355,7 @@ usage endpoint failed or returned no usable usage data.
| --- | --- |
| `plugin-sdk/memory-core` | Bundled memory-core helper surface for manager/config/file/CLI helpers |
| `plugin-sdk/memory-core-engine-runtime` | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-embedding-registry` | Lightweight memory embedding provider registry helpers |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers. `registerMemoryEmbeddingProvider` on this surface is deprecated; use the generic embedding provider API for new providers. |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports |

View File

@@ -9,7 +9,8 @@ title: "Workboard plugin"
The Workboard plugin adds an optional Kanban-style board to the
[Control UI](/web/control-ui). Use it to collect agent-sized work cards, assign
them to agents, and jump from a card into the linked dashboard session.
them to agents, and track the linked background task, run, and dashboard
session from one card.
Workboard is intentionally small. It tracks local operating work for an
OpenClaw Gateway; it is not a replacement for GitHub Issues, Linear, Jira, or
@@ -47,8 +48,8 @@ Each card stores:
- priority: `low`, `normal`, `high`, or `urgent`
- labels
- optional agent id
- optional linked session, run, task, or source URL
- optional execution metadata for a Codex or Claude session started from the card
- optional linked task, run, session, or source URL
- optional execution metadata for a Codex or Claude run started from the card
- compact metadata for attempts, comments, links, proof, artifacts, automation,
attachments, worker logs, worker protocol state, claims, diagnostics,
notifications, templates, archive state, and stale-session detection
@@ -65,26 +66,35 @@ proof snippets, related links, comments, archive markers, and stale-session
markers are intentionally local metadata; they do not replace session
transcripts or GitHub issue history.
## Card executions
## Card executions and tasks
Unlinked cards can start work from the card. Start uses the Gateway's configured
Unlinked cards can start work from the card. Autonomous starts use the
Gateway's task-tracked agent run path, then Workboard links the resulting task,
run id, and session key back onto the card. Start uses the Gateway's configured
default agent and model. Codex and Claude actions are optional explicit model
choices:
- Run Codex or Run Claude creates a dashboard session, sends the card prompt,
and marks the card `running`.
- Run Codex or Run Claude starts a task-backed agent run, sends the card
prompt, and marks the card `running`.
- Open Codex or Open Claude creates a linked dashboard session without sending
the card prompt or moving the card, so you can work manually while it stays
attached to the board.
Execution metadata stores the selected engine, mode, model ref, session key,
run id, and lifecycle status on the card. Codex executions use
`openai/gpt-5.5`; Claude executions use `anthropic/claude-sonnet-4-6`.
run id, task id when available, and lifecycle status on the card. Codex
executions use `openai/gpt-5.5`; Claude executions use
`anthropic/claude-sonnet-4-6`.
Each linked execution also records an attempt summary on the same card record.
The attempt summary keeps the engine, mode, model, run id, timestamps, status,
and rolling failure count so repeated failures remain visible on the board.
The dashboard refreshes task status from the Gateway task ledger and matches
tasks back to cards by task id, run id, or linked session key. If a task is
queued or running, the card lifecycle shows active task state. If the task
finishes, fails, times out, or is cancelled, the card lifecycle moves toward
review or blocked status using the same lifecycle sync as linked sessions.
## Agent coordination
Workboard also exposes optional agent tools for board-aware workflows:
@@ -160,13 +170,15 @@ blocked cards that need attention, repeated failures, done cards without proof,
and running cards that only have a loose session link.
Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating
system processes; normal OpenClaw subagent sessions still own execution. A
dispatch nudge promotes dependency-ready cards, records dispatch metadata on
system processes; normal OpenClaw subagent sessions still own execution. The
dispatch action promotes dependency-ready cards, records dispatch metadata on
ready cards, blocks expired claims or timed-out runs, marks board-configured
triage cards as orchestration candidates, then claims a small batch of ready
cards and starts worker runs through the Gateway subagent runtime. Workers get
bounded card context plus the claim token they need to heartbeat, complete, or
block the card through the Workboard tools.
cards and starts worker runs through the Gateway subagent runtime. Assigned
cards use `agent:<id>:subagent:workboard-*` worker session keys; unassigned
cards use unscoped `subagent:workboard-*` keys so the Gateway still resolves the
configured default agent. Workers get bounded card context plus the claim token
they need to heartbeat, complete, or block the card through the Workboard tools.
### Dispatch worker selection

View File

@@ -6,7 +6,7 @@ read_when:
title: "MiniMax"
---
OpenClaw's MiniMax provider defaults to **MiniMax M2.7**.
OpenClaw's MiniMax provider defaults to **MiniMax M3**.
MiniMax also provides:
@@ -26,7 +26,8 @@ Provider split:
| Model | Type | Description |
| ------------------------ | ---------------- | ---------------------------------------- |
| `MiniMax-M2.7` | Chat (reasoning) | Default hosted reasoning model |
| `MiniMax-M3` | Chat (reasoning) | Default hosted reasoning model |
| `MiniMax-M2.7` | Chat (reasoning) | Previous hosted reasoning model |
| `MiniMax-M2.7-highspeed` | Chat (reasoning) | Faster M2.7 reasoning tier |
| `MiniMax-VL-01` | Vision | Image understanding model |
| `image-01` | Image generation | Text-to-image and image-to-image editing |
@@ -79,7 +80,7 @@ Choose your preferred auth method and follow the setup steps.
</Tabs>
<Note>
OAuth setups use the `minimax-portal` provider id. Model refs follow the form `minimax-portal/MiniMax-M2.7`.
OAuth setups use the `minimax-portal` provider id. Model refs follow the form `minimax-portal/MiniMax-M3`.
</Note>
<Tip>
@@ -131,7 +132,7 @@ Choose your preferred auth method and follow the setup steps.
```json5
{
env: { MINIMAX_API_KEY: "sk-..." },
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.7" } } },
agents: { defaults: { model: { primary: "minimax/MiniMax-M3" } } },
models: {
mode: "merge",
providers: {
@@ -140,6 +141,15 @@ Choose your preferred auth method and follow the setup steps.
apiKey: "${MINIMAX_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M3",
name: "MiniMax M3",
reasoning: true,
input: ["text", "image"],
cost: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 131072,
},
{
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
@@ -170,7 +180,7 @@ Choose your preferred auth method and follow the setup steps.
</Warning>
<Note>
API-key setups use the `minimax` provider id. Model refs follow the form `minimax/MiniMax-M2.7`.
API-key setups use the `minimax` provider id. Model refs follow the form `minimax/MiniMax-M3`.
</Note>
</Tab>
@@ -243,9 +253,10 @@ through the CN endpoint; the default global endpoint is
`https://api.minimax.io`.
When onboarding or API-key setup writes explicit `models.providers.minimax`
entries, OpenClaw materializes `MiniMax-M2.7` and
`MiniMax-M2.7-highspeed` as text-only chat models. Image understanding is
exposed separately through the plugin-owned `MiniMax-VL-01` media provider.
entries, OpenClaw materializes `MiniMax-M3`, `MiniMax-M2.7`, and
`MiniMax-M2.7-highspeed` as chat models. M3 advertises text and image input;
image understanding remains exposed separately through the plugin-owned
`MiniMax-VL-01` media provider.
<Note>
See [Image Generation](/tools/image-generation) for shared tool parameters, provider selection, and failover behavior.
@@ -353,7 +364,7 @@ catalog:
| `minimax-portal` | `MiniMax-VL-01` |
That is why automatic media routing can use MiniMax image understanding even
when the bundled text-provider catalog still shows text-only M2.7 chat refs.
when the bundled text-provider catalog also includes M3 image-capable chat refs.
### Web search
@@ -437,12 +448,12 @@ See [MiniMax Search](/tools/minimax-search) for full web search configuration an
- Model refs follow the auth path:
- API-key setup: `minimax/<model>`
- OAuth setup: `minimax-portal/<model>`
- Default chat model: `MiniMax-M2.7`
- Alternate chat model: `MiniMax-M2.7-highspeed`
- Onboarding and direct API-key setup write text-only model definitions for both M2.7 variants
- Default chat model: `MiniMax-M3`
- Alternate chat models: `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`
- Onboarding and direct API-key setup write model definitions for M3 and both M2.7 variants
- Image understanding uses the plugin-owned `MiniMax-VL-01` media provider
- Update pricing values in `models.json` if you need exact cost tracking
- Use `openclaw models list` to confirm the current provider id, then switch with `openclaw models set minimax/MiniMax-M2.7` or `openclaw models set minimax-portal/MiniMax-M2.7`
- Use `openclaw models list` to confirm the current provider id, then switch with `openclaw models set minimax/MiniMax-M3` or `openclaw models set minimax-portal/MiniMax-M3`
<Tip>
Referral link for MiniMax Coding Plan (10% off): [MiniMax Coding Plan](https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link)
@@ -455,7 +466,7 @@ See [Model providers](/concepts/model-providers) for provider rules.
## Troubleshooting
<AccordionGroup>
<Accordion title='"Unknown model: minimax/MiniMax-M2.7"'>
<Accordion title='"Unknown model: minimax/MiniMax-M3"'>
This usually means the **MiniMax provider is not configured** (no matching provider entry and no MiniMax auth profile/env key found). A fix for this detection is in **2026.1.12**. Fix by:
- Upgrading to **2026.1.12** (or run from source `main`), then restarting the gateway.
@@ -465,8 +476,8 @@ See [Model providers](/concepts/model-providers) for provider rules.
Make sure the model id is **case-sensitive**:
- API-key path: `minimax/MiniMax-M2.7` or `minimax/MiniMax-M2.7-highspeed`
- OAuth path: `minimax-portal/MiniMax-M2.7` or `minimax-portal/MiniMax-M2.7-highspeed`
- API-key path: `minimax/MiniMax-M3`, `minimax/MiniMax-M2.7`, or `minimax/MiniMax-M2.7-highspeed`
- OAuth path: `minimax-portal/MiniMax-M3`, `minimax-portal/MiniMax-M2.7`, or `minimax-portal/MiniMax-M2.7-highspeed`
Then recheck with:

View File

@@ -20,7 +20,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
prompt surface. It is bounded by `skills.limits.maxSkillsPromptChars`, with
optional per-agent override at `agents.list[].skillsLimits.maxSkillsPromptChars`.
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Native Codex turns do not paste raw `MEMORY.md` from the configured agent workspace when memory tools are available for that workspace; they include a small memory pointer in turn-scoped collaboration developer instructions and use memory tools on demand. If tools are disabled, memory search is unavailable, or the active workspace differs from the agent memory workspace, `MEMORY.md` uses the normal bounded turn-context path. Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large injected files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but reset/startup model runs can prepend a one-shot startup-context block with recent daily memory for that first turn. Bare chat `/new` and `/reset` commands are acknowledged without invoking the model. The startup prelude is controlled by `agents.defaults.startupContext`. Post-compaction AGENTS.md excerpts are separate and require explicit `agents.defaults.compaction.postCompactionSections` opt-in.
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Native Codex turns do not paste raw `MEMORY.md` from the configured agent workspace when memory tools are available for that workspace; they include a small memory pointer in turn-scoped collaboration developer instructions and use memory tools on demand. If tools are disabled, memory search is unavailable, or the active workspace differs from the agent memory workspace, `MEMORY.md` uses the normal bounded turn-context path. Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large injected files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but reset/startup model runs can prepend a one-shot startup-context block with recent daily memory for that first turn. Bare chat `/new` and `/reset` commands are acknowledged without invoking the model. The startup prelude is controlled by `agents.defaults.startupContext`. Post-compaction AGENTS.md excerpts are separate and require explicit `agents.defaults.compaction.postCompactionSections` opt-in.
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)

View File

@@ -47,7 +47,7 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
- **Cloudflare AI Gateway**: prompts for Account ID, Gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
- More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- **MiniMax**: config is auto-written; hosted default is `MiniMax-M2.7`.
- **MiniMax**: config is auto-written; hosted default is `MiniMax-M3`.
API-key setup uses `minimax/...`, and OAuth setup uses
`minimax-portal/...`.
- More detail: [MiniMax](/providers/minimax)

View File

@@ -182,7 +182,7 @@ What you set:
More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway).
</Accordion>
<Accordion title="MiniMax">
Config is auto-written. Hosted default is `MiniMax-M2.7`; API-key setup uses
Config is auto-written. Hosted default is `MiniMax-M3`; API-key setup uses
`minimax/...`, and OAuth setup uses `minimax-portal/...`.
More detail: [MiniMax](/providers/minimax).
</Accordion>

View File

@@ -216,7 +216,9 @@ Install the Diff Viewer Language Pack plugin to highlight other languages:
openclaw plugins install clawhub:@openclaw/diffs-language-pack
```
With the language pack available, OpenClaw automatically uses it for languages outside the default list. Without it, those files stay readable as plain text.
With the language pack available, OpenClaw can highlight many more languages. If the pack is not installed, files outside the default list still render as readable plain text. Examples include Astro, Vue, Svelte, MDX, GraphQL, Terraform/HCL, Nix, Clojure, Elixir, Haskell, OCaml, Scala, Zig, Solidity, Verilog/VHDL, Fortran, MATLAB, LaTeX, Mermaid, Sass/Less/SCSS, Nginx, Apache, CSV, dotenv, INI, and diff files.
See [Diffs Language Pack plugin](/plugins/reference/diffs-language-pack) for details and [Shiki languages](https://shiki.style/languages) for Shiki's upstream language and alias catalog.
## Output details contract

View File

@@ -312,6 +312,120 @@ describe("prepareAcpxCodexAuthConfig", () => {
expect(path.resolve(String(launched.codexHome))).toBe(expectedCodexHome);
});
it("writes API-key auth into the isolated Codex ACP home when env auth is present", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const installedBinPath = path.join(root, "codex-acp-bin.js");
await fs.writeFile(
installedBinPath,
"console.log(JSON.stringify({ codexHome: process.env.CODEX_HOME }));\n",
"utf8",
);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
await execFileAsync(process.execPath, [generated.wrapperPath], {
cwd: root,
env: { ...process.env, CODEX_API_KEY: "", OPENAI_API_KEY: "sk-test-api-key" },
});
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as {
auth_mode?: unknown;
OPENAI_API_KEY?: unknown;
};
expect(auth).toMatchObject({
OPENAI_API_KEY: "sk-test-api-key",
});
expect(auth).not.toHaveProperty("auth_mode");
if (process.platform !== "win32") {
const mode = (await fs.stat(authPath)).mode & 0o777;
expect(mode).toBe(0o600);
}
});
it("preserves existing isolated Codex auth when env auth is present", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const installedBinPath = path.join(root, "codex-acp-bin.js");
await fs.writeFile(installedBinPath, "console.log('ok');\n", "utf8");
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
const existingAuth = {
auth_mode: "chatgpt",
tokens: { access_token: "existing-token" },
last_refresh: null,
};
await fs.writeFile(authPath, `${JSON.stringify(existingAuth)}\n`, { mode: 0o600 });
await execFileAsync(process.execPath, [generated.wrapperPath], {
cwd: root,
env: { ...process.env, OPENAI_API_KEY: "sk-test-api-key" },
});
expect(JSON.parse(await fs.readFile(authPath, "utf8"))).toEqual(existingAuth);
});
it("updates existing isolated Codex API-key auth when env auth changes", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const installedBinPath = path.join(root, "codex-acp-bin.js");
await fs.writeFile(installedBinPath, "console.log('ok');\n", "utf8");
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
await fs.writeFile(
authPath,
`${JSON.stringify({
OPENAI_API_KEY: "sk-old-api-key",
tokens: null,
last_refresh: null,
})}\n`,
{ mode: 0o600 },
);
await execFileAsync(process.execPath, [generated.wrapperPath], {
cwd: root,
env: { ...process.env, CODEX_API_KEY: "sk-new-api-key", OPENAI_API_KEY: "sk-other-key" },
});
expect(JSON.parse(await fs.readFile(authPath, "utf8"))).toMatchObject({
OPENAI_API_KEY: "sk-new-api-key",
tokens: null,
last_refresh: null,
});
});
it("launches the locally installed Claude ACP bin without going through npm", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");

View File

@@ -4,11 +4,11 @@ import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
import { quoteCommandPart, splitCommandParts } from "./command-line.js";
import {
extractTrustedCodexProjectPaths,
renderIsolatedCodexConfig,
} from "./codex-trust-config.js";
import { quoteCommandPart, splitCommandParts } from "./command-line.js";
import { resolveAcpxPluginRoot } from "./config.js";
import type { ResolvedAcpxPluginConfig } from "./config.js";
import {
@@ -528,6 +528,35 @@ function buildCodexAcpWrapperScript(installedBinPath?: string): string {
installedBinPath,
stderrLogFileNamePrefix: "codex-acp-wrapper.stderr",
envSetup: `const codexHome = fileURLToPath(new URL("./codex-home/", import.meta.url));
const codexAuthPath = fileURLToPath(new URL("./codex-home/auth.json", import.meta.url));
const codexApiKey = (process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY || "").trim();
let shouldWriteCodexApiKeyAuth = false;
if (codexApiKey) {
if (!existsSync(codexAuthPath)) {
shouldWriteCodexApiKeyAuth = true;
} else {
try {
const existingCodexAuth = JSON.parse(readFileSync(codexAuthPath, "utf8"));
shouldWriteCodexApiKeyAuth =
!existingCodexAuth ||
typeof existingCodexAuth !== "object" ||
typeof existingCodexAuth.OPENAI_API_KEY === "string";
} catch {
shouldWriteCodexApiKeyAuth = true;
}
}
}
if (shouldWriteCodexApiKeyAuth) {
writeFileSync(
codexAuthPath,
JSON.stringify({
OPENAI_API_KEY: codexApiKey,
tokens: null,
last_refresh: null,
}) + "\\n",
{ mode: 0o600 },
);
}
const env = {
...process.env,
CODEX_HOME: codexHome,

View File

@@ -68,7 +68,7 @@ class LegacyRunTurnEventQueue {
return item;
}
if (this.error) {
throw this.error;
throw toLintErrorObject(this.error, "Non-Error thrown");
}
if (this.closed) {
return null;
@@ -178,3 +178,17 @@ export function lazyStartRuntimeTurn(
},
};
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -286,7 +286,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
})
.then(
() => ({ status: "resolved" as const }),
(error) => ({ status: "rejected" as const, error }),
(error: unknown) => ({ status: "rejected" as const, error }),
);
expect(outcome.status).toBe("rejected");
@@ -298,7 +298,12 @@ describe("AcpxRuntime fresh reset wrapper", () => {
code: "ACP_SESSION_INIT_FAILED",
message: expect.stringContaining("deployment missing"),
});
expect(outcome.error.message).not.toContain("sk-testsecret1234567890");
const error = outcome.error;
expect(error).toBeInstanceOf(AcpRuntimeError);
if (!(error instanceof AcpRuntimeError)) {
throw new Error("expected AcpRuntimeError");
}
expect(error.message).not.toContain("sk-testsecret1234567890");
});
it("adds Codex wrapper stderr tail to generic first-turn failures", async () => {

View File

@@ -218,13 +218,21 @@ describe("active-memory plugin", () => {
};
const waitForAbort = async (abortSignal?: AbortSignal): Promise<never> => {
if (abortSignal?.aborted) {
throw (abortSignal.reason as unknown) ?? new Error("Operation aborted");
throw toLintErrorObject(
(abortSignal.reason as unknown) ?? new Error("Operation aborted"),
"Non-Error thrown",
);
}
return await new Promise<never>((_resolve, reject) => {
abortSignal?.addEventListener(
"abort",
() => {
reject((abortSignal.reason as unknown) ?? new Error("Operation aborted"));
reject(
toLintErrorObject(
(abortSignal.reason as unknown) ?? new Error("Operation aborted"),
"Non-Error rejection",
),
);
},
{ once: true },
);
@@ -4350,3 +4358,17 @@ describe("active-memory plugin", () => {
expect(config.circuitBreakerCooldownMs).toBe(5000);
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -1011,7 +1011,6 @@ function buildPromptStyleLines(style: ActiveMemoryPromptStyle): string[] {
"If relevant memory is mostly a stable user preference or recurring habit, lean toward returning it.",
"If the strongest match is only a one-off historical fact and not a recurring preference or habit, prefer NONE unless the latest user message clearly asks for that fact.",
];
case "balanced":
default:
return [
"Treat the latest user message as the primary query.",
@@ -1982,7 +1981,7 @@ async function waitForSubagentPartialTimeoutData(
(await Promise.race([
subagentPromise.then(
() => undefined,
(error) => readPartialTimeoutData(error),
(error: unknown) => readPartialTimeoutData(error),
),
timeoutPromise,
])) ?? {}

View File

@@ -1,7 +0,0 @@
import { describePluginRegistrationContract } from "openclaw/plugin-sdk/plugin-test-contracts";
describePluginRegistrationContract({
pluginId: "alibaba",
videoGenerationProviderIds: ["alibaba"],
requireGenerateVideo: true,
});

View File

@@ -571,7 +571,7 @@ export async function startGatewayBonjourAdvertiser(
.then(() => {
logger.info(`bonjour: advertised ${serviceSummary(label, svc)}`);
})
.catch((err) => {
.catch((err: unknown) => {
handleAdvertiseFailure(label, svc, err, "failed");
});
} catch (err) {
@@ -747,7 +747,7 @@ export async function startGatewayBonjourAdvertiser(
)})`,
);
try {
void svc.advertise().catch((err) => {
void svc.advertise().catch((err: unknown) => {
logger.warn(
`bonjour: watchdog re-advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);

View File

@@ -416,7 +416,8 @@ describe("cdp.helpers internal", () => {
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.ok");
const rejectRawString = () => Promise.reject("raw-string-from-callback");
const rejectRawString = () =>
Promise.reject(toLintErrorObject("raw-string-from-callback", "Non-Error rejection"));
return rejectRawString();
}),
).rejects.toThrow(/raw-string-from-callback/);
@@ -572,3 +573,17 @@ describe("openCdpWebSocket option handling", () => {
ws.close();
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -666,6 +666,535 @@ describe("chrome MCP page parsing", () => {
expect(result).toBe(123);
});
it("keeps a shared pending session alive when one waiter aborts", async () => {
let factoryCalls = 0;
let releaseFactory: (() => void) | undefined;
const factoryGate = new Promise<void>((resolve) => {
releaseFactory = resolve;
});
if (!releaseFactory) {
throw new Error("Expected Chrome MCP factory release callback to be initialized");
}
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
await factoryGate;
const session = createFakeSession();
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const keptCtrl = new AbortController();
const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const tabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: keptCtrl.signal,
});
const abortedTabsExpectation =
expect(abortedTabsPromise).rejects.toThrow(/first caller cancelled/);
ctrl.abort(new Error("first caller cancelled"));
releaseFactory();
await abortedTabsExpectation;
await expect(tabsPromise).resolves.toHaveLength(2);
expect(factoryCalls).toBe(1);
expect(closeMock).not.toHaveBeenCalled();
});
it("closes a shared pending session when every waiter aborts", async () => {
let factoryCalls = 0;
let releaseFactory: (() => void) | undefined;
const factoryGate = new Promise<void>((resolve) => {
releaseFactory = resolve;
});
if (!releaseFactory) {
throw new Error("Expected Chrome MCP factory release callback to be initialized");
}
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
await factoryGate;
const session = createFakeSession();
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const tabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const tabsExpectation = expect(tabsPromise).rejects.toThrow(/caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
ctrl.abort(new Error("caller cancelled"));
releaseFactory();
await tabsExpectation;
await vi.waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1));
expect(factoryCalls).toBe(1);
});
it("starts a fresh shared session after every waiter aborts a pending attach", async () => {
let factoryCalls = 0;
const releaseFactories: Array<() => void> = [];
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
let releaseFactory: (() => void) | undefined;
const factoryGate = new Promise<void>((resolve) => {
releaseFactory = resolve;
});
if (!releaseFactory) {
throw new Error("Expected Chrome MCP factory release callback to be initialized");
}
releaseFactories.push(releaseFactory);
await factoryGate;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const abortedTabsExpectation = expect(abortedTabsPromise).rejects.toThrow(/caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
ctrl.abort(new Error("caller cancelled"));
await abortedTabsExpectation;
const tabsPromise = listChromeMcpTabs("chrome-live");
await vi.waitFor(() => expect(factoryCalls).toBe(2));
releaseFactories[0]?.();
releaseFactories[1]?.();
await expect(tabsPromise).resolves.toHaveLength(2);
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
expect(closeMocks[1]).not.toHaveBeenCalled();
});
it("closes a shared pending session when every waiter aborts before ready", async () => {
let factoryCalls = 0;
let releaseReady: (() => void) | undefined;
const readyGate = new Promise<void>((resolve) => {
releaseReady = resolve;
});
if (!releaseReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
session.ready = readyGate;
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const tabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const tabsExpectation = expect(tabsPromise).rejects.toThrow(/caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
ctrl.abort(new Error("caller cancelled"));
releaseReady();
await tabsExpectation;
await vi.waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1));
});
it("starts a fresh session while last-waiter abort cleanup is closing", async () => {
let factoryCalls = 0;
let releaseFirstClose: (() => void) | undefined;
const firstCloseGate = new Promise<void>((resolve) => {
releaseFirstClose = resolve;
});
if (!releaseFirstClose) {
throw new Error("Expected Chrome MCP close release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock =
factoryCalls === 1
? vi.fn(async () => {
await firstCloseGate;
})
: vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
session.ready = new Promise<void>(() => {});
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const abortedTabsExpectation = expect(abortedTabsPromise).rejects.toThrow(/caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
ctrl.abort(new Error("caller cancelled"));
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
const tabsPromise = listChromeMcpTabs("chrome-live");
await vi.waitFor(() => expect(factoryCalls).toBe(2));
await expect(tabsPromise).resolves.toHaveLength(2);
expect(closeMocks[1]).not.toHaveBeenCalled();
releaseFirstClose();
await abortedTabsExpectation;
});
it("keeps a ready-pending shared session cached when another waiter remains", async () => {
let factoryCalls = 0;
let releaseReady: (() => void) | undefined;
const readyGate = new Promise<void>((resolve) => {
releaseReady = resolve;
});
const readyThen = vi.spyOn(readyGate, "then");
if (!releaseReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
session.ready = readyGate;
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const abortedTabsExpectation =
expect(abortedTabsPromise).rejects.toThrow(/first caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(readyThen).toHaveBeenCalledTimes(1));
const keptCtrl = new AbortController();
const tabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: keptCtrl.signal,
});
await vi.waitFor(() => expect(readyThen).toHaveBeenCalledTimes(2));
ctrl.abort(new Error("first caller cancelled"));
releaseReady();
await abortedTabsExpectation;
await expect(tabsPromise).resolves.toHaveLength(2);
await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2);
expect(factoryCalls).toBe(1);
expect(closeMock).not.toHaveBeenCalled();
});
it("starts a fresh shared session when a ready-pending session loses its transport", async () => {
let factoryCalls = 0;
let firstSession: ChromeMcpSession | undefined;
let releaseFirstReady: (() => void) | undefined;
const firstReadyGate = new Promise<void>((resolve) => {
releaseFirstReady = resolve;
});
const firstReadyThen = vi.spyOn(firstReadyGate, "then");
if (!releaseFirstReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
firstSession = session;
session.ready = firstReadyGate;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const firstTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const firstTabsExpectation = expect(firstTabsPromise).rejects.toThrow(/first waiter cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(1));
if (!firstSession) {
throw new Error("Expected first Chrome MCP session to be created");
}
(firstSession.transport as { pid: number | null }).pid = null;
const tabsPromise = listChromeMcpTabs("chrome-live");
const siblingTabsPromise = listChromeMcpTabs("chrome-live");
ctrl.abort(new Error("first waiter cancelled"));
releaseFirstReady();
await vi.waitFor(() => expect(factoryCalls).toBe(2));
const [tabs, siblingTabs] = await Promise.all([tabsPromise, siblingTabsPromise]);
expect(tabs).toHaveLength(2);
expect(siblingTabs).toHaveLength(2);
await firstTabsExpectation;
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
expect(closeMocks[1]).not.toHaveBeenCalled();
});
it("surfaces startup failures before treating null-pid pending sessions as stale", async () => {
let factoryCalls = 0;
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
if (factoryCalls > 1) {
throw new Error("unexpected retry");
}
const session = createFakeSession();
(session.transport as { pid: number | null }).pid = null;
const readyFailure = Promise.reject(new Error("startup failed"));
readyFailure.catch(() => {});
session.ready = readyFailure;
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
await expect(listChromeMcpTabs("chrome-live")).rejects.toThrow(/startup failed/);
expect(factoryCalls).toBe(1);
await vi.waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1));
});
it("bounds retries when ready sessions keep losing their transport", async () => {
let factoryCalls = 0;
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
(session.transport as { pid: number | null }).pid = null;
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
await expect(listChromeMcpTabs("chrome-live")).rejects.toThrow(
/subprocess exited before it became usable/,
);
expect(factoryCalls).toBe(2);
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalled());
await vi.waitFor(() => expect(closeMocks[1]).toHaveBeenCalled());
});
it("does not reuse a stale ready-pending session for ephemeral probes", async () => {
let factoryCalls = 0;
let firstSession: ChromeMcpSession | undefined;
let releaseFirstReady: (() => void) | undefined;
const firstReadyGate = new Promise<void>((resolve) => {
releaseFirstReady = resolve;
});
const firstReadyThen = vi.spyOn(firstReadyGate, "then");
if (!releaseFirstReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
firstSession = session;
session.ready = firstReadyGate;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const firstAvailablePromise = ensureChromeMcpAvailable("chrome-live", undefined, {
signal: ctrl.signal,
});
const firstAvailableExpectation =
expect(firstAvailablePromise).rejects.toThrow(/first waiter cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(1));
if (!firstSession) {
throw new Error("Expected first Chrome MCP session to be created");
}
(firstSession.transport as { pid: number | null }).pid = null;
const availablePromise = ensureChromeMcpAvailable("chrome-live", undefined, {
ephemeral: true,
});
ctrl.abort(new Error("first waiter cancelled"));
releaseFirstReady();
await expect(availablePromise).resolves.toBeUndefined();
expect(factoryCalls).toBe(2);
await vi.waitFor(() => expect(closeMocks[1]).toHaveBeenCalledTimes(1));
await firstAvailableExpectation;
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
});
it("does not let ephemeral probes persist canceled pending attaches", async () => {
let factoryCalls = 0;
let releaseFirstReady: (() => void) | undefined;
const firstReadyGate = new Promise<void>((resolve) => {
releaseFirstReady = resolve;
});
const firstReadyThen = vi.spyOn(firstReadyGate, "then");
if (!releaseFirstReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
session.ready = firstReadyGate;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const firstAvailablePromise = ensureChromeMcpAvailable("chrome-live", undefined, {
signal: ctrl.signal,
});
const firstAvailableExpectation =
expect(firstAvailablePromise).rejects.toThrow(/first waiter cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(1));
await expect(
ensureChromeMcpAvailable("chrome-live", undefined, {
ephemeral: true,
}),
).resolves.toBeUndefined();
expect(factoryCalls).toBe(2);
expect(firstReadyThen).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(closeMocks[1]).toHaveBeenCalledTimes(1));
ctrl.abort(new Error("first waiter cancelled"));
releaseFirstReady();
await firstAvailableExpectation;
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2);
expect(factoryCalls).toBe(3);
});
it("keeps a shared session after a readiness timeout while another waiter remains", async () => {
let factoryCalls = 0;
let releaseFirstReady: (() => void) | undefined;
const firstReadyGate = new Promise<void>((resolve) => {
releaseFirstReady = resolve;
});
const firstReadyThen = vi.spyOn(firstReadyGate, "then");
if (!releaseFirstReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
session.ready = firstReadyGate;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const keptCtrl = new AbortController();
const timedOutTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
timeoutMs: 1,
});
const timedOutTabsExpectation = expect(timedOutTabsPromise).rejects.toThrow(/timed out/);
const keptTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: keptCtrl.signal,
});
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(2));
await timedOutTabsExpectation;
const laterTabsPromise = listChromeMcpTabs("chrome-live");
releaseFirstReady();
await expect(keptTabsPromise).resolves.toHaveLength(2);
await expect(laterTabsPromise).resolves.toHaveLength(2);
expect(factoryCalls).toBe(1);
expect(closeMocks[0]).not.toHaveBeenCalled();
keptCtrl.abort(new Error("kept waiter cancelled"));
});
it("closes a shared pending session after a readiness timeout with no other waiters", async () => {
let factoryCalls = 0;
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
session.ready = new Promise<void>(() => {});
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
await expect(
listChromeMcpTabs("chrome-live", undefined, {
timeoutMs: 1,
}),
).rejects.toThrow(/timed out/);
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2);
expect(factoryCalls).toBe(2);
expect(closeMocks[1]).not.toHaveBeenCalled();
});
it("preserves session after tool-level errors (isError)", async () => {
let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => {

View File

@@ -80,6 +80,22 @@ type ChromeMcpSessionFactory = (
options?: NormalizedChromeMcpProfileOptions,
) => Promise<ChromeMcpSession>;
type PendingChromeMcpSession = {
cacheKey: string;
id: symbol;
promise: Promise<ChromeMcpSession>;
abortController: AbortController;
state: {
waiters: number;
settled: boolean;
};
};
type PendingChromeMcpSessionLease = {
session: ChromeMcpSession;
release: (closeIfLastWaiter: boolean) => Promise<boolean>;
};
export type ChromeMcpProcessInfo = {
pid: number;
ppid: number;
@@ -123,7 +139,7 @@ const STALE_SELECTED_PAGE_ERROR =
const execFileAsync = promisify(execFile);
const sessions = new Map<string, ChromeMcpSession>();
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
const pendingSessions = new Map<string, PendingChromeMcpSession>();
let sessionFactory: ChromeMcpSessionFactory | null = null;
let chromeMcpProcessCleanupDepsForTest: ChromeMcpProcessCleanupDeps | null = null;
@@ -258,7 +274,7 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown {
}
}
if (lastError) {
throw lastError;
throw toLintErrorObject(lastError, "Non-Error thrown");
}
return null;
}
@@ -362,9 +378,10 @@ async function closeChromeMcpSessionsForProfile(
): Promise<boolean> {
let closed = false;
for (const key of Array.from(pendingSessions.keys())) {
for (const [key, pending] of Array.from(pendingSessions.entries())) {
if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) {
pendingSessions.delete(key);
abortPendingChromeMcpSession(pending, new Error("Chrome MCP profile session was replaced"));
closed = true;
}
}
@@ -629,7 +646,7 @@ async function closeChromeMcpClientAndProcess(params: {
return;
}
await params.client.close().catch(() => {});
await terminateChromeMcpProcessTree(rootPid, descendantPids).catch((err) => {
await terminateChromeMcpProcessTree(rootPid, descendantPids).catch((err: unknown) => {
log.trace(
`Unable to fully terminate Chrome MCP subprocess tree for pid ${rootPid}: ${err instanceof Error ? err.message : String(err)}`,
);
@@ -650,10 +667,9 @@ async function withChromeMcpHandshakeTimeout<T>(task: Promise<T>): Promise<T> {
return await Promise.race([
task,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("Chrome MCP handshake timed out")),
CHROME_MCP_HANDSHAKE_TIMEOUT_MS,
);
timer = setTimeout(() => {
reject(new Error("Chrome MCP handshake timed out"));
}, CHROME_MCP_HANDSHAKE_TIMEOUT_MS);
timer.unref?.();
}),
]);
@@ -761,7 +777,8 @@ async function waitForChromeMcpReady(
if (signal) {
racers.push(
new Promise<never>((_, reject) => {
abortListener = () => reject(signal.reason ?? new Error("aborted"));
abortListener = () =>
reject(toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"));
signal.addEventListener("abort", abortListener, { once: true });
}),
);
@@ -793,7 +810,8 @@ async function waitForChromeMcpPendingSession(
return await Promise.race([
pending,
new Promise<never>((_, reject) => {
abortListener = () => reject(signal.reason ?? new Error("aborted"));
abortListener = () =>
reject(toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"));
signal.addEventListener("abort", abortListener, { once: true });
}),
]);
@@ -810,21 +828,132 @@ async function createChromeMcpSession(
signal?: AbortSignal,
): Promise<ChromeMcpSession> {
const created = (sessionFactory ?? createRealSession)(profileName, options);
let closedAfterAbort = false;
try {
const session = await waitForChromeMcpPendingSession(created, signal);
if (signal?.aborted) {
closedAfterAbort = true;
await closeChromeMcpSessionHandle(session);
throw signal.reason ?? new Error("aborted");
}
return session;
} catch (err) {
if (signal?.aborted) {
if (signal?.aborted && !closedAfterAbort) {
void created.then((session) => closeChromeMcpSessionHandle(session)).catch(() => {});
}
throw err;
}
}
function abortPendingChromeMcpSession(
pending: PendingChromeMcpSession,
reason: unknown = new Error("Chrome MCP session attach no longer has active waiters"),
): void {
if (!pending.state.settled && !pending.abortController.signal.aborted) {
pending.abortController.abort(reason);
}
}
function forgetCachedChromeMcpSessionIfCurrent(
cacheKey: string,
session: ChromeMcpSession,
): boolean {
const current = sessions.get(cacheKey);
if (current?.transport !== session.transport) {
return false;
}
sessions.delete(cacheKey);
return true;
}
function forgetPendingChromeMcpSessionIfCurrent(
cacheKey: string,
pending: PendingChromeMcpSession,
): boolean {
if (pendingSessions.get(cacheKey) !== pending) {
return false;
}
pendingSessions.delete(cacheKey);
return true;
}
function createSharedPendingChromeMcpSession(
cacheKey: string,
profileName: string,
options: NormalizedChromeMcpProfileOptions,
): PendingChromeMcpSession {
const id = Symbol(cacheKey);
const abortController = new AbortController();
const state = {
waiters: 0,
settled: false,
};
const promise = (async () => {
try {
const created = await createChromeMcpSession(profileName, options, abortController.signal);
if (pendingSessions.get(cacheKey)?.id === id) {
sessions.set(cacheKey, created);
} else {
await closeChromeMcpSessionHandle(created);
}
return created;
} finally {
state.settled = true;
if (state.waiters === 0 && pendingSessions.get(cacheKey)?.id === id) {
pendingSessions.delete(cacheKey);
}
}
})();
const pending: PendingChromeMcpSession = {
cacheKey,
id,
promise,
abortController,
state,
};
void promise.catch(() => {});
return pending;
}
async function waitForSharedPendingChromeMcpSession(
pending: PendingChromeMcpSession,
signal?: AbortSignal,
): Promise<PendingChromeMcpSessionLease> {
pending.state.waiters += 1;
let released = false;
let leasedSession: ChromeMcpSession | undefined;
const release = async (closeIfLastWaiter: boolean) => {
if (released) {
return false;
}
released = true;
pending.state.waiters = Math.max(0, pending.state.waiters - 1);
if (pending.state.waiters !== 0) {
return false;
}
if (pendingSessions.get(pending.cacheKey) === pending) {
pendingSessions.delete(pending.cacheKey);
}
if (!pending.state.settled) {
abortPendingChromeMcpSession(pending, signal?.reason);
} else if (closeIfLastWaiter && leasedSession) {
forgetCachedChromeMcpSessionIfCurrent(pending.cacheKey, leasedSession);
await closeChromeMcpSessionHandle(leasedSession);
}
return true;
};
try {
leasedSession = await waitForChromeMcpPendingSession(pending.promise, signal);
return {
session: leasedSession,
release,
};
} catch (err) {
await release(signal?.aborted === true);
throw err;
}
}
async function getSession(
profileName: string,
profileOptions?: ChromeMcpOptionsInput,
@@ -834,43 +963,79 @@ async function getSession(
const options = normalizeChromeMcpOptions(profileOptions);
const cacheKey = buildChromeMcpSessionCacheKey(profileName, options);
await closeChromeMcpSessionsForProfile(profileName, cacheKey);
if (signal?.aborted) {
throw signal.reason ?? new Error("aborted");
}
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) {
sessions.delete(cacheKey);
session = undefined;
}
if (!session) {
let pending = pendingSessions.get(cacheKey);
if (!pending) {
pending = (async () => {
const created = await createChromeMcpSession(profileName, options, signal);
if (pendingSessions.get(cacheKey) === pending) {
sessions.set(cacheKey, created);
} else {
await closeChromeMcpSessionHandle(created);
}
return created;
})();
pendingSessions.set(cacheKey, pending);
}
try {
session = await pending;
} finally {
if (pendingSessions.get(cacheKey) === pending) {
pendingSessions.delete(cacheKey);
}
}
}
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
return session;
} catch (err) {
const current = sessions.get(cacheKey);
if (current?.transport === session.transport) {
let staleReadySessionRetries = 0;
for (;;) {
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) {
sessions.delete(cacheKey);
session = undefined;
}
let pendingLease: PendingChromeMcpSessionLease | undefined;
let leasedPending: PendingChromeMcpSession | undefined;
const pending = pendingSessions.get(cacheKey);
if (pending) {
leasedPending = pending;
pendingLease = await waitForSharedPendingChromeMcpSession(pending, signal);
session = pendingLease.session;
}
if (!session) {
const createdPending = createSharedPendingChromeMcpSession(cacheKey, profileName, options);
pendingSessions.set(cacheKey, createdPending);
leasedPending = createdPending;
pendingLease = await waitForSharedPendingChromeMcpSession(createdPending, signal);
session = pendingLease.session;
}
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
if (session.transport.pid === null) {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
if (leasedPending) {
forgetPendingChromeMcpSessionIfCurrent(cacheKey, leasedPending);
}
if (pendingLease) {
await pendingLease.release(true);
pendingLease = undefined;
}
staleReadySessionRetries += 1;
if (staleReadySessionRetries > 1) {
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${redactChromeMcpProfileLabelForDiagnostic(profileName)}". ` +
"The Chrome MCP subprocess exited before it became usable.",
);
}
continue;
}
return session;
} catch (err) {
if (signal?.aborted && pendingLease) {
await pendingLease.release(true);
pendingLease = undefined;
} else if (pendingLease && leasedPending && leasedPending.state.waiters > 1) {
await pendingLease.release(false);
pendingLease = undefined;
} else {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
if (leasedPending) {
forgetPendingChromeMcpSessionIfCurrent(cacheKey, leasedPending);
}
if (pendingLease) {
await pendingLease.release(true);
pendingLease = undefined;
} else {
await closeChromeMcpSessionHandle(session);
}
}
throw err;
} finally {
await pendingLease?.release(false);
}
throw err;
}
}
@@ -879,41 +1044,65 @@ async function getExistingSession(
profileName: string,
timeoutMs?: number,
signal?: AbortSignal,
includePending = true,
): Promise<ChromeMcpSession | null> {
if (!includePending && pendingSessions.has(cacheKey)) {
return null;
}
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) {
sessions.delete(cacheKey);
session = undefined;
}
const pending = pendingSessions.get(cacheKey);
if (includePending && pending) {
const pendingLease = await waitForSharedPendingChromeMcpSession(pending, signal);
let pendingLeaseReleased = false;
session = pendingLease.session;
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
if (session.transport.pid === null) {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
forgetPendingChromeMcpSessionIfCurrent(cacheKey, pending);
await pendingLease.release(true);
pendingLeaseReleased = true;
return null;
}
return session;
} catch (err) {
if (signal?.aborted) {
await pendingLease.release(true);
pendingLeaseReleased = true;
} else if (pending.state.waiters > 1) {
await pendingLease.release(false);
pendingLeaseReleased = true;
} else {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
forgetPendingChromeMcpSessionIfCurrent(cacheKey, pending);
await pendingLease.release(true);
pendingLeaseReleased = true;
}
throw err;
} finally {
if (!pendingLeaseReleased) {
await pendingLease.release(false);
}
}
}
if (session) {
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
return session;
} catch (err) {
const current = sessions.get(cacheKey);
if (current?.transport === session.transport) {
sessions.delete(cacheKey);
}
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
throw err;
}
}
const pending = pendingSessions.get(cacheKey);
if (!pending) {
return null;
}
session = await waitForChromeMcpPendingSession(pending, signal);
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
return session;
} catch (err) {
const current = sessions.get(cacheKey);
if (current?.transport === session.transport) {
sessions.delete(cacheKey);
}
throw err;
}
return null;
}
async function createEphemeralSession(
@@ -960,6 +1149,7 @@ async function leaseSession(
profileName,
options.timeoutMs,
options.signal,
false,
);
if (existingSession) {
return {
@@ -1022,7 +1212,8 @@ async function callTool(
if (signal) {
racers.push(
new Promise<never>((_, reject) => {
abortListener = () => reject(signal.reason ?? new Error("aborted"));
abortListener = () =>
reject(toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"));
signal.addEventListener("abort", abortListener, { once: true });
}),
);
@@ -1536,7 +1727,24 @@ export function setChromeMcpProcessCleanupDepsForTest(
export async function resetChromeMcpSessionsForTest(): Promise<void> {
sessionFactory = null;
for (const pending of pendingSessions.values()) {
abortPendingChromeMcpSession(pending, new Error("Chrome MCP sessions reset for test"));
}
pendingSessions.clear();
await stopAllChromeMcpSessions();
chromeMcpProcessCleanupDepsForTest = null;
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -315,9 +315,17 @@ export async function fetchBrowserJson<T>(
let abortListener: (() => void) | undefined;
const abortPromise: Promise<never> = abortCtrl.signal.aborted
? Promise.reject(abortCtrl.signal.reason ?? new Error("aborted"))
? Promise.reject(
toLintErrorObject(abortCtrl.signal.reason ?? new Error("aborted"), "Non-Error rejection"),
)
: new Promise((_, reject) => {
abortListener = () => reject(abortCtrl.signal.reason ?? new Error("aborted"));
abortListener = () =>
reject(
toLintErrorObject(
abortCtrl.signal.reason ?? new Error("aborted"),
"Non-Error rejection",
),
);
abortCtrl.signal.addEventListener("abort", abortListener, { once: true });
});
@@ -382,3 +390,17 @@ export const testApi = {
withLoopbackBrowserAuth: withLoopbackBrowserAuthImpl,
};
export { testApi as __test };
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -1359,12 +1359,12 @@ export async function gotoPageWithNavigationGuard(
try {
const response = await opts.page.goto(opts.url, { timeout: opts.timeoutMs });
if (blockedError) {
throw blockedError;
throw toLintErrorObject(blockedError, "Non-Error thrown");
}
return response;
} catch (err) {
if (blockedError) {
throw blockedError;
throw toLintErrorObject(blockedError, "Non-Error thrown");
}
throw err;
} finally {
@@ -1813,3 +1813,17 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
}
}
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -192,7 +192,7 @@ async function assertObservedDelayedNavigations(opts: {
});
}
if (subframeError) {
throw subframeError;
throw toLintErrorObject(subframeError, "Non-Error thrown");
}
}
@@ -276,7 +276,7 @@ function scheduleDelayedInteractionNavigationGuard(opts: {
const settle = (err?: unknown) => {
cleanup();
if (err) {
reject(err);
reject(toLintErrorObject(err, "Non-Error rejection"));
return;
}
resolve();
@@ -428,11 +428,11 @@ async function assertInteractionNavigationCompletedSafely<T>(opts: {
}
if (subframeError) {
throw subframeError;
throw toLintErrorObject(subframeError, "Non-Error thrown");
}
if (actionError) {
throw actionError;
throw toLintErrorObject(actionError, "Non-Error thrown");
}
return result as T;
}
@@ -478,12 +478,14 @@ function createAbortPromiseWithListener(
const abortPromise: Promise<never> = signal.aborted
? (() => {
onAbort?.(signal.reason);
return Promise.reject(signal.reason ?? new Error("aborted"));
return Promise.reject(
toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"),
);
})()
: new Promise((_, reject) => {
abortListener = () => {
onAbort?.(signal.reason);
reject(signal.reason ?? new Error("aborted"));
reject(toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"));
};
signal.addEventListener("abort", abortListener, { once: true });
});
@@ -1712,3 +1714,17 @@ export async function batchViaPlaywright(opts: {
}
return { results };
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -178,7 +178,7 @@ async function runExistingSessionActionWithNavigationGuard<T>(params: {
}
if (actionError) {
throw actionError;
throw toLintErrorObject(actionError, "Non-Error thrown");
}
return result as T;
@@ -809,3 +809,17 @@ export function registerBrowserAgentActRoutes(
}),
);
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -701,7 +701,7 @@ export function registerBrowserAgentSnapshotRoutes(
const pw = await getPwAiModule();
const snap = plan.wantsRoleSnapshot
? pw
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs).catch(async (err) => {
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs).catch(async (err: unknown) => {
const fallback = await cdpRoleSnapshot();
if (fallback) {
return fallback;

View File

@@ -20,10 +20,21 @@ describe("browser route dispatcher (abort)", () => {
const signal = req.signal;
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new Error("aborted"));
reject(
toLintErrorObject(
signal.reason ?? new Error("aborted"),
"Non-Error rejection",
),
);
return;
}
const onAbort = () => reject(signal?.reason ?? new Error("aborted"));
const onAbort = () =>
reject(
toLintErrorObject(
signal?.reason ?? new Error("aborted"),
"Non-Error rejection",
),
);
signal?.addEventListener("abort", onAbort, { once: true });
queueMicrotask(() => {
signal?.removeEventListener("abort", onAbort);
@@ -81,3 +92,17 @@ describe("browser route dispatcher (abort)", () => {
expect(body.error).toBe("invalid path parameter encoding: id");
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -66,7 +66,7 @@ export async function normalizeBrowserScreenshot(
}
if (processorUnavailableError) {
throw processorUnavailableError;
throw toLintErrorObject(processorUnavailableError, "Non-Error thrown");
}
const best = smallest?.buffer ?? buffer;
@@ -74,3 +74,17 @@ export async function normalizeBrowserScreenshot(
`Browser screenshot could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${(best.byteLength / (1024 * 1024)).toFixed(2)}MB)`,
);
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -377,7 +377,7 @@ export function createProfileTabOps({
method: "PUT",
},
getCdpControlPolicy(),
).catch(async (err) => {
).catch(async (err: unknown) => {
if (String(err).includes("HTTP 405")) {
return await fetchJson<CdpTarget>(
endpoint,

View File

@@ -57,7 +57,10 @@ vi.mock("../sdk-node-runtime.js", () => ({
new Promise<never>((_, reject) => {
abortCtrl.signal.addEventListener(
"abort",
() => reject(abortCtrl.signal.reason ?? timeoutError),
() =>
reject(
toLintErrorObject(abortCtrl.signal.reason ?? timeoutError, "Non-Error rejection"),
),
{ once: true },
);
}),
@@ -490,3 +493,17 @@ describe("runBrowserProxyCommand", () => {
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -45,11 +45,14 @@ function waitForAbort(
cleanup: () => void;
} {
if (signal.aborted) {
return { promise: Promise.reject(signal.reason ?? fallback), cleanup: () => undefined };
return {
promise: Promise.reject(toLintErrorObject(signal.reason ?? fallback, "Non-Error rejection")),
cleanup: () => undefined,
};
}
let listener: (() => void) | undefined;
const promise = new Promise<never>((_, reject) => {
listener = () => reject(signal.reason ?? fallback);
listener = () => reject(toLintErrorObject(signal.reason ?? fallback, "Non-Error rejection"));
signal.addEventListener("abort", listener, { once: true });
});
return {
@@ -82,3 +85,17 @@ export async function withTimeout<T>(
abort.cleanup();
}
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -84,7 +84,7 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
const server = await new Promise<Server>((resolve, reject) => {
const s = app.listen(port, "127.0.0.1", () => resolve(s));
s.once("error", reject);
}).catch((err) => {
}).catch((err: unknown) => {
logServer.error(`openclaw browser server failed to bind 127.0.0.1:${port}: ${String(err)}`);
return null;
});

View File

@@ -1,8 +0,0 @@
import { describePluginRegistrationContract } from "openclaw/plugin-sdk/plugin-test-contracts";
describePluginRegistrationContract({
pluginId: "byteplus",
providerIds: ["byteplus", "byteplus-plan"],
videoGenerationProviderIds: ["byteplus"],
requireGenerateVideo: true,
});

View File

@@ -193,8 +193,6 @@ async function pollBytePlusTask(params: {
throw new Error(
readBytePlusErrorMessage(payload.error) || "BytePlus video generation failed",
);
case "queued":
case "running":
default:
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
break;

View File

@@ -222,7 +222,9 @@ async function main() {
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});
await main().catch(
/** @param {unknown} error */ (error) => {
fail(error instanceof Error ? error.message : String(error));
},
);
}

View File

@@ -42,8 +42,10 @@ async function main() {
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((err) => {
console.error(String(err));
process.exit(1);
});
main().catch(
/** @param {unknown} err */ (err) => {
console.error(String(err));
process.exit(1);
},
);
}

View File

@@ -487,7 +487,7 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<Canva
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
})().catch((err) => {
})().catch((err: unknown) => {
opts.runtime.error(`Canvas host request failed: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");

View File

@@ -11,7 +11,7 @@ function formatErrorMessage(error: unknown): string {
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
serveCodexSupervisorMcp().catch((err) => {
serveCodexSupervisorMcp().catch((err: unknown) => {
process.stderr.write(`codex-supervisor-serve: ${formatErrorMessage(err)}\n`);
process.exit(1);
});

View File

@@ -303,7 +303,7 @@ describe("createCodexDynamicToolBridge", () => {
tools: [
createTool({ name: "message" }),
createTool({
name: "dofbot_move_angles",
name: "fuzzplugin_move_angles",
parameters: { type: "array", items: { type: "number" } },
execute: badExecute,
}),
@@ -324,17 +324,17 @@ describe("createCodexDynamicToolBridge", () => {
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
expect(bridge.telemetry.quarantinedTools).toEqual([
{
tool: "dofbot_move_angles",
violations: ['dofbot_move_angles.inputSchema.type must be "object"'],
tool: "fuzzplugin_move_angles",
violations: ['fuzzplugin_move_angles.inputSchema.type must be "object"'],
},
]);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("dofbot_move_angles"),
expect.stringContaining("fuzzplugin_move_angles"),
expect.objectContaining({
tools: [
{
tool: "dofbot_move_angles",
violations: ['dofbot_move_angles.inputSchema.type must be "object"'],
tool: "fuzzplugin_move_angles",
violations: ['fuzzplugin_move_angles.inputSchema.type must be "object"'],
},
],
}),
@@ -349,9 +349,9 @@ describe("createCodexDynamicToolBridge", () => {
runId: "run-1",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
toolName: "dofbot_move_angles",
toolName: "fuzzplugin_move_angles",
deniedReason: "unsupported_tool_schema",
reason: 'dofbot_move_angles.inputSchema.type must be "object"',
reason: 'fuzzplugin_move_angles.inputSchema.type must be "object"',
}),
);
@@ -360,13 +360,13 @@ describe("createCodexDynamicToolBridge", () => {
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "dofbot_move_angles",
tool: "fuzzplugin_move_angles",
arguments: {},
});
expect(result).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: dofbot_move_angles" }],
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: fuzzplugin_move_angles" }],
});
expect(badExecute).not.toHaveBeenCalled();
});

View File

@@ -185,6 +185,41 @@ describe("Codex native hook relay config", () => {
});
});
it("keeps selected no-policy PreToolUse installed with an unavailable no-op marker", () => {
expect(
buildCodexNativeHookRelayConfig({
relay: createRelay({ inactiveEvents: ["pre_tool_use"] }),
events: ["pre_tool_use"],
}),
).toEqual({
"features.hooks": true,
"hooks.PreToolUse": [
{
hooks: [
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --pre-tool-use-unavailable noop",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
},
],
},
],
"hooks.state": {
"/<session-flags>/config.toml:pre_tool_use:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"<session-flags>/config.toml:pre_tool_use:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
},
});
});
it("clears omitted hook events when requested", () => {
expect(
buildCodexNativeHookRelayConfig({
@@ -276,7 +311,11 @@ function createRelay(options?: {
expiresAtMs: Date.now() + 1000,
shouldRelayEvent: (event) => !inactiveEvents.has(event),
commandForEvent: (event) =>
`openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event ${event}`,
`openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event ${event}${
event === "pre_tool_use" && inactiveEvents.has(event)
? " --pre-tool-use-unavailable noop"
: ""
}`,
renew: () => undefined,
unregister: () => undefined,
};

View File

@@ -231,7 +231,11 @@ export function buildCodexNativeHookRelayConfig(params: {
for (const event of CODEX_NATIVE_HOOK_RELAY_EVENTS) {
const codexEvent = CODEX_HOOK_EVENT_BY_NATIVE_EVENT[event];
const selected = selectedEvents.has(event);
if (!selected || !params.relay.shouldRelayEvent(event)) {
const shouldRelay = params.relay.shouldRelayEvent(event);
// Keep no-policy PreToolUse commands installed with an explicit no-op marker;
// otherwise a stale relay fallback cannot distinguish no policy from unknown policy.
const selectedNoopPreToolUse = selected && event === "pre_tool_use" && !shouldRelay;
if (!selected || (!shouldRelay && !selectedNoopPreToolUse)) {
if (selected || params.clearOmittedEvents) {
config[`hooks.${codexEvent}`] = [] satisfies JsonValue;
}

View File

@@ -556,7 +556,7 @@ export class CodexNativeSubagentMonitor {
childState.transcriptPollTimer = setTimeout(() => {
childState.transcriptPollTimer = undefined;
void this.reconcileChildTranscript(childState.childThreadId)
.catch((error) => {
.catch((error: unknown) => {
embeddedAgentLog.warn("Failed to reconcile Codex native subagent transcript", {
childThreadId: childState.childThreadId,
error: formatErrorMessage(error),
@@ -595,7 +595,7 @@ export class CodexNativeSubagentMonitor {
}
this.taskRowReconcileTimer = setInterval(
() => {
void this.reconcileKnownTaskRows().catch((error) => {
void this.reconcileKnownTaskRows().catch((error: unknown) => {
embeddedAgentLog.warn("Failed to reconcile Codex native subagent task rows", {
error: formatErrorMessage(error),
});

View File

@@ -88,10 +88,10 @@ export async function waitForPluginApprovalDecision(params: {
let onAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
if (params.signal!.aborted) {
reject(params.signal!.reason);
reject(toLintErrorObject(params.signal!.reason, "Non-Error rejection"));
return;
}
onAbort = () => reject(params.signal!.reason);
onAbort = () => reject(toLintErrorObject(params.signal!.reason, "Non-Error rejection"));
params.signal!.addEventListener("abort", onAbort, { once: true });
});
try {
@@ -121,3 +121,17 @@ export function mapExecDecisionToOutcome(
function truncateForGateway(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 3))}...`;
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -1037,7 +1037,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await vi.waitFor(
() => {
if (runError) {
throw runError;
throw toLintErrorObject(runError, "Non-Error thrown");
}
expect(harness.requests.map((request) => request.method)).toContain("turn/start");
},
@@ -1709,3 +1709,17 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
expect(maintain).not.toHaveBeenCalled();
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -696,7 +696,7 @@ describe("runCodexAppServerAttempt", () => {
});
await expect(
client.request("turn/start", turnParams).catch(async (error) => {
client.request("turn/start", turnParams).catch(async (error: unknown) => {
await releaseCodexSandboxExecServerEnvironment(sandbox);
throw error;
}),
@@ -763,7 +763,7 @@ describe("runCodexAppServerAttempt", () => {
nativeCodeModeOnlyEnabled: false,
userMcpServersEnabled: false,
environmentSelection,
}).catch(async (error) => {
}).catch(async (error: unknown) => {
await releaseCodexSandboxExecServerEnvironment(sandbox);
throw error;
}),
@@ -1237,7 +1237,7 @@ describe("runCodexAppServerAttempt", () => {
params.prompt = "already persisted prompt";
params.suppressNextUserMessagePersistence = true;
const readTranscript = async () =>
fs.readFile(sessionFile, "utf8").catch((error) => {
fs.readFile(sessionFile, "utf8").catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}

View File

@@ -94,9 +94,11 @@ export function createCodexUserInputBridge(params: {
resolvePending(emptyUserInputResponse());
return;
}
void deliverUserInputPrompt(params.paramsForRun, requestParams.questions).catch((error) => {
embeddedAgentLog.warn("failed to deliver codex user input prompt", { error });
});
void deliverUserInputPrompt(params.paramsForRun, requestParams.questions).catch(
(error: unknown) => {
embeddedAgentLog.warn("failed to deliver codex user input prompt", { error });
},
);
});
},
handleQueuedMessage(text) {

View File

@@ -388,22 +388,24 @@ async function withPluginMigrationEligibility(params: {
return evaluated;
}
const snapshot = await refreshSourceAppInventory(params.requestOptions).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: {
code: "app_inventory_unavailable",
apps,
error: message,
},
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but source app inventory could not be read: ${message}`,
});
}
return undefined;
});
const snapshot = await refreshSourceAppInventory(params.requestOptions).catch(
(error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: {
code: "app_inventory_unavailable",
apps,
error: message,
},
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but source app inventory could not be read: ${message}`,
});
}
return undefined;
},
);
if (!snapshot) {
return evaluated;
}

View File

@@ -1,11 +0,0 @@
import { describePluginRegistrationContract } from "openclaw/plugin-sdk/plugin-test-contracts";
describePluginRegistrationContract({
pluginId: "comfy",
providerIds: ["comfy"],
imageGenerationProviderIds: ["comfy"],
musicGenerationProviderIds: ["comfy"],
videoGenerationProviderIds: ["comfy"],
requireGenerateImage: true,
requireGenerateVideo: true,
});

View File

@@ -1396,7 +1396,7 @@ describe("runCopilotAttempt", () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
pool.release = vi.fn(async () => {
throw "release failed";
throw toLintErrorObject("release failed", "Non-Error thrown");
});
await expect(runCopilotAttempt(makeParams(), { pool })).rejects.toThrow("release failed");
@@ -1414,7 +1414,7 @@ describe("runCopilotAttempt", () => {
});
const pool = makeFakePool(sdk);
pool.release = vi.fn(async () => {
throw "release failed";
throw toLintErrorObject("release failed", "Non-Error thrown");
});
const result = await runCopilotAttempt(makeParams(), { pool });
@@ -2534,3 +2534,17 @@ describe("runCopilotAttempt", () => {
});
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -115,7 +115,7 @@ export function attachEventBridge(
});
deltaChain = deltaQueue.then(() => {
if (firstDeltaError !== undefined) {
throw firstDeltaError;
throw toLintErrorObject(firstDeltaError, "Non-Error thrown");
}
});
void deltaChain.catch(() => undefined);
@@ -354,3 +354,17 @@ function registerListener<K extends SessionEventType>(
session.off?.(eventType, handler as (...args: unknown[]) => void);
});
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -419,7 +419,7 @@ describe("createCopilotClientPool", () => {
it("normalizes non-Error stop failures during dispose", async () => {
const sdk = makeFake({
stop: () => {
throw "stop-string";
throw toLintErrorObject("stop-string", "Non-Error thrown");
},
});
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
@@ -485,3 +485,17 @@ describe("createCopilotClientPool", () => {
expect(String(sdk.ctorCalls[0]?.baseDirectory)).toBe(normalizedHome);
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -30,7 +30,7 @@ export async function loadCopilotSdk(options: LoadCopilotSdkOptions = {}): Promi
const promise = doLoad(options);
if (useCache) {
cached = promise.catch((err) => {
cached = promise.catch((err: unknown) => {
cached = undefined;
throw err;
});

View File

@@ -204,7 +204,7 @@ describe("createTraceContextProvider", () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceparent: () => {
throw "string-boom";
throw toLintErrorObject("string-boom", "Non-Error thrown");
},
onError,
});
@@ -236,3 +236,17 @@ describe("createTraceContextProvider", () => {
expect(getTraceparent).not.toHaveBeenCalled();
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -504,11 +504,11 @@ export function createPairingNotifierService(api: OpenClawPluginApi): OpenClawPl
await notifyPendingPairingRequests({ api, statePath });
};
await tick().catch((err) => {
await tick().catch((err: unknown) => {
api.logger.warn(`device-pair: initial notify poll failed: ${formatErrorMessage(err)}`);
});
notifyInterval = setInterval(() => {
tick().catch((err) => {
tick().catch((err: unknown) => {
api.logger.warn(`device-pair: notify poll failed: ${formatErrorMessage(err)}`);
});
}, NOTIFY_POLL_INTERVAL_MS);

View File

@@ -4,6 +4,8 @@ Official extended syntax highlighting pack for the OpenClaw Diffs plugin.
The base `@openclaw/diffs` plugin ships a curated language set. Install this package when you want the full Shiki language catalog available in rendered diff viewers and diff image/PDF output.
The pack adds highlighting for languages outside the default diffs viewer set, including Astro, Vue, Svelte, MDX, GraphQL, Terraform/HCL, Nix, Clojure, Elixir, Haskell, OCaml, Scala, Zig, Solidity, Verilog/VHDL, Fortran, MATLAB, LaTeX, Mermaid, Sass/Less/SCSS, Nginx, Apache, CSV, dotenv, INI, and diff files. See the plugin reference and Shiki language catalog for details.
## Install
```bash

View File

@@ -331,7 +331,7 @@ async function resolveBrowserExecutablePath(config: OpenClawConfig): Promise<str
return await executablePathCache.valuePromise;
}
const valuePromise = resolveBrowserExecutablePathUncached(config).catch((error) => {
const valuePromise = resolveBrowserExecutablePathUncached(config).catch((error: unknown) => {
if (executablePathCache?.valuePromise === valuePromise) {
executablePathCache = null;
}
@@ -405,7 +405,7 @@ async function acquireSharedBrowser(params: {
}
return browser;
})
.catch((error) => {
.catch((error: unknown) => {
if (sharedBrowserState?.browserPromise === browserPromise) {
sharedBrowserState = null;
}

View File

@@ -226,7 +226,7 @@ export class DiffArtifactStore {
this.nextCleanupAt = now + this.cleanupIntervalMs;
const cleanupPromise = this.cleanupExpired()
.catch((error) => {
.catch((error: unknown) => {
this.nextCleanupAt = 0;
this.logger?.warn(`Failed to clean expired diff artifacts: ${String(error)}`);
})

View File

@@ -163,7 +163,7 @@ describe("createDiscordRestClient proxy support", () => {
},
})
.catch((err: unknown) => {
reject(err);
reject(toLintErrorObject(err, "Non-Error rejection"));
server.close();
});
});
@@ -175,3 +175,17 @@ describe("createDiscordRestClient proxy support", () => {
expect(received.body).toContain('"attachments":[{"id":0,"filename":"image.png"}]');
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -153,7 +153,6 @@ export function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle
return ButtonStyle.Danger;
case "link":
return ButtonStyle.Link;
case "primary":
default:
return ButtonStyle.Primary;
}

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